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
+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 {