feat: 增加真实采购任务安全模式 (#98)
This commit is contained in:
@@ -19,7 +19,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 4
|
||||
const mysqlSchemaVersion = 5
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
@@ -471,10 +471,77 @@ func MigrateMySQL(db *sql.DB) error {
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 4, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v4 失败: %w", err)
|
||||
}
|
||||
current = 4
|
||||
}
|
||||
if current < 5 {
|
||||
if err := migrateMySQLV5(db); err != nil {
|
||||
return fmt.Errorf("执行 MySQL schema v5 失败: %w", err)
|
||||
}
|
||||
if err := checkMySQLV5Shape(db); err != nil {
|
||||
return fmt.Errorf("MySQL schema v5 自检失败,未记录版本: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 5, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v5 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return CheckMySQLSchema(db)
|
||||
}
|
||||
|
||||
// migrateMySQLV5 给任务增加不可变执行模式和真实下单创建审计。
|
||||
// 每条 DDL 都先检查存在性,MySQL 在任意一步隐式提交后都可以安全重放。
|
||||
func migrateMySQLV5(db *sql.DB) error {
|
||||
columns := []struct {
|
||||
name string
|
||||
ddl string
|
||||
}{
|
||||
{"execution_mode", `ALTER TABLE tasks ADD COLUMN execution_mode VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'dry_run' AFTER status`},
|
||||
{"live_confirmed_by", `ALTER TABLE tasks ADD COLUMN live_confirmed_by VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER finished_at`},
|
||||
{"live_confirmed_at", `ALTER TABLE tasks ADD COLUMN live_confirmed_at VARCHAR(35) NULL AFTER live_confirmed_by`},
|
||||
}
|
||||
for _, column := range columns {
|
||||
exists, err := mysqlColumnExists(db, "tasks", column.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(column.ddl); err != nil {
|
||||
return fmt.Errorf("增加 tasks.%s 失败: %w", column.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE tasks SET execution_mode='dry_run' WHERE execution_mode IS NULL OR execution_mode=''`); err != nil {
|
||||
return fmt.Errorf("回填任务执行模式失败: %w", err)
|
||||
}
|
||||
constraints := []struct {
|
||||
name string
|
||||
ddl string
|
||||
}{
|
||||
{"chk_tasks_execution_mode", `ALTER TABLE tasks ADD CONSTRAINT chk_tasks_execution_mode CHECK (execution_mode IN ('dry_run','live'))`},
|
||||
{"chk_tasks_live_confirmation", `ALTER TABLE tasks ADD CONSTRAINT chk_tasks_live_confirmation CHECK ((execution_mode='dry_run' AND live_confirmed_by IS NULL AND live_confirmed_at IS NULL) OR (execution_mode='live' AND live_confirmed_by IS NOT NULL AND live_confirmed_by<>'' AND live_confirmed_at IS NOT NULL AND live_confirmed_at<>''))`},
|
||||
}
|
||||
for _, constraint := range constraints {
|
||||
exists, err := mysqlConstraintExists(db, "tasks", constraint.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(constraint.ddl); err != nil {
|
||||
return fmt.Errorf("增加 tasks.%s 失败: %w", constraint.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
indexExists, err := mysqlIndexExists(db, "tasks", "idx_tasks_claim_mode")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !indexExists {
|
||||
if _, err := db.Exec(`ALTER TABLE tasks ADD INDEX idx_tasks_claim_mode (assigned_client, status, execution_mode, priority DESC, created_at)`); err != nil {
|
||||
return fmt.Errorf("增加任务模式领取索引失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLSchemaV3(db *sql.DB) error {
|
||||
tables := []string{"shopee_products", "shopee_skus", "pdd_products", "syb_orders", "spec_mappings", "tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state", "users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock"}
|
||||
if err := checkMySQLSchema(db, tables); err != nil {
|
||||
@@ -563,6 +630,15 @@ func mysqlConstraintExists(db *sql.DB, table, constraint string) (bool, error) {
|
||||
return count == 1, nil
|
||||
}
|
||||
|
||||
func mysqlIndexExists(db *sql.DB, table, index string) (bool, error) {
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
|
||||
WHERE table_schema=DATABASE() AND table_name=? AND index_name=?`, table, index).Scan(&count); err != nil {
|
||||
return false, fmt.Errorf("检查 MySQL 索引 %s.%s 失败: %w", table, index, err)
|
||||
}
|
||||
return count == 1, nil
|
||||
}
|
||||
|
||||
func backfillSybSpecKeys(db *sql.DB) error {
|
||||
const batchSize = 500
|
||||
cursor := ""
|
||||
@@ -729,7 +805,65 @@ func CheckMySQLSchema(db *sql.DB) error {
|
||||
if err := checkMySQLV3Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV4Shape(db)
|
||||
if err := checkMySQLV4Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV5Shape(db)
|
||||
}
|
||||
|
||||
func checkMySQLV5Shape(db *sql.DB) error {
|
||||
if err := checkMySQLVarcharColumn(db, "tasks", "execution_mode", 16, false, "utf8mb4_bin", "dry_run"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkMySQLVarcharColumn(db, "tasks", "live_confirmed_by", 191, true, "utf8mb4_bin", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkMySQLNullDefault(db, "tasks", "live_confirmed_by"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkMySQLVarcharColumn(db, "tasks", "live_confirmed_at", 35, true, "utf8mb4_0900_ai_ci", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkMySQLNullDefault(db, "tasks", "live_confirmed_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, check := range []struct {
|
||||
name string
|
||||
required []string
|
||||
}{
|
||||
{"chk_tasks_execution_mode", []string{"execution_modein'dry_run','live'"}},
|
||||
{"chk_tasks_live_confirmation", []string{"execution_mode='dry_run'", "live_confirmed_byisnull", "live_confirmed_atisnull", "execution_mode='live'", "live_confirmed_byisnotnull", "live_confirmed_by<>''", "live_confirmed_atisnotnull", "live_confirmed_at<>''"}},
|
||||
} {
|
||||
var enforced, clause string
|
||||
if err := db.QueryRow(`SELECT tc.enforced,cc.check_clause FROM information_schema.table_constraints tc JOIN information_schema.check_constraints cc ON cc.constraint_schema=tc.constraint_schema AND cc.constraint_name=tc.constraint_name WHERE tc.constraint_schema=DATABASE() AND tc.table_name='tasks' AND tc.constraint_name=? AND tc.constraint_type='CHECK'`, check.name).Scan(&enforced, &clause); err != nil {
|
||||
return fmt.Errorf("任务 CHECK %s 缺失或不可读: %w", check.name, err)
|
||||
}
|
||||
normalized := strings.NewReplacer("`", "", " ", "", "(", "", ")", "", "_utf8mb4", "", `\`, "").Replace(strings.ToLower(clause))
|
||||
if enforced != "YES" {
|
||||
return fmt.Errorf("任务 CHECK %s 未启用", check.name)
|
||||
}
|
||||
for _, fragment := range check.required {
|
||||
if !strings.Contains(normalized, fragment) {
|
||||
return fmt.Errorf("任务 CHECK %s 表达式不正确", check.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
var cols string
|
||||
if err := db.QueryRow(`SELECT GROUP_CONCAT(CONCAT(column_name,':',collation) ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tasks' AND index_name='idx_tasks_claim_mode'`).Scan(&cols); err != nil || cols != "assigned_client:A,status:A,execution_mode:A,priority:D,created_at:A" {
|
||||
return fmt.Errorf("任务模式领取索引不正确")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLNullDefault(db *sql.DB, table, column string) error {
|
||||
var defaultValue sql.NullString
|
||||
if err := db.QueryRow(`SELECT column_default FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`, table, column).Scan(&defaultValue); err != nil {
|
||||
return fmt.Errorf("检查 MySQL 列 %s.%s 默认值失败: %w", table, column, err)
|
||||
}
|
||||
if defaultValue.Valid {
|
||||
return fmt.Errorf("MySQL 列 %s.%s 默认值应为 NULL", table, column)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLV4Shape(db *sql.DB) error {
|
||||
|
||||
Reference in New Issue
Block a user