Compare commits

...
17 changed files with 1045 additions and 18 deletions
+14 -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: e7a91d1d9c0f628297fb6ebe41b0d27d551c79ea
synchronized_at: 2026-09-10T01:41:52Z
wiki_revision: 46a5f8234be2d55298779532058a0490a94d3167
synchronized_at: 2026-09-10T03:36:58Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -399,3 +399,15 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 加载状态同时绑定商品加载代次和操作序号。关闭/切换立即结束该页面等待状态;同商品重开读取详情,旧响应/旧 finally 不得污染新操作。匹配期间禁止在当前抽屉编辑映射,原人工草稿放弃确认保留。
- POST 未正常返回时只回读详情及关联 PDD,不自动重发 POST;回读失败结束转圈并保留刷新入口。GET 沿用单请求10秒超时,完整结果回读最多两次串行 GET,不是无限等待。
- 验证:Go shopeeproduct/aimatching/clientapi 测试与 go build;Web `node --test tests/unit/shopee-match-lifecycle.test.cjs`,Playwright shopee-match-lifecycle 与 shopee-pdd-picker,受影响 ESLint 与 build:prod。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
- Web `syb-products/index.vue` 在“去关联”旁增加“一键关联+下拉箭头”,共享 `quick-link.js` 页面级状态。`quick-link-device-preference.js` 按当前浏览器来源、API环境、用户ID保存设备ID,独立于批量采购偏好;不存密钥或商品数据。首次主动作选机继续原行,箭头选机只保存偏好。
- Server `shopeeproduct/latest_collection.go` 复用 LinkPDD 的可选 Admin 模式。短事务核对 SYB→Shopee、未关联/规格版本、设备状态,选择并锁定最新 completed/completed_partial 的 agent_current_page 任务及其 PDD;仅完整采集、active且有可用颜色或尺码时更新,WHERE pdd_product_id IS NULL 不覆盖其他操作者的关联。响应保留该事务产生的规格版本及来源task/device/PDD,不能用后续GET替换冻结上下文再自动匹配。
- 不新增表、迁移、路由或权限对账。原手动关联/替换请求不变;共享 Handler 明确拒绝 Client API 使用此跨模块模式,不能借 Shopee-only write grant 读取 SYB/设备上下文。来源任务原规则快照不改,taskId 可回溯既有采集证据。
- 关联后读取现有采购准备;需要人工确认或不具备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、生产数据、跨设备人工闭环待验证。
+18 -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: c1ff5d88432826af67d3e40f58837bd98d93cfce
synchronized_at: 2026-09-10T01:41:58Z
wiki_revision: 31d2dbd24acd8ac119dfd0d538c12d0525a2764f
synchronized_at: 2026-09-10T06:54:35Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -37,6 +37,8 @@ synchronized_at: 2026-09-10T01:41:58Z
- 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-10T01:41:58Z
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
- #256(Android 0.9.73,实现 43703e0):采集执行器可识别无“已选/请选择”摘要且当前仅一个规格标题的滚动规格面板。必须匹配 PDD 包名、任务详情 Activity 与选择器,无登录/验证等异常,并同时具备唯一有界规格滚动区域、唯一标题及非空候选、关闭、唯一数量输入/增减、支付区和唯一下方提交订单只读动作;缺失任一证据仍拒绝。此分支只由采集上下文启用,不生成虚假选中摘要,采购与其他解析调用维持原条件。订单动作仍仅作只读证据,采集绝不点击。
## 采集任务
- 任务与结果共用 `collection_task` 主表,不建立独立 `collection_result` 主表。
@@ -544,3 +548,15 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 前端超时、关闭或切换详情不代表后端已取消、未保存或已回滚;必须以回读结果为准,无法回读显示结果未确认,不自动重复匹配、不自动创建采购。
- 单商品详情的成功/失败/超时/关闭重开都必须结束失效的等待状态;旧请求不得覆盖新商品或新操作。匹配进行期间本抽屉不能人工改映射,已有未保存草稿仍按原确认流程处理。
- UI匹配提示的等待上限仅指POST,不含提交前GET及结果核对GET;结果核对也有限时,不能让失败提示再次陷入无限转圈。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
- 未关联 PDD 且既有处理阶段提供“去关联”的 SYB 行可用一键关联;原人工入口不删除。首次点击选择采集手机后继续当前行,后续使用记忆手机;小箭头查看/切换手机,保存只修改全行共用偏好。取消不改变偏好、不执行关联。与批量采购设备选择互不影响。
- 以所选手机的临时采集 source=agent_current_page 为范围,从 completed、completed_partial 中按 finished_at DESC、id DESC 取最新一条;失败/待执行/执行中不参与。最新为部分成功或商品停用、无可用规格时明确停止,不回退更旧商品,不切换其他手机。离线手机允许使用已有成功结果,停用或已删除手机需要重新选择。
- 主动作开始冻结行、蝦皮商品和设备;服务端关联事务固定来源采集任务、PDD商品与规格版本。仅未关联时自动写入,已有或被其他操作改变的关联不覆盖。关联作用于蝦皮商品,因此共用该蝦皮档案的其他 SYB 明细也受影响;切换手机不会重绑已有商品,也不会改变在途操作。
- 先保存关联,再复用现有颜色/尺码自动匹配及保存。有效已确认映射保留;普通部分匹配保存合格结果,AI故障不提交本轮新映射,但已保存关联保留。后续可从“去匹配”或详情人工继续。failed 且无人工/AI确认时不能自动跳过人工解析;uncertain 不统一拦截,仍以原采购准备规则为准。
- 操作完成不保证立即可采购,必须刷新现有处理阶段;缺规格、缺映射、解析需确认或其他原资格不满足时继续显示真实待处理阶段。不自动创建采购任务、不下单、不支付。
- 请求超时/网络中断只做有限回读,不重复提交;读回失败明确结果未确认,避免无限转圈。离开页面不代表后端停止或回滚。普通用户沿用现有采购员权限,无新管理员专属开关。
+22 -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: 9898c6141b878407c2622c986eb9e15f0b39774d
synchronized_at: 2026-09-10T01:42:20Z
wiki_revision: 23160c055e8f6185a4108e284afc8216b41373e5
synchronized_at: 2026-09-10T03:37:18Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -1073,3 +1073,23 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
- Admin 点击前先GET新详情取得预算,POST timeout=(autoMatchTimeoutSeconds+10)×1000毫秒;缺字段、非整数或超范围时明确拒绝开始匹配,不猜配置。前后端需配套升级。
- 准备后配置可能变化,浏览器仍可能先超时;不要据超时响应断言事务是否提交。POST失败/断连/超时只执行有限GET回读,不自动重发POST;失败回读显示结果未知。回读结果表示当前已保存状态,不保证这次请求已经永久不再写入,也不保证全部项目都匹配成功。
- 普通GET默认10秒;本轮未修改建议接口610秒、其他AI功能或后台批量匹配600秒预算。T=60时130/140秒、T=180时370/380秒。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
本节仅补充 Admin API;Android 和客户端密钥旧请求不变。
`POST /api/admin/v1/shopee-products/{productId}/link-pdd` 新增与非零 pddProductId 互斥的可选模式:
```json
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"详情返回的未关联版本"}}
```
- 校验当前 SYB→Shopee、未关联、规格版本和设备存在/未停用;离线可用。短事务选择最新已结束成功或部分成功的临时采集,排序 finished_at DESC、id DESC;部分成功不跳过回退旧记录。只接受 completed、存在 finishedAt/PDD、PDD active 且存在可选颜色或尺码。
- 成功返回原 `product`,及 `collectionSource: {taskId, deviceId, pddProductId, requiresSpecConfirmation}`。product.specContextVersion 是此次事务写入后的冻结版本;requiresSpecConfirmation 对应 failed 且没有人工或AI确认,完整资格仍由现有 batch-preview 派生。不得把后续其他人的商品版本当作本次自动匹配上下文。
- 409 `PDD_LINK_CONFLICT` 表示已有关联;409 `LATEST_COLLECTION_UNAVAILABLE` 表示设备/最近采集/候选不可用;409 `SPEC_CONTEXT_VERSION_STALE` 沿用现有语义。非法参数422。重复请求不会覆盖已有关联,成功响应丢失后重发也会冲突,客户端应回读而非自动重试。last_update_request_id 保留本次请求标识;不承诺额外持久化完整重放响应。
- `/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升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。
@@ -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)
}
})
}
}
+7 -1
View File
@@ -96,6 +96,12 @@ func (handler Handler) LinkPDD(c *gin.Context) {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
// This composite workflow reads SYB and device context. It must not inherit
// a client key's Shopee-only write grant through the shared handler.
if request.LatestCollection != nil && !strings.HasPrefix(c.FullPath(), "/api/admin/v1/") {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "一键关联仅限 Admin 入口"})
return
}
service, ok := handler.service(c)
if !ok {
return
@@ -467,7 +473,7 @@ func writeError(c *gin.Context, err error) {
status = http.StatusConflict
case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound:
status = http.StatusNotFound
case CodePDDProductDisabled, CodeSpecContextStale:
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
status = http.StatusConflict
case CodeAIUnavailable:
status = http.StatusServiceUnavailable
@@ -0,0 +1,126 @@
package shopeeproduct
import (
"context"
"errors"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
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"`
}
type LatestCollectionSource struct {
TaskID uint64 `json:"taskId"`
DeviceID uint64 `json:"deviceId"`
PDDProductID uint64 `json:"pddProductId"`
RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"`
}
func latestUnavailable(message string) error {
return &ServiceError{Code: CodeLatestCollectionUnavailable, Message: message}
}
// Resolve the candidate exactly once under short row locks; never hold a
// transaction while calling AI. The returned context belongs to this write,
// not a later GET that could silently adopt another operator's association.
func (service *Service) linkLatestCollection(ctx context.Context, id uint64, request LinkPDDRequest) (SaveResponse, error) {
r := request.LatestCollection
if request.PDDProductID != 0 || r.DeviceID == 0 || r.SYBProductID == 0 || r.SpecContextVersion == "" {
return SaveResponse{}, invalidRequest("一键关联需要 SYB、设备及规格版本,不能同时指定 PDD 商品")
}
var response SaveResponse
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var syb models.SYBProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, r.SYBProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("SYB 明细不存在,请刷新列表")
}
return err
}
if syb.ShopeeProductID == nil || *syb.ShopeeProductID != id {
return contextVersionStale()
}
var shopee models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return productNotFound()
}
return err
}
if shopee.PDDProductID != nil {
return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"}
}
if computeSpecContextVersion(nil, shopee.SpecsJSON, "") != r.SpecContextVersion {
return contextVersionStale()
}
var device models.AgentDevice
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, r.DeviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("手机已不存在,请重新选择")
}
return err
}
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
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
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品")
}
if err != nil {
return err
}
if task.Status != models.TaskStatusCompleted || task.FinishedAt == nil || task.PDDProductID == nil {
return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品")
}
var pdd models.PDDProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, *task.PDDProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("最近采集的 PDD 商品已不存在")
}
return err
}
if pdd.Status != "active" {
return latestUnavailable("最近采集的 PDD 商品尚不可用,请先检查采集结果")
}
color, err := selectablePDDValues(pdd.SpecsJSON, RoleColor)
if err != nil {
return latestUnavailable("最近采集的 PDD 规格无效,请先补采")
}
size, err := selectablePDDValues(pdd.SpecsJSON, RoleSize)
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 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}
return nil
})
if err != nil {
var domain *ServiceError
if errors.As(err, &domain) {
return SaveResponse{}, err
}
return SaveResponse{}, internalError(err)
}
return response, nil
}
@@ -0,0 +1,194 @@
package shopeeproduct
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func latestFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask) {
t.Helper()
db := openTestDB(t)
device := models.AgentDevice{InstallID: uuid.NewString(), TokenDigest: uuid.NewString(), Status: models.DeviceStatusOffline}
if err := db.Create(&device).Error; err != nil {
t.Fatal(err)
}
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
pdd := seedPDDProduct(t, db, "active")
s := NewService(db)
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: uuid.NewString(), Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "黑色", Source: ValueSourceManual}}}}})
if err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "synthetic", DetailID: 1, Quantity: 1, ParseStatus: "success", ShopeeProductID: &created.Product.ID}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
task := models.CollectionTask{DeviceID: &device.ID, RuleID: rule.ID, PDDProductID: &pdd.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted, RuleSnapshot: "{}", FinishedAt: &now}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
return s, created.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), LatestCollection: &LatestCollectionRequest{DeviceID: device.ID, SYBProductID: syb.ID, SpecContextVersion: created.Product.SpecContextVersion}}, task
}
func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
s, id, req, task := latestFixture(t)
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil {
t.Fatal(err)
}
if result.CollectionSource.TaskID != task.ID || *result.Product.PDDProductID != *task.PDDProductID || result.Product.SpecContextVersion == req.LatestCollection.SpecContextVersion {
t.Fatalf("invalid frozen response: %+v", result)
}
// Another request (or an ambiguous network retry) cannot overwrite the link.
req.RequestID = uuid.NewString()
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeLinkConflict {
t.Fatalf("expected conflict: %v", err)
}
other := seedPDDProduct(t, s.DB, "active")
if _, err := s.LinkPDD(context.Background(), id, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: other.ID}); err != nil {
t.Fatalf("manual replacement regressed: %v", err)
}
}
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
cases := []struct {
name string
change func(*gorm.DB, *LinkPDDRequest, models.CollectionTask)
}{
{"partial", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&task).Update("status", "completed_partial")
}},
{"disabled-device", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
db.Model(&models.AgentDevice{}).Where("id = ?", r.LatestCollection.DeviceID).Update("status", "disabled")
}},
{"missing-device", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { r.LatestCollection.DeviceID += 100 }},
{"admin-source", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&task).Update("source", "admin")
}},
{"disabled-pdd", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("status", "disabled")
}},
{"empty-specs", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", "[]")
}},
{"stale-version", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
r.LatestCollection.SpecContextVersion = "stale"
}},
{"syb-link-changed", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
db.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("shopee_product_id", nil)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, id, r, task := latestFixture(t)
tc.change(s.DB, &r, task)
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("unexpected successful write")
}
var product models.ShopeeProduct
s.DB.First(&product, id)
if product.PDDProductID != nil {
t.Fatal("rejected request wrote association")
}
})
}
}
func TestLatestCollectionOrdersByFinishThenIDAndDoesNotSkipPartial(t *testing.T) {
s, id, r, first := latestFixture(t)
newer := first
newer.ID = 0
newer.Status = models.TaskStatusCompletedPartial
if err := s.DB.Create(&newer).Error; err != nil {
t.Fatal(err)
}
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("skipped latest partial")
}
// A newer ID with an older completion time must not win.
older := newer
older.ID = 0
older.Status = models.TaskStatusCompleted
ts := first.FinishedAt.Add(-time.Hour)
older.FinishedAt = &ts
if err := s.DB.Create(&older).Error; err != nil {
t.Fatal(err)
}
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("sorted by id instead of completion")
}
if err := s.DB.Model(&newer).Update("status", models.TaskStatusCompleted).Error; err != nil {
t.Fatal(err)
}
result, err := s.LinkPDD(context.Background(), id, r)
if err != nil || result.CollectionSource.TaskID != newer.ID {
t.Fatalf("wrong latest result: %+v %v", result, err)
}
}
func TestLatestCollectionKeepsParseConfirmationBoundary(t *testing.T) {
for _, status := range []string{"success", "uncertain", "failed"} {
t.Run(status, func(t *testing.T) {
s, id, r, _ := latestFixture(t)
s.DB.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("parse_status", status)
result, err := s.LinkPDD(context.Background(), id, r)
if err != nil || result.CollectionSource.RequiresSpecConfirmation != (status == "failed") {
t.Fatalf("parse boundary: %+v %v", result, err)
}
})
}
}
func TestClientKeyCannotUseAdminCompositeLink(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/client/v1/shopee-products/:productId/link-pdd", Handler{}.LinkPDD)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/client/v1/shopee-products/1/link-pdd", strings.NewReader(`{"latestCollection":{"deviceId":1,"sybProductId":1,"specContextVersion":"x"}}`)))
if w.Code != http.StatusForbidden {
t.Fatalf("unexpected status: %d %s", w.Code, w.Body.String())
}
}
// SQLite has no row-level FOR UPDATE. A single test connection serializes
// transactions to exercise concurrent callers; production MySQL locking must
// additionally be verified in its deployment environment.
func TestLatestCollectionConcurrentCallersOnlyOneWins(t *testing.T) {
s, id, r, _ := latestFixture(t)
sqlDB, err := s.DB.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
errors := make(chan error, 2)
start := make(chan struct{})
for range 2 {
go func() { <-start; _, err := s.LinkPDD(context.Background(), id, r); errors <- err }()
}
close(start)
wins := 0
for range 2 {
err := <-errors
if err == nil {
wins++
} else if errCode(t, err) != CodeLinkConflict {
t.Fatal(err)
}
}
if wins != 1 {
t.Fatalf("expected one write, got %d", wins)
}
}
+10 -5
View File
@@ -76,9 +76,10 @@ type ProductView struct {
}
type SaveResponse struct {
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
CollectionSource *LatestCollectionSource `json:"collectionSource,omitempty"`
}
type ListItemView struct {
@@ -273,8 +274,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
// ---------------------------------------------------------------- link PDD
type LinkPDDRequest struct {
RequestID string `json:"requestId"`
PDDProductID uint64 `json:"pddProductId"`
RequestID string `json:"requestId"`
PDDProductID uint64 `json:"pddProductId"`
LatestCollection *LatestCollectionRequest `json:"latestCollection,omitempty"`
}
// LinkPDD sets or changes the linked PDD product via the search-and-select
@@ -285,6 +287,9 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil {
return SaveResponse{}, invalidRequest("requestId 必须是 UUID")
}
if request.LatestCollection != nil {
return service.linkLatestCollection(ctx, id, request)
}
if request.PDDProductID == 0 {
return SaveResponse{}, invalidRequest("pddProductId 必填")
}
+3 -1
View File
@@ -255,7 +255,9 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
return serviceError(CodeCurrentPageIdentityConflict, "当前商品与任务已识别商品不一致")
}
now := service.Now()
if err := tx.Model(&models.CollectionTask{}).Where("id = ?", record.ID).Updates(map[string]any{
// Validate the locked task's real state; an empty model fails BeforeSave
// when a retried current-page task already has a product binding.
if err := tx.Model(&record).Where("id = ?", record.ID).Updates(map[string]any{
"identify_request_id": request.RequestID, "identity_resolved_at": now,
}).Error; err != nil {
return internalError(err)
@@ -0,0 +1,153 @@
package task
import (
"context"
"reflect"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/rulecontract"
"github.com/google/uuid"
)
func TestCurrentPageReidentifyAfterSameTaskRetry(t *testing.T) {
db := openTaskDatabase(t)
_, token := registerTaskDeviceWithCapabilities(t, db, "retry-device", []string{
rulecontract.CapabilitySchemaV2,
rulecontract.CapabilityPDDProductDetailV1,
rulecontract.CapabilityPDDCurrentPageShareV1,
})
_, otherToken := registerTaskDevice(t, db, "other-device")
rule := models.CollectionRule{Name: "retry-rule", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil {
t.Fatal(err)
}
service := newTaskService(db)
ctx := context.Background()
created, err := service.CreateCurrentPage(ctx, CurrentPageCreateRequest{RequestID: uuid.NewString()}, token)
if err != nil {
t.Fatal(err)
}
load := func() models.CollectionTask {
t.Helper()
var record models.CollectionTask
if err := db.First(&record, created.TaskID).Error; err != nil {
t.Fatal(err)
}
return record
}
request := CurrentPageIdentifyRequest{
RequestID: uuid.NewString(), ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000255",
}
// Direct links resolve locally: the regression never calls PDD or a live DB.
identity, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil {
t.Fatal(err)
}
const failureCode = "SPEC_PANEL_EVIDENCE_NOT_MATCHED"
if _, err := service.SubmitFailure(ctx, created.TaskID, FailRequest{
RequestID: uuid.NewString(), Code: failureCode, Message: "Synthetic panel failure",
}, token); err != nil {
t.Fatal(err)
}
reset, err := service.ResetForDevice(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token)
if err != nil || reset.TaskID != created.TaskID || reset.AttemptNumber != 2 || reset.Status != models.TaskStatusPending {
t.Fatalf("same-task reset: %+v, %v", reset, err)
}
resetRecord := load()
if resetRecord.PDDProductID == nil || *resetRecord.PDDProductID != identity.PDDProductID ||
resetRecord.IdentifyRequestID != nil || resetRecord.IdentityResolvedAt != nil {
t.Fatal("reset must keep the product and clear per-attempt identification")
}
if _, err := service.Claim(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
t.Fatal(err)
}
if _, err := service.Start(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
t.Fatal(err)
}
before := load()
request.RequestID = uuid.NewString()
for _, tc := range []struct {
name, token, url, code string
}{
{"other device", otherToken, request.ShareURL, CodeTaskNotFound},
{"other product", token, "https://mobile.yangkeduo.com/goods.html?goods_id=700000000256", CodeCurrentPageIdentityConflict},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := service.IdentifyCurrentPage(ctx, created.TaskID, CurrentPageIdentifyRequest{RequestID: uuid.NewString(), ShareURL: tc.url}, tc.token)
if taskErrorCode(t, err) != tc.code {
t.Fatalf("expected %s, got %v", tc.code, err)
}
if !reflect.DeepEqual(before, load()) {
t.Fatal("rejected identification changed the task")
}
})
}
reidentified, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil {
t.Fatalf("reidentify same product after retry: %v", err)
}
if reidentified.TaskID != created.TaskID || reidentified.PDDProductID != identity.PDDProductID ||
reidentified.GoodsID != identity.GoodsID || reidentified.URL != identity.URL || !reidentified.Replayed {
t.Fatalf("identity changed: %+v", reidentified)
}
after := load()
if after.IdentifyRequestID == nil || *after.IdentifyRequestID != request.RequestID || after.IdentityResolvedAt == nil {
t.Fatal("new attempt identification was not persisted")
}
// Apart from identity metadata and the normal update timestamp, every field
// (including status, device, rule, lease and uniqueness slots) must survive.
expected := before
expected.IdentifyRequestID = after.IdentifyRequestID
expected.IdentityResolvedAt = after.IdentityResolvedAt
expected.UpdatedAt = after.UpdatedAt
if !reflect.DeepEqual(expected, after) {
t.Fatal("reidentification changed unrelated task fields")
}
service.ResolveCurrentPageShare = func(context.Context, string) (ResolvedPDDShare, error) {
t.Fatal("replay must not resolve the link again")
return ResolvedPDDShare{}, nil
}
replay, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
if err != nil || replay != reidentified || !reflect.DeepEqual(after, load()) {
t.Fatalf("replay changed identity or task: %+v, %v", replay, err)
}
service.ResolveCurrentPageShare = nil
title := "Synthetic collected product"
result, err := service.SubmitResult(ctx, created.TaskID, ResultRequest{
RequestID: uuid.NewString(), Status: models.TaskStatusCompleted,
Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title},
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}},
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1200}},
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色"}, PriceCent: 1200, Available: true}},
}, token)
if err != nil || result.Task.ID != created.TaskID || result.Task.Status != models.TaskStatusCompleted {
t.Fatalf("complete retried collection: %+v, %v", result, err)
}
terminal := load()
request.RequestID = uuid.NewString()
if _, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token); taskErrorCode(t, err) != CodeTaskStateConflict {
t.Fatalf("expected terminal-state rejection: %v", err)
}
if !reflect.DeepEqual(terminal, load()) {
t.Fatal("terminal identification changed the task")
}
var archived []models.CollectionTaskAttempt
if err := db.Where("task_id = ?", created.TaskID).Find(&archived).Error; err != nil {
t.Fatal(err)
}
if len(archived) != 1 || archived[0].AttemptNumber != 1 || archived[0].Status != models.TaskStatusFailed ||
archived[0].ErrorCode == nil || *archived[0].ErrorCode != failureCode {
t.Fatal("original failed attempt was not preserved")
}
for _, model := range []any{&models.CollectionTask{}, &models.PDDProduct{}} {
var count int64
if err := db.Model(model).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("retry duplicated task/product: count=%d, %v", count, err)
}
}
}
+2 -2
View File
@@ -16,8 +16,8 @@ export function updateShopeeProduct(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'patch', data })
}
export function linkShopeeProductPdd(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data })
export function linkShopeeProductPdd(productId, data, options = {}) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data, ...options })
}
export function restoreShopeeProduct(productId, data) {
@@ -0,0 +1,24 @@
// Separate from the purchase-device preference; browser origin provides the
// outer isolation and API environment + user identify the inner namespace.
function key(userId) {
const id = Number(userId)
return Number.isSafeInteger(id) && id > 0 ? `goauto:quick-link-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}` : null
}
export function readQuickLinkDevice(userId) {
try {
const name = key(userId)
if (!name) return null
const id = JSON.parse(localStorage.getItem(name) || 'null')
return Number.isSafeInteger(id) && id > 0 ? id : null
} catch { return null }
}
export function rememberQuickLinkDevice(userId, deviceId) {
try {
const name = key(userId)
if (!name || !Number.isSafeInteger(deviceId) || deviceId <= 0) return false
localStorage.setItem(name, JSON.stringify(deviceId))
return true
} catch { return false }
}
+48 -3
View File
@@ -25,12 +25,54 @@
<el-table-column label="数量" prop="quantity" width="56" />
<el-table-column label="售价" width="110"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
<el-table-column label="解析状态" width="80"><template #default="{ row }"><el-tag :type="parseMeta(row.parseStatus).type">{{ parseMeta(row.parseStatus).label }}</el-tag><el-tag v-if="row.manuallyConfirmed" size="small" type="success" effect="plain" style="margin-left:4px">人工已确认</el-tag></template></el-table-column>
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else><el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag><div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div><div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div><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></template></template></el-table-column>
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210">
<template #default="{ row }">
<span v-if="purchaseReadinessLoading" class="muted">正在检查</span>
<template v-else>
<el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag>
<div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div>
<div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div>
<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-dropdown trigger="click" @command="quickOpenPicker()">
<el-button size="small" type="primary" plain aria-label="一键关联使用的手机" :icon="ArrowDown" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>当前手机:{{ quickDeviceLabel() }}</el-dropdown-item>
<el-dropdown-item command="switch">切换手机</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-button-group>
</div>
</template>
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
<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%">
<el-option v-for="device in quickDevices" :key="device.id" :value="device.id" :label="`${device.name}(#${device.id} · ${device.status === 'online' ? '在线' : '离线'})`" />
</el-select>
</el-form-item>
</el-form>
<p class="muted">使用这台手机最近一次成功的临时采集;离线也可使用已有结果。关联会作用于共用该蝦皮商品的 SYB 明细。</p>
<p v-if="quickDeviceError" role="alert">{{ quickDeviceError }} <el-button link type="primary" @click="quickOpenPicker(quickPicker.row)">重试</el-button></p>
<template #footer>
<el-button @click="quickCancelPicker">取消</el-button>
<el-button type="primary" :disabled="quickDeviceLoading || !!quickDeviceError || !quickDevices.some(d => d.id === quickPicker.deviceId)" @click="quickSaveDevice">{{ quickPicker.row ? '选择并继续' : '保存选择' }}</el-button>
</template>
</el-dialog>
<!-- 批量创建采购任务确认 -->
<el-dialog v-model="purchaseDialog.open" title="批量创建采购任务" width="900px" :close-on-click-modal="false">
<div v-loading="purchaseDialog.loading">
@@ -171,7 +213,8 @@
<script>
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
import quickLink from './quick-link'
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
import { listSybShops } from '@/api/goauto/syb-shops'
import { listDevices } from '@/api/goauto/devices'
@@ -186,7 +229,8 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
export default {
name: 'GoAutoSybProducts',
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
setup() { return { Search, RefreshLeft } },
mixins: [quickLink],
setup() { return { Search, RefreshLeft, ArrowDown } },
data() {
return {
loading: false, products: [], selectedProducts: [], total: 0,
@@ -513,6 +557,7 @@ export default {
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
.link{color:#1677ff;cursor:pointer}
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -0,0 +1,121 @@
import { ElMessage } 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'
import { createRequestId } from '@/utils/request-id'
import { readQuickLinkDevice, rememberQuickLinkDevice } from '@/utils/quick-link-device-preference'
const readOptions = { timeout: 10000, suppressErrorMessage: true }
export default {
data() {
return {
quickDeviceId: null, quickDevices: [], quickDeviceError: '', quickDeviceLoading: false,
quickPicker: { open: false, deviceId: null, row: null },
quickBusyId: null, quickPhase: '', quickMessages: {}, quickGeneration: 0, quickPickerGeneration: 0
}
},
created() { this.quickDeviceId = readQuickLinkDevice(this.$store.getters.userId) },
beforeUnmount() { this.quickInvalidate() },
deactivated() { this.quickInvalidate() },
methods: {
quickInvalidate() {
this.quickGeneration++
this.quickPickerGeneration++
if (this.quickBusyId) this.quickMessages[this.quickBusyId] = '操作结果待确认,请刷新核对;离开页面不代表服务端已取消'
this.quickBusyId = null
this.quickPhase = ''
this.quickPicker.open = false
},
quickCanLink(row) {
const ready = this.purchaseReady(row)
return this.canPurchase && !!row.shopeeProductId && ready.processNextAction === 'open_pdd_link' && !ready.pddProductId
},
quickDeviceLabel() {
const device = this.quickDevices.find(d => d.id === this.quickDeviceId)
return device ? `${device.name}(#${device.id})` : this.quickDeviceId ? `手机 #${this.quickDeviceId}` : '尚未选择'
},
async quickOpenPicker(row = null) {
const generation = ++this.quickPickerGeneration
this.quickPicker = { open: true, deviceId: this.quickDeviceId, row: row ? { ...row } : null }
this.quickDeviceLoading = true
this.quickDeviceError = ''
try {
// Include offline phones: historical collection does not require a live phone.
const response = await listDevices({ page: 1, pageSize: 100 })
if (generation !== this.quickPickerGeneration) return
this.quickDevices = response.data.items.filter(d => d.status === 'online' || d.status === 'offline')
if (!this.quickDevices.length) this.quickDeviceError = '暂无可选手机,请先在设备列表中登记或启用手机'
} catch { if (generation === this.quickPickerGeneration) this.quickDeviceError = '手机列表加载失败,请重试' } finally { if (generation === this.quickPickerGeneration) this.quickDeviceLoading = false }
},
quickCancelPicker() { this.quickPickerGeneration++; this.quickPicker.open = false },
quickSaveDevice() {
const id = this.quickPicker.deviceId
if (this.quickDeviceLoading || this.quickDeviceError || !this.quickDevices.some(d => d.id === id)) return
const row = this.quickPicker.row
this.quickDeviceId = id
if (!rememberQuickLinkDevice(this.$store.getters.userId, id)) ElMessage.warning('本次选择已生效,但浏览器未能保存;下次可能需要重新选择')
this.quickCancelPicker()
if (row) this.quickStart(row)
},
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 generation = ++this.quickGeneration
const current = () => generation === this.quickGeneration
this.quickBusyId = row.id
this.quickMessages[row.id] = ''
this.quickPhase = '正在关联'
let wrote = false
try {
const detail = (await getShopeeProduct(frozen.productId, readOptions)).data
if (!current()) return
if (detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
const linked = (await linkShopeeProductPdd(frozen.productId, {
requestId: createRequestId(),
latestCollection: { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
}, readOptions)).data
wrote = true
if (!current()) return
const ready = (await previewPurchaseTasks({ sybProductIds: [frozen.sybProductId] }, readOptions)).data.items[0]
if (!current()) return
if (linked.collectionSource.requiresSpecConfirmation || !ready?.aiMatchEligible) {
this.quickMessages[row.id] = ready?.eligible ? '已关联,当前明细可创建采购' : '已关联;请按处理阶段提示继续人工处理'
} 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 ? '已关联并保存有效匹配;剩余规格请去匹配处理' : '已关联并保存匹配;以最新处理阶段为准'
}
} catch (error) {
if (!current()) return
const reason = error?.response?.data?.message || error?.message || '请求失败'
// A definite validation/conflict rejection is not an unknown write.
if (!wrote && [403, 404, 409, 422].includes(error?.response?.status)) {
this.quickMessages[row.id] = reason
return
}
this.quickPhase = '正在核对结果'
try {
const saved = (await getShopeeProduct(frozen.productId, readOptions)).data.product
if (!current()) return
this.quickMessages[row.id] = saved.pddProductId
? `当前已有关联,匹配结果请去匹配查看。${reason}`
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
} catch {
if (current()) this.quickMessages[row.id] = `${wrote ? '关联已保存;' : ''}结果未确认,请刷新核对,不要连续重复点击。${reason}`
}
} finally {
if (current()) {
this.quickBusyId = null
this.quickPhase = ''
// Read-only refresh, never automatically resubmit a failed write.
void this.loadPurchaseReadiness(this.products.map(item => item.id))
}
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test'
async function setup(page: any) {
const state = { userId: 1, role: 'purchaser', links: [] as any[], matches: [] as any[], linked: new Set<number>(), matched: new Set<number>(), aiFailure: false }
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
const ok = (data: any) => route.fulfill({ json: { code: 200, data } })
if (path.startsWith('/src/api/')) return route.continue()
if (path.endsWith('/getinfo')) return ok({ userId: state.userId, roles: [state.role], name: '合成测试', avatar: '', permissions: [] })
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' })) })
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 (match[2] === '/link-pdd') {
const body = route.request().postDataJSON(); state.links.push(body); state.linked.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') {
state.matches.push(route.request().postDataJSON())
if (state.aiFailure) return route.fulfill({ status: 503, json: { code: 'AI_MATCHING_UNAVAILABLE', message: 'AI 匹配服务暂时不可用' } })
state.matched.add(id); return ok({ product, unmatchedCount: 0 })
}
return ok({ product, autoMatchTimeoutSeconds: 370 })
}
return ok([])
})
await page.goto('/#/syb-products')
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(2)
return state
}
async function choose(page: any, name: string, button: string) {
const dialog = page.getByRole('dialog')
await dialog.locator('.el-select').click()
await page.getByRole('option', { name: new RegExp(name) }).click()
await dialog.getByRole('button', { name: button, exact: true }).click()
await expect(dialog).toBeHidden()
}
test('first choose continues clicked row; saves match and real stage; preference survives refresh', async ({ page }) => {
const state = await setup(page)
await page.getByRole('button', { name: '一键关联', exact: true }).nth(1).click()
await choose(page, '手机A', '选择并继续')
await expect.poll(() => state.matches.length).toBe(1)
expect(state.links[0].latestCollection).toMatchObject({ sybProductId: 2, deviceId: 10 })
await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
await page.reload()
await page.getByRole('button', { name: '一键关联', exact: true }).click()
await expect.poll(() => state.links.length).toBe(2)
expect(state.links[1].latestCollection.deviceId).toBe(10)
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('arrow first-use saves only; switch from either row affects all rows without linking', async ({ page }) => {
const state = await setup(page)
await page.getByRole('button', { name: '一键关联使用的手机' }).first().click()
await page.getByRole('menuitem', { name: '切换手机' }).click()
await choose(page, '手机A', '保存选择')
expect(state.links).toHaveLength(0)
await page.getByRole('button', { name: '一键关联使用的手机' }).nth(1).click()
await expect(page.getByRole('menuitem', { name: /当前手机:手机A/ })).toBeVisible()
await page.getByRole('menuitem', { name: '切换手机' }).click()
await choose(page, '手机B', '保存选择')
expect(state.links).toHaveLength(0)
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
await expect.poll(() => state.links.length).toBe(1)
expect(state.links[0].latestCollection.deviceId).toBe(20)
})
test('AI failure retains association, ends waiting and offers manual matching', async ({ page }) => {
const state = await setup(page); state.aiFailure = true
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
await choose(page, '手机A', '选择并继续')
await expect(page.getByText(/当前已有关联,匹配结果请去匹配查看/)).toBeVisible()
await expect(page.getByRole('button', { name: '去匹配', exact: true })).toBeVisible()
expect(state.links).toHaveLength(1); expect(state.matches).toHaveLength(1)
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toBeEnabled()
})
test('cancel, different user and no purchaser role do not create links', async ({ page }) => {
const state = await setup(page)
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click()
expect(state.links).toHaveLength(0)
await page.getByRole('button', { name: '一键关联使用的手机' }).first().click()
await page.getByRole('menuitem', { name: '切换手机' }).click()
await choose(page, '手机A', '保存选择')
state.userId = 2; await page.reload()
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
await expect(page.getByRole('dialog', { name: '选择采集手机' })).toBeVisible()
state.role = 'viewer'; await page.reload()
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(0)
expect(state.links).toHaveLength(0)
})
+89
View File
@@ -0,0 +1,89 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/quick-link.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return')
const row = { id: 1, shopeeProductId: 11 }
const tick = () => new Promise(resolve => setImmediate(resolve))
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
function instance(overrides = {}) {
const calls = { link: [], match: [], reads: 0, saved: [], refresh: 0 }
const dependencies = {
ElMessage: { warning() {} },
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 } } } },
autoMatchShopeeSpecMappings: async (id, body, seconds) => { calls.match.push({ id, body, seconds }); return { data: { unmatchedCount: 0 } } },
previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: true }] } }),
createRequestId: () => 'request-id', readQuickLinkDevice: () => null,
rememberQuickLinkDevice: (user, id) => { calls.saved.push(id); return true }, ...overrides
}
const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies))
const vm = { ...options.methods, ...options.data(), canPurchase: true, products: [row], $store: { getters: { userId: 1 } }, purchaseReady: () => ({ processNextAction: 'open_pdd_link' }), loadPurchaseReadiness: async () => { calls.refresh++ } }
return { vm, calls, options }
}
test('first selection continues original row and forwards frozen context / #254 budget', async () => {
const { vm, calls } = instance()
await vm.quickStart(row); assert.equal(calls.link.length, 0)
vm.quickPicker.deviceId = 10; vm.quickSaveDevice(); await tick()
assert.equal(calls.link.length, 1); assert.equal(calls.link[0].latestCollection.sybProductId, 1)
assert.equal(calls.link[0].latestCollection.deviceId, 10)
assert.equal(calls.match[0].body.specContextVersion, 'after'); assert.equal(calls.match[0].seconds, 370)
assert.equal(vm.quickBusyId, null)
})
test('switch saves shared preference only; cancel changes nothing', async () => {
const { vm, calls } = instance(); vm.quickDeviceId = 10
await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickCancelPicker()
assert.equal(vm.quickDeviceId, 10); assert.equal(calls.saved.length, 0)
await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickSaveDevice()
assert.equal(vm.quickDeviceId, 20); assert.equal(calls.link.length, 0)
})
test('in-flight device is frozen and double click cannot duplicate POST', async () => {
const wait = deferred(); let links = 0, device
const { vm } = instance({ getShopeeProduct: () => wait.promise, linkShopeeProductPdd: async (id, body) => { links++; device = body.latestCollection.deviceId; return { data: { product: { specContextVersion: 'after' }, collectionSource: {} } } } })
vm.quickDeviceId = 10; const running = vm.quickStart(row)
vm.quickDeviceId = 20; await vm.quickStart(row)
wait.resolve({ data: { product: { specContextVersion: 'before' }, autoMatchTimeoutSeconds: 130 } }); await running
assert.equal(links, 1); assert.equal(device, 10)
})
test('leaving during prepare prevents later association / matching', async () => {
const wait = deferred(); const { vm, calls, options } = instance({ getShopeeProduct: () => wait.promise })
vm.quickDeviceId = 10; const running = vm.quickStart(row); options.beforeUnmount.call(vm)
wait.resolve({ data: { product: { specContextVersion: 'before' } } }); await running
assert.equal(calls.link.length, 0); assert.equal(calls.match.length, 0)
})
for (const error of ['timeout', 'Network Error', 'AI 匹配服务暂时不可用']) {
test(`${error}: finite readback, end loading, no automatic write retry`, async () => {
let matches = 0
const { vm, calls } = instance({ autoMatchShopeeSpecMappings: async () => { matches++; throw new Error(error) } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(calls.link.length, 1); assert.equal(matches, 1); assert.equal(calls.reads, 2)
assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /当前已有关联/)
})
}
test('failed recovery reports unknown and clears busy', async () => {
const { vm } = instance({ getShopeeProduct: async () => { throw new Error('offline') } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /结果未确认/)
})
test('definite candidate rejection explains cause without AI or a write retry', async () => {
let links = 0
const { vm, calls } = instance({ linkShopeeProductPdd: async () => { links++; throw { response: { status: 409, data: { message: '最近一次临时采集不完整,请先补采' } } } } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(links, 1); assert.equal(calls.match.length, 0); assert.equal(calls.reads, 1)
assert.equal(vm.quickBusyId, null); assert.equal(vm.quickMessages[1], '最近一次临时采集不完整,请先补采')
})
test('manual-required readiness retains association without AI call', async () => {
const { vm, calls } = instance({ previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: false, eligible: false }] } }) })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(calls.link.length, 1); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /人工处理/)
})
test('preference isolates user and API environment, handles storage failure', () => {
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/quick-link-device-preference.js'), 'utf8').replace(/export function/g, 'function')
const map = new Map(); const process = { env: { VUE_APP_BASE_API: '/a' } }
const storage = { getItem: k => map.get(k), setItem: (k, v) => map.set(k, v) }
const p = new Function('localStorage', 'process', source + '; return {readQuickLinkDevice,rememberQuickLinkDevice}')(storage, process)
p.rememberQuickLinkDevice(1, 10); assert.equal(p.readQuickLinkDevice(1), 10); assert.equal(p.readQuickLinkDevice(2), null)
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)
})