feat: 定义运行时规格解析契约与审计模型 (#254)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user