feat: 定义运行时规格解析契约与审计模型 (#254)

This commit is contained in:
chengma
2026-08-17 16:53:11 +08:00
parent b11a586527
commit 12414658d0
8 changed files with 736 additions and 16 deletions
+50
View File
@@ -407,6 +407,56 @@ type AISpecMatchDecision struct {
DecidedBy, DecidedAt string
}
// PurchaseSpecResolutionOutcome 是一次采购运行时规格解析的持久化结论。
// pending 只表示候选观察已经落库,尚未完成规则或 AI 决策。
type PurchaseSpecResolutionOutcome string
const (
PurchaseSpecResolutionPending PurchaseSpecResolutionOutcome = "pending"
PurchaseSpecResolutionMatched PurchaseSpecResolutionOutcome = "matched"
PurchaseSpecResolutionUncertain PurchaseSpecResolutionOutcome = "uncertain"
PurchaseSpecResolutionRejected PurchaseSpecResolutionOutcome = "rejected"
PurchaseSpecResolutionFailed PurchaseSpecResolutionOutcome = "failed"
)
// PurchaseSpecResolutionSource 记录最终结论由哪条受控路径产生。
type PurchaseSpecResolutionSource string
const (
PurchaseSpecResolutionRule PurchaseSpecResolutionSource = "rule"
PurchaseSpecResolutionAI PurchaseSpecResolutionSource = "ai"
PurchaseSpecResolutionReused PurchaseSpecResolutionSource = "reused"
)
// PurchaseSpecResolution 同时保存 Client 的候选观察和 Admin 的最终决策。
// JSON 字段只允许保存接口契约定义的规格数据,不得写入控件树、订单或凭据。
type PurchaseSpecResolution struct {
ResolutionID, TaskID, AttemptID, ClientID string
TaskVersion int64
PddGoodsID, OriginalOptionsJSON, SelectedColor string
TargetSize, CandidatesJSON, CandidateSnapshotHash string
ObservedAt, RequestHash string
Outcome PurchaseSpecResolutionOutcome
DecisionSource PurchaseSpecResolutionSource
ChosenCandidateID, ResolvedOptionsJSON string
ConfidenceBPS int
ConfidenceSet bool
Reason, ProviderID, SourceModel, ConfigFingerprint string
RulesVersion, PromptVersion, CreatedAt, DecidedAt string
}
// PurchaseSpecResolutionDecision 是 Repository 完成一次解析记录时需要的最小字段。
type PurchaseSpecResolutionDecision struct {
ResolutionID string
Outcome PurchaseSpecResolutionOutcome
DecisionSource PurchaseSpecResolutionSource
ChosenCandidateID, ResolvedOptionsJSON string
ConfidenceBPS int
ConfidenceSet bool
Reason, ProviderID, SourceModel, ConfigFingerprint string
RulesVersion, PromptVersion, DecidedAt string
}
type AIMatchBatch struct {
BatchID, Status, CreatedByUserID string
TotalCount, ProcessedCount, SuccessCount, ReusedCount int
+108 -2
View File
@@ -20,7 +20,7 @@ import (
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 25
const mysqlSchemaVersion = 26
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -724,10 +724,71 @@ func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 25, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v25 失败: %w", err)
}
current = 25
}
if current < 26 {
if err := migrateMySQLV26(db); err != nil {
return fmt.Errorf("执行 MySQL schema v26 失败: %w", err)
}
if err := checkMySQLV26Shape(db); err != nil {
return fmt.Errorf("MySQL schema v26 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 26, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v26 失败: %w", err)
}
}
return CheckMySQLSchema(db)
}
// migrateMySQLV26 建立采购运行时规格解析审计。一个记录同时保存候选观察和最终决策,
// 不覆盖 PDD 商品主数据,也不通过外键阻止任务的既有硬删除流程。
func migrateMySQLV26(db *sql.DB) error {
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS purchase_spec_resolutions (
resolution_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY,
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
attempt_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
client_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
task_version BIGINT NOT NULL,
pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
original_options_json JSON NOT NULL,
selected_color VARCHAR(191) NOT NULL,
target_size VARCHAR(191) NOT NULL,
candidates_json JSON NOT NULL,
candidate_snapshot_hash CHAR(64) COLLATE utf8mb4_bin NOT NULL,
request_hash CHAR(64) COLLATE utf8mb4_bin NOT NULL,
observed_at VARCHAR(35) NOT NULL,
outcome VARCHAR(16) NOT NULL DEFAULT 'pending',
decision_source VARCHAR(16) NULL,
chosen_candidate_id VARCHAR(16) COLLATE utf8mb4_bin NULL,
resolved_options_json JSON NULL,
confidence_bps INT NULL,
reason VARCHAR(500) NULL,
provider_id VARCHAR(191) COLLATE utf8mb4_bin NULL,
source_model VARCHAR(191) NULL,
config_fingerprint CHAR(64) COLLATE utf8mb4_bin NULL,
rules_version VARCHAR(32) NULL,
prompt_version VARCHAR(32) NULL,
created_at VARCHAR(35) NOT NULL,
decided_at VARCHAR(35) NULL,
UNIQUE KEY uq_purchase_spec_resolution_identity (task_id,attempt_id,candidate_snapshot_hash),
KEY idx_purchase_spec_resolution_task (task_id,created_at DESC,resolution_id),
KEY idx_purchase_spec_resolution_outcome (outcome,created_at,resolution_id),
CONSTRAINT chk_purchase_spec_resolution_task_version CHECK (task_version > 0),
CONSTRAINT chk_purchase_spec_resolution_options CHECK (
JSON_TYPE(original_options_json)='OBJECT' AND JSON_LENGTH(original_options_json) BETWEEN 1 AND 16 AND
JSON_TYPE(candidates_json)='ARRAY' AND JSON_LENGTH(candidates_json) BETWEEN 1 AND 100 AND
(resolved_options_json IS NULL OR JSON_TYPE(resolved_options_json)='OBJECT')),
CONSTRAINT chk_purchase_spec_resolution_outcome CHECK (outcome IN ('pending','matched','uncertain','rejected','failed')),
CONSTRAINT chk_purchase_spec_resolution_source CHECK (decision_source IS NULL OR decision_source IN ('rule','ai','reused')),
CONSTRAINT chk_purchase_spec_resolution_confidence CHECK (confidence_bps IS NULL OR confidence_bps BETWEEN 0 AND 10000),
CONSTRAINT chk_purchase_spec_resolution_decision CHECK (
(outcome='pending' AND decided_at IS NULL AND decision_source IS NULL AND chosen_candidate_id IS NULL AND resolved_options_json IS NULL) OR
(outcome='matched' AND decided_at IS NOT NULL AND decision_source IS NOT NULL AND chosen_candidate_id IS NOT NULL AND resolved_options_json IS NOT NULL AND reason IS NOT NULL) OR
(outcome IN ('uncertain','rejected','failed') AND decided_at IS NOT NULL AND chosen_candidate_id IS NULL AND resolved_options_json IS NULL AND reason IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`)
return err
}
// migrateMySQLV25 在现有档口入库码业务表上增加后台批次状态,不另建批次表。
func migrateMySQLV25(db *sql.DB) error {
for _, column := range []struct{ name, ddl string }{
@@ -2185,6 +2246,7 @@ func CheckMySQLSchema(db *sql.DB) error {
"ai_provider_configs", "ai_provider_audits",
"ai_spec_match_decisions",
"ai_match_batches", "ai_match_batch_items",
"purchase_spec_resolutions",
}
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
return err
@@ -2255,7 +2317,51 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV24Shape(db); err != nil {
return err
}
return checkMySQLV25Shape(db)
if err := checkMySQLV25Shape(db); err != nil {
return err
}
return checkMySQLV26Shape(db)
}
func checkMySQLV26Shape(db *sql.DB) error {
if err := checkMySQLSchema(db, []string{"purchase_spec_resolutions"}); err != nil {
return err
}
for _, name := range []string{
"resolution_id", "task_id", "attempt_id", "client_id", "task_version", "pdd_goods_id",
"original_options_json", "selected_color", "target_size", "candidates_json",
"candidate_snapshot_hash", "request_hash", "observed_at", "outcome", "decision_source",
"chosen_candidate_id", "resolved_options_json", "confidence_bps", "reason", "provider_id",
"source_model", "config_fingerprint", "rules_version", "prompt_version", "created_at", "decided_at",
} {
exists, err := mysqlColumnExists(db, "purchase_spec_resolutions", name)
if err != nil || !exists {
return fmt.Errorf("采购运行时规格解析字段 %s 缺失: %v", name, err)
}
}
for _, item := range []struct{ kind, name string }{
{"index", "uq_purchase_spec_resolution_identity"},
{"index", "idx_purchase_spec_resolution_task"},
{"index", "idx_purchase_spec_resolution_outcome"},
{"constraint", "chk_purchase_spec_resolution_task_version"},
{"constraint", "chk_purchase_spec_resolution_options"},
{"constraint", "chk_purchase_spec_resolution_outcome"},
{"constraint", "chk_purchase_spec_resolution_source"},
{"constraint", "chk_purchase_spec_resolution_confidence"},
{"constraint", "chk_purchase_spec_resolution_decision"},
} {
var exists bool
var err error
if item.kind == "index" {
exists, err = mysqlIndexExists(db, "purchase_spec_resolutions", item.name)
} else {
exists, err = mysqlConstraintExists(db, "purchase_spec_resolutions", item.name)
}
if err != nil || !exists {
return fmt.Errorf("采购运行时规格解析%s %s 缺失: %v", item.kind, item.name, err)
}
}
return nil
}
func checkMySQLV25Shape(db *sql.DB) error {
@@ -1166,6 +1166,100 @@ func TestMySQLMigrate_V24升级V25且后台队列状态有效(t *testing.T) {
}
}
func TestMySQLMigrate_V25升级V26且规格解析审计幂等(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, `DROP TABLE purchase_spec_resolutions`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=26`)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v25 升级 v26 失败: %v", err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v26 重复迁移失败: %v", err)
}
if err := checkMySQLV26Shape(db); err != nil {
t.Fatal(err)
}
createdAt := "2026-08-17T08:00:00Z"
resolution := model.PurchaseSpecResolution{
ResolutionID: "PSR-1",
TaskID: "cg254",
AttemptID: "attempt-254",
ClientID: "client-254",
TaskVersion: 3,
PddGoodsID: "937122477375",
OriginalOptionsJSON: `{"color":"黑色","size":"60公斤"}`,
SelectedColor: "黑色",
TargetSize: "60公斤",
CandidatesJSON: `[{"candidate_id":"c1","raw_text":"120斤","options":{"color":"黑色","size":"120斤"}}]`,
CandidateSnapshotHash: strings.Repeat("a", 64),
RequestHash: strings.Repeat("b", 64),
ObservedAt: createdAt,
CreatedAt: createdAt,
}
if err := InsertPurchaseSpecResolution(db, resolution); err != nil {
t.Fatal(err)
}
replayed, err := GetPurchaseSpecResolutionForReplay(db, resolution.TaskID, resolution.AttemptID,
resolution.CandidateSnapshotHash, resolution.RequestHash)
if err != nil || replayed == nil || replayed.ResolutionID != resolution.ResolutionID {
t.Fatalf("相同请求未命中解析记录: resolution=%+v err=%v", replayed, err)
}
if _, err := GetPurchaseSpecResolutionForReplay(db, resolution.TaskID, resolution.AttemptID,
resolution.CandidateSnapshotHash, strings.Repeat("c", 64)); !errors.Is(err, ErrPurchaseSpecResolutionConflict) {
t.Fatalf("相同业务身份的不同请求应冲突,实际 %v", err)
}
duplicate := resolution
duplicate.ResolutionID = "PSR-2"
if err := InsertPurchaseSpecResolution(db, duplicate); !errors.Is(err, ErrPurchaseSpecResolutionExists) {
t.Fatalf("业务身份唯一键未生效,实际 %v", err)
}
decision := model.PurchaseSpecResolutionDecision{
ResolutionID: resolution.ResolutionID,
Outcome: model.PurchaseSpecResolutionMatched,
DecisionSource: model.PurchaseSpecResolutionAI,
ChosenCandidateID: "c1",
ResolvedOptionsJSON: `{"color":"黑色","size":"120斤"}`,
ConfidenceBPS: 9300,
ConfidenceSet: true,
Reason: "候选唯一且通过门禁",
ProviderID: "AI-1",
SourceModel: "model-1",
ConfigFingerprint: strings.Repeat("d", 64),
RulesVersion: "runtime-v1",
PromptVersion: "runtime-v1",
DecidedAt: "2026-08-17T08:00:01Z",
}
if err := CompletePurchaseSpecResolution(db, decision); err != nil {
t.Fatal(err)
}
completed, err := GetPurchaseSpecResolutionByID(db, resolution.ResolutionID)
if err != nil || completed == nil || completed.Outcome != model.PurchaseSpecResolutionMatched ||
completed.DecisionSource != model.PurchaseSpecResolutionAI || !completed.ConfidenceSet || completed.ConfidenceBPS != 9300 {
t.Fatalf("解析决策读取不完整: resolution=%+v err=%v", completed, err)
}
if err := CompletePurchaseSpecResolution(db, decision); !errors.Is(err, ErrPurchaseSpecResolutionCompleted) {
t.Fatalf("已经完成的决策不应被覆盖,实际 %v", err)
}
tooManyCandidates := resolution
tooManyCandidates.ResolutionID = "PSR-TOO-MANY"
tooManyCandidates.AttemptID = "attempt-too-many"
tooManyCandidates.CandidateSnapshotHash = strings.Repeat("e", 64)
tooManyCandidates.RequestHash = strings.Repeat("f", 64)
tooManyCandidates.CandidatesJSON = `[` + strings.TrimSuffix(strings.Repeat(`{"candidate_id":"c1"},`, 101), ",") + `]`
if err := InsertPurchaseSpecResolution(db, tooManyCandidates); err == nil {
t.Fatal("数据库必须拒绝超过 100 个候选的解析观察")
}
}
func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
@@ -0,0 +1,143 @@
package repository
import (
"database/sql"
"errors"
"fmt"
"github.com/go-sql-driver/mysql"
"cmautobuy/admin/model"
)
var (
// ErrPurchaseSpecResolutionExists 表示解析编号或业务身份已经存在,调用方应读取旧记录复查。
ErrPurchaseSpecResolutionExists = errors.New("采购运行时规格解析记录已存在")
// ErrPurchaseSpecResolutionConflict 表示相同业务身份对应了不同请求内容。
ErrPurchaseSpecResolutionConflict = errors.New("采购运行时规格解析请求冲突")
// ErrPurchaseSpecResolutionNotFound 表示要完成的解析记录不存在。
ErrPurchaseSpecResolutionNotFound = errors.New("采购运行时规格解析记录不存在")
// ErrPurchaseSpecResolutionCompleted 表示解析记录已经完成,不能覆盖第一次决策。
ErrPurchaseSpecResolutionCompleted = errors.New("采购运行时规格解析记录已经完成")
)
// InsertPurchaseSpecResolution 保存一次候选观察。匹配逻辑不在 Repository 中执行。
func InsertPurchaseSpecResolution(q Execer, resolution model.PurchaseSpecResolution) error {
if resolution.Outcome == "" {
resolution.Outcome = model.PurchaseSpecResolutionPending
}
_, err := q.Exec(`INSERT INTO purchase_spec_resolutions
(resolution_id,task_id,attempt_id,client_id,task_version,pdd_goods_id,
original_options_json,selected_color,target_size,candidates_json,candidate_snapshot_hash,
request_hash,observed_at,outcome,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
resolution.ResolutionID, resolution.TaskID, resolution.AttemptID, resolution.ClientID,
resolution.TaskVersion, resolution.PddGoodsID, resolution.OriginalOptionsJSON,
resolution.SelectedColor, resolution.TargetSize, resolution.CandidatesJSON,
resolution.CandidateSnapshotHash, resolution.RequestHash, resolution.ObservedAt,
resolution.Outcome, resolution.CreatedAt)
if err == nil {
return nil
}
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return ErrPurchaseSpecResolutionExists
}
return fmt.Errorf("写入采购运行时规格解析观察失败: %w", err)
}
// GetPurchaseSpecResolutionByID 按公开解析编号读取候选观察和最终决策。
func GetPurchaseSpecResolutionByID(q Execer, resolutionID string) (*model.PurchaseSpecResolution, error) {
return scanPurchaseSpecResolution(q.QueryRow(purchaseSpecResolutionSelect+` WHERE resolution_id=?`, resolutionID))
}
// GetPurchaseSpecResolutionByIdentity 按任务、执行尝试和候选快照读取唯一解析记录。
func GetPurchaseSpecResolutionByIdentity(q Execer, taskID, attemptID, candidateSnapshotHash string) (*model.PurchaseSpecResolution, error) {
return scanPurchaseSpecResolution(q.QueryRow(purchaseSpecResolutionSelect+
` WHERE task_id=? AND attempt_id=? AND candidate_snapshot_hash=?`,
taskID, attemptID, candidateSnapshotHash))
}
// GetPurchaseSpecResolutionForReplay 复查幂等重放。相同业务身份但请求哈希不同必须冲突。
func GetPurchaseSpecResolutionForReplay(q Execer, taskID, attemptID, candidateSnapshotHash, requestHash string) (*model.PurchaseSpecResolution, error) {
resolution, err := GetPurchaseSpecResolutionByIdentity(q, taskID, attemptID, candidateSnapshotHash)
if err != nil || resolution == nil {
return resolution, err
}
if resolution.RequestHash != requestHash {
return nil, ErrPurchaseSpecResolutionConflict
}
return resolution, nil
}
// CompletePurchaseSpecResolution 只允许把 pending 记录完成一次,保留第一次决策审计。
func CompletePurchaseSpecResolution(q Execer, decision model.PurchaseSpecResolutionDecision) error {
result, err := q.Exec(`UPDATE purchase_spec_resolutions SET
outcome=?,decision_source=?,chosen_candidate_id=?,resolved_options_json=?,confidence_bps=?,
reason=?,provider_id=?,source_model=?,config_fingerprint=?,rules_version=?,prompt_version=?,decided_at=?
WHERE resolution_id=? AND outcome='pending'`,
decision.Outcome, nullableText(string(decision.DecisionSource)), nullableText(decision.ChosenCandidateID),
nullableText(decision.ResolvedOptionsJSON), nullableInt(decision.ConfidenceBPS, decision.ConfidenceSet),
nullableText(decision.Reason), nullableText(decision.ProviderID), nullableText(decision.SourceModel),
nullableText(decision.ConfigFingerprint), nullableText(decision.RulesVersion), nullableText(decision.PromptVersion),
decision.DecidedAt, decision.ResolutionID)
if err != nil {
return fmt.Errorf("完成采购运行时规格解析失败: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("读取采购运行时规格解析更新结果失败: %w", err)
}
if affected == 1 {
return nil
}
existing, err := GetPurchaseSpecResolutionByID(q, decision.ResolutionID)
if err != nil {
return err
}
if existing == nil {
return ErrPurchaseSpecResolutionNotFound
}
return ErrPurchaseSpecResolutionCompleted
}
const purchaseSpecResolutionSelect = `SELECT
resolution_id,task_id,attempt_id,client_id,task_version,pdd_goods_id,
original_options_json,selected_color,target_size,candidates_json,candidate_snapshot_hash,
request_hash,observed_at,outcome,decision_source,chosen_candidate_id,resolved_options_json,
confidence_bps,reason,provider_id,source_model,config_fingerprint,rules_version,prompt_version,
created_at,decided_at FROM purchase_spec_resolutions`
func scanPurchaseSpecResolution(row *sql.Row) (*model.PurchaseSpecResolution, error) {
var resolution model.PurchaseSpecResolution
var source, candidateID, resolvedOptions, reason, providerID sql.NullString
var sourceModel, configFingerprint, rulesVersion, promptVersion, decidedAt sql.NullString
var confidence sql.NullInt64
err := row.Scan(
&resolution.ResolutionID, &resolution.TaskID, &resolution.AttemptID, &resolution.ClientID,
&resolution.TaskVersion, &resolution.PddGoodsID, &resolution.OriginalOptionsJSON,
&resolution.SelectedColor, &resolution.TargetSize, &resolution.CandidatesJSON,
&resolution.CandidateSnapshotHash, &resolution.RequestHash, &resolution.ObservedAt,
&resolution.Outcome, &source, &candidateID, &resolvedOptions, &confidence, &reason,
&providerID, &sourceModel, &configFingerprint, &rulesVersion, &promptVersion,
&resolution.CreatedAt, &decidedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("读取采购运行时规格解析失败: %w", err)
}
resolution.DecisionSource = model.PurchaseSpecResolutionSource(source.String)
resolution.ChosenCandidateID = candidateID.String
resolution.ResolvedOptionsJSON = resolvedOptions.String
resolution.ConfidenceBPS = int(confidence.Int64)
resolution.ConfidenceSet = confidence.Valid
resolution.Reason = reason.String
resolution.ProviderID = providerID.String
resolution.SourceModel = sourceModel.String
resolution.ConfigFingerprint = configFingerprint.String
resolution.RulesVersion = rulesVersion.String
resolution.PromptVersion = promptVersion.String
resolution.DecidedAt = decidedAt.String
return &resolution, nil
}