feat: 实现采购运行时规格解析 (#255)
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user