Compare commits

...
19 changed files with 860 additions and 40 deletions
+20 -2
View File
@@ -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: 46a5f8234be2d55298779532058a0490a94d3167
synchronized_at: 2026-09-10T03:36:58Z
wiki_revision: aea36cd232cc24af80fd8576ef206fe92e6161f2
synchronized_at: 2026-09-10T08:09:12Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -411,3 +411,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 关联后读取现有采购准备;需要人工确认或不具备AI资格时停止自动链路并保留关联,其他情况调用原 AutoMatchMappings,沿用 #254 动态预算及原事务保存/上下文冲突保护。最后刷新实际处理阶段,不调用创建采购/订单接口。
- 运行期间冻结行、商品、手机;前端重复主动作禁用,切换偏好仍只影响未来操作。卸载或 keep-alive deactivated 令旧操作失效,禁止旧回调继续下一步;不声称已经取消服务端在途写入。异常仅有限GET回读和刷新,不自动重发POST。
- 验证命令:Server `go test -p 1 ./...`、`go build ./...`;Web `node --test tests/unit/syb-quick-link.test.cjs tests/unit/shopee-match-lifecycle.test.cjs`、`pnpm exec playwright test tests/e2e/syb-quick-link.spec.ts tests/e2e/shopee-match-lifecycle.spec.ts --workers=1`、受影响文件 ESLint、`pnpm build:prod`。SQLite 单连接并发测试不代替生产 MySQL 行锁验收;真实AI、生产数据、跨设备人工闭环待验证。
## SYB 行内一键替换(#258)
实现绑定 `2b01974`;部署与验收见 #258 发布证据。本节补充 #253,不改变原未关联入口。
- `syb-products/quick-link.js` 复用分体按钮、设备偏好和匹配/回读流程;已关联且允许处理阶段时同位置显示一键替换。先只读预览,固定任务、目标商品及规格版本;不同商品一次确认,同商品不重绑。
- `shopeeproduct/latest_collection.go` 在原 latestCollection 增加可选 replacement 模式。预览和提交均短事务锁定 SYB、Shopee、原PDD、设备、来源采集与目标PDD,核对旧关联及上下文。提交按预览 taskId 读取,不重新挑选后来采集的商品。
- Admin 路由注入 `purchase.ValidateQuickReplacement`,在事务内复用采购准备数据集及处理阶段计算,不调用 AI。缺少校验函数拒绝替换;客户端密钥不注入且仍拒绝 latestCollection。无新路由、权限、表或迁移。
- 关联完成后才执行现有 AI 匹配,不在数据库事务中等待 Provider;同一 SYB 正式采购创建与替换共用 SYB 行锁。历史采购快照不变。SQLite 合成测试覆盖冲突与串行并发请求,不能替代生产 MySQL 多连接锁验收。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
+25 -2
View File
@@ -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: 6513b68930ee8a29327e91bcb51efd4f47e26990
synchronized_at: 2026-09-10T03:37:02Z
wiki_revision: ab871d61c53fe5edae1b404adaebf2495d60f099
synchronized_at: 2026-09-10T08:09:15Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -37,6 +37,8 @@ synchronized_at: 2026-09-10T03:37:02Z
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
- #257:蝦皮颜色与尺码建议/一键匹配先保留仍有效的已确认映射,再复用相同标准化比较:简体、空白、全半角与大小写统一。颜色标准化后仅命中一个当前可选 PDD 候选时,不调用 AI,建议标为 matched,一键匹配按既有流程保存 exact_match + confirmed;未命中或多个不同候选标准化后相同,仍走原 AI 与置信度/候选校验。只转换比较键,蝦皮/PDD 原规格名不改,映射目标保存 PDD 原始候选名称;不引入颜色同义词猜测、不自动重算历史映射。
## SYB 商品明细
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
@@ -104,6 +106,8 @@ synchronized_at: 2026-09-10T03:37:02Z
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
- #256(Android 0.9.73,实现 43703e0):采集执行器可识别无“已选/请选择”摘要且当前仅一个规格标题的滚动规格面板。必须匹配 PDD 包名、任务详情 Activity 与选择器,无登录/验证等异常,并同时具备唯一有界规格滚动区域、唯一标题及非空候选、关闭、唯一数量输入/增减、支付区和唯一下方提交订单只读动作;缺失任一证据仍拒绝。此分支只由采集上下文启用,不生成虚假选中摘要,采购与其他解析调用维持原条件。订单动作仍仅作只读证据,采集绝不点击。
## 采集任务
- 任务与结果共用 `collection_task` 主表,不建立独立 `collection_result` 主表。
@@ -556,3 +560,22 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 先保存关联,再复用现有颜色/尺码自动匹配及保存。有效已确认映射保留;普通部分匹配保存合格结果,AI故障不提交本轮新映射,但已保存关联保留。后续可从“去匹配”或详情人工继续。failed 且无人工/AI确认时不能自动跳过人工解析;uncertain 不统一拦截,仍以原采购准备规则为准。
- 操作完成不保证立即可采购,必须刷新现有处理阶段;缺规格、缺映射、解析需确认或其他原资格不满足时继续显示真实待处理阶段。不自动创建采购任务、不下单、不支付。
- 请求超时/网络中断只做有限回读,不重复提交;读回失败明确结果未确认,避免无限转圈。离开页面不代表后端停止或回滚。普通用户沿用现有采购员权限,无新管理员专属开关。
## SYB 已关联商品的一键替换(#258)
实现绑定 `2b01974`;部署与验收见 #258 发布证据。
- 复用一键关联及小箭头的位置,保留原查看 PDD 商品。PDD 待采集、采集失败、停用或缺价格的对应处理阶段可替换;采集中、已创建/运行采购、成功或结果未知不开放。服务端提交时再次检查,不能只相信按钮。
- 复用每用户/浏览器/API环境的手机偏好;首次选择继续当前行,小箭头仅切换偏好。预览仅选所选手机最新 completed/completed_partial 临时采集,部分成功或不可用不回退旧商品。确认后固定来源任务和目标,不改用新采集;来源状态、设备、目标规格或旧关联改变时拒绝。
- 不同商品显示一次确认,包含旧/新商品、手机和共享蝦皮档案影响;取消不写入。同商品不弹替换确认且不重写关联,只继续匹配。共享该蝦皮档案的 SYB 明细使用新关联,历史采购任务快照不改。
- 沿用既有更换关联和有效映射检查,再复用自动匹配保存。部分匹配、AI失败或解析需人工处理时保留已保存关联,显示真实处理阶段,不保证立即可采购、不自动创建采购。
- 网络异常只有限回读,区分目标商品、仍为原商品和其他关联,不把“已有某个关联”误报为本次替换成功;不自动重发写请求。没有新增后台作业、订单或付款操作。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
+34 -2
View File
@@ -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: 23160c055e8f6185a4108e284afc8216b41373e5
synchronized_at: 2026-09-10T03:37:18Z
wiki_revision: a2e73f5b892385a3c306dfdeca543a3aeb89a32e
synchronized_at: 2026-09-10T08:09:35Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -1093,3 +1093,35 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
- `/api/client/v1/.../link-pdd` 带 latestCollection 时返回403 FORBIDDEN,不扩大API Key授权模块;原手动 pddProductId 模式行为不变。
- Web 先GET详情及 #254 autoMatchTimeoutSeconds,再提交关联;关联响应中的冻结版本交给现有 auto-match。其匹配仍自动保存,无额外“保存修改”写请求。普通请求/回读每次10秒,auto-match 单独使用派生前端预算2T+20秒;业务拒绝直接提示,网络/匹配失败至多一次详情回读,再刷新准备状态,禁止自动POST重试。
- Server/Web 需配套更新并包含 #254;无需迁移/Android升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。
## Admin 一键替换最近采集候选(#258)
实现绑定 `2b01974`;仅补充 Admin,Android/API Key 不变。沿用 `POST /api/admin/v1/shopee-products/{productId}/link-pdd` 和原权限,不新增路由或迁移。
先请求只读预览(仍需 UUID requestId):
```json
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"当前详情版本","replacement":{"preview":true,"expectedPddProductId":3}}}
```
响应 `product` 保持原关联,`collectionSource` 返回 taskId、deviceId、pddProductId、requiresSpecConfirmation,并增加 targetVersion、title、goodsId、previousTitle、previousGoodsId、deviceName。预览没有关联写入。
确认提交沿用同一 SYB/设备/详情版本与原商品ID,replacement 改为:
```json
{"preview":false,"expectedPddProductId":3,"taskId":4,"targetPddProductId":5,"targetVersion":"预览返回值"}
```
- 提交按指定 taskId 核对临时采集来源、设备、completed/finishedAt、目标ID、可用规格及 targetVersion;不重新选择最新任务。旧关联、旧/目标规格版本变更返回现有409上下文冲突;阶段不再允许返回409 PDD_LINK_CONFLICT;来源不可用409 LATEST_COLLECTION_UNAVAILABLE;缺字段422。
- 不同商品条件更新旧关联,返回更新后的 product.specContextVersion;同商品不更新关联及 last_update_request_id,但仍返回用于后续匹配的当前版本。请求不是持久化重放协议;客户端超时只回读、不自动重发。
- 预览与提交都校验当前允许阶段;采集中拒绝。复用现有人工更换关联的映射语义,后续 auto-match 自动保存及预算沿用 #254。部分匹配/AI故障不回滚已提交关联。
- 不提供 replacement 时 #253 行为不变;`/api/client/v1` 携带 latestCollection(包括 replacement)仍403,不扩大客户端密钥授权。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
+1 -1
View File
@@ -63,7 +63,7 @@ func InitRouter() {
goautoproduct.InitRouter(r, authMiddleware)
goautoreplacement.InitRouter(r, authMiddleware)
goautorule.InitRouter(r, authMiddleware)
goautoshopeeproduct.InitRouter(r, authMiddleware)
goautoshopeeproduct.InitRouter(r, authMiddleware, goautopurchase.ValidateQuickReplacement)
goautosybimport.InitRouter(r, authMiddleware)
goautosybinnercode.InitRouter(r, authMiddleware)
goautosybshop.InitRouter(r, authMiddleware)
@@ -0,0 +1,44 @@
package purchase
import (
"context"
"go-admin/app/goauto/shopeeproduct"
"gorm.io/gorm"
)
// Called inside the link transaction, after locking the SYB and Shopee rows.
// Reuse the authoritative stage calculation, without AI or task creation.
func ValidateQuickReplacement(ctx context.Context, tx *gorm.DB, id uint64) error {
s := NewService(tx)
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
return err
}
guard, _, err := s.readOnlyPriceGuard(ctx)
if err != nil {
return err
}
preview := s.previewFromDataset(id, dataset, guard)
qualification := aiMatchQualificationForDataset(id, dataset)
preview.AIMatchEligible = qualification.Eligible
preview.AIMatchDisabledReason = qualification.DisabledReason
stage := processStageFromDataset(id, dataset, preview)
syb := dataset.sybByID[id]
disabled := false
if syb.ShopeeProductID != nil {
shopee := dataset.shopeeByID[*syb.ShopeeProductID]
if shopee.PDDProductID != nil {
pdd := dataset.pddByID[*shopee.PDDProductID]
disabled = pdd.Status == "disabled"
if _, active := dataset.activeCollectionByPDD[pdd.ID]; active {
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "商品正在采集,请结束后刷新再操作"}
}
}
}
if stage.NextAction == "open_pdd" && (stage.Stage == ProcessStagePDDPending || stage.Stage == ProcessStagePDDCollectionFail ||
(stage.Stage == ProcessStageManualAction && (disabled || preview.ReasonCode == "PDD_PRICE_MISSING"))) {
return nil
}
return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "处理阶段已变化,当前不能一键替换,请刷新列表"}
}
@@ -0,0 +1,44 @@
package purchase
import (
"context"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestQuickReplacementUsesCurrentStage(t *testing.T) {
for _, name := range []string{"pending", "disabled", "price-missing", "ready", "parse-failed", "collecting", "purchase-pending", "purchase-running", "order-created", "order-unknown"} {
t.Run(name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
allow := name == "pending" || name == "disabled" || name == "price-missing"
switch name {
case "pending", "disabled":
db.Model(&f.pdd).Update("status", name)
case "ready":
setCollectedPDDPrice(t, db, f.pdd.ID)
case "parse-failed":
db.Model(&f.syb).Update("parse_status", "failed")
case "collecting":
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.CollectionTask{DeviceID: &f.device.ID, PDDProductID: &f.pdd.ID, RuleID: rule.ID, Status: models.TaskStatusRunning, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
case "purchase-pending", "purchase-running", "order-created", "order-unknown":
status := map[string]string{"purchase-pending": "pending", "purchase-running": "running", "order-created": "order_created", "order-unknown": "order_result_unknown"}[name]
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&models.PurchaseTask{ExecutionMode: "live", Quantity: 1, SYBProductID: &f.syb.ID, PDDProductID: f.pdd.ID, Status: status, CreateRequestID: name}).Error; err != nil {
t.Fatal(err)
}
}
err := ValidateQuickReplacement(context.Background(), db, f.syb.ID)
if (err == nil) != allow {
t.Fatalf("allowed=%v: %v", allow, err)
}
})
}
}
@@ -131,6 +131,12 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
item.Reason = "保留已确认映射"
item.Apply = true
response.MatchedCount++
case role == RoleColor && deterministicColorMatch(value.Name, candidateNames) != "":
item.PDDValue = deterministicColorMatch(value.Name, candidateNames)
item.Status = "matched"
item.Reason = "格式统一后唯一匹配"
item.Apply = true
response.MatchedCount++
case role == RoleSize && deterministicSizeMatch(value.Name, candidateNames) != "":
item.PDDValue = deterministicSizeMatch(value.Name, candidateNames)
item.Status = "matched"
@@ -237,6 +243,15 @@ func deterministicSizeMatch(value string, candidates []string) string {
return match.MappedSize
}
// Keep the original candidate label; normalization is only a comparison key.
func deterministicColorMatch(value string, candidates []string) string {
match, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: value, Colors: candidates})
if !ok {
return ""
}
return match.MappedColor
}
// aiSuggestErrorMessage turns an aimatching error into a message safe to
// return to the admin UI (no credential, no raw provider body).
func aiSuggestErrorMessage(err error) string {
@@ -0,0 +1,100 @@
package shopeeproduct
import (
"context"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/models"
)
func TestColorNormalizedMatchWithoutAIKeepsOriginalLabels(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
pdd.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"260#大红","selectable":true},{"name":"260#墨绿","selectable":true},{"name":"AbC黑色","selectable":true}]}]`
if err := db.Save(&pdd).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
sources := []string{"260#大紅", "260#墨綠", "abc 黑色"}
targets := []string{"260#大红", "260#墨绿", "AbC黑色"}
values := []SpecValue{}
for _, name := range sources {
values = append(values, SpecValue{Name: name, Source: ValueSourceImport})
}
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "normalized-color", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: values}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("exact colors must not require AI: %v", err)
}
for i, item := range preview.Items {
if item.Status != "matched" || !item.Apply || item.ValueName != sources[i] || item.PDDValue != targets[i] {
t.Fatalf("unexpected preview: %+v", item)
}
}
if len(preview.Items) != 3 || preview.MatchedCount != 3 {
t.Fatal("missing exact matches")
}
var stored models.ShopeeProduct
if err := db.First(&stored, created.Product.ID).Error; err != nil {
t.Fatal(err)
}
specs, err := Unmarshal(stored.SpecsJSON)
if err != nil {
t.Fatal(err)
}
for _, value := range specs[0].Values {
if value.Mapping != nil {
t.Fatal("preview wrote mappings")
}
}
result, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
if err != nil {
t.Fatal(err)
}
if result.ConfirmedCount != 3 || result.UnmatchedCount != 0 {
t.Fatalf("unexpected counts: %+v", result)
}
for i, value := range result.Product.Specs[0].Values {
m := value.Mapping
if value.Name != sources[i] || m == nil || m.PDDValue != targets[i] || m.Source != MappingSourceExactMatch || m.Status != MappingStatusConfirmed {
t.Fatalf("incorrect persisted mapping: %+v", value)
}
}
var after models.PDDProduct
if err := db.First(&after, pdd.ID).Error; err != nil {
t.Fatal(err)
}
if after.SpecsJSON != pdd.SpecsJSON {
t.Fatal("changed original PDD specs")
}
}
func TestColorNormalizedAmbiguityAndUnavailableStillRequireAI(t *testing.T) {
for _, tc := range []struct{ name, source, specs string }{
{"ambiguous", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true},{"name":"大紅","selectable":true}]}]`},
{"unavailable", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":false},{"name":"黑色","selectable":true}]}]`},
{"unmatched", "深紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true}]}]`},
} {
t.Run(tc.name, func(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
pdd.SpecsJSON = tc.specs
if err := db.Save(&pdd).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: tc.name, PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: tc.source, Source: ValueSourceImport}}}}})
if err != nil {
t.Fatal(err)
}
_, err = service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err == nil || errCode(t, err) != CodeAIUnavailable {
t.Fatalf("must fall back to AI, not guess: %v", err)
}
})
}
}
+5 -1
View File
@@ -14,7 +14,10 @@ import (
"gorm.io/gorm"
)
type Handler struct{ DB *gorm.DB }
type Handler struct {
DB *gorm.DB
ReplacementEligibility ReplacementEligibility
}
func (handler Handler) List(c *gin.Context) {
page, err := queryInt(c.Query("page"), 1)
@@ -106,6 +109,7 @@ func (handler Handler) LinkPDD(c *gin.Context) {
if !ok {
return
}
service.ReplacementEligibility = handler.ReplacementEligibility
response, err := service.LinkPDD(c.Request.Context(), id, request)
respond(c, response, err)
}
@@ -13,9 +13,18 @@ const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
const CodeLinkConflict = "PDD_LINK_CONFLICT"
type LatestCollectionRequest struct {
SYBProductID uint64 `json:"sybProductId"`
DeviceID uint64 `json:"deviceId"`
SpecContextVersion string `json:"specContextVersion"`
SYBProductID uint64 `json:"sybProductId"`
DeviceID uint64 `json:"deviceId"`
SpecContextVersion string `json:"specContextVersion"`
Replacement *LatestCollectionReplacement `json:"replacement,omitempty"`
}
type LatestCollectionReplacement struct {
Preview bool `json:"preview"`
ExpectedPDDProductID uint64 `json:"expectedPddProductId"`
TaskID uint64 `json:"taskId"`
TargetPDDProductID uint64 `json:"targetPddProductId"`
TargetVersion string `json:"targetVersion"`
}
type LatestCollectionSource struct {
@@ -23,6 +32,12 @@ type LatestCollectionSource struct {
DeviceID uint64 `json:"deviceId"`
PDDProductID uint64 `json:"pddProductId"`
RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"`
TargetVersion string `json:"targetVersion,omitempty"`
Title string `json:"title,omitempty"`
GoodsID string `json:"goodsId,omitempty"`
PreviousTitle string `json:"previousTitle,omitempty"`
PreviousGoodsID string `json:"previousGoodsId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
}
func latestUnavailable(message string) error {
@@ -56,10 +71,28 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
}
return err
}
if shopee.PDDProductID != nil {
replacement := r.Replacement
var old models.PDDProduct
if replacement != nil {
if shopee.PDDProductID == nil || replacement.ExpectedPDDProductID == 0 || *shopee.PDDProductID != replacement.ExpectedPDDProductID {
return contextVersionStale()
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&old, *shopee.PDDProductID).Error; err != nil {
return err
}
if service.ReplacementEligibility == nil {
return invalidRequest("一键替换入口未配置")
}
if err := service.ReplacementEligibility(ctx, tx, syb.ID); err != nil {
return err
}
if !replacement.Preview && (replacement.TaskID == 0 || replacement.TargetPDDProductID == 0 || replacement.TargetVersion == "") {
return invalidRequest("请先预览并确认替换商品")
}
} else if shopee.PDDProductID != nil {
return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"}
}
if computeSpecContextVersion(nil, shopee.SpecsJSON, "") != r.SpecContextVersion {
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, old.SpecsJSON) != r.SpecContextVersion {
return contextVersionStale()
}
var device models.AgentDevice
@@ -73,14 +106,18 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
return latestUnavailable("手机已停用,请重新选择")
}
var task models.CollectionTask
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).Order("finished_at DESC, id DESC").First(&task).Error
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
if replacement != nil && !replacement.Preview {
query = query.Where("id = ?", replacement.TaskID)
}
err := query.Order("finished_at DESC, id DESC").First(&task).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品")
}
if err != nil {
return err
}
if task.Status != models.TaskStatusCompleted || task.FinishedAt == nil || task.PDDProductID == nil {
if task.FinishedAt == nil || task.PDDProductID == nil {
return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品")
}
var pdd models.PDDProduct
@@ -101,18 +138,45 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
if err != nil || len(color)+len(size) == 0 {
return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采")
}
result := tx.Model(&models.ShopeeProduct{}).Where("id = ? AND pdd_product_id IS NULL", id).Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
if result.Error != nil {
return result.Error
if task.Status == models.TaskStatusCompletedPartial {
if err := validatePartialLinkResult(tx, task, color, size); err != nil {
return err
}
}
if result.RowsAffected != 1 {
targetVersion := computeSpecContextVersion(&pdd.ID, "", pdd.SpecsJSON)
if replacement != nil && !replacement.Preview && (replacement.TargetPDDProductID != pdd.ID || replacement.TargetVersion != targetVersion) {
return contextVersionStale()
}
// Preview never writes. Same-product continuation also preserves the
// existing association and request marker; matching is a separate call.
if replacement == nil || (!replacement.Preview && old.ID != pdd.ID) {
query := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id)
if replacement == nil {
query = query.Where("pdd_product_id IS NULL")
} else {
query = query.Where("pdd_product_id = ?", old.ID)
}
result := query.Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return contextVersionStale()
}
}
response, err = NewService(tx).Detail(ctx, id)
if err != nil {
return err
}
response.CollectionSource = &LatestCollectionSource{TaskID: task.ID, DeviceID: device.ID, PDDProductID: pdd.ID, RequiresSpecConfirmation: syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed}
if replacement != nil {
response.CollectionSource.TargetVersion = targetVersion
response.CollectionSource.Title = pdd.Title
response.CollectionSource.GoodsID = pdd.GoodsID
response.CollectionSource.PreviousTitle = old.Title
response.CollectionSource.PreviousGoodsID = old.GoodsID
response.CollectionSource.DeviceName = device.Name
}
return nil
})
if err != nil {
@@ -0,0 +1,93 @@
package shopeeproduct
import (
"encoding/json"
"strings"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// Only auxiliary metadata may be missing. Validate the source task, not the
// merged product archive, which can retain data from an older collection.
func validatePartialLinkResult(tx *gorm.DB, task models.CollectionTask, colors, sizes map[string]bool) error {
reject := func() error {
return latestUnavailable("最近一次采集的规格或价格仍不完整,请先补采;不会回退关联更早的商品")
}
var missing []string
if task.MissingJSON == nil || json.Unmarshal([]byte(*task.MissingJSON), &missing) != nil || len(missing) == 0 {
return reject()
}
for _, field := range missing {
switch field {
case "salesText", "shopName", "reviewCount":
default:
return reject()
}
}
var dimensions []models.CollectionDimension
if err := tx.Where("task_id = ?", task.ID).Find(&dimensions).Error; err != nil {
return err
}
values := map[string]map[string]bool{}
for _, dimension := range dimensions {
if (dimension.Key != RoleColor && dimension.Key != RoleSize) || values[dimension.Key] != nil {
return reject()
}
var rows []models.CollectionDimensionValue
if err := tx.Where("dimension_id = ?", dimension.ID).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return reject()
}
values[dimension.Key] = map[string]bool{}
for _, row := range rows {
if strings.TrimSpace(row.Value) == "" {
return reject()
}
values[dimension.Key][row.Value] = true
}
}
if len(values) == 0 {
return reject()
}
var skus []models.CollectionSKU
if err := tx.Where("task_id = ? AND available = ?", task.ID, true).Find(&skus).Error; err != nil {
return err
}
if len(skus) == 0 {
return reject()
}
covered := map[string]map[string]bool{RoleColor: {}, RoleSize: {}}
for _, sku := range skus {
var specs map[string]string
if !sku.Complete || sku.PriceCent < 0 || json.Unmarshal([]byte(sku.SpecKey), &specs) != nil || len(specs) != len(values) {
return reject()
}
for role, value := range specs {
if !values[role][value] {
return reject()
}
covered[role][value] = true
}
}
var prices []models.CollectionColorPrice
if err := tx.Where("task_id = ?", task.ID).Find(&prices).Error; err != nil {
return err
}
priced := map[string]bool{}
for _, price := range prices {
if price.PriceCent >= 0 {
priced[price.Color] = true
}
}
for role, candidates := range map[string]map[string]bool{RoleColor: colors, RoleSize: sizes} {
for value := range candidates {
if !covered[role][value] || (role == RoleColor && !priced[value]) {
return reject()
}
}
}
return nil
}
@@ -0,0 +1,121 @@
package shopeeproduct
import (
"context"
"encoding/json"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func seedPartialResult(t *testing.T, db *gorm.DB, task models.CollectionTask, missing string, single bool) {
t.Helper()
if err := db.Model(&task).Updates(map[string]any{"status": models.TaskStatusCompletedPartial, "missing_json": missing}).Error; err != nil {
t.Fatal(err)
}
dimensions := map[string][]string{"color": {"黑色"}, "size": {"M", "L", "XL", "2XL"}}
if single {
delete(dimensions, "size")
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"黑色","selectable":true,"priceCent":1000}]}]`).Error; err != nil {
t.Fatal(err)
}
}
for role, values := range dimensions {
d := models.CollectionDimension{TaskID: task.ID, Key: role, Name: role}
if err := db.Create(&d).Error; err != nil {
t.Fatal(err)
}
for _, value := range values {
if err := db.Create(&models.CollectionDimensionValue{DimensionID: d.ID, Value: value}).Error; err != nil {
t.Fatal(err)
}
}
}
if err := db.Create(&models.CollectionColorPrice{TaskID: task.ID, Color: "黑色", PriceCent: 1000}).Error; err != nil {
t.Fatal(err)
}
sizes := dimensions["size"]
if single {
sizes = []string{""}
}
for _, size := range sizes {
specs := map[string]string{"color": "黑色"}
if size != "" {
specs["size"] = size
}
raw, _ := json.Marshal(specs)
if err := db.Create(&models.CollectionSKU{TaskID: task.ID, SpecKey: string(raw), PriceCent: 1000, Available: true, Complete: true}).Error; err != nil {
t.Fatal(err)
}
}
}
func TestPartialAuxiliaryMetadataAllowsLinkAndKeepsStatus(t *testing.T) {
for _, missing := range []string{`["salesText"]`, `["shopName","reviewCount","salesText"]`} {
t.Run(missing, func(t *testing.T) {
s, id, req, task := latestFixture(t)
seedPartialResult(t, s.DB, task, missing, false)
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || result.CollectionSource.TaskID != task.ID {
t.Fatalf("link: %+v %v", result, err)
}
s.DB.First(&task, task.ID)
if task.Status != models.TaskStatusCompletedPartial || *task.MissingJSON != missing {
t.Fatal("changed historical completeness")
}
})
}
}
func TestPartialMetadataAllowsSingleDimensionAndReplacement(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
seedPartialResult(t, s.DB, task, `["salesText"]`, true)
preview := previewReplacement(t, s, id, &req)
if *preview.Product.PDDProductID != old.ID {
t.Fatal("preview wrote")
}
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || *result.Product.PDDProductID != *task.PDDProductID {
t.Fatalf("replacement: %+v %v", result, err)
}
}
func TestPartialMissingOrResultDefectsStillReject(t *testing.T) {
for _, scenario := range []string{"[]", "null", "bad-json", `["salesText","size"]`, `["title"]`, `["price:黑色"]`, `["skuLimit:12>8"]`, `["unknown"]`, "sku-incomplete", "sku-unavailable", "sku-invalid", "no-current-price", "old-archive-value"} {
t.Run(scenario, func(t *testing.T) {
s, id, req, task := latestFixture(t)
missing := scenario
if scenario[0] != '[' && scenario != "null" && scenario != "bad-json" {
missing = `["salesText"]`
}
seedPartialResult(t, s.DB, task, missing, false)
switch scenario {
case "sku-incomplete":
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("complete", false)
case "sku-unavailable":
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("available", false)
case "sku-invalid":
var sku models.CollectionSKU
if err := s.DB.Where("task_id = ?", task.ID).First(&sku).Error; err != nil {
t.Fatal(err)
}
if err := s.DB.Model(&sku).Update("spec_key", `{}`).Error; err != nil {
t.Fatal(err)
}
case "no-current-price":
s.DB.Model(&models.CollectionColorPrice{}).Where("task_id = ?", task.ID).Update("color", "不是本次颜色")
case "old-archive-value":
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","values":[{"name":"旧颜色","selectable":true,"priceCent":1000}]}]`)
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("accepted incomplete current result")
}
var product models.ShopeeProduct
s.DB.First(&product, id)
if product.PDDProductID != nil {
t.Fatal("rejected request wrote association")
}
})
}
}
@@ -0,0 +1,145 @@
package shopeeproduct
import (
"context"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func replacementFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask, models.PDDProduct) {
s, id, req, task := latestFixture(t)
old := seedPDDProduct(t, s.DB, "pending")
if err := s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", old.ID).Error; err != nil {
t.Fatal(err)
}
detail, err := s.Detail(context.Background(), id)
if err != nil {
t.Fatal(err)
}
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
req.LatestCollection.Replacement = &LatestCollectionReplacement{Preview: true, ExpectedPDDProductID: old.ID}
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return nil }
return s, id, req, task, old
}
func previewReplacement(t *testing.T, s *Service, id uint64, req *LinkPDDRequest) SaveResponse {
t.Helper()
preview, err := s.LinkPDD(context.Background(), id, *req)
if err != nil {
t.Fatal(err)
}
r := req.LatestCollection.Replacement
r.Preview = false
r.TaskID = preview.CollectionSource.TaskID
r.TargetPDDProductID = preview.CollectionSource.PDDProductID
r.TargetVersion = preview.CollectionSource.TargetVersion
return preview
}
func TestReplacementPreviewIsReadOnlyAndFreezesCandidate(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
preview := previewReplacement(t, s, id, &req)
if *preview.Product.PDDProductID != old.ID {
t.Fatal("preview changed association")
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if *record.PDDProductID != old.ID || record.LastUpdateRequestID != nil {
t.Fatal("preview wrote")
}
newer := task
newer.ID = 0
newer.Status = models.TaskStatusCompletedPartial
if err := s.DB.Create(&newer).Error; err != nil {
t.Fatal(err)
}
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil || *result.Product.PDDProductID != *task.PDDProductID || result.CollectionSource.TaskID != task.ID {
t.Fatalf("frozen candidate: %+v %v", result, err)
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("duplicate replaced stale association")
}
}
func TestReplacementSameProductDoesNotRebind(t *testing.T) {
s, id, req, task, _ := replacementFixture(t)
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", *task.PDDProductID)
detail, _ := s.Detail(context.Background(), id)
req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion
req.LatestCollection.Replacement.ExpectedPDDProductID = *task.PDDProductID
previewReplacement(t, s, id, &req)
if _, err := s.LinkPDD(context.Background(), id, req); err != nil {
t.Fatal(err)
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if record.LastUpdateRequestID != nil {
t.Fatal("same product wrote association")
}
}
func TestReplacementRejectsChangesAfterPreview(t *testing.T) {
for _, change := range []string{"association", "specs", "target-specs", "task-status", "device", "stage", "missing-validator", "target-id", "task-id"} {
t.Run(change, func(t *testing.T) {
s, id, req, task, old := replacementFixture(t)
previewReplacement(t, s, id, &req)
expected := old.ID
switch change {
case "association":
other := seedPDDProduct(t, s.DB, "active")
expected = other.ID
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", expected)
case "specs":
s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("specs_json", "[]")
case "target-specs":
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"白色","selectable":true}]}]`)
case "task-status":
s.DB.Model(&task).Update("status", "completed_partial")
case "device":
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("status", "disabled")
case "stage":
s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return &ServiceError{Code: CodeLinkConflict} }
case "missing-validator":
s.ReplacementEligibility = nil
case "target-id":
req.LatestCollection.Replacement.TargetPDDProductID++
case "task-id":
req.LatestCollection.Replacement.TaskID++
}
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
t.Fatal("accepted changed context")
}
var record models.ShopeeProduct
s.DB.First(&record, id)
if *record.PDDProductID != expected {
t.Fatal("overwrote rejected association")
}
})
}
}
func TestReplacementConcurrentConfirmationsOnlyOneWrites(t *testing.T) {
s, id, req, _, _ := replacementFixture(t)
previewReplacement(t, s, id, &req)
connection, err := s.DB.DB()
if err != nil {
t.Fatal(err)
}
connection.SetMaxOpenConns(1) // SQLite serialization, not a MySQL lock claim.
results := make(chan error, 2)
for range 2 {
go func() { _, err := s.LinkPDD(context.Background(), id, req); results <- err }()
}
wins := 0
for range 2 {
if <-results == nil {
wins++
}
}
if wins != 1 {
t.Fatalf("writes=%d", wins)
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware, eligibility ReplacementEligibility) {
handler := Handler{ReplacementEligibility: eligibility}
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
+6 -1
View File
@@ -57,7 +57,12 @@ func (err *ServiceError) Error() string {
}
func (err *ServiceError) Unwrap() error { return err.Cause }
type Service struct{ DB *gorm.DB }
type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
type Service struct {
DB *gorm.DB
ReplacementEligibility ReplacementEligibility
}
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
+3 -3
View File
@@ -35,9 +35,9 @@
<div class="quick-link-actions">
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
<el-button-group v-if="quickCanLink(row)">
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">一键关联</el-button>
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">{{ quickActionLabel(row) }}</el-button>
<el-dropdown trigger="click" @command="quickOpenPicker()">
<el-button size="small" type="primary" plain aria-label="一键关联使用的手机" :icon="ArrowDown" />
<el-button size="small" type="primary" plain :aria-label="`${quickActionLabel(row)}使用的手机`" :icon="ArrowDown" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>当前手机:{{ quickDeviceLabel() }}</el-dropdown-item>
@@ -57,7 +57,7 @@
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
<p>{{ quickPicker.row ? '选择后继续关联刚才的商品。' : '只修改各行共用的手机,不启动关联,也不影响正在进行的操作。' }}</p>
<p>{{ quickPicker.row ? '选择后继续处理刚才的商品;更换为其他商品前会再次确认。' : '只修改各行共用的手机,不启动关联或替换,也不影响正在进行的操作。' }}</p>
<el-form label-width="80px">
<el-form-item label="采集手机">
<el-select v-model="quickPicker.deviceId" :loading="quickDeviceLoading" placeholder="请选择手机" style="width:100%">
+50 -10
View File
@@ -1,4 +1,4 @@
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listDevices } from '@/api/goauto/devices'
import { getShopeeProduct, linkShopeeProductPdd, autoMatchShopeeSpecMappings } from '@/api/goauto/shopee-products'
import { previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
@@ -20,6 +20,7 @@ export default {
deactivated() { this.quickInvalidate() },
methods: {
quickInvalidate() {
if (this.quickPhase === '等待替换确认') ElMessageBox.close()
this.quickGeneration++
this.quickPickerGeneration++
if (this.quickBusyId) this.quickMessages[this.quickBusyId] = '操作结果待确认,请刷新核对;离开页面不代表服务端已取消'
@@ -29,7 +30,16 @@ export default {
},
quickCanLink(row) {
const ready = this.purchaseReady(row)
return this.canPurchase && !!row.shopeeProductId && ready.processNextAction === 'open_pdd_link' && !ready.pddProductId
return this.canPurchase && !!row.shopeeProductId && ((ready.processNextAction === 'open_pdd_link' && !ready.pddProductId) || this.quickCanReplace(row))
},
quickCanReplace(row) {
const ready = this.purchaseReady(row)
return !!ready.pddProductId && ready.processNextAction === 'open_pdd' && (
['pdd_pending', 'pdd_collection_failed'].includes(ready.processStage) ||
(ready.processStage === 'manual_action' && ['PDD_NOT_READY', 'PDD_PRICE_MISSING'].includes(ready.reasonCode)))
},
quickActionLabel(row) {
return this.quickCanReplace(row) ? '一键替换' : '一键关联'
},
quickDeviceLabel() {
const device = this.quickDevices.find(d => d.id === this.quickDeviceId)
@@ -61,34 +71,60 @@ export default {
async quickStart(row) {
if (!this.quickCanLink(row) || this.quickBusyId) return
if (!this.quickDeviceId) return this.quickOpenPicker(row)
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId }
const replacing = this.quickCanReplace(row)
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId, oldPddId: this.purchaseReady(row).pddProductId }
const generation = ++this.quickGeneration
const current = () => generation === this.quickGeneration
this.quickBusyId = row.id
this.quickMessages[row.id] = ''
this.quickPhase = '正在关联'
let wrote = false
let targetId = null
try {
const detail = (await getShopeeProduct(frozen.productId, readOptions)).data
if (!current()) return
if (detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
if (!replacing && detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
if (replacing && detail.product.pddProductId !== frozen.oldPddId) throw new Error('关联已变化,请刷新列表')
const latestCollection = { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
if (replacing) {
this.quickPhase = '正在读取最近采集'
const preview = (await linkShopeeProductPdd(frozen.productId, {
requestId: createRequestId(), latestCollection: { ...latestCollection, replacement: { preview: true, expectedPddProductId: frozen.oldPddId }}
}, readOptions)).data
if (!current()) return
const source = preview.collectionSource
targetId = source.pddProductId
latestCollection.replacement = { preview: false, expectedPddProductId: frozen.oldPddId, taskId: source.taskId, targetPddProductId: targetId, targetVersion: source.targetVersion }
if (targetId !== frozen.oldPddId) {
this.quickPhase = '等待替换确认'
try {
await ElMessageBox.confirm(`将「${source.previousTitle || '原商品'}(${source.previousGoodsId || frozen.oldPddId})」替换为「${source.title || '最近采集商品'}(${source.goodsId || targetId})」。来源:${source.deviceName || '手机'} #${frozen.deviceId}。共用此蝦皮商品的 SYB 明细也会使用新关联;已有采购任务不变。`, '确认替换 PDD 商品', { confirmButtonText: '确认替换并匹配', cancelButtonText: '取消', closeOnClickModal: false })
} catch {
if (current()) this.quickMessages[row.id] = '已取消,未修改关联'
return
}
if (!current()) return
}
this.quickPhase = targetId === frozen.oldPddId ? '正在核对现有关联' : '正在替换'
}
const linked = (await linkShopeeProductPdd(frozen.productId, {
requestId: createRequestId(),
latestCollection: { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
latestCollection
}, readOptions)).data
wrote = true
if (!current()) return
const ready = (await previewPurchaseTasks({ sybProductIds: [frozen.sybProductId] }, readOptions)).data.items[0]
if (!current()) return
const outcome = replacing && targetId !== frozen.oldPddId ? '已替换' : '已关联'
if (linked.collectionSource.requiresSpecConfirmation || !ready?.aiMatchEligible) {
this.quickMessages[row.id] = ready?.eligible ? '已关联,当前明细可创建采购' : '已关联;请按处理阶段提示继续人工处理'
this.quickMessages[row.id] = ready?.eligible ? `${outcome},当前明细可创建采购` : `${outcome};请按处理阶段提示继续人工处理`
} else {
this.quickPhase = '正在匹配并保存'
const result = (await autoMatchShopeeSpecMappings(frozen.productId, {
requestId: createRequestId(), specContextVersion: linked.product.specContextVersion
}, detail.autoMatchTimeoutSeconds)).data
if (!current()) return
this.quickMessages[row.id] = result.unmatchedCount ? '已关联并保存有效匹配;剩余规格请去匹配处理' : '已关联并保存匹配;以最新处理阶段为准'
this.quickMessages[row.id] = result.unmatchedCount ? `${outcome}并保存有效匹配;剩余规格请去匹配处理` : `${outcome}并保存匹配;以最新处理阶段为准`
}
} catch (error) {
if (!current()) return
@@ -102,9 +138,13 @@ export default {
try {
const saved = (await getShopeeProduct(frozen.productId, readOptions)).data.product
if (!current()) return
this.quickMessages[row.id] = saved.pddProductId
? `当前已有关联,匹配结果请去匹配查看。${reason}`
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
this.quickMessages[row.id] = replacing
? (targetId && saved.pddProductId === targetId
? `当前关联为本次目标商品,匹配结果请去匹配查看。${reason}`
: `当前关联${saved.pddProductId === frozen.oldPddId ? '仍为原商品' : '已变化'};本次结果未确认,请刷新核对,不自动重试。${reason}`)
: saved.pddProductId
? `当前已有关联,匹配结果请去匹配查看。${reason}`
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
} catch {
if (current()) this.quickMessages[row.id] = `${wrote ? '关联已保存;' : ''}结果未确认,请刷新核对,不要连续重复点击。${reason}`
}
+31 -5
View File
@@ -1,7 +1,8 @@
import { expect, test } from '@playwright/test'
async function setup(page: any) {
async function setup(page: any, replacing = false) {
const state = { userId: 1, role: 'purchaser', links: [] as any[], matches: [] as any[], linked: new Set<number>(), matched: new Set<number>(), aiFailure: false }
const replaced = new Set<number>()
await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
await page.route('**/api/**', async (route: any) => {
const url = new URL(route.request().url()), path = url.pathname
@@ -11,13 +12,18 @@ async function setup(page: any) {
if (path.endsWith('/menurole')) return ok([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }])
if (path.endsWith('/syb-products')) return ok({ items: [1, 2].map(id => ({ id, orderCode: `TEST-${id}`, shopeeItemId: `100${id}`, shopeeProductId: id + 10, productTitle: '测试商品', targetColor: '黑色', targetSize: 'XL', quantity: 1, parseStatus: 'success' })), total: 2 })
if (path.endsWith('/devices')) return ok({ items: [{ id: 10, name: '手机A', status: 'offline' }, { id: 20, name: '手机B', status: 'online' }], total: 2 })
if (path.endsWith('/batch-preview')) return ok({ items: route.request().postDataJSON().sybProductIds.map((id: number) => ({ sybProductId: id, pddProductId: state.linked.has(id) ? 55 : null, eligible: state.matched.has(id), aiMatchEligible: state.linked.has(id) && !state.matched.has(id), processStage: state.matched.has(id) ? 'purchase_ready' : state.linked.has(id) ? 'color_mapping' : 'pdd_unlinked', processNextAction: state.linked.has(id) ? 'open_mapping' : 'open_pdd_link' })) })
if (path.endsWith('/batch-preview')) return ok({ items: route.request().postDataJSON().sybProductIds.map((id: number) => replacing && !replaced.has(id)
? { sybProductId: id, pddProductId: 44, processStage: 'pdd_collection_failed', processNextAction: 'open_pdd' }
: { sybProductId: id, pddProductId: state.linked.has(id) ? 55 : null, eligible: state.matched.has(id), aiMatchEligible: state.linked.has(id) && !state.matched.has(id), processStage: state.matched.has(id) ? 'purchase_ready' : state.linked.has(id) ? 'color_mapping' : 'pdd_unlinked', processNextAction: state.linked.has(id) ? 'open_mapping' : 'open_pdd_link' }) })
const match = path.match(/shopee-products\/(\d+)(.*)/)
if (match) {
const productId = Number(match[1]), id = productId - 10
const product = { id: productId, pddProductId: state.linked.has(id) ? 55 : null, specContextVersion: state.linked.has(id) ? 'after' : 'before', specs: [] }
if (replacing && !replaced.has(id)) product.pddProductId = 44
if (match[2] === '/link-pdd') {
const body = route.request().postDataJSON(); state.links.push(body); state.linked.add(id)
const body = route.request().postDataJSON()
if (body.latestCollection.replacement?.preview) return ok({ product, collectionSource: { taskId: 7, deviceId: 10, pddProductId: 55, targetVersion: 'frozen', title: '新测试商品', previousTitle: '原测试商品', deviceName: '手机A' } })
state.links.push(body); state.linked.add(id); replaced.add(id)
return ok({ product: { ...product, pddProductId: 55, specContextVersion: 'after' }, collectionSource: { taskId: 7, deviceId: body.latestCollection.deviceId, pddProductId: 55 } })
}
if (match[2] === '/specs/mapping/auto-match') {
@@ -30,12 +36,32 @@ async function setup(page: any) {
return ok([])
})
await page.goto('/#/syb-products')
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(2)
await expect(page.getByRole('button', { name: replacing ? '一键替换' : '一键关联', exact: true })).toHaveCount(2)
return state
}
test('replacement reuses row position; cancel writes nothing; confirm freezes target and matches', async ({ page }, testInfo) => {
const state = await setup(page, true)
await expect(page.getByRole('button', { name: '查看 PDD 商品', exact: true })).toHaveCount(2)
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(0)
await page.getByRole('button', { name: '一键替换', exact: true }).first().click()
await choose(page, '手机A', '选择并继续')
const confirm = page.getByRole('dialog', { name: '确认替换 PDD 商品' })
await expect(confirm).toContainText('共用此蝦皮商品')
await page.screenshot({ path: testInfo.outputPath('replacement-confirm.png'), animations: 'disabled' })
await confirm.getByRole('button', { name: '取消', exact: true }).click()
expect(state.links).toHaveLength(0); expect(state.matches).toHaveLength(0)
await page.getByRole('button', { name: '一键替换', exact: true }).first().click()
await confirm.getByRole('button', { name: '确认替换并匹配', exact: true }).click()
await expect.poll(() => state.matches.length).toBe(1)
expect(state.links).toHaveLength(1)
expect(state.links[0].latestCollection.replacement).toMatchObject({ expectedPddProductId: 44, taskId: 7, targetPddProductId: 55, targetVersion: 'frozen' })
await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
await expect(confirm).toBeHidden()
})
async function choose(page: any, name: string, button: string) {
const dialog = page.getByRole('dialog')
const dialog = page.getByRole('dialog', { name: /^(选择|切换)采集手机$/ })
await dialog.locator('.el-select').click()
await page.getByRole('option', { name: new RegExp(name) }).click()
await dialog.getByRole('button', { name: button, exact: true }).click()
+46
View File
@@ -10,6 +10,7 @@ function instance(overrides = {}) {
const calls = { link: [], match: [], reads: 0, saved: [], refresh: 0 }
const dependencies = {
ElMessage: { warning() {} },
ElMessageBox: { confirm: async () => {}, close() {} },
listDevices: async () => ({ data: { items: [{ id: 10, status: 'offline', name: 'A' }, { id: 20, status: 'online', name: 'B' }] } }),
getShopeeProduct: async id => { calls.reads++; return { data: { product: { id, specContextVersion: 'before', pddProductId: calls.link.length ? 55 : null }, autoMatchTimeoutSeconds: 370 } } },
linkShopeeProductPdd: async (id, body) => { calls.link.push(body); return { data: { product: { id, specContextVersion: 'after' }, collectionSource: { taskId: 9, pddProductId: 55 } } } },
@@ -87,3 +88,48 @@ test('preference isolates user and API environment, handles storage failure', ()
process.env.VUE_APP_BASE_API = '/b'; assert.equal(p.readQuickLinkDevice(1), null)
storage.setItem = () => { throw new Error('denied') }; assert.equal(p.rememberQuickLinkDevice(1, 20), false)
})
function replacementInstance(overrides = {}) {
const writes = [], previews = [], confirms = []
const context = instance({
getShopeeProduct: async () => ({ data: { product: { pddProductId: 44, specContextVersion: 'old' }, autoMatchTimeoutSeconds: 130 } }),
ElMessageBox: { confirm: async (...args) => { confirms.push(args) }, close() {} },
linkShopeeProductPdd: async (id, body) => {
const r = body.latestCollection.replacement
;(r.preview ? previews : writes).push(body)
return { data: { product: { specContextVersion: 'new', pddProductId: r.preview ? 44 : 55 }, collectionSource: { taskId: 9, pddProductId: 55, targetVersion: 'target', title: '新商品', previousTitle: '原商品', deviceName: 'A' } } }
}, ...overrides
})
context.vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: 'pdd_collection_failed' })
context.vm.quickDeviceId = 10
return { ...context, writes, previews, confirms }
}
test('replacement confirms once and submits the frozen source', async () => {
const { vm, writes, previews, confirms, calls } = replacementInstance()
await vm.quickStart(row)
assert.equal(previews.length, 1); assert.equal(confirms.length, 1); assert.equal(writes.length, 1)
assert.deepEqual(writes[0].latestCollection.replacement, { preview: false, expectedPddProductId: 44, taskId: 9, targetPddProductId: 55, targetVersion: 'target' })
assert.equal(calls.match.length, 1)
})
test('cancel replacement never writes or matches', async () => {
const { vm, writes, calls } = replacementInstance({ ElMessageBox: { confirm: async () => { throw 'cancel' } } })
await vm.quickStart(row)
assert.equal(writes.length, 0); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /已取消/)
})
test('same product continues matching without replacement confirmation', async () => {
const { vm, confirms, calls } = replacementInstance({ linkShopeeProductPdd: async () => ({ data: { product: { specContextVersion: 'old' }, collectionSource: { taskId: 9, pddProductId: 44, targetVersion: 'same' } } }) })
await vm.quickStart(row)
assert.equal(confirms.length, 0); assert.equal(calls.match.length, 1)
})
test('failed replacement readback does not call old association a success', async () => {
const { vm, calls } = replacementInstance({ linkShopeeProductPdd: async () => { throw new Error('timeout') } })
await vm.quickStart(row)
assert.match(vm.quickMessages[1], /仍为原商品/); assert.equal(calls.match.length, 0)
})
test('replacement is not shown for collecting, successful, unknown or other manual stages', () => {
const { vm } = replacementInstance()
for (const stage of ['pdd_collecting', 'purchase_succeeded', 'order_review', 'task_created', 'color_mapping', 'manual_action']) {
vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: stage })
assert.equal(vm.quickCanLink(row), false, stage)
}
})