Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002ab9b7ee |
@@ -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: 2a897541f264a12eee8a455b99da0f5c556972d7
|
||||
synchronized_at: 2026-10-08T08:55:40Z
|
||||
wiki_revision: b9efffa2045d01399bfe02ce626cfd1bb379ec41
|
||||
synchronized_at: 2026-10-08T07:10:48Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -859,15 +859,3 @@ 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,可能向断开的调用端返回错误,已提交数据不因此回滚。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 6ca0b35cb1e8058b2930a9a01029822af8b571d2
|
||||
synchronized_at: 2026-10-08T07:10:52Z
|
||||
wiki_revision: c2b8ffc6e686f6a97d45361bcc30c9dce4d69b78
|
||||
synchronized_at: 2026-10-08T07:35:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -320,3 +320,18 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
4. 管理员从采购详情执行记录下载ZIP;无现场、未截取、partial、上传中/失败应按实际结果区分。排错日志仅固定错误分类,禁止复制ZIP内容到日志、工单、Wiki或消息;分析只摘必要脱敏结构。沿用现有Agent HTTPS/显式HTTP例外,HTTP上传有个人数据明文风险。
|
||||
5. 代码回滚时保留追加表和已存数据,不降级或删除手机数据库来恢复;回滚到尚不识别schema v4的旧Agent须先单独评估,不承诺可直接覆盖安装。保留期限清理须有仍在运行的相应新版维护组件。
|
||||
6. 本机换机后的数据库经用户授权初始化于 `127.0.0.1:3308/goauto`,配置仍来自仓库根目录已忽略的config.yaml;已完成baseline迁移及#364幂等、容量、权限验证,未启动业务服务或连接线上。不是将历史3307运行说明套用于所有环境;实际Supervisor目录/发布按目标环境另核对。
|
||||
|
||||
|
||||
## 2026-10-08 换机后本地 Supervisor 联调配置(#364 后续运行准备)
|
||||
|
||||
本节是当前电脑的配置,优先于上文历史 D:/supervisor、3307 和 main-runtime 路径;不改变线上拓扑。
|
||||
|
||||
- Supervisor 位于 `D:/supervisord`,主配置 `supervisord.conf` 通过 `[include] files=programs/goauto.conf` 引入两个实例:`goauto-admin-api` 和 `goauto-admin-ui`。两者 `autostart=false`、`autorestart=true`,添加/重读配置不等于批准启动所有服务;只对目标实例操作,不重启整个Supervisor。
|
||||
- 独立运行目录 `D:/OPC/goauto-runtime` 干净地固定在已合并main的 `e450ac28989de6827328b411e4bdc050dded9ab3`,用detached HEAD避免与开发分支混淆。根仓库 `D:/OPC/goauto` 仍保留用户旧分支和未提交资料,不在其中切main或覆盖。
|
||||
- 两实例读取 `D:/OPC/goauto/config.yaml`;本地MySQL为127.0.0.1:3308/goauto,API端口8010,Web端口9527。后端调用运行目录的 `scripts/start-server.ps1 -ConfigPath D:/OPC/goauto/config.yaml -SkipMigration`,前端调用 `scripts/start-web.ps1 -ConfigPath D:/OPC/goauto/config.yaml`。后端仍会校验/确保数据库存在并执行既有启动权限对账;SkipMigration不是只读启动。
|
||||
- 当前电脑默认go为1.24且GOTOOLCHAIN=local,继承的GOSUMDB=off又会阻止自动下载1.26.5;仅后端实例的PowerShell命令把已安装 `C:/Users/Qiu/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.5.windows-amd64/bin` 前置到进程PATH,并设置GOTOOLCHAIN=local。已用该环境构建验证;未更改全局环境、校验策略或项目go.mod。此绝对路径是本机配置,其他电脑须核实已安装工具链,不照抄。
|
||||
- 前端依赖属于运行目录自身,按锁文件安装,不链接其他worktree的node_modules。若旧残留依赖出现ERR_MODULE_NOT_FOUND且普通安装未补齐,先用 `node -e "import('vite')"` 复现,再在本目录按锁文件重新安装;不通过修改业务代码或升级依赖绕过。
|
||||
- 手动启动顺序为API就绪后Web;Web原脚本最多等待API健康60秒,并将/api同源代理到本机8010。浏览器本机入口 `http://127.0.0.1:9527/`,Agent使用电脑实际局域网IP的8010端口,不把127.0.0.1配置给手机。
|
||||
- 日志为 `D:/supervisord/goauto-admin-api.log`、`D:/supervisord/goauto-admin-ui.log`。检查依赖、端口和实际子进程环境;不能只因Supervisor显示Running就宣称HTTP就绪。
|
||||
- 新库预置SYB每小时同步与Yeeke同步status=2(启用);蝦皮规格自动匹配、异常规格AI解析status=1(关闭)。本轮只读检查,未改任务状态;用户手动启动API后既有调度会运行,真实外部访问须结合本地凭据/配置确认,不能把本地环境当作自动隔离的沙箱。
|
||||
- 配置动作未新增迁移、未部署线上、未安装APK;用户随后确认亲自点击了启动。健康/页面验证记录在工单,不将健康检查冒充登录、诊断下载或真机端到端验收。
|
||||
|
||||
@@ -2,7 +2,6 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -16,8 +15,8 @@ import (
|
||||
)
|
||||
|
||||
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
|
||||
// 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
|
||||
// writes an association when the task was a complete collection, 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)。
|
||||
@@ -33,7 +32,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 || !imageSearchResultAllowsAutoLink(task) || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
return autoLinkedImageSearch{}, nil
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(task)
|
||||
@@ -95,31 +94,6 @@ 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).
|
||||
//
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user