2026-08-14 10:08:32 +08:00
package service
import (
"context"
"database/sql"
2026-08-21 10:13:43 +08:00
"encoding/json"
2026-08-14 10:08:32 +08:00
"errors"
2026-08-21 10:13:43 +08:00
"fmt"
2026-08-14 10:08:32 +08:00
"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 ) {
2026-08-21 10:52:32 +08:00
t . Run ( "150个合格候选允许调用模型" , func ( t * testing . T ) {
2026-08-21 10:13:43 +08:00
db := newTestDB ( t )
2026-08-21 10:52:32 +08:00
actor := seedAIMatchContextWithData ( t , db , "SYB-AI-150" , "黑色,M" , collectedAIChoicesWithCount ( t , 150 ))
2026-08-21 10:13:43 +08:00
fake := & fakeAIModelClient { response : AIModelMatchResponse { Conclusion : "match" , CandidateID : "C01" , ConfidenceBPS : 9200 , Reason : "候选之一规格一致" }}
2026-08-21 10:52:32 +08:00
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 )
2026-08-21 10:13:43 +08:00
}
})
2026-08-21 10:52:32 +08:00
t . Run ( "151个合格候选不调用模型也不保存映射" , func ( t * testing . T ) {
2026-08-21 10:13:43 +08:00
db := newTestDB ( t )
2026-08-21 10:52:32 +08:00
actor := seedAIMatchContextWithData ( t , db , "SYB-AI-151" , "黑色,M" , collectedAIChoicesWithCount ( t , 151 ))
2026-08-21 10:13:43 +08:00
fake := & fakeAIModelClient { err : errors . New ( "候选超过上限时不应调用模型" )}
2026-08-21 10:52:32 +08:00
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 )
2026-08-21 10:13:43 +08:00
}
assertNoAIMapping ( t , db )
})
2026-08-14 10:08:32 +08:00
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}]}`
2026-08-21 10:13:43 +08:00
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 )
}
2026-08-14 10:08:32 +08:00
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 )
}
}