Files
cmautobuy/admin/service/ai_specmatch_test.go
T

255 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("150个合格候选允许调用模型", func(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContextWithData(t, db, "SYB-AI-150", "黑色,M", collectedAIChoicesWithCount(t, 150))
fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9200, Reason: "候选之一规格一致"}}
result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-150", "")
if err != nil || result.Outcome != "ai_saved" || fake.calls != 1 || len(fake.last.Candidates) != 150 {
t.Fatalf("150 条候选应调用模型: result=%+v calls=%d candidate_count=%d err=%v", result, fake.calls, len(fake.last.Candidates), err)
}
})
t.Run("151个合格候选不调用模型也不保存映射", func(t *testing.T) {
db := newTestDB(t)
actor := seedAIMatchContextWithData(t, db, "SYB-AI-151", "黑色,M", collectedAIChoicesWithCount(t, 151))
fake := &fakeAIModelClient{err: errors.New("候选超过上限时不应调用模型")}
result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-151", "")
if err != nil || result.Outcome != "rejected" || result.Message != "可购买候选超过 150 条,请先人工核对" || fake.calls != 0 {
t.Fatalf("151 条候选应在模型调用前拒绝: result=%+v calls=%d candidate_count=%d err=%v", result, fake.calls, len(fake.last.Candidates), 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)
}
}