255 lines
12 KiB
Go
255 lines
12 KiB
Go
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)
|
||
}
|
||
}
|