package service import ( "context" "database/sql" "encoding/json" "errors" "fmt" "os" "strings" "sync" "sync/atomic" "testing" "time" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) func TestMatchPurchaseSpecByRule_只接受唯一等价候选(t *testing.T) { tests := []struct { name string target string values []string terminal bool outcome model.PurchaseSpecResolutionOutcome candidate string }{ {name: "逐字相同", target: "M码", values: []string{"S码", "M码"}, terminal: true, outcome: model.PurchaseSpecResolutionMatched, candidate: "c2"}, {name: "繁简大小写和展示符号", target: "藍 色 / XL 碼", values: []string{"蓝色/xl码"}, terminal: true, outcome: model.PurchaseSpecResolutionMatched, candidate: "c1"}, {name: "公斤和斤单值", target: "60公斤", values: []string{"100斤", "120斤"}, terminal: true, outcome: model.PurchaseSpecResolutionMatched, candidate: "c2"}, {name: "公斤和斤正区间", target: "50-60 kg", values: []string{"80~100斤", "100至120斤"}, terminal: true, outcome: model.PurchaseSpecResolutionMatched, candidate: "c2"}, {name: "多个重叠区间直接不确定", target: "50-60公斤", values: []string{"90-110斤", "110-130斤"}, terminal: true, outcome: model.PurchaseSpecResolutionUncertain}, {name: "倒序区间不自动修正", target: "60-50公斤", values: []string{"100-120斤"}, terminal: false}, {name: "加减尺码不能被符号归一化合并", target: "M+", values: []string{"M-"}, terminal: false}, {name: "多个等价候选不选一个", target: "60公斤", values: []string{"120斤", "60kg"}, terminal: true, outcome: model.PurchaseSpecResolutionUncertain}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { candidates := runtimeCandidates("黑色", test.values...) decision, terminal := matchPurchaseSpecByRule(test.target, candidates) if terminal != test.terminal || decision.Outcome != test.outcome { t.Fatalf("terminal=%v outcome=%s", terminal, decision.Outcome) } if test.candidate != "" && (decision.Candidate == nil || decision.Candidate.CandidateID != test.candidate) { t.Fatalf("candidate=%+v", decision.Candidate) } if test.outcome != model.PurchaseSpecResolutionMatched && decision.Candidate != nil { t.Fatalf("非 matched 不应返回候选: %+v", decision.Candidate) } }) } } func TestParsePurchaseSpecResolutionRequest_校验候选快照和确定性幂等键(t *testing.T) { req, body, key := validRuntimeSpecRequest(t, "cg255", "M码", "S码", "M码") parsed, err := parsePurchaseSpecResolutionRequest("cg255", key, body) if err != nil || parsed.CandidateSnapshotHash != req.CandidateSnapshotHash { t.Fatalf("合法请求解析失败: parsed=%+v err=%v", parsed, err) } badHash := append([]byte(nil), body...) var changed map[string]any if err := json.Unmarshal(badHash, &changed); err != nil { t.Fatal(err) } changed["candidate_snapshot_hash"] = "0000000000000000000000000000000000000000000000000000000000000000" badHash, _ = json.Marshal(changed) if _, err := parsePurchaseSpecResolutionRequest("cg255", key, badHash); !errors.Is(err, ErrSpecResolutionHashMismatch) { t.Fatalf("错误快照哈希 err=%v", err) } req.Candidates[0].CandidateID = "c2" req.CandidateSnapshotHash = purchaseSpecCandidateSnapshotHash(req) invalidBody, _ := json.Marshal(req) invalidKey := purchaseSpecIdempotencyKey("cg255", req) if _, err := parsePurchaseSpecResolutionRequest("cg255", invalidKey, invalidBody); !errors.Is(err, ErrInvalidSpecResolutionRequest) { t.Fatalf("跳号候选 err=%v", err) } tooLarge := make([]byte, MaxPurchaseSpecResolutionBodyBytes+1) if _, err := parsePurchaseSpecResolutionRequest("cg255", key, tooLarge); !errors.Is(err, ErrInvalidSpecResolutionBody) { t.Fatalf("超大请求 err=%v", err) } } type stubAIModelClient struct { response AIModelMatchResponse err error delay time.Duration calls atomic.Int32 last AIModelMatchRequest mu sync.Mutex } func (client *stubAIModelClient) Match(ctx context.Context, _ model.AIProviderConfig, _ string, request AIModelMatchRequest) (AIModelMatchResponse, error) { client.calls.Add(1) client.mu.Lock() client.last = request client.mu.Unlock() if client.delay > 0 { select { case <-time.After(client.delay): case <-ctx.Done(): return AIModelMatchResponse{}, ctx.Err() } } return client.response, client.err } func TestMatchPurchaseSpecWithAI_只接受请求候选并执行安全门禁(t *testing.T) { tests := []struct { name string response AIModelMatchResponse modelErr error threshold int outcome model.PurchaseSpecResolutionOutcome candidate string }{ {name: "白名单内高置信命中", threshold: 8000, outcome: model.PurchaseSpecResolutionMatched, candidate: "c2", response: AIModelMatchResponse{Conclusion: "match", CandidateID: "c2", ConfidenceBPS: 9200, Reason: "唯一对应"}}, {name: "越权候选拒绝", threshold: 8000, outcome: model.PurchaseSpecResolutionRejected, response: AIModelMatchResponse{Conclusion: "match", CandidateID: "invented", ConfidenceBPS: 9200, Reason: "错误编号"}}, {name: "低于阈值不自动选择", threshold: 9500, outcome: model.PurchaseSpecResolutionUncertain, response: AIModelMatchResponse{Conclusion: "match", CandidateID: "c2", ConfidenceBPS: 9200, Reason: "置信不足"}}, {name: "冲突维度不自动选择", threshold: 8000, outcome: model.PurchaseSpecResolutionUncertain, response: AIModelMatchResponse{Conclusion: "match", CandidateID: "c2", ConfidenceBPS: 9200, Reason: "仍缺信息", MissingDimensions: []string{"尺码"}}}, {name: "模型格式错误记失败", threshold: 8000, outcome: model.PurchaseSpecResolutionFailed, response: AIModelMatchResponse{Conclusion: "other", ConfidenceBPS: 9200, Reason: "非法"}}, {name: "模型超时和密钥不进入原因", threshold: 8000, outcome: model.PurchaseSpecResolutionFailed, modelErr: errors.New("upstream timeout for secret")}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { client := &stubAIModelClient{response: test.response, err: test.modelErr} loader := func(context.Context, *sql.DB, AISecretStore, AIEndpointPolicy) (AIMatchSnapshot, error) { return AIMatchSnapshot{Provider: model.AIProviderConfig{ProviderID: "provider-1", Model: "model-1", ConfidenceThresholdBPS: test.threshold}, ConfigFingerprint: "fingerprint", Secret: "secret", Client: client}, nil } req, _, _ := validRuntimeSpecRequest(t, "cg255", "L码", "M码", "XL码") decision := matchPurchaseSpecWithAI(context.Background(), nil, nil, AIEndpointPolicy{}, req, loader) if decision.Outcome != test.outcome { t.Fatalf("outcome=%s reason=%s", decision.Outcome, decision.Reason) } if test.modelErr != nil && strings.Contains(decision.Reason, "secret") { t.Fatalf("模型错误泄露密钥: %s", decision.Reason) } if test.candidate != "" && (decision.Candidate == nil || decision.Candidate.CandidateID != test.candidate) { t.Fatalf("candidate=%+v", decision.Candidate) } if test.outcome != model.PurchaseSpecResolutionMatched && decision.Candidate != nil { t.Fatalf("非 matched 不得带候选: %+v", decision.Candidate) } if client.calls.Load() != 1 { t.Fatalf("model calls=%d", client.calls.Load()) } for index, candidate := range client.last.Candidates { if candidate.ID != fmt.Sprintf("c%d", index+1) { t.Fatalf("AI 候选编号被改写: %+v", client.last.Candidates) } } }) } } func TestResolvePurchaseSpec_规则命中幂等且不改任务主数据(t *testing.T) { db := newRuntimeSpecTestDB(t) prepareRuntimeSpecTask(t, db, "cg255-rule", model.TaskPurchase, true, `{"color":"黑色","size":"60公斤"}`) now := model.NowISO() originalSKUs := `{"spec_source":"client","skus":[{"options":{"color":"黑色","size":"60公斤"},"available":true}]}` if _, err := db.Exec(`INSERT INTO pdd_products (goods_id,url,title,skus_json,collect_status,created_at,updated_at) VALUES(?,?,?,?,'collected',?,?)`, "PDD-255", "https://mobile.yangkeduo.com/goods.html?goods_id=PDD-255", "测试商品", originalSKUs, now, now); err != nil { t.Fatal(err) } _, body, key := validRuntimeSpecRequest(t, "cg255-rule", "60公斤", "100斤", "120斤") var loaderCalls atomic.Int32 loader := func(context.Context, *sql.DB, AISecretStore, AIEndpointPolicy) (AIMatchSnapshot, error) { loaderCalls.Add(1) return AIMatchSnapshot{}, errors.New("规则命中不应加载 AI") } first, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil, AIEndpointPolicy{}, "cg255-rule", "CLIENT-255", key, body, loader) if err != nil { t.Fatal(err) } second, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil, AIEndpointPolicy{}, "cg255-rule", "CLIENT-255", key, body, loader) if err != nil || second != first { t.Fatalf("幂等重放不同: first=%s second=%s err=%v", first, second, err) } if loaderCalls.Load() != 0 { t.Fatalf("规则命中却调用 AI %d 次", loaderCalls.Load()) } var response PurchaseSpecResolutionResponse if err := json.Unmarshal([]byte(first), &response); err != nil { t.Fatal(err) } if response.Outcome != model.PurchaseSpecResolutionMatched || response.Source == nil || *response.Source != model.PurchaseSpecResolutionRule || response.Match == nil || response.Match.CandidateID != "c2" { t.Fatalf("响应错误: %+v", response) } var status, options string var version, maxPrice int64 var resultData sql.NullString if err := db.QueryRow(`SELECT status,version,pdd_options,max_price_cent,result_data FROM tasks WHERE task_id=?`, "cg255-rule"). Scan(&status, &version, &options, &maxPrice, &resultData); err != nil { t.Fatal(err) } if status != string(model.TaskClaimed) || version != 1 || options != `{"color":"黑色","size":"60公斤"}` || maxPrice != 2000 || resultData.Valid { t.Fatalf("任务被规格解析改写: status=%s version=%d options=%s max=%d result=%q", status, version, options, maxPrice, resultData.String) } var actualSKUs string if err := db.QueryRow(`SELECT skus_json FROM pdd_products WHERE goods_id='PDD-255'`).Scan(&actualSKUs); err != nil || actualSKUs != originalSKUs { t.Fatalf("PDD 主规格被改写: skus=%s err=%v", actualSKUs, err) } var count int if err := db.QueryRow(`SELECT COUNT(*) FROM purchase_spec_resolutions WHERE task_id=?`, "cg255-rule").Scan(&count); err != nil || count != 1 { t.Fatalf("审计记录 count=%d err=%v", count, err) } } func TestResolvePurchaseSpec_业务权限错误不写解析记录(t *testing.T) { tests := []struct { name string taskType model.TaskType claimed bool mutate func(*PurchaseSpecResolutionRequest) expected error }{ {name: "非采购任务", taskType: model.TaskCollect, claimed: true, expected: ErrTaskNotPurchase}, {name: "从未领取", taskType: model.TaskPurchase, claimed: false, expected: ErrNeverClaimed}, {name: "任务版本冲突", taskType: model.TaskPurchase, claimed: true, mutate: func(req *PurchaseSpecResolutionRequest) { req.TaskVersion = 2 }, expected: ErrTaskVersionConflict}, {name: "PDD 商品不一致", taskType: model.TaskPurchase, claimed: true, mutate: func(req *PurchaseSpecResolutionRequest) { req.PddGoodsID = "OTHER" }, expected: ErrPDDGoodsMismatch}, {name: "任务原始规格被改写", taskType: model.TaskPurchase, claimed: true, mutate: func(req *PurchaseSpecResolutionRequest) { req.OriginalOptions["size"] = "XL码" }, expected: ErrInvalidSpecResolutionRequest}, } for index, test := range tests { t.Run(test.name, func(t *testing.T) { db := newRuntimeSpecTestDB(t) taskID := fmt.Sprintf("cg255-reject-%d", index) prepareRuntimeSpecTask(t, db, taskID, test.taskType, test.claimed, `{"color":"黑色","size":"M码"}`) req, _, _ := validRuntimeSpecRequest(t, taskID, "M码", "M码", "L码") if test.mutate != nil { test.mutate(&req) } req.CandidateSnapshotHash = purchaseSpecCandidateSnapshotHash(req) body, _ := json.Marshal(req) key := purchaseSpecIdempotencyKey(taskID, req) _, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil, AIEndpointPolicy{}, taskID, "CLIENT-255", key, body, func(context.Context, *sql.DB, AISecretStore, AIEndpointPolicy) (AIMatchSnapshot, error) { t.Fatal("权限错误前不应调用 AI") return AIMatchSnapshot{}, nil }) if !errors.Is(err, test.expected) { t.Fatalf("err=%v expected=%v", err, test.expected) } var count int if queryErr := db.QueryRow(`SELECT COUNT(*) FROM purchase_spec_resolutions`).Scan(&count); queryErr != nil || count != 0 { t.Fatalf("拒绝请求产生审计记录 count=%d err=%v", count, queryErr) } }) } } func TestResolvePurchaseSpec_AI并发重放只产生一个模型调用和一个结论(t *testing.T) { db := newRuntimeSpecTestDB(t) prepareRuntimeSpecTask(t, db, "cg255-ai", model.TaskPurchase, true, `{"color":"黑色","size":"L码"}`) _, body, key := validRuntimeSpecRequest(t, "cg255-ai", "L码", "M码", "XL码") client := &stubAIModelClient{delay: 50 * time.Millisecond, response: AIModelMatchResponse{Conclusion: "match", CandidateID: "c2", ConfidenceBPS: 9300, Reason: "模型唯一匹配"}} loader := func(context.Context, *sql.DB, AISecretStore, AIEndpointPolicy) (AIMatchSnapshot, error) { return AIMatchSnapshot{Provider: model.AIProviderConfig{ProviderID: "provider-1", Model: "model-1", ConfidenceThresholdBPS: 9000}, ConfigFingerprint: "fingerprint", Secret: "secret", Client: client}, nil } const workers = 6 responses := make(chan string, workers) errorsFound := make(chan error, workers) var wait sync.WaitGroup for range workers { wait.Add(1) go func() { defer wait.Done() response, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil, AIEndpointPolicy{}, "cg255-ai", "CLIENT-255", key, body, loader) responses <- response errorsFound <- err }() } wait.Wait() close(responses) close(errorsFound) for err := range errorsFound { if err != nil { t.Fatal(err) } } first := "" for response := range responses { if first == "" { first = response } else if response != first { t.Fatalf("并发响应不一致: first=%s other=%s", first, response) } } if client.calls.Load() != 1 { t.Fatalf("模型调用次数=%d", client.calls.Load()) } var records, finalRecords, idempotency int if err := db.QueryRow(`SELECT COUNT(*),SUM(outcome<>'pending') FROM purchase_spec_resolutions WHERE task_id=?`, "cg255-ai").Scan(&records, &finalRecords); err != nil { t.Fatal(err) } if err := db.QueryRow(`SELECT COUNT(*) FROM idempotency_keys WHERE `+"`key`"+`=?`, key).Scan(&idempotency); err != nil { t.Fatal(err) } if records != 1 || finalRecords != 1 || idempotency != 1 { t.Fatalf("records=%d final=%d idempotency=%d", records, finalRecords, idempotency) } } func TestResolvePurchaseSpec_ShopeeBackfill只作诊断不阻断规则(t *testing.T) { db := newRuntimeSpecTestDB(t) prepareRuntimeSpecTask(t, db, "cg255-backfill", model.TaskPurchase, true, `{"color":"黑色","size":"60公斤"}`) now := model.NowISO() _, err := db.Exec(`INSERT INTO pdd_products (goods_id,url,title,skus_json,collect_status,created_at,updated_at) VALUES(?,?,?,?,'collected',?,?)`, "PDD-255", "https://mobile.yangkeduo.com/goods.html?goods_id=PDD-255", "测试商品", `{"spec_source":"shopee_backfill","skus":[]}`, now, now) if err != nil { t.Fatal(err) } _, body, key := validRuntimeSpecRequest(t, "cg255-backfill", "60公斤", "120斤") response, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil, AIEndpointPolicy{}, "cg255-backfill", "CLIENT-255", key, body, func(context.Context, *sql.DB, AISecretStore, AIEndpointPolicy) (AIMatchSnapshot, error) { t.Fatal("规则唯一命中不应调用 AI") return AIMatchSnapshot{}, nil }) if err != nil { t.Fatal(err) } var parsed PurchaseSpecResolutionResponse if err := json.Unmarshal([]byte(response), &parsed); err != nil || parsed.Outcome != model.PurchaseSpecResolutionMatched { t.Fatalf("response=%s err=%v", response, err) } if !strings.Contains(parsed.Reason, "诊断来源:shopee_backfill") { t.Fatalf("未保存回填规格诊断来源: %+v", parsed) } } func runtimeCandidates(color string, values ...string) []PurchaseSpecCandidate { result := make([]PurchaseSpecCandidate, 0, len(values)) for index, value := range values { result = append(result, PurchaseSpecCandidate{CandidateID: fmt.Sprintf("c%d", index+1), RawText: value, Options: map[string]string{"color": color, "size": value}}) } return result } func validRuntimeSpecRequest(t *testing.T, taskID, target string, candidates ...string) (PurchaseSpecResolutionRequest, []byte, string) { t.Helper() req := PurchaseSpecResolutionRequest{SchemaVersion: 1, TaskVersion: 1, AttemptID: "attempt-255", PddGoodsID: "PDD-255", OriginalOptions: map[string]string{"color": "黑色", "size": target}, SelectedColor: "黑色", TargetSize: target, Candidates: runtimeCandidates("黑色", candidates...), ObservedAt: "2026-08-17T08:00:00Z"} req.CandidateSnapshotHash = purchaseSpecCandidateSnapshotHash(req) body, err := json.Marshal(req) if err != nil { t.Fatal(err) } return req, body, purchaseSpecIdempotencyKey(taskID, req) } func prepareRuntimeSpecTask(t *testing.T, db *sql.DB, taskID string, taskType model.TaskType, claimed bool, options string) { t.Helper() now := model.NowISO() _, err := db.Exec(`INSERT INTO clients (client_id,name,device_address,platform,pdd_package,capabilities,last_seen_at,created_at,updated_at) VALUES('CLIENT-255','测试客户端','','android','','{}',?,?,?)`, now, now, now) if err != nil { t.Fatal(err) } _, err = db.Exec(`INSERT INTO tasks (task_id,task_type,status,version,assigned_client,goods_id,pdd_goods_url,pdd_goods_id,pdd_options, quantity,max_price_cent,created_at,updated_at) VALUES(?,?,?,1,'CLIENT-255','SHOPEE-255',?,?,?,1,2000,?,?)`, taskID, taskType, model.TaskClaimed, "https://mobile.yangkeduo.com/goods.html?goods_id=PDD-255", "PDD-255", options, now, now) if err != nil { t.Fatal(err) } if claimed { if _, err := db.Exec(`INSERT INTO task_claims(task_id,client_id,claimed_at) VALUES(?,?,?)`, taskID, "CLIENT-255", now); err != nil { t.Fatal(err) } } } // newRuntimeSpecTestDB 默认使用历史 SQLite 测试库跑服务编排;显式打开 MySQL 测试时 // 自动切到隔离的 MySQL 8 数据库。生产代码仍只运行在 MySQL。 func newRuntimeSpecTestDB(t *testing.T) *sql.DB { t.Helper() if os.Getenv("CMAUTOBUY_MYSQL_TEST") == "1" { return newTestDB(t) } db, err := repository.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if err := repository.Migrate(db); err != nil { t.Fatal(err) } _, err = db.Exec(`CREATE TABLE purchase_spec_resolutions ( resolution_id TEXT PRIMARY KEY,task_id TEXT NOT NULL,attempt_id TEXT NOT NULL,client_id TEXT NOT NULL, task_version INTEGER NOT NULL,pdd_goods_id TEXT NOT NULL,original_options_json TEXT NOT NULL, selected_color TEXT NOT NULL,target_size TEXT NOT NULL,candidates_json TEXT NOT NULL, candidate_snapshot_hash TEXT NOT NULL,request_hash TEXT NOT NULL,observed_at TEXT NOT NULL, outcome TEXT NOT NULL DEFAULT 'pending',decision_source TEXT,chosen_candidate_id TEXT, resolved_options_json TEXT,confidence_bps INTEGER,reason TEXT,provider_id TEXT,source_model TEXT, config_fingerprint TEXT,rules_version TEXT,prompt_version TEXT,created_at TEXT NOT NULL,decided_at TEXT, UNIQUE(task_id,attempt_id,candidate_snapshot_hash) )`) if err != nil { t.Fatal(err) } return db }