441 lines
19 KiB
Go
441 lines
19 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
|
|
}
|
|
|
|
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 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
|
|
}
|