Files
cmautobuy/admin/service/purchase_spec_resolution_test.go
T

498 lines
22 KiB
Go

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
}
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
}
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
}
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)
}
}
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
}