test: 增加采购规格跨端联合验收 (#258)

This commit is contained in:
chengma
2026-08-17 18:34:35 +08:00
parent 24f91bb7d7
commit be900b2411
6 changed files with 1235 additions and 0 deletions
@@ -0,0 +1,343 @@
package api
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
type apiSpecContractFixture struct {
Contract string `json:"contract"`
SchemaVersion int `json:"schema_version"`
Defaults apiSpecContractDefaults `json:"defaults"`
Cases []apiSpecContractCase `json:"cases"`
ErrorCases []apiSpecContractErrorCase `json:"error_cases"`
}
type apiSpecContractDefaults struct {
ClientID string `json:"client_id"`
TaskVersion int64 `json:"task_version"`
PDDGoodsID string `json:"pdd_goods_id"`
SelectedColor string `json:"selected_color"`
AttemptID string `json:"attempt_id"`
ObservedAt string `json:"observed_at"`
}
type apiSpecContractCase struct {
Name string `json:"name"`
TaskID string `json:"task_id"`
TargetSize string `json:"target_size"`
Candidates []string `json:"candidates"`
CandidateSnapshotHash string `json:"candidate_snapshot_hash"`
IdempotencyKey string `json:"idempotency_key"`
Mode string `json:"mode"`
}
type apiSpecContractErrorCase struct {
Name string `json:"name"`
BaseCase string `json:"base_case"`
Mutation string `json:"mutation"`
ExpectedHTTPStatus int `json:"expected_http_status"`
ExpectedErrorCode string `json:"expected_error_code"`
}
func TestPurchaseSpecResolutionContractVectors_AdminHTTPRuleReplay(t *testing.T) {
fixture := loadAPISpecContractFixture(t)
var ruleCase apiSpecContractCase
for _, candidate := range fixture.Cases {
if candidate.Name == "rule_weight_unique" {
ruleCase = candidate
break
}
}
if ruleCase.Name == "" {
t.Fatal("共享向量缺少 rule_weight_unique")
}
db := newAPISpecContractDB(t)
prepareAPISpecContractTask(t, db, fixture.Defaults, ruleCase, model.TaskPurchase, true)
router := newAPISpecContractRouter(db)
requestBody, key := buildAPISpecContractRequest(fixture.Defaults, ruleCase)
first := performAPISpecContractRequest(router, ruleCase.TaskID, fixture.Defaults.ClientID, key, requestBody)
second := performAPISpecContractRequest(router, ruleCase.TaskID, fixture.Defaults.ClientID, key, requestBody)
if first.Code != http.StatusOK || second.Code != http.StatusOK || first.Body.String() != second.Body.String() {
t.Fatalf("HTTP 幂等响应不一致 first=%d %s second=%d %s",
first.Code, first.Body.String(), second.Code, second.Body.String())
}
var response map[string]any
if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
match, _ := response["match"].(map[string]any)
if response["outcome"] != "matched" || response["source"] != "rule" || match["candidate_id"] != "c2" {
t.Fatalf("HTTP 规则响应=%v", response)
}
}
func TestPurchaseSpecResolutionContractVectors_AdminHTTPErrors(t *testing.T) {
fixture := loadAPISpecContractFixture(t)
caseByName := make(map[string]apiSpecContractCase, len(fixture.Cases))
for _, testCase := range fixture.Cases {
caseByName[testCase.Name] = testCase
}
for _, errorCase := range fixture.ErrorCases {
t.Run(errorCase.Name, func(t *testing.T) {
base, ok := caseByName[errorCase.BaseCase]
if !ok {
t.Fatalf("未知基础向量 %q", errorCase.BaseCase)
}
db := newAPISpecContractDB(t)
taskType := model.TaskPurchase
claimed := true
if errorCase.Mutation == "task_not_purchase" {
taskType = model.TaskCollect
}
if errorCase.Mutation == "task_not_claimed" {
claimed = false
}
if errorCase.Mutation != "task_not_found" {
prepareAPISpecContractTask(t, db, fixture.Defaults, base, taskType, claimed)
}
router := newAPISpecContractRouter(db)
body, key := buildAPISpecContractRequest(fixture.Defaults, base)
taskID := base.TaskID
applyAPISpecContractMutation(errorCase.Mutation, &taskID, body, &key)
if errorCase.Mutation == "idempotency_conflict" {
original, originalKey := buildAPISpecContractRequest(fixture.Defaults, base)
first := performAPISpecContractRequest(router, base.TaskID, fixture.Defaults.ClientID,
originalKey, original)
if first.Code != http.StatusOK {
t.Fatalf("冲突向量首请求失败 status=%d body=%s", first.Code, first.Body.String())
}
}
recorder := performAPISpecContractRequest(router, taskID, fixture.Defaults.ClientID, key, body)
if recorder.Code != errorCase.ExpectedHTTPStatus {
t.Fatalf("status=%d expected=%d body=%s", recorder.Code,
errorCase.ExpectedHTTPStatus, recorder.Body.String())
}
var response struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Error.Code != errorCase.ExpectedErrorCode {
t.Fatalf("error.code=%s expected=%s body=%s", response.Error.Code,
errorCase.ExpectedErrorCode, recorder.Body.String())
}
})
}
}
func loadAPISpecContractFixture(t *testing.T) apiSpecContractFixture {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "..", "testdata", "contracts",
"purchase_spec_resolution_v1.json"))
if err != nil {
t.Fatal(err)
}
var fixture apiSpecContractFixture
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatal(err)
}
if fixture.Contract != "purchase-spec-resolution-v1" || fixture.SchemaVersion != 1 {
t.Fatalf("共享契约向量头无效: %+v", fixture)
}
return fixture
}
func buildAPISpecContractRequest(defaults apiSpecContractDefaults,
testCase apiSpecContractCase) (map[string]any, string) {
candidates := make([]any, 0, len(testCase.Candidates))
for index, value := range testCase.Candidates {
candidates = append(candidates, map[string]any{
"candidate_id": "c" + strconv.Itoa(index+1),
"raw_text": value,
"options": map[string]any{"color": defaults.SelectedColor, "size": value},
})
}
request := map[string]any{
"schema_version": 1,
"task_version": defaults.TaskVersion,
"attempt_id": defaults.AttemptID,
"pdd_goods_id": defaults.PDDGoodsID,
"original_options": map[string]any{"color": defaults.SelectedColor, "size": testCase.TargetSize},
"selected_color": defaults.SelectedColor,
"target_size": testCase.TargetSize,
"candidates": candidates,
"candidate_snapshot_hash": testCase.CandidateSnapshotHash,
"observed_at": defaults.ObservedAt,
}
if actual := apiSpecSnapshotHash(request); actual != testCase.CandidateSnapshotHash {
panic(fmt.Sprintf("共享向量候选哈希错误 actual=%s expected=%s", actual, testCase.CandidateSnapshotHash))
}
if actual := apiSpecIdempotencyKey(testCase.TaskID, request); actual != testCase.IdempotencyKey {
panic(fmt.Sprintf("共享向量幂等键错误 actual=%s expected=%s", actual, testCase.IdempotencyKey))
}
return request, testCase.IdempotencyKey
}
func applyAPISpecContractMutation(mutation string, taskID *string, request map[string]any, key *string) {
switch mutation {
case "schema_version_2":
request["schema_version"] = 2
case "candidate_id_gap":
request["candidates"].([]any)[0].(map[string]any)["candidate_id"] = "c2"
apiSpecRefreshHashes(*taskID, request, key)
case "snapshot_hash_mismatch":
request["candidate_snapshot_hash"] = strings.Repeat("0", 64)
*key = apiSpecIdempotencyKey(*taskID, request)
case "selected_color_not_claimed":
request["selected_color"] = "测试白"
for _, raw := range request["candidates"].([]any) {
raw.(map[string]any)["options"].(map[string]any)["color"] = "测试白"
}
apiSpecRefreshHashes(*taskID, request, key)
case "target_size_not_claimed":
request["target_size"] = "70公斤"
case "observed_at_without_timezone":
request["observed_at"] = "2026-08-17T08:00:00"
case "attempt_id_too_long":
request["attempt_id"] = strings.Repeat("a", 192)
*key = apiSpecIdempotencyKey(*taskID, request)
case "body_too_large":
request["future_padding"] = strings.Repeat("x", 64<<10)
case "task_not_found":
*taskID = "cg-contract-missing"
*key = apiSpecIdempotencyKey(*taskID, request)
case "task_not_purchase", "task_not_claimed":
case "task_version_conflict":
request["task_version"] = float64(4)
case "pdd_goods_mismatch":
request["pdd_goods_id"] = "PDD-CONTRACT-OTHER"
apiSpecRefreshHashes(*taskID, request, key)
case "idempotency_conflict":
request["observed_at"] = "2026-08-17T08:00:01Z"
default:
panic("未知契约变体: " + mutation)
}
}
func apiSpecRefreshHashes(taskID string, request map[string]any, key *string) {
request["candidate_snapshot_hash"] = apiSpecSnapshotHash(request)
*key = apiSpecIdempotencyKey(taskID, request)
}
func apiSpecSnapshotHash(request map[string]any) string {
values := []string{"spec-resolution-v1", request["pdd_goods_id"].(string),
request["selected_color"].(string), strconv.Itoa(len(request["candidates"].([]any)))}
for _, raw := range request["candidates"].([]any) {
candidate := raw.(map[string]any)
options := candidate["options"].(map[string]any)
values = append(values, candidate["candidate_id"].(string), candidate["raw_text"].(string),
options["color"].(string), options["size"].(string))
}
return apiSpecSHA256Frames(values...)
}
func apiSpecIdempotencyKey(taskID string, request map[string]any) string {
return "spec-resolution-v1:" + apiSpecSHA256Frames(taskID, request["attempt_id"].(string),
request["candidate_snapshot_hash"].(string), "spec-resolution-v1")
}
func apiSpecSHA256Frames(values ...string) string {
var material strings.Builder
for _, value := range values {
material.WriteString(strconv.Itoa(len([]byte(value))))
material.WriteByte(':')
material.WriteString(value)
}
sum := sha256.Sum256([]byte(material.String()))
return hex.EncodeToString(sum[:])
}
func newAPISpecContractDB(t *testing.T) *sql.DB {
t.Helper()
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
}
func prepareAPISpecContractTask(t *testing.T, db *sql.DB, defaults apiSpecContractDefaults,
testCase apiSpecContractCase, taskType model.TaskType, claimed bool) {
t.Helper()
now := model.NowISO()
if _, err := db.Exec(`INSERT INTO clients
(client_id,name,device_address,platform,pdd_package,capabilities,last_seen_at,created_at,updated_at)
VALUES(?,?,'','android','','{}',?,?,?)`, defaults.ClientID, "契约测试客户端", now, now, now); err != nil {
t.Fatal(err)
}
options, _ := json.Marshal(map[string]string{"color": defaults.SelectedColor, "size": testCase.TargetSize})
if _, 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(?,?,'claimed',?,?,?,?,?,?,1,2000,?,?)`, testCase.TaskID, taskType, defaults.TaskVersion,
defaults.ClientID, "SHOPEE-CONTRACT", "https://example.invalid/contract-goods", defaults.PDDGoodsID,
string(options), now, now); err != nil {
t.Fatal(err)
}
if claimed {
if _, err := db.Exec(`INSERT INTO task_claims(task_id,client_id,claimed_at) VALUES(?,?,?)`,
testCase.TaskID, defaults.ClientID, now); err != nil {
t.Fatal(err)
}
}
}
func newAPISpecContractRouter(db *sql.DB) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
Register(router, db, nil, service.AIEndpointPolicy{})
return router
}
func performAPISpecContractRequest(router *gin.Engine, taskID, clientID, key string,
body map[string]any) *httptest.ResponseRecorder {
encoded, _ := json.Marshal(body)
request := httptest.NewRequest(http.MethodPost,
"/api/v1/client/tasks/"+taskID+"/spec-resolution", bytes.NewReader(encoded))
request.Header.Set("X-Client-Id", clientID)
request.Header.Set("Idempotency-Key", key)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
return recorder
}
@@ -0,0 +1,265 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"cmautobuy/admin/model"
)
type purchaseSpecContractFixture struct {
Contract string `json:"contract"`
SchemaVersion int `json:"schema_version"`
Defaults purchaseSpecContractDefaults `json:"defaults"`
Cases []purchaseSpecContractCase `json:"cases"`
ErrorCases []purchaseSpecContractErrorCase `json:"error_cases"`
}
type purchaseSpecContractDefaults struct {
ClientID string `json:"client_id"`
TaskType string `json:"task_type"`
TaskVersion int64 `json:"task_version"`
PDDGoodsID string `json:"pdd_goods_id"`
SelectedColor string `json:"selected_color"`
AttemptID string `json:"attempt_id"`
ObservedAt string `json:"observed_at"`
AIConfidenceThresholdBPS int `json:"ai_confidence_threshold_bps"`
}
type purchaseSpecContractCase struct {
Name string `json:"name"`
TaskID string `json:"task_id"`
TargetSize string `json:"target_size"`
Candidates []string `json:"candidates"`
CandidateSnapshotHash string `json:"candidate_snapshot_hash"`
IdempotencyKey string `json:"idempotency_key"`
Mode string `json:"mode"`
Model *purchaseSpecContractModel `json:"model"`
Expected purchaseSpecContractExpected `json:"expected"`
}
type purchaseSpecContractModel struct {
Conclusion string `json:"conclusion"`
CandidateID string `json:"candidate_id"`
ConfidenceBPS int `json:"confidence_bps"`
Reason string `json:"reason"`
MissingDimensions []string `json:"missing_dimensions"`
Error string `json:"error"`
}
type purchaseSpecContractExpected struct {
Outcome model.PurchaseSpecResolutionOutcome `json:"outcome"`
Source model.PurchaseSpecResolutionSource `json:"source"`
CandidateID *string `json:"candidate_id"`
ConfidenceBPS *int `json:"confidence_bps"`
ModelCalls int `json:"model_calls"`
}
type purchaseSpecContractErrorCase struct {
Name string `json:"name"`
BaseCase string `json:"base_case"`
Mutation string `json:"mutation"`
ExpectedHTTPStatus int `json:"expected_http_status"`
ExpectedErrorCode string `json:"expected_error_code"`
}
type contractAIModelClient struct {
response AIModelMatchResponse
err error
calls atomic.Int32
}
func (client *contractAIModelClient) Match(context.Context, model.AIProviderConfig, string,
AIModelMatchRequest) (AIModelMatchResponse, error) {
client.calls.Add(1)
return client.response, client.err
}
func TestPurchaseSpecResolutionContractVectors_AdminDecisionAndReplay(t *testing.T) {
fixture := loadPurchaseSpecContractFixture(t)
for _, testCase := range fixture.Cases {
t.Run(testCase.Name, func(t *testing.T) {
db := newRuntimeSpecTestDB(t)
preparePurchaseSpecContractTask(t, db, fixture.Defaults, testCase)
req, body := buildPurchaseSpecContractRequest(fixture.Defaults, testCase)
if actual := purchaseSpecCandidateSnapshotHash(req); actual != testCase.CandidateSnapshotHash {
t.Fatalf("候选哈希=%s,向量=%s", actual, testCase.CandidateSnapshotHash)
}
if actual := purchaseSpecIdempotencyKey(testCase.TaskID, req); actual != testCase.IdempotencyKey {
t.Fatalf("幂等键=%s,向量=%s", actual, testCase.IdempotencyKey)
}
client := contractAIClientForCase(testCase)
var loaderCalls atomic.Int32
loader := func(context.Context, *sql.DB, AISecretStore, AIEndpointPolicy) (AIMatchSnapshot, error) {
loaderCalls.Add(1)
if testCase.Mode != "ai" {
return AIMatchSnapshot{}, errors.New("规则向量不应加载 AI")
}
return AIMatchSnapshot{
Provider: model.AIProviderConfig{
ProviderID: "contract-provider",
Model: "contract-model",
ConfidenceThresholdBPS: fixture.Defaults.AIConfidenceThresholdBPS,
},
ConfigFingerprint: "contract-fingerprint",
Secret: "not-a-real-secret",
Client: client,
}, nil
}
first, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil,
AIEndpointPolicy{}, testCase.TaskID, fixture.Defaults.ClientID,
testCase.IdempotencyKey, body, loader)
if err != nil {
t.Fatal(err)
}
second, err := resolvePurchaseSpecWithSnapshotLoader(context.Background(), db, nil,
AIEndpointPolicy{}, testCase.TaskID, fixture.Defaults.ClientID,
testCase.IdempotencyKey, body, loader)
if err != nil || second != first {
t.Fatalf("幂等重放不一致 first=%s second=%s err=%v", first, second, err)
}
var response PurchaseSpecResolutionResponse
if err := json.Unmarshal([]byte(first), &response); err != nil {
t.Fatal(err)
}
assertPurchaseSpecContractResponse(t, response, testCase)
if int(client.calls.Load()) != testCase.Expected.ModelCalls {
t.Fatalf("模型调用=%d,期望=%d", client.calls.Load(), testCase.Expected.ModelCalls)
}
if testCase.Mode == "rule" && loaderCalls.Load() != 0 {
t.Fatalf("规则唯一/冲突向量不应加载 AI,实际=%d", loaderCalls.Load())
}
var records, decisions, idempotency int
if err := db.QueryRow(`SELECT COUNT(*),SUM(outcome<>'pending') FROM purchase_spec_resolutions WHERE task_id=?`,
testCase.TaskID).Scan(&records, &decisions); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM idempotency_keys WHERE `+"`key`"+`=?`,
testCase.IdempotencyKey).Scan(&idempotency); err != nil {
t.Fatal(err)
}
if records != 1 || decisions != 1 || idempotency != 1 {
t.Fatalf("records=%d decisions=%d idempotency=%d", records, decisions, idempotency)
}
})
}
}
func loadPurchaseSpecContractFixture(t *testing.T) purchaseSpecContractFixture {
t.Helper()
path := filepath.Join("..", "..", "testdata", "contracts", "purchase_spec_resolution_v1.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var fixture purchaseSpecContractFixture
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatal(err)
}
if fixture.Contract != "purchase-spec-resolution-v1" || fixture.SchemaVersion != 1 || len(fixture.Cases) == 0 {
t.Fatalf("共享契约向量头无效: %+v", fixture)
}
return fixture
}
func buildPurchaseSpecContractRequest(defaults purchaseSpecContractDefaults,
testCase purchaseSpecContractCase) (PurchaseSpecResolutionRequest, []byte) {
req := PurchaseSpecResolutionRequest{
SchemaVersion: 1,
TaskVersion: defaults.TaskVersion,
AttemptID: defaults.AttemptID,
PddGoodsID: defaults.PDDGoodsID,
OriginalOptions: map[string]string{"color": defaults.SelectedColor, "size": testCase.TargetSize},
SelectedColor: defaults.SelectedColor,
TargetSize: testCase.TargetSize,
ObservedAt: defaults.ObservedAt,
}
for index, value := range testCase.Candidates {
req.Candidates = append(req.Candidates, PurchaseSpecCandidate{
CandidateID: "c" + strconv.Itoa(index+1),
RawText: value,
Options: map[string]string{"color": defaults.SelectedColor, "size": value},
})
}
req.CandidateSnapshotHash = testCase.CandidateSnapshotHash
body, _ := json.Marshal(req)
return req, body
}
func preparePurchaseSpecContractTask(t *testing.T, db *sql.DB, defaults purchaseSpecContractDefaults,
testCase purchaseSpecContractCase) {
t.Helper()
now := model.NowISO()
if _, err := db.Exec(`INSERT INTO clients
(client_id,name,device_address,platform,pdd_package,capabilities,last_seen_at,created_at,updated_at)
VALUES(?,?,'','android','','{}',?,?,?)`, defaults.ClientID, "契约测试客户端", now, now, now); err != nil {
t.Fatal(err)
}
options, _ := json.Marshal(map[string]string{"color": defaults.SelectedColor, "size": testCase.TargetSize})
if _, 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(?,?,'claimed',?,?,?, ?,?,?,1,2000,?,?)`,
testCase.TaskID, model.TaskPurchase, defaults.TaskVersion, defaults.ClientID, "SHOPEE-CONTRACT",
"https://example.invalid/contract-goods", defaults.PDDGoodsID, string(options), now, now); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO task_claims(task_id,client_id,claimed_at) VALUES(?,?,?)`,
testCase.TaskID, defaults.ClientID, now); err != nil {
t.Fatal(err)
}
}
func contractAIClientForCase(testCase purchaseSpecContractCase) *contractAIModelClient {
client := &contractAIModelClient{}
if testCase.Model == nil {
return client
}
if testCase.Model.Error == "timeout" {
client.err = context.DeadlineExceeded
return client
}
client.response = AIModelMatchResponse{
Conclusion: testCase.Model.Conclusion,
CandidateID: testCase.Model.CandidateID,
ConfidenceBPS: testCase.Model.ConfidenceBPS,
Reason: testCase.Model.Reason,
MissingDimensions: append([]string(nil), testCase.Model.MissingDimensions...),
}
return client
}
func assertPurchaseSpecContractResponse(t *testing.T, response PurchaseSpecResolutionResponse,
testCase purchaseSpecContractCase) {
t.Helper()
if response.Outcome != testCase.Expected.Outcome || response.CandidateSnapshotHash != testCase.CandidateSnapshotHash {
t.Fatalf("响应结论=%s hash=%s", response.Outcome, response.CandidateSnapshotHash)
}
if response.Source == nil || *response.Source != testCase.Expected.Source {
t.Fatalf("响应来源=%v,期望=%s", response.Source, testCase.Expected.Source)
}
if testCase.Expected.CandidateID == nil {
if response.Match != nil {
t.Fatalf("非 matched 不应返回候选: %+v", response.Match)
}
} else if response.Match == nil || response.Match.CandidateID != *testCase.Expected.CandidateID {
t.Fatalf("候选=%+v,期望=%s", response.Match, *testCase.Expected.CandidateID)
}
if testCase.Expected.ConfidenceBPS == nil {
if response.ConfidenceBPS != nil {
t.Fatalf("置信度=%v,期望 null", response.ConfidenceBPS)
}
} else if response.ConfidenceBPS == nil || *response.ConfidenceBPS != *testCase.Expected.ConfidenceBPS {
t.Fatalf("置信度=%v,期望=%d", response.ConfidenceBPS, *testCase.Expected.ConfidenceBPS)
}
}
@@ -0,0 +1,399 @@
"""Admin、HTTP Gateway 与 Mock 共用的运行时规格解析契约向量。"""
import hashlib
import io
import json
import unittest
from copy import deepcopy
from pathlib import Path
from urllib.error import HTTPError
from src.admin_gateway import (
AdminGatewayError,
AdminTask,
AndroidDeviceInfo,
ClaimCapabilities,
ClientInfo,
SpecResolutionMatch,
SpecResolutionReceipt,
)
from src.http_admin_gateway import HttpAdminGateway
from src.mock_admin_gateway import MockAdminGateway
from src.task_models import TaskType
CONTRACT_PATH = (
Path(__file__).resolve().parents[2]
/ "testdata"
/ "contracts"
/ "purchase_spec_resolution_v1.json"
)
class _FakeResponse:
def __init__(self, status: int, payload: dict):
self.status = status
self._body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
def getcode(self):
return self.status
def read(self):
return self._body
def __enter__(self):
return self
def __exit__(self, *_args):
return False
class _RecordingOpener:
def __init__(self, response):
self.response = response
self.request = None
def __call__(self, request, timeout):
self.request = request
if isinstance(self.response, Exception):
raise self.response
return self.response
def _frame(value: str) -> str:
return f"{len(value.encode('utf-8'))}:{value}"
def _snapshot_hash(request: dict) -> str:
values = [
"spec-resolution-v1",
request["pdd_goods_id"],
request["selected_color"],
str(len(request["candidates"])),
]
for candidate in request["candidates"]:
values.extend(
(
candidate["candidate_id"],
candidate["raw_text"],
candidate["options"]["color"],
candidate["options"]["size"],
)
)
return hashlib.sha256(
"".join(_frame(value) for value in values).encode("utf-8")
).hexdigest()
def _idempotency_key(task_id: str, request: dict) -> str:
values = (
task_id,
request["attempt_id"],
request["candidate_snapshot_hash"],
"spec-resolution-v1",
)
return "spec-resolution-v1:" + hashlib.sha256(
"".join(_frame(value) for value in values).encode("utf-8")
).hexdigest()
def _build_request(defaults: dict, case: dict) -> tuple[dict, str]:
candidates = [
{
"candidate_id": f"c{index}",
"raw_text": value,
"options": {
"color": defaults["selected_color"],
"size": value,
},
}
for index, value in enumerate(case["candidates"], start=1)
]
request = {
"schema_version": 1,
"task_version": defaults["task_version"],
"attempt_id": defaults["attempt_id"],
"pdd_goods_id": defaults["pdd_goods_id"],
"original_options": {
"color": defaults["selected_color"],
"size": case["target_size"],
},
"selected_color": defaults["selected_color"],
"target_size": case["target_size"],
"candidates": candidates,
"candidate_snapshot_hash": case["candidate_snapshot_hash"],
"observed_at": defaults["observed_at"],
}
if _snapshot_hash(request) != case["candidate_snapshot_hash"]:
raise AssertionError(f"{case['name']} 的候选哈希与共享向量不一致")
key = _idempotency_key(case["task_id"], request)
if key != case["idempotency_key"]:
raise AssertionError(f"{case['name']} 的幂等键与共享向量不一致")
return request, key
def _receipt_for_case(case: dict, request: dict) -> SpecResolutionReceipt:
expected = case["expected"]
match = None
if expected["candidate_id"] is not None:
candidate = next(
item
for item in request["candidates"]
if item["candidate_id"] == expected["candidate_id"]
)
match = SpecResolutionMatch(
candidate["candidate_id"],
candidate["raw_text"],
dict(candidate["options"]),
)
return SpecResolutionReceipt(
1,
f"psr-vector-{case['name']}",
expected["outcome"],
expected["source"],
request["candidate_snapshot_hash"],
match,
expected["confidence_bps"],
f"共享向量 {case['name']}",
"2026-08-17T08:00:01Z",
)
def _receipt_payload(receipt: SpecResolutionReceipt) -> dict:
return {
"schema_version": receipt.schema_version,
"resolution_id": receipt.resolution_id,
"outcome": receipt.outcome,
"source": receipt.source,
"candidate_snapshot_hash": receipt.candidate_snapshot_hash,
"match": (
{
"candidate_id": receipt.match.candidate_id,
"raw_text": receipt.match.raw_text,
"options": dict(receipt.match.options),
}
if receipt.match is not None
else None
),
"confidence_bps": receipt.confidence_bps,
"reason": receipt.reason,
"resolved_at": receipt.resolved_at,
}
class PurchaseSpecResolutionContractVectorTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.fixture = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
if (
cls.fixture.get("contract") != "purchase-spec-resolution-v1"
or cls.fixture.get("schema_version") != 1
):
raise AssertionError("共享规格解析契约向量头无效")
cls.cases = {
case["name"]: case for case in cls.fixture["cases"]
}
def test_fixture_contains_only_synthetic_business_identifiers(self):
serialized = json.dumps(self.fixture, ensure_ascii=False).lower()
for forbidden_key in (
'"order_no"',
'"phone"',
'"address"',
'"cookie"',
'"token"',
'"password"',
'"api_key"',
'"device_serial"',
):
self.assertNotIn(forbidden_key, serialized)
defaults = self.fixture["defaults"]
self.assertTrue(defaults["client_id"].startswith("CLIENT-CONTRACT"))
self.assertTrue(defaults["pdd_goods_id"].startswith("PDD-CONTRACT"))
for case in self.fixture["cases"]:
self.assertTrue(case["task_id"].startswith("cg-contract-"))
def _task(self, case: dict, task_type=TaskType.PURCHASE) -> AdminTask:
defaults = self.fixture["defaults"]
return AdminTask(
task_id=case["task_id"],
task_type=task_type,
version=defaults["task_version"],
priority=1,
payload={
"goods_id": defaults["pdd_goods_id"],
"options": {
"color": defaults["selected_color"],
"size": case["target_size"],
},
},
)
@staticmethod
def _claim(gateway: MockAdminGateway):
return gateway.claim_next(
ClientInfo("CLIENT-CONTRACT"),
ClaimCapabilities(
device=AndroidDeviceInfo("TEST-DEVICE"),
supported_types=(TaskType.COLLECT, TaskType.PURCHASE),
),
)
def test_valid_vectors_are_identical_for_http_and_mock(self):
defaults = self.fixture["defaults"]
for case in self.fixture["cases"]:
with self.subTest(case=case["name"]):
request, key = _build_request(defaults, case)
expected = _receipt_for_case(case, request)
mock = MockAdminGateway()
mock.enqueue_task(self._task(case), defaults["client_id"])
self._claim(mock)
mock.set_next_spec_resolution(expected)
first = mock.resolve_purchase_spec(
case["task_id"], key, request
)
second = mock.resolve_purchase_spec(
case["task_id"], key, request
)
self.assertEqual(first, expected)
self.assertEqual(second, expected)
self.assertEqual(mock.spec_resolution_count, 1)
opener = _RecordingOpener(
_FakeResponse(200, _receipt_payload(expected))
)
http = HttpAdminGateway(
opener=opener,
client_id=defaults["client_id"],
)
parsed = http.resolve_purchase_spec(
case["task_id"], key, request
)
self.assertEqual(parsed, expected)
self.assertEqual(
json.loads(opener.request.data.decode("utf-8")), request
)
headers = {
name.lower(): value
for name, value in opener.request.header_items()
}
self.assertEqual(headers["idempotency-key"], key)
def test_error_vectors_are_identical_for_http_and_mock(self):
defaults = self.fixture["defaults"]
for error_case in self.fixture["error_cases"]:
with self.subTest(case=error_case["name"]):
case = self.cases[error_case["base_case"]]
request, key = _build_request(defaults, case)
task_id = case["task_id"]
task_id, key = self._mutate(
error_case["mutation"], task_id, request, key
)
mock = MockAdminGateway()
if error_case["mutation"] != "task_not_found":
task_type = (
TaskType.COLLECT
if error_case["mutation"] == "task_not_purchase"
else TaskType.PURCHASE
)
mock.enqueue_task(
self._task(case, task_type), defaults["client_id"]
)
if error_case["mutation"] != "task_not_claimed":
self._claim(mock)
if error_case["mutation"] == "idempotency_conflict":
original, original_key = _build_request(defaults, case)
mock.set_next_spec_resolution(
_receipt_for_case(case, original)
)
mock.resolve_purchase_spec(
case["task_id"], original_key, original
)
with self.assertRaises(AdminGatewayError) as mock_error:
mock.resolve_purchase_spec(task_id, key, request)
self.assertEqual(
mock_error.exception.code,
error_case["expected_error_code"],
)
self.assertFalse(mock_error.exception.retryable)
error_payload = {
"error": {
"code": error_case["expected_error_code"],
"message": "共享契约错误向量",
"retryable": False,
"request_id": "request-vector",
"details": {},
}
}
http_error = HTTPError(
"http://127.0.0.1/spec-resolution",
error_case["expected_http_status"],
"contract error",
hdrs=None,
fp=io.BytesIO(
json.dumps(error_payload).encode("utf-8")
),
)
http = HttpAdminGateway(
opener=_RecordingOpener(http_error),
client_id=defaults["client_id"],
)
with self.assertRaises(AdminGatewayError) as http_raised:
http.resolve_purchase_spec(task_id, key, request)
self.assertEqual(
http_raised.exception.code,
error_case["expected_error_code"],
)
self.assertFalse(http_raised.exception.retryable)
@staticmethod
def _mutate(
mutation: str, task_id: str, request: dict, key: str
) -> tuple[str, str]:
if mutation == "schema_version_2":
request["schema_version"] = 2
elif mutation == "candidate_id_gap":
request["candidates"][0]["candidate_id"] = "c2"
request["candidate_snapshot_hash"] = _snapshot_hash(request)
key = _idempotency_key(task_id, request)
elif mutation == "snapshot_hash_mismatch":
request["candidate_snapshot_hash"] = "0" * 64
key = _idempotency_key(task_id, request)
elif mutation == "selected_color_not_claimed":
request["selected_color"] = "测试白"
for candidate in request["candidates"]:
candidate["options"]["color"] = "测试白"
request["candidate_snapshot_hash"] = _snapshot_hash(request)
key = _idempotency_key(task_id, request)
elif mutation == "target_size_not_claimed":
request["target_size"] = "70公斤"
elif mutation == "observed_at_without_timezone":
request["observed_at"] = "2026-08-17T08:00:00"
elif mutation == "attempt_id_too_long":
request["attempt_id"] = "a" * 192
key = _idempotency_key(task_id, request)
elif mutation == "body_too_large":
request["future_padding"] = "x" * (64 * 1024)
elif mutation == "task_not_found":
task_id = "cg-contract-missing"
key = _idempotency_key(task_id, request)
elif mutation in {"task_not_purchase", "task_not_claimed"}:
pass
elif mutation == "task_version_conflict":
request["task_version"] = 4
elif mutation == "pdd_goods_mismatch":
request["pdd_goods_id"] = "PDD-CONTRACT-OTHER"
request["candidate_snapshot_hash"] = _snapshot_hash(request)
key = _idempotency_key(task_id, request)
elif mutation == "idempotency_conflict":
request["observed_at"] = "2026-08-17T08:00:01Z"
else:
raise AssertionError(f"未知契约变体 {mutation}")
return task_id, key
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -281,3 +281,33 @@ CMAutoBuyAdmin/
`interrupted`;成功映射不回滚,未完成条目允许重新勾选,且不能因为重试覆盖人工映射。 `interrupted`;成功映射不回滚,未完成条目允许重新勾选,且不能因为重试覆盖人工映射。
- 批次状态接口按创建人隔离,管理员除外;响应和日志只含批次号、计数和脱敏原因,不含密钥、 - 批次状态接口按创建人隔离,管理员除外;响应和日志只含批次号、计数和脱敏原因,不含密钥、
完整模型请求/响应或订单隐私数据。 完整模型请求/响应或订单隐私数据。
### 11.1 采购运行时规格解析联合验收
Admin 和 Client 必须共同读取
`testdata/contracts/purchase_spec_resolution_v1.json`,不得各自维护一份容易漂移的样例。
这份样例只使用虚构任务号、Client 编号、商品标题和规格,不得加入真实订单、账号、地址、
手机号、设备号或凭据。
联合验收至少覆盖:
- 规则唯一命中、规则歧义、AI 白名单命中、低置信度、额外维度、伪造候选和模型超时;
- `snapshot_hash`、`idempotency_key`、任务版本、领取关系和请求大小等 HTTP 错误;
- 同一请求重放返回同一结果,冲突请求不得覆盖原结果或重复调用模型;
- SQLite 服务测试和以 `_test` 结尾的 MySQL 8 测试库均验证迁移、唯一约束和审计持久化;
- Client 的 Mock 与真实 HTTP Gateway 对同一向量产生相同请求和错误分类。
从仓库根目录执行核心联合测试:
```powershell
Set-Location admin
$env:GOTOOLCHAIN='go1.23.0'
go test ./handler/api ./service -run PurchaseSpecResolutionContractVectors -count=1
Set-Location ../client
C:/Python310/python.exe -m pytest -q test/test_purchase_spec_resolution_contract_vectors.py
```
真实设备验收不是自动测试的替代品。它只能在项目负责人指定测试商品、账号、Client、Android
设备和价格上限后执行,并且最多创建一个未付款订单;到达不可逆阶段后只能核对订单,禁止重试
下单,整个过程不进入付款页面。
+25
View File
@@ -298,6 +298,31 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置
第 8、9 项开发者**不要自己判断验收通过**。新增依赖时把包名和许可证类型报给项目负责人;真实设备下单验收必须由项目负责人和实际操作的人共同确认,见 §3。 第 8、9 项开发者**不要自己判断验收通过**。新增依赖时把包名和许可证类型报给项目负责人;真实设备下单验收必须由项目负责人和实际操作的人共同确认,见 §3。
### 10.1 采购运行时规格解析联合验收
Admin 和 Client 共同使用仓库根目录的
`testdata/contracts/purchase_spec_resolution_v1.json`。样例只允许虚构任务、商品和规格,
不得写入真实订单、账号、地址、手机号、设备号或凭据。
Client 联合验收必须确认:
- Mock Admin 与真实 HTTP Gateway 对同一请求生成相同正文、幂等键、回执和错误分类;
- 规则命中、AI 命中、无法判断、拒绝和超时均按约定保存结果,不静默改用错误规格;
- 请求发出前先保存规格快照,超时后不自动重复请求;
- 收到结果后重新读取商品页,页面变化或候选消失时停止;
- 选中规格后再次核对商品、规格、数量和价格,再写入不可逆标记并且只提交一次;
- 不可逆标记存在时,重启或异常恢复只允许核对订单,不能再次下单。
从 `client/` 目录执行共享契约测试:
```powershell
C:/Python310/python.exe -m pytest -q test/test_purchase_spec_resolution_contract_vectors.py
```
真实设备验收必须由项目负责人先指定测试商品、账号、Client、Android 设备和价格上限,并由
操作人员全程观察。每次验收最多产生一个未付款订单,不进入付款页面;一旦写入不可逆标记,
任何超时、断网、程序退出或页面异常都只核对订单,不重新下单。
## 11. 任务完成定义 ## 11. 任务完成定义
单元任务只有同时满足以下条件才算完成: 单元任务只有同时满足以下条件才算完成:
+173
View File
@@ -0,0 +1,173 @@
{
"contract": "purchase-spec-resolution-v1",
"schema_version": 1,
"description": "Admin、Client HTTP 与 Mock 共用的脱敏采购运行时规格解析向量。",
"defaults": {
"client_id": "CLIENT-CONTRACT",
"task_type": "purchase",
"task_version": 3,
"pdd_goods_id": "PDD-CONTRACT-001",
"selected_color": "测试黑",
"attempt_id": "attempt-contract-001",
"observed_at": "2026-08-17T08:00:00Z",
"ai_confidence_threshold_bps": 9000
},
"cases": [
{
"name": "rule_weight_unique",
"task_id": "cg-contract-rule",
"target_size": "60公斤",
"candidates": ["100斤", "120斤"],
"candidate_snapshot_hash": "4d5cd9f3c8edcc894ef7806df7bd445718ba674b134abd55733df293f25ba23c",
"idempotency_key": "spec-resolution-v1:2c84cb3ec6a934384cfe62b8b143d5def0f4cafdc86b77538f8606478bd16ba3",
"mode": "rule",
"model": null,
"expected": {
"outcome": "matched",
"source": "rule",
"candidate_id": "c2",
"confidence_bps": 10000,
"model_calls": 0
}
},
{
"name": "rule_overlap_uncertain",
"task_id": "cg-contract-overlap",
"target_size": "50-60公斤",
"candidates": ["90-110斤", "110-130斤"],
"candidate_snapshot_hash": "1f186fc40144039ccbaf8d3fbe2bf462cb9b24af101f0f6420fb4ca55dabab72",
"idempotency_key": "spec-resolution-v1:a812466f92356dccaae11e59346408fcfa5c2bccdbfcd88991647bbce955f67e",
"mode": "rule",
"model": null,
"expected": {
"outcome": "uncertain",
"source": "rule",
"candidate_id": null,
"confidence_bps": null,
"model_calls": 0
}
},
{
"name": "ai_whitelist_match",
"task_id": "cg-contract-ai-match",
"target_size": "L码",
"candidates": ["M码", "XL码"],
"candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa",
"idempotency_key": "spec-resolution-v1:76722c891bf2c154ec7a129f8628404042305235084c2ea11ecc696383545030",
"mode": "ai",
"model": {
"conclusion": "match",
"candidate_id": "c2",
"confidence_bps": 9300,
"reason": "测试模型唯一选择"
},
"expected": {
"outcome": "matched",
"source": "ai",
"candidate_id": "c2",
"confidence_bps": 9300,
"model_calls": 1
}
},
{
"name": "ai_forged_candidate",
"task_id": "cg-contract-ai-forged",
"target_size": "L码",
"candidates": ["M码", "XL码"],
"candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa",
"idempotency_key": "spec-resolution-v1:ef9c252b3df772cca63bb296492f714a340a4c3e26fe504d97a79445a4c6e1b4",
"mode": "ai",
"model": {
"conclusion": "match",
"candidate_id": "c99",
"confidence_bps": 9500,
"reason": "测试伪造候选"
},
"expected": {
"outcome": "rejected",
"source": "ai",
"candidate_id": null,
"confidence_bps": 9500,
"model_calls": 1
}
},
{
"name": "ai_low_confidence",
"task_id": "cg-contract-ai-low",
"target_size": "L码",
"candidates": ["M码", "XL码"],
"candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa",
"idempotency_key": "spec-resolution-v1:cd8ef7bb122d5045434bd3f49e7641ac23a3f43be5bc002fed1363276e1c7142",
"mode": "ai",
"model": {
"conclusion": "match",
"candidate_id": "c2",
"confidence_bps": 7000,
"reason": "测试低置信度"
},
"expected": {
"outcome": "uncertain",
"source": "ai",
"candidate_id": null,
"confidence_bps": 7000,
"model_calls": 1
}
},
{
"name": "ai_missing_dimension",
"task_id": "cg-contract-ai-missing",
"target_size": "L码",
"candidates": ["M码", "XL码"],
"candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa",
"idempotency_key": "spec-resolution-v1:6fdc71c5d80cae48c76e8fb3f152539c0b063731e47e7c49d21ff0cc4acba0f6",
"mode": "ai",
"model": {
"conclusion": "match",
"candidate_id": "c2",
"confidence_bps": 9500,
"reason": "测试缺少维度",
"missing_dimensions": ["尺码"]
},
"expected": {
"outcome": "uncertain",
"source": "ai",
"candidate_id": null,
"confidence_bps": 9500,
"model_calls": 1
}
},
{
"name": "ai_timeout",
"task_id": "cg-contract-ai-timeout",
"target_size": "L码",
"candidates": ["M码", "XL码"],
"candidate_snapshot_hash": "f634515c8a7a0ebd688fd926520405051e12c622c87a4783a05936099ecfb2fa",
"idempotency_key": "spec-resolution-v1:374abeb2ec0a0dec57f2ba9c2976b9598b977de19cc1bfe1af4b23bc768af912",
"mode": "ai",
"model": {"error": "timeout"},
"expected": {
"outcome": "failed",
"source": "ai",
"candidate_id": null,
"confidence_bps": null,
"model_calls": 1
}
}
],
"error_cases": [
{"name": "invalid_schema", "base_case": "rule_weight_unique", "mutation": "schema_version_2", "expected_http_status": 400, "expected_error_code": "INVALID_SPEC_RESOLUTION_SCHEMA"},
{"name": "candidate_id_gap", "base_case": "rule_weight_unique", "mutation": "candidate_id_gap", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"},
{"name": "snapshot_hash_mismatch", "base_case": "rule_weight_unique", "mutation": "snapshot_hash_mismatch", "expected_http_status": 422, "expected_error_code": "SPEC_RESOLUTION_HASH_MISMATCH"},
{"name": "selected_color_not_claimed", "base_case": "rule_weight_unique", "mutation": "selected_color_not_claimed", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"},
{"name": "target_size_not_claimed", "base_case": "rule_weight_unique", "mutation": "target_size_not_claimed", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"},
{"name": "observed_at_without_timezone", "base_case": "rule_weight_unique", "mutation": "observed_at_without_timezone", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"},
{"name": "attempt_id_too_long", "base_case": "rule_weight_unique", "mutation": "attempt_id_too_long", "expected_http_status": 422, "expected_error_code": "INVALID_SPEC_RESOLUTION_REQUEST"},
{"name": "body_too_large", "base_case": "rule_weight_unique", "mutation": "body_too_large", "expected_http_status": 400, "expected_error_code": "INVALID_BODY"},
{"name": "task_not_found", "base_case": "rule_weight_unique", "mutation": "task_not_found", "expected_http_status": 404, "expected_error_code": "TASK_NOT_FOUND"},
{"name": "task_not_purchase", "base_case": "rule_weight_unique", "mutation": "task_not_purchase", "expected_http_status": 422, "expected_error_code": "TASK_NOT_PURCHASE"},
{"name": "task_version_conflict", "base_case": "rule_weight_unique", "mutation": "task_version_conflict", "expected_http_status": 409, "expected_error_code": "TASK_VERSION_CONFLICT"},
{"name": "pdd_goods_mismatch", "base_case": "rule_weight_unique", "mutation": "pdd_goods_mismatch", "expected_http_status": 422, "expected_error_code": "PDD_GOODS_MISMATCH"},
{"name": "task_not_claimed", "base_case": "rule_weight_unique", "mutation": "task_not_claimed", "expected_http_status": 403, "expected_error_code": "TASK_NOT_CLAIMED_BY_CLIENT"},
{"name": "idempotency_conflict", "base_case": "rule_weight_unique", "mutation": "idempotency_conflict", "expected_http_status": 409, "expected_error_code": "IDEMPOTENCY_CONFLICT"}
]
}