feat: 统一蝦皮与顺运宝 PDD 关联入口 (#68)

This commit is contained in:
chengma
2026-08-09 22:40:58 +08:00
parent eb0e296b7c
commit 518e6df349
14 changed files with 429 additions and 98 deletions
+13 -7
View File
@@ -192,18 +192,19 @@ type ShopeeSpecView struct {
}
// ShopeeProductDetail 是双击弹窗要显示的全部内容。
//
// `[必须]` 这个弹窗只读,不含可提交的表单——本工单不实现保存,
// ShopeeSave 仍是 501,见 #41。
type ShopeeProductDetail struct {
GoodsID string
Title string
ShopeeStatus string
MainSKUCode string
PddURL string
PddMissing bool
StatusText string
PddURL string
PddGoodsID string
PddMissing bool
StatusText string
CollectStatus string
CollectMsg string
CanCollect bool
SKUs []ShopeeSpecView
PendingCount int
@@ -222,6 +223,7 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
Title: p.Title,
ShopeeStatus: p.ShopeeStatus,
MainSKUCode: p.MainSKUCode,
PddGoodsID: p.PddGoodsID,
}
if d.Title == "" {
d.Title = placeholder
@@ -239,12 +241,16 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
d.StatusText = "未填链接"
} else {
d.PddURL = p.PddGoodsURL
status, _, err := repository.GetShopeeCollectStatus(db, p.PddGoodsID)
status, msg, err := repository.GetShopeeCollectStatus(db, p.PddGoodsID)
if err != nil {
return nil, err
}
if status.Valid {
d.CollectStatus = status.String
d.CollectMsg = msg.String
d.StatusText = collectStatusText(model.CollectStatus(status.String))
d.CanCollect = status.String == string(model.CollectPending) ||
status.String == string(model.CollectFailed)
} else {
d.StatusText = "PDD 商品缺失"
}
+94
View File
@@ -0,0 +1,94 @@
package service
import (
"database/sql"
"errors"
"fmt"
"strings"
"cmautobuy/admin/repository"
)
// ErrPddAssociationReplaceRequired 表示当前蝦皮商品已经关联了另一个 PDD 商品。
var ErrPddAssociationReplaceRequired = errors.New("更换 PDD 商品需要明确确认")
// AssociateShopeePdd 把蝦皮商品关联到 PDD 商品档案。
// 解析、建档/复活和关联更新在同一事务内完成,任一步失败都不留下半成品。
func AssociateShopeePdd(db *sql.DB, shopeeGoodsID, rawURL string, confirmReplace bool) (string, error) {
shopeeGoodsID = strings.TrimSpace(shopeeGoodsID)
rawURL = strings.TrimSpace(rawURL)
pddGoodsID, err := ParsePddGoodsID(rawURL)
if err != nil {
return "", err
}
tx, err := db.Begin()
if err != nil {
return "", fmt.Errorf("开始关联 PDD 商品事务失败: %w", err)
}
defer tx.Rollback()
product, err := repository.GetShopeeProductByGoodsID(tx, shopeeGoodsID)
if err != nil {
return "", err
}
if product == nil {
return "", fmt.Errorf("蝦皮商品 %s 不存在", shopeeGoodsID)
}
if product.PddGoodsID != "" && product.PddGoodsID != pddGoodsID && !confirmReplace {
return "", fmt.Errorf("%w:当前是 %s,新链接解析为 %s",
ErrPddAssociationReplaceRequired, product.PddGoodsID, pddGoodsID)
}
if _, err := repository.EnsurePddProduct(tx, pddGoodsID, rawURL); err != nil {
return "", err
}
if err := repository.UpdateShopeePddLink(tx, shopeeGoodsID, pddGoodsID, rawURL); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("提交 PDD 商品关联失败: %w", err)
}
return pddGoodsID, nil
}
// CreateShopeePddCollectTask 按蝦皮商品当前关联创建采集任务,避免相信表单里的 PDD ID。
func CreateShopeePddCollectTask(db *sql.DB, shopeeGoodsID string) (CollectTaskResult, error) {
product, err := repository.GetShopeeProductByGoodsID(db, strings.TrimSpace(shopeeGoodsID))
if err != nil {
return CollectTaskResult{}, err
}
if product == nil {
return CollectTaskResult{}, fmt.Errorf("蝦皮商品不存在")
}
if product.PddGoodsID == "" {
return CollectTaskResult{}, fmt.Errorf("请先关联 PDD 商品")
}
return CreatePddCollectTasks(db, []string{product.PddGoodsID})
}
// AssociateSybPdd 从顺运宝明细解析出可信的蝦皮商品,再复用统一关联逻辑。
func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (string, error) {
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
if err != nil {
return "", err
}
if context == nil {
return "", fmt.Errorf("顺运宝明细不存在")
}
if context.Order.ShopeeSKUID == "" {
return "", fmt.Errorf("请先确认蝦皮规格")
}
return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, rawURL, confirmReplace)
}
// CreateSybPddCollectTask 按顺运宝明细当前关联创建采集任务。
func CreateSybPddCollectTask(db *sql.DB, sybID string) (CollectTaskResult, error) {
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
if err != nil {
return CollectTaskResult{}, err
}
if context == nil {
return CollectTaskResult{}, fmt.Errorf("顺运宝明细不存在")
}
return CreateShopeePddCollectTask(db, context.Order.ShopeeGoodsID)
}
+103
View File
@@ -0,0 +1,103 @@
package service
import (
"errors"
"testing"
"cmautobuy/admin/repository"
)
const (
pddURLA = "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267"
pddURLB = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
)
func TestAssociateShopeePdd_新建并重复复用(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品一")
for i := 0; i < 2; i++ {
got, err := AssociateShopeePdd(db, "1001", pddURLA, false)
if err != nil {
t.Fatalf("第 %d 次关联失败: %v", i+1, err)
}
if got != "737116531267" {
t.Fatalf("goods_id = %q", got)
}
}
var count int
db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id = ?`, "737116531267").Scan(&count)
if count != 1 {
t.Fatalf("同一 PDD 商品建了 %d 条,想要 1", count)
}
}
func TestAssociateShopeePdd_无效链接不写任何数据(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品一")
if _, err := AssociateShopeePdd(db, "1001", "https://p.pinduoduo.com/short", false); err == nil {
t.Fatal("短链接应被拒绝")
}
var pddCount int
db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&pddCount)
product, _ := repository.GetShopeeProductByGoodsID(db, "1001")
if pddCount != 0 || product.PddGoodsID != "" {
t.Fatalf("失败后仍写入:pdd=%d, link=%q", pddCount, product.PddGoodsID)
}
}
func TestAssociateShopeePdd_换商品必须确认且旧档案保留(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品一")
if _, err := AssociateShopeePdd(db, "1001", pddURLA, false); err != nil {
t.Fatal(err)
}
if _, err := AssociateShopeePdd(db, "1001", pddURLB, false); !errors.Is(err, ErrPddAssociationReplaceRequired) {
t.Fatalf("未确认换品错误 = %v", err)
}
product, _ := repository.GetShopeeProductByGoodsID(db, "1001")
if product.PddGoodsID != "737116531267" {
t.Fatalf("未确认时关联被改成 %q", product.PddGoodsID)
}
if _, err := AssociateShopeePdd(db, "1001", pddURLB, true); err != nil {
t.Fatal(err)
}
product, _ = repository.GetShopeeProductByGoodsID(db, "1001")
if product.PddGoodsID != "937122477375" {
t.Fatalf("确认后关联 = %q", product.PddGoodsID)
}
old, _ := repository.GetPddProductByGoodsID(db, "737116531267")
if old == nil {
t.Fatal("换品不应删除旧 PDD 档案")
}
}
func TestAssociateShopeePdd_软删除商品可复活(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品一")
repository.EnsurePddProduct(db, "737116531267", pddURLA)
if _, err := repository.SoftDeletePddProducts(db, []string{"737116531267"}); err != nil {
t.Fatal(err)
}
if _, err := AssociateShopeePdd(db, "1001", pddURLA, false); err != nil {
t.Fatal(err)
}
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
if p == nil || p.IsDeleted() {
t.Fatal("关联应复活软删除的 PDD 商品")
}
}
func TestCreateShopeePddCollectTask_按当前关联创建(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品一")
AssociateShopeePdd(db, "1001", pddURLA, false)
result, err := CreateShopeePddCollectTask(db, "1001")
if err != nil || result.Created != 1 {
t.Fatalf("创建结果 = %+v, err=%v", result, err)
}
_, _, _, ok := collectTaskOf(t, db, "737116531267")
if !ok {
t.Fatal("没有创建采集任务")
}
}
+10
View File
@@ -1177,6 +1177,11 @@ type SybProcessingDetail struct {
StageHelp string
Candidates []SybSKUCandidateView
ShopeeExists bool
PddGoodsID string
PddURL string
CollectStatus string
CollectMsg string
CanCollect bool
}
func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) {
@@ -1189,7 +1194,12 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title, ProductSpec: o.ProductSpec,
ShopeeGoodsID: o.ShopeeGoodsID, ShopeeSKUID: o.ShopeeSKUID,
Quantity: o.Quantity, ShopeeExists: context.ShopeeExists,
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
}
d.CanCollect = context.PddGoodsID != "" &&
(context.PddCollectStatus == string(model.CollectPending) ||
context.PddCollectStatus == string(model.CollectFailed))
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
if context.ShopeeExists {
skus, err := repository.ListShopeeSKUsByGoodsID(db, o.ShopeeGoodsID)