Compare commits

...
5 changed files with 277 additions and 3 deletions
+6 -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: 31d2dbd24acd8ac119dfd0d538c12d0525a2764f
synchronized_at: 2026-09-10T06:54:35Z
<!-- 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` 主表。
@@ -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)
}
})
}
}
+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)
}
}
}