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); +});