2026-08-17 17:20:07 +08:00
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
}
2026-08-18 08:56:56 +08:00
type runtimeDeadlineAIClient struct {
deadline time . Time
deadlineOK bool
contextErr error
}
func ( client * runtimeDeadlineAIClient ) Match ( ctx context . Context , _ model . AIProviderConfig , _ string ,
_ AIModelMatchRequest ) ( AIModelMatchResponse , error ) {
client . deadline , client . deadlineOK = ctx . Deadline ()
client . contextErr = ctx . Err ()
if client . contextErr != nil {
return AIModelMatchResponse {}, client . contextErr
}
return AIModelMatchResponse { Conclusion : "uncertain" , ConfidenceBPS : 7000 ,
Reason : "测试信息不足" }, nil
}
2026-08-17 17:20:07 +08:00
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
}
2026-08-18 08:56:56 +08:00
func TestMatchPurchaseSpecWithRuntimeAI_限制30秒并继承更早取消 ( t * testing . T ) {
req , _ , _ := validRuntimeSpecRequest ( t , "cg257-timeout" , "L码" , "M码" , "XL码" )
client := & runtimeDeadlineAIClient {}
loader := func ( context . Context , * sql . DB , AISecretStore , AIEndpointPolicy ) ( AIMatchSnapshot , error ) {
return AIMatchSnapshot { Provider : model . AIProviderConfig {
ProviderID : "provider-timeout" , Model : "model-timeout" , TimeoutSeconds : 120 ,
ConfidenceThresholdBPS : 9000 ,
}, ConfigFingerprint : "fingerprint" , Secret : "not-a-real-secret" , Client : client }, nil
}
startedAt := time . Now ()
decision := matchPurchaseSpecWithRuntimeAI ( context . Background (), nil , nil ,
AIEndpointPolicy {}, req , loader )
if decision . Outcome != model . PurchaseSpecResolutionUncertain {
t . Fatalf ( "outcome=%s reason=%s" , decision . Outcome , decision . Reason )
}
if ! client . deadlineOK {
t . Fatal ( "运行时 AI 上下文缺少截止时间" )
}
budget := client . deadline . Sub ( startedAt )
if budget < 29 * time . Second || budget > purchaseSpecResolutionAITimeout {
t . Fatalf ( "运行时 AI 时间预算=%s,期望不超过 %s" , budget , purchaseSpecResolutionAITimeout )
}
cancelledContext , cancel := context . WithCancel ( context . Background ())
cancel ()
cancelledClient := & runtimeDeadlineAIClient {}
cancelledLoader := func ( context . Context , * sql . DB , AISecretStore , AIEndpointPolicy ) ( AIMatchSnapshot , error ) {
return AIMatchSnapshot { Provider : model . AIProviderConfig {
ProviderID : "provider-timeout" , Model : "model-timeout" , TimeoutSeconds : 120 ,
ConfidenceThresholdBPS : 9000 ,
}, ConfigFingerprint : "fingerprint" , Secret : "not-a-real-secret" , Client : cancelledClient }, nil
}
decision = matchPurchaseSpecWithRuntimeAI ( cancelledContext , nil , nil ,
AIEndpointPolicy {}, req , cancelledLoader )
if decision . Outcome != model . PurchaseSpecResolutionFailed ||
! errors . Is ( cancelledClient . contextErr , context . Canceled ) {
t . Fatalf ( "更早取消未继承: outcome=%s context_err=%v" , decision . Outcome , cancelledClient . contextErr )
}
}
2026-08-17 17:20:07 +08:00
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
}