From 6c7a7298e3dd672bf6abb8cab6c1da8c7cd8b1db Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Thu, 20 Aug 2026 11:54:11 +0800
Subject: [PATCH] feat(#49): complete SYB shop management
---
docs/00-project-profile.md | 10 +-
docs/02-architecture-and-code-map.md | 6 +-
docs/03-business-rules-and-glossary.md | 13 +-
docs/08-agent-api-contract.md | 22 +-
docs/09-delivery-issues.md | 7 +-
docs/12-syb-erp-interface.md | 7 +-
prototypes/49/v1/index.html | 812 ++++++++++++++++++
prototypes/README.md | 6 +
server/app/goauto/sybimport/import_handler.go | 25 +-
.../goauto/sybimport/import_handler_test.go | 50 ++
server/app/goauto/sybimport/sync.go | 16 +-
server/app/goauto/sybimport/sync_test.go | 25 +-
web/src/api/goauto/syb-shops.js | 25 +
web/src/router/index.js | 13 +
web/src/views/goauto/syb-products/index.vue | 13 +-
web/src/views/goauto/syb-shops/index.vue | 131 +++
web/tests/e2e/syb-shop-module.spec.ts | 81 ++
17 files changed, 1230 insertions(+), 32 deletions(-)
create mode 100644 prototypes/49/v1/index.html
create mode 100644 server/app/goauto/sybimport/import_handler_test.go
create mode 100644 web/src/api/goauto/syb-shops.js
create mode 100644 web/src/views/goauto/syb-shops/index.vue
create mode 100644 web/tests/e2e/syb-shop-module.spec.ts
diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md
index 8c3f2cd..0ca61e8 100644
--- a/docs/00-project-profile.md
+++ b/docs/00-project-profile.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
-wiki_revision: 1d9156c3b118f6458aa040ae6031ac400826385f
-synchronized_at: 2026-08-19T01:19:44Z
+wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
+synchronized_at: 2026-08-20T03:52:46Z
# 项目档案
@@ -17,8 +17,8 @@ synchronized_at: 2026-08-19T01:19:44Z
| 主要使用者 | 管理员、采购人员、开发维护者 |
| Gitea 仓库 | `OPC/goauto` |
| 默认分支 | `main` |
-| 当前已实施范围 | PDD 正式商品档案、采集规则、采集任务、Android 执行、任务详情结果 |
-| 后续设计范围 | Shopee/SYB 商品关系、采购演练、创建待付款订单和物流回填 |
+| 当前已实施范围 | PDD 商品与采集闭环、Shopee/SYB 商品档案、SYB 接口导入与店铺准入管理 |
+| 后续设计范围 | 采购演练、创建待付款订单和物流回填 |
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
## 建设基线
@@ -67,3 +67,5 @@ synchronized_at: 2026-08-19T01:19:44Z
当前采集 MVP 的 T01~T22 和 T24 已完成实现并由用户验收;T08 只读实时屏幕已延期。T23、T25~T28 的一加真机增强、假售罄恢复、浏览器重开恢复和端口配置已经实现,其中部分工单仍等待用户验收。T24 的规格遍历已在一加和三星真机复核;三星未识别评价数量的问题由用户确认暂不处理。
#31 PDD 商品档案原型、数据库、API、Admin 页面和采集结果写回已于 2026-08-17 通过用户验收;#45 PDD 商品列表批量采集入口已于 2026-08-18 通过用户验收。#32 采购闭环原型及后续采购工单仍按各自门禁推进。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。
+
+#49 已完成 SYB 店铺表、管理 API、Admin 只读/管理界面、真实列表发现及导入双重过滤,正在等待用户验收。真实 MySQL 迁移和既有错误导入数据清理尚未执行;清理必须先确认精确 SQL 与影响行数。
diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index ddadf6b..24aaf5e 100644
--- a/docs/02-architecture-and-code-map.md
+++ b/docs/02-architecture-and-code-map.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
-wiki_revision: a81b23771f859e89d16a8626735f1f3425d063e2
-synchronized_at: 2026-08-20T01:27:29Z
+wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
+synchronized_at: 2026-08-20T03:52:51Z
# 架构与代码地图
@@ -110,9 +110,11 @@ Android Portal/Agent
| SYB 商品明细导入、解析与虾皮档案合并 | `server/app/goauto/sybimport/`(解析、幂等落库、同步编排、导入端点、管理端 API 和 Admin 页面均已实现) |
| 顺云宝(SYB)ERP HTTP 客户端与登录会话 | `server/app/goauto/sybclient/`(登录、OCR 验证码、会话缓存、列表与明细读取;见 [SYB-ERP-Interface-Contract](SYB-ERP-Interface-Contract)) |
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
+| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
| 任务领取、结果、重置与删除 | `server/app/goauto/task/` |
| 管理端基线 | `web/`(go-admin-ui v3.0.0) |
| 管理端闭环页面 | `web/src/views/goauto/` |
+| SYB 店铺管理页面与接口封装 | `web/src/views/goauto/syb-shops/`、`web/src/api/goauto/syb-shops.js`;确认原型快照 `prototypes/49/v1/index.html` |
| Android Agent 基线 | `android/app/src/main/java/cn/ilapage/goauto/agent/` |
| Android 无障碍规则执行 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/` |
| 三端统一验证 | `scripts/verify.ps1` |
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index 9eedb70..902acb6 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
-wiki_revision: 3871bcc837677c0ef75f6b75d9b9e0bde147cf78
-synchronized_at: 2026-08-19T08:15:34Z
+wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
+synchronized_at: 2026-08-20T03:52:54Z
# 业务规则与术语
@@ -45,6 +45,15 @@ synchronized_at: 2026-08-19T08:15:34Z
- 人工修正的颜色/尺码视为与解析成功同等可信,同样会合并进虾皮商品档案;标记为已人工确认。
- 不创建采购任务,不保存 PDD 订单号、快递单号、支付或有效订单状态;采购发起入口不在本域实现。
+## SYB 店铺过滤
+
+- `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
+- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
+- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
+- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
+- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
+- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
+
## 采集规则
- 规则创建后立即可用,不存在草稿、发布、版本或停用流程。
diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md
index 8533ada..b495ccd 100644
--- a/docs/08-agent-api-contract.md
+++ b/docs/08-agent-api-contract.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
-wiki_revision: fca6dfac9f1e5dcba51278664fc24f58e3ce3cce
-synchronized_at: 2026-08-19T07:09:33Z
+wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
+synchronized_at: 2026-08-20T03:53:07Z
# MVP 共享 API 契约
@@ -62,12 +62,13 @@ POST /api/admin/v1/shopee-products/batch-delete
```http
GET /api/admin/v1/syb-products
GET /api/admin/v1/syb-products/{productId}
+POST /api/admin/v1/syb-products/import
POST /api/admin/v1/syb-products/{productId}/reparse
POST /api/admin/v1/syb-products/reparse-batch
PATCH /api/admin/v1/syb-products/{productId}/correction
```
-**没有导入/创建端点**:服务端尚未接入顺云宝 ERP 的真实接口凭据与拉取客户端,明细目前只能通过服务端内部调用 `sybimport.ApplyDetail` 写入(当前仅测试代码调用),管理端无法触发导入。这是已知且明确记录的缺口,不是遗漏。
+`import` 提交日期范围后同步调用 SYB 客户端完成列表完整性校验、启用店铺过滤、明细读取和幂等落库。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`;#50 将另行把长耗时导入改为后台任务,本接口当前仍为同步执行。
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配货运单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
@@ -75,6 +76,21 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
`correction` 提交 `targetColor`、`targetSize`,覆盖解析结果并标记 `manuallyConfirmed=true`;非空的人工修正与解析成功同等可信,会合并进已关联的虾皮商品档案。
+## 管理端:SYB 店铺
+
+```http
+GET /api/admin/v1/syb-shops
+POST /api/admin/v1/syb-shops
+PATCH /api/admin/v1/syb-shops/{shopId}/name
+PATCH /api/admin/v1/syb-shops/{shopId}/enabled
+DELETE /api/admin/v1/syb-shops/{shopId}
+POST /api/admin/v1/syb-shops/discover
+```
+
+列表支持 `keyword`、`enabledOnly`、`page` 和 `pageSize`,返回店铺原名、规范化名称、启用状态及分页汇总。写操作由 go-admin/Casbin 仅授权管理员;其他角色只读。删除为软删除,不清理已经导入的数据。
+
+`discover` 提交日期范围,从 SYB 真实货运单列表发现店铺,返回 `shopName` 和 `status`(`new` / `exists`);发现只读,不自动新增。店铺比较固定执行首尾去空白、全角/半角统一和忽略大小写。同步先完成原始列表完整性校验,再按一次性启用店铺快照过滤;明细响应中的店铺名为空或不在快照内时不得写入。
+
## 管理端:采集规则
```http
diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md
index c7fc55d..e969948 100644
--- a/docs/09-delivery-issues.md
+++ b/docs/09-delivery-issues.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Issues
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
-wiki_revision: e3cb3e3043c09471273cd1176590dafed40e1834
-synchronized_at: 2026-08-19T07:09:36Z
+wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
+synchronized_at: 2026-08-20T03:53:09Z
# 当前 MVP 交付工单索引
@@ -64,6 +64,9 @@ synchronized_at: 2026-08-19T07:09:36Z
| T39 | [#41](https://git.ilapage.cn/OPC/goauto/issues/41) | SYB 货运单商品导入与虾皮信息提取 | #40;源数据域独立于采购任务;Stage B 解析/落库/管理端 API/Admin 页面已完成,SYB 接口拉取客户端待实施 |
| T40 | [#42](https://git.ilapage.cn/OPC/goauto/issues/42) | Android 采购演练规则与持久执行基线 | #33、#34;只演练,不改地址、不创建订单 |
| T43 | [#45](https://git.ilapage.cn/OPC/goauto/issues/45) | Admin PDD 商品列表多选与批量采集任务创建(2026-08-18 已验收) | 已完成 |
+| T46 | [#48](https://git.ilapage.cn/OPC/goauto/issues/48) | SYB ERP 客户端移植与真实导入 | #41;已实施,等待验收 |
+| T47 | [#49](https://git.ilapage.cn/OPC/goauto/issues/49) | 店铺管理与 SYB 同步店铺过滤 | #48;代码与原型已完成,等待验收;真实迁移和历史数据清理需另行确认 |
+| T48 | [#50](https://git.ilapage.cn/OPC/goauto/issues/50) | SYB 导入后台任务与进度展示 | #48、#49 |
推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。
diff --git a/docs/12-syb-erp-interface.md b/docs/12-syb-erp-interface.md
index a80ebe9..2a054c1 100644
--- a/docs/12-syb-erp-interface.md
+++ b/docs/12-syb-erp-interface.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
-wiki_revision: ee43fb44302307a67261d5f6195b4dd19c28d092
-synchronized_at: 2026-08-19T08:22:06Z
+wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
+synchronized_at: 2026-08-20T03:53:19Z
# 12 顺云宝(SYB)ERP 接口契约
@@ -464,8 +464,7 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
这几条不是抓包结论,是本项目的决定,写在这里避免每次重新讨论:
-> 下面两条「店铺准入」是上游 cmautobuy 的业务规则,**GoAuto 尚未采纳**。
-> 保留原文是因为其中「先做完整性校验、再做过滤」的顺序结论对任何过滤策略都成立。
+> 以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
diff --git a/prototypes/49/v1/index.html b/prototypes/49/v1/index.html
new file mode 100644
index 0000000..c0baac3
--- /dev/null
+++ b/prototypes/49/v1/index.html
@@ -0,0 +1,812 @@
+
+
+
+
+
+GoAuto - 店铺管理原型
+
+
+
+
+
+ Quant-UX 离线导出
+
+
+
diff --git a/prototypes/README.md b/prototypes/README.md
index 1be5bea..7eec753 100644
--- a/prototypes/README.md
+++ b/prototypes/README.md
@@ -20,3 +20,9 @@
四份文件由 QuantUX MCP 导出(#31、#32 于 2026-08-17,#40、#41 于 2026-08-18),是包含图片和交互逻辑的单文件离线 HTML,可直接用浏览器打开。MCP 导出验证结果均为 `PASS`,没有运行时错误或不支持的控件类型。
原型审核状态:#31 PDD 商品档案原型 2026-08-17 已确认;#32 采购闭环原型 2026-08-18 已确认,可作为采购实施依据;#40 虾皮商品档案原型 2026-08-19 按 #46 修订后重新确认;#41 SYB 商品导入原型 2026-08-19 已确认。四份原型均可作为实现依据。其余原型以对应 Gitea 工单中记录的用户审核状态为准;草稿不能作为正式实现依据。重新导出时必须使用相同 App ID,并审查差异,不能静默覆盖。
+
+## 分工单版本化审核快照
+
+- `49/v1/index.html`:#49「GoAuto - 店铺管理原型」,线上 App ID `6a866e98191a826306a7ef78`,1440×900,7 个页面、347 个控件、19 条流程线。覆盖管理员店铺列表、启停、新增、改名、软删除、从 SYB 发现店铺、无启用店铺时的导入拦截、采购员只读状态和模块边界。2026-08-20 用户确认线上原型后导出,MCP 校验 `PASS`,导出文件不含访问凭据。
+
+从 #47 之后新增的原型使用 `prototypes/<工单号>/<版本>/index.html`。已确认版本不得原位覆盖;后续结构或流程变化必须导出到新版本目录并重新确认。
diff --git a/server/app/goauto/sybimport/import_handler.go b/server/app/goauto/sybimport/import_handler.go
index c4d4f3c..ad4e91a 100644
--- a/server/app/goauto/sybimport/import_handler.go
+++ b/server/app/goauto/sybimport/import_handler.go
@@ -7,6 +7,7 @@ import (
"time"
"go-admin/app/goauto/sybclient"
+ "go-admin/app/goauto/sybshop"
"go-admin/config"
"github.com/gin-gonic/gin"
@@ -51,6 +52,25 @@ func (handler Handler) Import(c *gin.Context) {
return
}
+ service, ok := handler.service(c)
+ if !ok {
+ return
+ }
+ // `[必须]` Refuse before Connect: Connect may log in and send a captcha to
+ // the configured OCR service. With no enabled shop there is no valid sync
+ // to run, so consuming either remote service would be wasteful and would
+ // violate #49's pre-flight boundary. Sync checks again to cover a shop being
+ // disabled between this pre-flight and the actual run.
+ enabled, err := sybshop.EnabledNames(c.Request.Context(), service.DB)
+ if err != nil {
+ writeError(c, internalError(err))
+ return
+ }
+ if len(enabled) == 0 {
+ writeError(c, invalidRequest(ErrNoEnabledShop.Error()))
+ return
+ }
+
settings := config.ExtConfig.SYB.Resolved()
if !settings.HasCredentials() {
// Name the file that was actually consulted. The previous wording only
@@ -60,11 +80,6 @@ func (handler Handler) Import(c *gin.Context) {
return
}
- service, ok := handler.service(c)
- if !ok {
- return
- }
-
importGate.Lock()
if importGate.running {
importGate.Unlock()
diff --git a/server/app/goauto/sybimport/import_handler_test.go b/server/app/goauto/sybimport/import_handler_test.go
new file mode 100644
index 0000000..2d1f4be
--- /dev/null
+++ b/server/app/goauto/sybimport/import_handler_test.go
@@ -0,0 +1,50 @@
+package sybimport
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "go-admin/app/goauto/models"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+// 没有启用店铺时必须在读取凭据、登录、OCR 和任何 SYB 请求之前拒绝。
+// 这里不配置任何 SYB 客户端;若前置顺序回退,响应会变成凭据错误或尝试联网。
+func TestImportRejectsNoEnabledShopBeforeConnect(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open("file:import-no-shop?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := db.AutoMigrate(&models.SYBShop{}); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ gin.SetMode(gin.TestMode)
+ engine := gin.New()
+ engine.POST("/api/admin/v1/syb-products/import", Handler{DB: db}.Import)
+ body := bytes.NewBufferString(`{"dateFrom":"2026-08-19","dateTo":"2026-08-19"}`)
+ request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", body)
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
+ }
+ var response struct {
+ Message string `json:"message"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if !strings.Contains(response.Message, "没有启用任何店铺") {
+ t.Fatalf("unexpected response: %s", recorder.Body.String())
+ }
+}
diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go
index f35effd..fbd4787 100644
--- a/server/app/goauto/sybimport/sync.go
+++ b/server/app/goauto/sybimport/sync.go
@@ -189,12 +189,16 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
// this order belongs to an enabled shop; if the detail disagrees,
// the two views are inconsistent and importing it would write a
// row for a shop nobody enabled.
- if name := stringField(detail.Raw, "shopName"); !sybshop.IsBlank(name) {
- if _, ok := allowed[sybshop.Normalize(name)]; !ok {
- report.AcceptedCount--
- report.ShopSkipped++
- continue
- }
+ name := stringField(detail.Raw, "shopName")
+ if sybshop.IsBlank(name) {
+ report.AcceptedCount--
+ report.ShopSkipped++
+ continue
+ }
+ if _, ok := allowed[sybshop.Normalize(name)]; !ok {
+ report.AcceptedCount--
+ report.ShopSkipped++
+ continue
}
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
return report, err
diff --git a/server/app/goauto/sybimport/sync_test.go b/server/app/goauto/sybimport/sync_test.go
index e61f03a..0f9632b 100644
--- a/server/app/goauto/sybimport/sync_test.go
+++ b/server/app/goauto/sybimport/sync_test.go
@@ -63,7 +63,8 @@ type fakeSYB struct {
shopNames []string
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
// 用于构造「列表说是 A 店、明细说是 B 店」的不一致。
- detailShopName string
+ detailShopName string
+ blankDetailShopName bool
}
func (f *fakeSYB) shopFor(i int) string {
@@ -118,10 +119,14 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
if id == f.detailDropID {
continue
}
+ detailShopName := f.detailShopName
+ if detailShopName == "" && !f.blankDetailShopName {
+ detailShopName = f.shopFor(int(id - 1000))
+ }
list = append(list, map[string]any{
"id": id,
"code": fmt.Sprintf("ORD-%d", id),
- "shopName": f.detailShopName,
+ "shopName": detailShopName,
"details": []any{map[string]any{
"id": float64(id*10 + 1), "productId": float64(9001),
"productTitle": "测试商品", "productSpec": "白色,L",
@@ -397,3 +402,19 @@ func TestSyncRechecksShopOnDetailResponse(t *testing.T) {
t.Fatalf("不应写入任何行,实际 %d", written)
}
}
+
+// 列表命中启用店铺,但明细店铺名为空时同样必须拦住;空值不能沿用列表值猜测。
+func TestSyncRejectsBlankShopOnDetailResponse(t *testing.T) {
+ db := newSyncTestDB(t)
+ f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, blankDetailShopName: true}
+
+ report, err := Sync(context.Background(), db, newSyncClient(t, f),
+ SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
+ if err != nil {
+ t.Fatalf("同步失败: %v", err)
+ }
+ if report.DetailCount != 0 || report.AcceptedCount != 0 || report.ShopSkipped != 2 {
+ t.Fatalf("空店铺名应全部拦住: details=%d accepted=%d skipped=%d",
+ report.DetailCount, report.AcceptedCount, report.ShopSkipped)
+ }
+}
diff --git a/web/src/api/goauto/syb-shops.js b/web/src/api/goauto/syb-shops.js
new file mode 100644
index 0000000..4dfba50
--- /dev/null
+++ b/web/src/api/goauto/syb-shops.js
@@ -0,0 +1,25 @@
+import request from '@/utils/request'
+
+export function listSybShops(params) {
+ return request({ url: '/api/admin/v1/syb-shops', method: 'get', params })
+}
+
+export function createSybShop(data) {
+ return request({ url: '/api/admin/v1/syb-shops', method: 'post', data })
+}
+
+export function renameSybShop(shopId, data) {
+ return request({ url: `/api/admin/v1/syb-shops/${shopId}/name`, method: 'patch', data })
+}
+
+export function setSybShopEnabled(shopId, data) {
+ return request({ url: `/api/admin/v1/syb-shops/${shopId}/enabled`, method: 'patch', data })
+}
+
+export function deleteSybShop(shopId) {
+ return request({ url: `/api/admin/v1/syb-shops/${shopId}`, method: 'delete' })
+}
+
+export function discoverSybShops(data) {
+ return request({ url: '/api/admin/v1/syb-shops/discover', method: 'post', data, timeout: 30 * 60 * 1000 })
+}
diff --git a/web/src/router/index.js b/web/src/router/index.js
index f8f2969..00127bf 100644
--- a/web/src/router/index.js
+++ b/web/src/router/index.js
@@ -132,6 +132,19 @@ export const constantRoutes = [
}
]
},
+ {
+ path: '/syb-shops',
+ component: Layout,
+ redirect: '/syb-shops/index',
+ children: [
+ {
+ path: 'index',
+ component: () => import('@/views/goauto/syb-shops/index'),
+ name: 'GoAutoSybShops',
+ meta: { title: 'SYB 店铺', icon: 'shopping' }
+ }
+ ]
+ },
{
path: '/collection-rules',
component: Layout,
diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue
index 00841df..f745cdd 100644
--- a/web/src/views/goauto/syb-products/index.vue
+++ b/web/src/views/goauto/syb-products/index.vue
@@ -4,6 +4,7 @@
从 SYB 导入
+ 店铺管理
查询重置
@@ -133,9 +134,10 @@
+
+
diff --git a/web/tests/e2e/syb-shop-module.spec.ts b/web/tests/e2e/syb-shop-module.spec.ts
new file mode 100644
index 0000000..20efae6
--- /dev/null
+++ b/web/tests/e2e/syb-shop-module.spec.ts
@@ -0,0 +1,81 @@
+import { expect, test } from '@playwright/test';
+
+const shops = [
+ { id: 1, displayName: '晨曦优选店', normalizedName: '晨曦优选店', enabled: true, lastSeenAt: '2026-08-20T02:00:00Z', lastSeenOrderCount: 126, updatedAt: '2026-08-20T02:00:00Z' },
+ { id: 2, displayName: 'ABC服饰', normalizedName: 'abc服饰', enabled: false, lastSeenAt: null, lastSeenOrderCount: null, updatedAt: '2026-08-19T02:00:00Z' },
+];
+
+async function mockPage(page: any, context: any, roles: string[]) {
+ const calls: { creates: any[]; enabled: any[] } = { creates: [], enabled: [] };
+ await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
+ await page.route('**/api/**', async (route: any) => {
+ const url = new URL(route.request().url());
+ if (url.pathname.startsWith('/src/api/')) return route.continue();
+ if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles, name: roles.includes('admin') ? '管理员' : '采购员', avatar: '', introduction: '', permissions: [] } } });
+ if (url.pathname.endsWith('/api/admin/v1/syb-shops/discover')) return route.fulfill({ json: { code: 200, data: { items: [{ displayName: '新发现店铺', orderCount: 38, status: 'new' }, { displayName: '晨曦优选店', orderCount: 126, status: 'enabled' }] } } });
+ if (url.pathname.endsWith('/api/admin/v1/syb-shops') && route.request().method() === 'POST') {
+ calls.creates.push(route.request().postDataJSON());
+ return route.fulfill({ json: { code: 200, data: { id: 3, ...route.request().postDataJSON() } } });
+ }
+ if (url.pathname.endsWith('/api/admin/v1/syb-shops/2/enabled')) {
+ calls.enabled.push(route.request().postDataJSON());
+ return route.fulfill({ json: { code: 200, data: { ...shops[1], ...route.request().postDataJSON() } } });
+ }
+ if (url.pathname.endsWith('/api/admin/v1/syb-shops')) return route.fulfill({ json: { code: 200, data: { items: shops, total: 2, enabledCount: 1, page: 1, pageSize: 20 } } });
+ return route.fulfill({ json: { code: 200, data: [] } });
+ });
+ return calls;
+}
+
+test('管理员可以发现、添加并启用 SYB 店铺', async ({ page, context }) => {
+ const calls = await mockPage(page, context, ['admin']);
+ await page.goto('http://localhost:9527/#/syb-shops/index');
+ await expect(page.getByText('晨曦优选店').first()).toBeVisible();
+ await expect(page.getByText('已启用 1 个')).toBeVisible();
+
+ await page.getByRole('button', { name: '从 SYB 发现店铺', exact: true }).click();
+ await page.getByRole('button', { name: '开始发现' }).click();
+ const dialog = page.getByRole('dialog', { name: '从 SYB 发现店铺' });
+ const rows = dialog.locator('.el-table__body-wrapper tbody tr');
+ await rows.nth(0).locator('.el-checkbox').click();
+ await expect(rows.nth(1).getByRole('checkbox')).toBeDisabled();
+ await dialog.getByRole('button', { name: '添加选中的 1 家店铺' }).click();
+ await expect.poll(() => calls.creates).toEqual([{ displayName: '新发现店铺', enabled: true }]);
+ await dialog.getByRole('button', { name: '关闭此对话框' }).click();
+
+ await page.locator('.el-table__body-wrapper tbody tr').filter({ hasText: 'ABC服饰' }).locator('.el-switch').click();
+ await expect.poll(() => calls.enabled).toEqual([{ enabled: true }]);
+});
+
+test('采购员查看店铺时配置控件保持只读', async ({ page, context }) => {
+ await mockPage(page, context, ['purchaser']);
+ await page.goto('http://localhost:9527/#/syb-shops/index');
+ await expect(page.getByText('当前账号只有查看权限')).toBeVisible();
+ await expect(page.getByRole('button', { name: '从 SYB 发现店铺', exact: true })).toBeDisabled();
+ await expect(page.getByRole('button', { name: '新增店铺' })).toBeDisabled();
+ await expect(page.locator('.el-table').getByRole('switch')).toHaveCount(0);
+ await expect(page.getByText('已启用', { exact: true }).first()).toBeVisible();
+ await expect(page.getByText('只读').first()).toBeVisible();
+});
+
+test('没有启用店铺时从 SYB 导入在前端预检阶段停止', async ({ page, context }) => {
+ let importCalled = false;
+ await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
+ await page.route('**/api/**', async route => {
+ const url = new URL(route.request().url());
+ if (url.pathname.startsWith('/src/api/')) return route.continue();
+ if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] } } });
+ if (url.pathname.endsWith('/api/admin/v1/syb-shops')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, enabledCount: 0, page: 1, pageSize: 1 } } });
+ if (url.pathname.endsWith('/api/admin/v1/syb-products/import')) {
+ importCalled = true;
+ return route.fulfill({ json: { code: 500, message: '不应调用' } });
+ }
+ if (url.pathname.endsWith('/api/admin/v1/syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 } } });
+ return route.fulfill({ json: { code: 200, data: [] } });
+ });
+
+ await page.goto('http://localhost:9527/#/syb-products/index');
+ await page.getByRole('button', { name: '从 SYB 导入' }).click();
+ await expect(page.getByRole('dialog', { name: '无法导入' })).toContainText('还没有启用任何店铺');
+ expect(importCalled).toBe(false);
+});