Compare commits

..
3 changed files with 367 additions and 5 deletions
+14 -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: b9efffa2045d01399bfe02ce626cfd1bb379ec41
synchronized_at: 2026-10-08T07:10:48Z
wiki_revision: 2a897541f264a12eee8a455b99da0f5c556972d7
synchronized_at: 2026-10-08T08:55:40Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -859,3 +859,15 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- 每attempt最多保留一份ZIP,已有ZIP不可覆盖;只有无ZIP未截取记录可升级为首次恢复ZIP。以该现场 recordedAt 起保留30天,重复上传不续期;新恢复ZIP采用真实恢复时刻。两端定期清理,手机快照逻辑总量64MiB,淘汰时同时终止上传队列。清理不修改采购结果。
- 管理员采购详情可见完整/部分/未截取/暂无现场数据及安全下载入口;普通采购员、售后、客户端密钥均无原始诊断下载权限。
- 1500ms、5000节点、2MiB ZIP、8MiB展开、128窗口与深度保护是合成测试覆盖的开发边界,未经过代表性真机样本验收;单次系统Binder读取不能被这些前后检查强行中断。未知/部分必须如实显示,不宣称拿到了系统未暴露的节点。
## 图搜采集部分完成的自动关联资格(#368)
实现基线:工单分支 `fix/368-image-search-partial-link` 提交 `15ccab3`(2026-10-08);尚未合并 main 或发布,生产是否启用以发布记录为准。
- 此规则仅作用于 `source=image_search` 的结果后置自动关联。正常 `completed` 沿用原行为,包括 `missing_json=[]`。
- `completed_partial` 仅在 `missing_json` 为可解析、非空字符串数组且每一项精确属于 `reviewCount`、`salesText`、`shopName` 时允许自动关联。三项均为可选描述信息。
- partial 的空值、JSON null、空数组、非法 JSON、非字符串项、空项、未知项、颜色/尺码/价格/不支持维度缺失一律不放宽。不修剪字段名、不按前缀推测。
- 保留原图搜快照、目标 PDD active、`OriginalPDDProductID` 乐观比较;人工改动后的关联不被覆盖。只有实际写入关联并返回 Linked,才进入既有规格同步和匹配流程。
- 采集任务仍为 `completed_partial`,missing 保留,界面仍显示部分完成;关联成功不等于 AI 匹配成功。匹配无结果或不可用沿用原处理,不自动创建采购。
- 不改变普通采集、手动当前页采集、手动关联、价格保护、映射算法、同步后置 hook 的耗时及重放机制。同图多结果沿用原提交顺序和乐观比较,不新增历史筛选、补关联或回退旧商品。
- 验证边界:自动化测试使用隔离 SQLite 与本地模拟 ERP 服务验证实际 SubmitResult、关联及映射落库;未以此宣称 MySQL、真实 AI、真机或线上发布验证。请求在匹配开始后取消时,匹配使用原有脱钩 context 继续;最后 Detail 读取仍使用原请求 context,可能向断开的调用端返回错误,已提交数据不因此回滚。
+29 -3
View File
@@ -2,6 +2,7 @@ package task
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -15,8 +16,8 @@ import (
)
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
// writes an association when the task was a complete collection, the frozen
// original association is unchanged, and the target product is active. Manual
// writes an association when the collection is complete or missing only optional
// metadata, the frozen original association is unchanged, and the target product is active. Manual
// changes made while the Agent was running win by the CAS predicate.
// autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。
// 只有确实写入时才值得触发后续的规格匹配(#287)。
@@ -32,7 +33,7 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
return autoLinkedImageSearch{}, internalError(err)
}
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
if task.Source != models.CollectionTaskSourceImageSearch || !imageSearchResultAllowsAutoLink(task) || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
return autoLinkedImageSearch{}, nil
}
snapshot, err := loadImageSearchSnapshot(task)
@@ -94,6 +95,31 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
}, nil
}
// #368: Keep completed behavior unchanged. A partial result is eligible only
// with explicit, nonempty evidence that every missing field is optional.
// Unknown/malformed missing data must not silently widen this whitelist.
func imageSearchResultAllowsAutoLink(task models.CollectionTask) bool {
if task.Status == models.TaskStatusCompleted {
return true
}
if task.Status != models.TaskStatusCompletedPartial || task.MissingJSON == nil {
return false
}
var missing []string
if err := json.Unmarshal([]byte(*task.MissingJSON), &missing); err != nil || len(missing) == 0 {
return false
}
for _, field := range missing {
switch field {
case "reviewCount", "salesText", "shopName":
// Optional descriptive metadata; never colors, sizes or prices.
default:
return false
}
}
return true
}
// matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the
// details the image-search task just linked (#287).
//
@@ -0,0 +1,324 @@
package task
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"sync/atomic"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
// This fixture leaves the hook uncalled so tests can change the submitted
// status, missing fields and association independently of the assertions.
func seedPartialImageSearch(t *testing.T, db *gorm.DB, deviceID *uint64) (models.CollectionTask, models.ShopeeProduct) {
t.Helper()
record := createTask(t, db, deviceID)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "active").Error; err != nil {
t.Fatal(err)
}
shopee := models.ShopeeProduct{
ShopeeItemID: "test-item", Title: "test", Currency: "TWD",
SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"M","source":"import"}]}]`,
}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{
OrderCode: "TEST-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID,
ShopeeProductID: &shopee.ID, TargetColor: "黑色", TargetSize: "M",
Quantity: 1, UnitPriceCent: 1000, ParseStatus: "success", RawJSON: "{}",
}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
snapshot := ImageSearchSnapshot{
ShopeeProductID: shopee.ID, RepresentativeSYBProductID: syb.ID, SYBProductIDs: []uint64{syb.ID},
ReferenceCurrency: "TWD", ReferencePriceCent: 1000, MaxPriceRatio: 3, PriceGuardSkipped: true,
ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"},
}
raw, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&record).Updates(map[string]any{"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": string(raw)}).Error; err != nil {
t.Fatal(err)
}
return record, shopee
}
func TestAutoLinkImageSearchPartialMissingWhitelist(t *testing.T) {
cases := []struct {
name, status, missing string
nilMissing, want bool
}{
{name: "completed_empty", status: models.TaskStatusCompleted, missing: "[]", want: true},
{name: "completed_nil", status: models.TaskStatusCompleted, nilMissing: true, want: true},
{name: "nil", nilMissing: true},
{name: "blank"},
{name: "null", missing: "null"},
{name: "empty", missing: "[]"},
{name: "malformed", missing: `["reviewCount"`},
{name: "object", missing: `{ "reviewCount": true }`},
{name: "scalar", missing: `"reviewCount"`},
{name: "number_entry", missing: `["reviewCount",1]`},
{name: "null_entry", missing: `["reviewCount",null]`},
{name: "blank_entry", missing: `[""]`},
{name: "unknown", missing: `["reviewCount","futureField"]`},
{name: "case_mismatch", missing: `["ReviewCount"]`},
{name: "whitespace", missing: `[" reviewCount "]`},
{name: "color", missing: `["reviewCount","color"]`},
{name: "size", missing: `["size"]`},
{name: "price", missing: `["price"]`},
{name: "color_price", missing: `["reviewCount","price:黑色"]`},
{name: "unsupported_dimension", missing: `["unsupportedDimension:款式"]`},
{name: "invalid_dimension", missing: `["spec_dimension_invalid:size"]`},
{name: "pending", status: models.TaskStatusPending, missing: `["reviewCount"]`},
{name: "failed", status: models.TaskStatusFailed, missing: `["reviewCount"]`},
{name: "duplicate_optional", missing: `["reviewCount","reviewCount"]`, want: true},
}
fields := []string{"reviewCount", "salesText", "shopName"}
for mask := 1; mask < 8; mask++ {
var missing []string
for i, field := range fields {
if mask&(1<<i) != 0 {
missing = append(missing, field)
}
}
raw, _ := json.Marshal(missing)
cases = append(cases, struct {
name, status, missing string
nilMissing, want bool
}{name: fmt.Sprintf("allowed_%d", mask), missing: string(raw), want: true})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := openTaskDatabase(t)
record, shopee := seedPartialImageSearch(t, db, nil)
status := tc.status
if status == "" {
status = models.TaskStatusCompletedPartial
}
var missing *string
if !tc.nilMissing {
missing = &tc.missing
}
if err := record.SetStatus(status); err != nil {
t.Fatal(err)
}
if err := db.Model(&record).Updates(map[string]any{"status": status, "missing_json": missing, "active_slot": record.ActiveSlot, "device_run_slot": record.DeviceRunSlot}).Error; err != nil {
t.Fatal(err)
}
linked, err := NewService(db).autoLinkImageSearch(context.Background(), record.ID)
if err != nil || linked.Linked != tc.want {
t.Fatalf("Linked=%v want=%v error=%v", linked.Linked, tc.want, err)
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if shopee.ImageSearchLinked != tc.want || (shopee.PDDProductID != nil) != tc.want {
t.Fatalf("association/marker mismatch: %+v", shopee)
}
if tc.want && (*shopee.PDDProductID != *record.PDDProductID || len(linked.SYBProductIDs) != 1) {
t.Fatal("linked target or downstream SYB identifiers lost")
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.Status != status || !reflect.DeepEqual(record.MissingJSON, missing) {
t.Fatal("eligibility must not rewrite collection status or missing fields")
}
})
}
}
func TestSubmitImageSearchPartialLinkAndMatch(t *testing.T) {
for _, scenario := range []string{"optional_partial", "completed", "critical_missing", "admin_source", "current_page_source", "disabled_pdd", "association_changed", "snapshot_missing", "ai_unavailable", "request_cancelled_during_match"} {
t.Run(scenario, func(t *testing.T) {
requestContext, cancelRequest := context.WithCancel(context.Background())
defer cancelRequest()
var syncCalls atomic.Int32
erp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
syncCalls.Add(1)
if scenario == "request_cancelled_during_match" {
// Cancel only after the real post-result matching hook has
// started. Its detached context must still persist mappings.
cancelRequest()
}
if scenario == "ai_unavailable" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"color":["黑色"],"size":["M"]}}`)
}))
defer erp.Close()
t.Setenv("GOAUTO_ERPGO_BASE_URL", erp.URL)
t.Setenv("GOAUTO_ERPGO_APIKEY", "synthetic-test-key")
db := openTaskDatabase(t)
device, token := registerTaskDevice(t, db, "partial-image-search")
record, shopee := seedPartialImageSearch(t, db, &device.ID)
service := newTaskService(db)
// Execution is outside this test's scope; submit through the real
// authenticated running-owner path with a persisted image-search snapshot.
if err := db.Model(&record).Update("status", models.TaskStatusRunning).Error; err != nil {
t.Fatal(err)
}
title, shop, sales, reviews := "商品", "店铺", "已拼100件", int64(12)
request := ResultRequest{
RequestID: uuid.NewString(), Status: models.TaskStatusCompletedPartial,
Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot, Title: &title, ShopName: &shop, SalesText: &sales},
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}, {Key: "size", Name: "尺码", Values: []string{"M"}}},
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1000}},
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色", "size": "M"}, PriceCent: 1000, Available: true}},
Missing: []string{"reviewCount"},
}
wantLinked, wantMapped := true, true
var unchangedPDD *uint64
switch scenario {
case "completed":
request.Status, request.Missing, request.Product.ReviewCount = models.TaskStatusCompleted, []string{}, &reviews
case "critical_missing":
request.Missing = append(request.Missing, "size")
wantLinked, wantMapped = false, false
case "admin_source", "current_page_source":
source := models.CollectionTaskSourceAdmin
if scenario == "current_page_source" {
source = models.CollectionTaskSourceAgentCurrentPage
}
if err := db.Model(&record).Update("source", source).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "disabled_pdd":
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "disabled").Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "association_changed":
manual := seedAutoLinkPDD(t, db, "manual-pdd")
unchangedPDD = &manual.ID
if err := db.Model(&shopee).Update("pdd_product_id", manual.ID).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "snapshot_missing":
if err := db.Model(&record).Update("image_search_snapshot", nil).Error; err != nil {
t.Fatal(err)
}
wantLinked, wantMapped = false, false
case "ai_unavailable":
// No exact match and no configured AI provider: the existing
// matching path must not invent mappings or undo successful collection.
request.Dimensions[0].Values = []string{"白色"}
request.Dimensions[1].Values = []string{"L"}
request.ColorPrices[0].Color = "白色"
request.SKUs[0].Specs = map[string]string{"color": "白色", "size": "L"}
wantMapped = false
}
for attempt := 0; attempt < 2; attempt++ {
ctx := context.Background()
if attempt == 0 {
ctx = requestContext
}
detail, err := service.SubmitResult(ctx, record.ID, request, token)
if scenario == "request_cancelled_during_match" && attempt == 0 {
// Detail still uses the original request context. Preserve that
// existing return behavior, then inspect the committed state
// independently; do not pretend the disconnected client got OK.
if requestContext.Err() != context.Canceled || err == nil {
t.Fatalf("expected cancelled response read, got %v", err)
}
detail, err = service.Detail(context.Background(), record.ID)
}
if err != nil {
t.Fatalf("submit attempt %d: %v", attempt, err)
}
if detail.Task.Status != request.Status || !reflect.DeepEqual(detail.Missing, request.Missing) || detail.Replayed != (attempt == 1) {
t.Fatalf("submission/replay changed result: status=%s missing=%v replayed=%v", detail.Task.Status, detail.Missing, detail.Replayed)
}
if len(detail.SKUs) != 1 || len(detail.Dimensions) != 2 {
t.Fatal("result children missing or duplicated")
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if shopee.ImageSearchLinked != wantLinked {
t.Fatalf("image search marker=%v", shopee.ImageSearchLinked)
}
wantPDD := unchangedPDD
if wantLinked {
wantPDD = record.PDDProductID
}
if !reflect.DeepEqual(shopee.PDDProductID, wantPDD) {
t.Fatal("wrong association or concurrent change overwritten")
}
wantCalls := int32(0)
if wantLinked {
wantCalls = 1
}
if syncCalls.Load() != wantCalls {
t.Fatalf("matching hook calls=%d want=%d", syncCalls.Load(), wantCalls)
}
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
if err != nil {
t.Fatal(err)
}
if len(specs) != 2 {
t.Fatalf("unexpected archive dimensions: %d", len(specs))
}
for _, dimension := range specs {
for _, value := range dimension.Values {
if wantMapped {
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed || value.Mapping.PDDValue != value.Name {
t.Fatalf("matching not persisted: %+v", value)
}
} else if value.Mapping != nil {
t.Fatalf("unexpected mapping: %+v", value)
}
}
}
}
if scenario == "optional_partial" {
// A second result for the same image carries the same original
// association snapshot. The first committed link wins; no fallback
// to another result or a second matching pass is introduced.
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
second := createTask(t, db, &device.ID)
if err := db.Model(&second).Updates(map[string]any{
"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": record.ImageSearchSnapshot,
"status": models.TaskStatusRunning,
}).Error; err != nil {
t.Fatal(err)
}
request.RequestID, request.Product.PDDGoodsID = uuid.NewString(), second.GoodsIDSnapshot
if _, err := service.SubmitResult(context.Background(), second.ID, request, token); err != nil {
t.Fatal(err)
}
if err := db.First(&shopee, shopee.ID).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(shopee.PDDProductID, record.PDDProductID) || syncCalls.Load() != 1 {
t.Fatal("competing image-search result replaced association or retriggered matching")
}
}
var purchases int64
if err := db.Model(&models.PurchaseTask{}).Count(&purchases).Error; err != nil {
t.Fatal(err)
}
if purchases != 0 {
t.Fatal("matching must not create a purchase task")
}
})
}
}