diff --git a/admin/main_test.go b/admin/main_test.go index a7afecf..46733b6 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "cmautobuy/admin/model" "cmautobuy/admin/repository" "cmautobuy/admin/service" ) @@ -127,6 +128,33 @@ func TestMainPagesReturnOK(t *testing.T) { t.Error("顺运宝页面不应再显示旧的指定日期同步入口") } + _, err = repository.UpsertSybOrder(db, model.SybOrder{ + SybID: "SYB-PDD-ID-INPUT", OrderNo: "ORDER-PDD-ID-INPUT", Title: "输入测试商品", + ProductSpec: "黑色,M", ShopeeGoodsID: "SP-PDD-ID-INPUT", Quantity: 1, SybData: `{}`, + }) + if err != nil { + t.Fatalf("准备顺运宝详情测试数据失败: %v", err) + } + detailRequest := httptest.NewRequest(http.MethodGet, "/syb/detail?id=SYB-PDD-ID-INPUT", nil) + addAuth(detailRequest) + detailResponse := httptest.NewRecorder() + router.ServeHTTP(detailResponse, detailRequest) + detailBody := detailResponse.Body.String() + if detailResponse.Code != http.StatusOK { + t.Fatalf("GET /syb/detail = %d,响应:%s", detailResponse.Code, detailBody) + } + for _, want := range []string{ + "PDD 商品链接或商品 ID", `type="text" name="pdd_url"`, + "6~24 位纯数字商品 ID", + } { + if !strings.Contains(detailBody, want) { + t.Errorf("顺运宝详情输入区缺少 %q,响应:%s", want, detailBody) + } + } + if strings.Contains(detailBody, `type="url" name="pdd_url"`) { + t.Error("顺运宝详情不应继续使用会拦截纯数字的 URL 输入类型") + } + partialRequest := httptest.NewRequest(http.MethodGet, "/syb/sync-history?history_page=1&order_no=ORDER-1&date_from=2026-08-01&date_to=2026-08-09", nil) addAuth(partialRequest) diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index 159a216..030ef89 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -2,6 +2,7 @@ package service import ( "database/sql" + "errors" "testing" "time" @@ -9,6 +10,86 @@ import ( "cmautobuy/admin/repository" ) +func TestAssociateSybPdd_兼容商品ID并复用已有采集结果(t *testing.T) { + db := newTestDB(t) + seedWorkflowOrder(t, db, "SYB-PDD-ID", "SP-PDD-ID", "黑色,M") + + if _, err := repository.EnsurePddProduct(db, "737116531267", pddURLA); err != nil { + t.Fatal(err) + } + if err := repository.SetCollectResult(db, "737116531267", "已采集商品", "已有店铺", collectedThreeDimensions); err != nil { + t.Fatal(err) + } + + goodsID, err := AssociateSybPdd(db, "SYB-PDD-ID", " 737116531267 ", false) + if err != nil || goodsID != "737116531267" { + t.Fatalf("纯商品 ID 关联结果 goodsID=%q err=%v", goodsID, err) + } + + product, err := repository.GetPddProductByGoodsID(db, goodsID) + if err != nil || product == nil { + t.Fatalf("读取复用商品失败: product=%+v err=%v", product, err) + } + if product.URL != pddURLA || product.CollectStatus != model.CollectCollected || + product.Title != "已采集商品" || product.SkusJSON != collectedThreeDimensions { + t.Fatalf("已有采集结果不应被清空或重置: %+v", product) + } + var productCount, taskCount int + db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id = ?`, goodsID).Scan(&productCount) + db.QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&taskCount) + if productCount != 1 || taskCount != 0 { + t.Fatalf("应复用一条商品且不自动建任务: products=%d tasks=%d", productCount, taskCount) + } +} + +func TestAssociateSybPdd_新商品ID生成标准链接且完整链接保持兼容(t *testing.T) { + t.Run("纯商品ID", func(t *testing.T) { + db := newTestDB(t) + seedWorkflowOrder(t, db, "SYB-NEW-ID", "SP-NEW-ID", "白色,L") + goodsID, err := AssociateSybPdd(db, "SYB-NEW-ID", "123456789", false) + if err != nil || goodsID != "123456789" { + t.Fatalf("关联结果 goodsID=%q err=%v", goodsID, err) + } + product, _ := repository.GetPddProductByGoodsID(db, goodsID) + if product == nil || product.URL != canonicalPddGoodsURL+goodsID || product.CollectStatus != model.CollectPending { + t.Fatalf("新商品 ID 没有生成待采集标准档案: %+v", product) + } + }) + + t.Run("完整链接", func(t *testing.T) { + db := newTestDB(t) + seedWorkflowOrder(t, db, "SYB-FULL-URL", "SP-FULL-URL", "白色,L") + goodsID, err := AssociateSybPdd(db, "SYB-FULL-URL", pddURLA, false) + if err != nil || goodsID != "737116531267" { + t.Fatalf("原完整链接行为回归: goodsID=%q err=%v", goodsID, err) + } + }) +} + +func TestAssociateSybPdd_商品ID输入边界和换品确认(t *testing.T) { + for _, input := range []string{"12345", "1234567890123456789012345", "737116abc", "普通文本"} { + t.Run(input, func(t *testing.T) { + db := newTestDB(t) + seedWorkflowOrder(t, db, "SYB-BAD-ID", "SP-BAD-ID", "黑色,M") + if _, err := AssociateSybPdd(db, "SYB-BAD-ID", input, false); !errors.Is(err, ErrBadPddURL) { + t.Fatalf("输入 %q 应返回 ErrBadPddURL,实际 %v", input, err) + } + }) + } + + db := newTestDB(t) + seedWorkflowOrder(t, db, "SYB-REPLACE", "SP-REPLACE", "黑色,M") + if _, err := AssociateSybPdd(db, "SYB-REPLACE", "737116531267", false); err != nil { + t.Fatal(err) + } + if _, err := AssociateSybPdd(db, "SYB-REPLACE", "937122477375", false); !errors.Is(err, ErrPddAssociationReplaceRequired) { + t.Fatalf("纯 ID 换成其他商品仍应要求确认,实际 %v", err) + } + if _, err := AssociateSybPdd(db, "SYB-REPLACE", "937122477375", true); err != nil { + t.Fatalf("确认后应允许更换: %v", err) + } +} + const collectedThreeDimensions = `{ "goods_id":"737116531267", "price_granularity":"sku", diff --git a/admin/service/shopee_pdd.go b/admin/service/shopee_pdd.go index d3df9e4..3a46718 100644 --- a/admin/service/shopee_pdd.go +++ b/admin/service/shopee_pdd.go @@ -12,6 +12,36 @@ import ( // ErrPddAssociationReplaceRequired 表示当前蝦皮商品已经关联了另一个 PDD 商品。 var ErrPddAssociationReplaceRequired = errors.New("更换 PDD 商品需要明确确认") +const canonicalPddGoodsURL = "https://mobile.yangkeduo.com/goods.html?goods_id=" + +// normalizeSybPddReference 只在顺运宝详情入口兼容纯商品 ID。 +// 其他入口仍沿用完整 PDD 链接校验,避免无意扩大已经确认的输入范围。 +func normalizeSybPddReference(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", fmt.Errorf("%w: PDD 商品链接或商品 ID 不能为空", ErrBadPddURL) + } + if isGoodsID(value) { + return canonicalPddGoodsURL + value, nil + } + if isASCIIDigits(value) { + return "", fmt.Errorf("%w: PDD 商品 ID 必须是 6~24 位纯数字", ErrBadPddURL) + } + if !strings.Contains(value, "://") { + return "", fmt.Errorf("%w: 请输入带 goods_id 的完整拼多多链接,或 6~24 位纯数字商品 ID", ErrBadPddURL) + } + return value, nil +} + +func isASCIIDigits(value string) bool { + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} + // AssociateShopeePdd 把蝦皮商品关联到 PDD 商品档案。 // 解析、建档/复活和关联更新在同一事务内完成,任一步失败都不留下半成品。 func AssociateShopeePdd(db *sql.DB, shopeeGoodsID, rawURL string, confirmReplace bool) (string, error) { @@ -81,7 +111,11 @@ func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (str if context.Order.SpecKey == "" { return "", fmt.Errorf("顺运宝未提供规格,不能进入采购流程") } - return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, rawURL, confirmReplace) + normalizedURL, err := normalizeSybPddReference(rawURL) + if err != nil { + return "", err + } + return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, normalizedURL, confirmReplace) } // CreateSybPddCollectTask 按顺运宝明细当前关联创建采集任务。 diff --git a/admin/templates/syb/detail_modal.html b/admin/templates/syb/detail_modal.html index 2b4e474..8728ac9 100644 --- a/admin/templates/syb/detail_modal.html +++ b/admin/templates/syb/detail_modal.html @@ -27,9 +27,9 @@