package service import ( "context" "database/sql" "encoding/json" "errors" "fmt" "testing" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) type fakeAIModelClient struct { response AIModelMatchResponse err error calls int last AIModelMatchRequest beforeReturn func() } func (f *fakeAIModelClient) Match(_ context.Context, _ model.AIProviderConfig, _ string, request AIModelMatchRequest) (AIModelMatchResponse, error) { f.calls++ f.last = request if f.beforeReturn != nil { f.beforeReturn() } return f.response, f.err } func TestValidateAIModelMatchResponse_严格结构(t *testing.T) { valid := AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9000, Reason: "颜色和尺码一致"} if err := validateAIModelMatchResponse(valid); err != nil { t.Fatal(err) } for _, invalid := range []AIModelMatchResponse{ {Conclusion: "yes", Reason: "x"}, {Conclusion: "match", ConfidenceBPS: 9000, Reason: "x"}, {Conclusion: "uncertain", ConfidenceBPS: 10001, Reason: "x"}, {Conclusion: "uncertain", ConfidenceBPS: 1}, } { if err := validateAIModelMatchResponse(invalid); err == nil { t.Fatalf("无效模型结论应被拒绝: %+v", invalid) } } } func TestBindingByID_伪造候选不能映射到真实选项(t *testing.T) { bindings := bindAICandidates([]PddOptionChoice{{Key: `{"color":"黑色"}`}}) if _, ok := bindingByID(bindings, "C99"); ok { t.Fatal("伪造候选编号不能命中服务端白名单") } if got, ok := bindingByID(bindings, "C01"); !ok || got.Choice.Key == "" { t.Fatal("服务端生成的候选编号应能还原真实选项") } } func TestResolveExtraDimensionSignals_额外维度必须有确定信号(t *testing.T) { first := map[string]string{"color": "黑色", "size": "M", "style": "常规"} second := map[string]string{"color": "黑色", "size": "M", "style": "加绒"} firstKey, _ := OptionKey(first) secondKey, _ := OptionKey(second) choices := []PddOptionChoice{{Key: firstKey, Label: "黑色 M 常规", Options: first}, {Key: secondKey, Label: "黑色 M 加绒", Options: second}} keys, names := []string{"color", "size", "style"}, []string{"颜色", "尺码", "款式"} if _, reason := resolveExtraDimensionSignals("黑色,M", choices, keys, names); reason == "" { t.Fatal("没有款式信号时不能交给 AI 猜额外维度") } got, reason := resolveExtraDimensionSignals("黑色,M,加绒", choices, keys, names) if reason != "" || len(got) != 1 || got[0].Options["style"] != "加绒" { t.Fatalf("明确额外维度应缩小候选: got=%+v reason=%q", got, reason) } } func TestMatchSybSpecWithAI_候选白名单低置信度和人工优先(t *testing.T) { t.Run("100个合格候选允许调用模型", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContextWithData(t, db, "SYB-AI-100", "黑色,M", collectedAIChoicesWithCount(t, 100)) fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9200, Reason: "候选之一规格一致"}} result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-100", "") if err != nil || result.Outcome != "ai_saved" || fake.calls != 1 || len(fake.last.Candidates) != 100 { t.Fatalf("100 条候选应调用模型: result=%+v calls=%d candidate_count=%d err=%v", result, fake.calls, len(fake.last.Candidates), err) } }) t.Run("101个合格候选不调用模型也不保存映射", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContextWithData(t, db, "SYB-AI-101", "黑色,M", collectedAIChoicesWithCount(t, 101)) fake := &fakeAIModelClient{err: errors.New("候选超过上限时不应调用模型")} result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-101", "") if err != nil || result.Outcome != "rejected" || result.Message != "可购买候选超过 100 条,请先人工核对" || fake.calls != 0 { t.Fatalf("101 条候选应在模型调用前拒绝: result=%+v calls=%d err=%v", result, fake.calls, err) } assertNoAIMapping(t, db) }) t.Run("合格AI结果直接保存", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContext(t, db, "SYB-AI-SAVE") fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9200, Reason: "主色和尺码一致"}} result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-SAVE", "") if err != nil || result.Outcome != "ai_saved" || result.Source != "ai" || fake.calls != 1 { t.Fatalf("AI 保存结果=%+v calls=%d err=%v", result, fake.calls, err) } var source string if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "ai" { t.Fatalf("当前映射来源=%q err=%v", source, err) } if err := SaveSybMapping(db, "SYB-AI-SAVE", result.OptionKey, actor.UserID); err != nil { t.Fatalf("人工覆盖 AI 映射失败: %v", err) } var auditCount int if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "manual" { t.Fatalf("人工覆盖后来源=%q err=%v", source, err) } if err := db.QueryRow(`SELECT COUNT(*) FROM ai_spec_match_decisions WHERE shopee_goods_id='SP-AI' AND outcome='ai_saved'`).Scan(&auditCount); err != nil || auditCount != 1 { t.Fatalf("人工覆盖不得删除 AI 审计: count=%d err=%v", auditCount, err) } }) t.Run("伪造候选不写映射", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContext(t, db, "SYB-AI-FORGE") fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C99", ConfidenceBPS: 9900, Reason: "尝试越界"}} result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-FORGE", "") if err != nil || result.Outcome != "rejected" { t.Fatalf("伪造候选结果=%+v err=%v", result, err) } assertNoAIMapping(t, db) }) t.Run("低置信度不写映射", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContext(t, db, "SYB-AI-LOW") fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 7999, Reason: "信号偏弱"}} result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-LOW", "") if err != nil || result.Outcome != "rejected" { t.Fatalf("低置信度结果=%+v err=%v", result, err) } assertNoAIMapping(t, db) }) t.Run("已有人工映射不调用模型", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContext(t, db, "SYB-AI-MANUAL") key, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"}) if err := SaveSybMapping(db, "SYB-AI-MANUAL", key, actor.UserID); err != nil { t.Fatal(err) } fake := &fakeAIModelClient{err: errors.New("不应调用")} result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-MANUAL", "") if err != nil || result.Outcome != "reused" || result.Source != "manual" || fake.calls != 0 { t.Fatalf("人工复用结果=%+v calls=%d err=%v", result, fake.calls, err) } }) t.Run("唯一确定规则结果不调用模型", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContextWithData(t, db, "SYB-AI-RULE", "灰色-小個子,L建議53-57公斤", collectedRuleChoices) fake := &fakeAIModelClient{err: errors.New("不应调用")} result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-RULE", "") if err != nil || result.Outcome != "rule_saved" || result.Source != "rule" || fake.calls != 0 { t.Fatalf("规则保存结果=%+v calls=%d err=%v", result, fake.calls, err) } }) t.Run("模型调用期间人工保存仍然优先", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContext(t, db, "SYB-AI-RACE") key, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"}) fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9500, Reason: "规格一致"}} fake.beforeReturn = func() { if err := SaveSybMapping(db, "SYB-AI-RACE", key, actor.UserID); err != nil { t.Fatalf("并发人工保存失败: %v", err) } } result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-RACE", "") if err != nil || result.Outcome != "manual_exists" || result.Source != "manual" { t.Fatalf("人工并发优先结果=%+v err=%v", result, err) } var source string if err := db.QueryRow(`SELECT source FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&source); err != nil || source != "manual" { t.Fatalf("并发后来源=%q err=%v", source, err) } }) } const collectedAIChoices = `{"goods_id":"737116531267","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"黑色","size":"M"},"price_cent":1180,"available":true},{"options":{"color":"白色","size":"L"},"price_cent":1280,"available":true}]}` const collectedRuleChoices = `{"goods_id":"737116531267","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"灰色中长款","size":"L(106-114斤)"},"price_cent":1180,"available":true},{"options":{"color":"黑色","size":"L(106-114斤)"},"price_cent":1280,"available":true}]}` func collectedAIChoicesWithCount(t *testing.T, count int) string { t.Helper() type collectedSKU struct { Options map[string]string `json:"options"` PriceCent int64 `json:"price_cent"` Available bool `json:"available"` } payload := struct { GoodsID string `json:"goods_id"` Dimensions []map[string]string `json:"dimensions"` SKUs []collectedSKU `json:"skus"` }{ GoodsID: "737116531267", Dimensions: []map[string]string{ {"key": "color", "name": "颜色"}, {"key": "size", "name": "尺码"}, }, SKUs: make([]collectedSKU, 0, count), } for index := 1; index <= count; index++ { payload.SKUs = append(payload.SKUs, collectedSKU{ Options: map[string]string{"color": fmt.Sprintf("黑色款%03d", index), "size": "M"}, PriceCent: 1180, Available: true, }) } raw, err := json.Marshal(payload) if err != nil { t.Fatal(err) } return string(raw) } func seedAIMatchContext(t *testing.T, db *sql.DB, sybID string) model.User { return seedAIMatchContextWithData(t, db, sybID, "黑色,M", collectedAIChoices) } func seedAIMatchContextWithData(t *testing.T, db *sql.DB, sybID, rawSpec, collected string) model.User { t.Helper() actor := model.User{UserID: "USR-AI", Username: "ai-buyer", PasswordHash: "test-hash", Role: model.RolePurchaser, Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()} if err := repository.CreateUser(db, actor); err != nil { t.Fatal(err) } seedWorkflowOrder(t, db, sybID, "SP-AI", rawSpec) if _, err := AssociateShopeePdd(db, "SP-AI", pddURLA, false); err != nil { t.Fatal(err) } if err := repository.SetCollectResult(db, "737116531267", "PDD 测试商品", "测试店铺", collected); err != nil { t.Fatal(err) } return actor } func testAIMatchSnapshot(client AIModelClient) AIMatchSnapshot { provider := model.AIProviderConfig{ProviderID: "AIP-TEST", Name: "假模型", Model: "fake-model", ConfidenceThresholdBPS: 8000} return AIMatchSnapshot{Provider: provider, ConfigFingerprint: "test-fingerprint", Secret: "fake-secret-only-test", Client: client} } func assertNoAIMapping(t *testing.T, db *sql.DB) { t.Helper() var count int if err := db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SP-AI'`).Scan(&count); err != nil || count != 0 { t.Fatalf("不应写映射: count=%d err=%v", count, err) } }