feat: 实现 AI 规格候选匹配与审计 (#201)
This commit is contained in:
@@ -20,7 +20,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 20
|
||||
const mysqlSchemaVersion = 21
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
@@ -664,10 +664,89 @@ func MigrateMySQL(db *sql.DB) error {
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 20, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v20 失败: %w", err)
|
||||
}
|
||||
current = 20
|
||||
}
|
||||
if current < 21 {
|
||||
if err := migrateMySQLV21(db); err != nil {
|
||||
return fmt.Errorf("执行 MySQL schema v21 失败: %w", err)
|
||||
}
|
||||
if err := checkMySQLV21Shape(db); err != nil {
|
||||
return fmt.Errorf("MySQL schema v21 自检失败,未记录版本: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 21, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v21 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return CheckMySQLSchema(db)
|
||||
}
|
||||
|
||||
// migrateMySQLV21 给当前有效规格映射增加来源,并建立只追加的 AI 决策审计。
|
||||
func migrateMySQLV21(db *sql.DB) error {
|
||||
columns := []struct{ name, ddl string }{
|
||||
{"source", `ALTER TABLE spec_mappings ADD COLUMN source VARCHAR(16) NOT NULL DEFAULT 'manual' AFTER mapped_by`},
|
||||
{"source_provider_id", `ALTER TABLE spec_mappings ADD COLUMN source_provider_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER source`},
|
||||
{"source_model", `ALTER TABLE spec_mappings ADD COLUMN source_model VARCHAR(191) NULL AFTER source_provider_id`},
|
||||
{"confidence_bps", `ALTER TABLE spec_mappings ADD COLUMN confidence_bps INT NULL AFTER source_model`},
|
||||
{"source_reason", `ALTER TABLE spec_mappings ADD COLUMN source_reason VARCHAR(500) NULL AFTER confidence_bps`},
|
||||
{"source_version", `ALTER TABLE spec_mappings ADD COLUMN source_version VARCHAR(64) NULL AFTER source_reason`},
|
||||
{"context_version", `ALTER TABLE spec_mappings ADD COLUMN context_version CHAR(64) COLLATE utf8mb4_bin NULL AFTER source_version`},
|
||||
}
|
||||
for _, column := range columns {
|
||||
exists, err := mysqlColumnExists(db, "spec_mappings", column.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(column.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range []struct{ name, ddl string }{
|
||||
{"chk_spec_mappings_source", `ALTER TABLE spec_mappings ADD CONSTRAINT chk_spec_mappings_source CHECK (source IN ('manual','rule','ai'))`},
|
||||
{"chk_spec_mappings_confidence", `ALTER TABLE spec_mappings ADD CONSTRAINT chk_spec_mappings_confidence CHECK (confidence_bps IS NULL OR confidence_bps BETWEEN 0 AND 10000)`},
|
||||
} {
|
||||
exists, err := mysqlConstraintExists(db, "spec_mappings", item.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(item.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS ai_spec_match_decisions (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
context_version CHAR(64) COLLATE utf8mb4_bin NOT NULL,
|
||||
rules_version VARCHAR(32) NOT NULL,
|
||||
prompt_version VARCHAR(32) NOT NULL,
|
||||
provider_id VARCHAR(191) COLLATE utf8mb4_bin,
|
||||
provider_name VARCHAR(191),
|
||||
model VARCHAR(191),
|
||||
config_fingerprint CHAR(64) COLLATE utf8mb4_bin,
|
||||
candidates_json LONGTEXT NOT NULL,
|
||||
chosen_candidate_id VARCHAR(32) COLLATE utf8mb4_bin,
|
||||
chosen_option_key VARCHAR(191) COLLATE utf8mb4_bin,
|
||||
confidence_bps INT,
|
||||
outcome VARCHAR(32) NOT NULL,
|
||||
reason VARCHAR(500),
|
||||
conflict_dimensions_json LONGTEXT NOT NULL,
|
||||
missing_dimensions_json LONGTEXT NOT NULL,
|
||||
decided_by VARCHAR(191) COLLATE utf8mb4_bin,
|
||||
decided_at VARCHAR(35) NOT NULL,
|
||||
KEY idx_ai_match_identity (shopee_goods_id,spec_key,pdd_goods_id,decided_at DESC,id DESC),
|
||||
KEY idx_ai_match_outcome (outcome,decided_at DESC,id DESC),
|
||||
CONSTRAINT fk_ai_match_actor FOREIGN KEY (decided_by) REFERENCES users(user_id),
|
||||
CONSTRAINT chk_ai_match_confidence CHECK (confidence_bps IS NULL OR confidence_bps BETWEEN 0 AND 10000),
|
||||
CONSTRAINT chk_ai_match_outcome CHECK (outcome IN ('rule_saved','ai_saved','manual_exists','reused','rejected','failed','stale'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`)
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateMySQLV20 保存 AI 服务商的非敏感配置和配置审计。
|
||||
// API Key 只存在独立密钥文件中,不在这两张表里留列。
|
||||
func migrateMySQLV20(db *sql.DB) error {
|
||||
@@ -1864,6 +1943,7 @@ func CheckMySQLSchema(db *sql.DB) error {
|
||||
"task_sequences",
|
||||
"syb_allowed_shops",
|
||||
"ai_provider_configs", "ai_provider_audits",
|
||||
"ai_spec_match_decisions",
|
||||
}
|
||||
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
||||
return err
|
||||
@@ -1919,7 +1999,43 @@ func CheckMySQLSchema(db *sql.DB) error {
|
||||
if err := checkMySQLV19Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV20Shape(db)
|
||||
if err := checkMySQLV20Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV21Shape(db)
|
||||
}
|
||||
|
||||
func checkMySQLV21Shape(db *sql.DB) error {
|
||||
if err := checkMySQLSchema(db, []string{"spec_mappings", "ai_spec_match_decisions"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"source", "source_provider_id", "source_model", "confidence_bps",
|
||||
"source_reason", "source_version", "context_version"} {
|
||||
exists, err := mysqlColumnExists(db, "spec_mappings", name)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("规格映射来源字段 %s 缺失: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, item := range []struct{ kind, table, name string }{
|
||||
{"constraint", "spec_mappings", "chk_spec_mappings_source"},
|
||||
{"constraint", "spec_mappings", "chk_spec_mappings_confidence"},
|
||||
{"constraint", "ai_spec_match_decisions", "chk_ai_match_confidence"},
|
||||
{"constraint", "ai_spec_match_decisions", "chk_ai_match_outcome"},
|
||||
{"constraint", "ai_spec_match_decisions", "fk_ai_match_actor"},
|
||||
{"index", "ai_spec_match_decisions", "idx_ai_match_identity"},
|
||||
} {
|
||||
var exists bool
|
||||
var err error
|
||||
if item.kind == "index" {
|
||||
exists, err = mysqlIndexExists(db, item.table, item.name)
|
||||
} else {
|
||||
exists, err = mysqlConstraintExists(db, item.table, item.name)
|
||||
}
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("AI 规格审计%s %s.%s 缺失: %v", item.kind, item.table, item.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLV20Shape(db *sql.DB) error {
|
||||
|
||||
Reference in New Issue
Block a user