test: 增加采购规格跨端联合验收 (#258)
This commit is contained in:
@@ -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()
|
||||||
@@ -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
|
||||||
|
设备和价格上限后执行,并且最多创建一个未付款订单;到达不可逆阶段后只能核对订单,禁止重试
|
||||||
|
下单,整个过程不进入付款页面。
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user