package fixture import ( "context" "database/sql" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strings" ) // 本文件是**结构对齐工具**:把 dev 库 `yuansiwei_2026` 的时间列默认值对齐到 DDL 文件的口径。 // // 为什么需要它:目标库是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。 // GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表这些列 **NOT NULL 且无默认值**, // 裸 SQL 插入会在随机某几张表上直接 `Error 1364`。 // // ⚠ 三类时间列的语义**完全不同**,不能一视同仁: // - `created_at`:**审计**——这行什么时候入库的。取当前时间合理。 // - `create_time`:**业务**——这笔钱/这节课什么时候发生的。取当前时间 = **静默写错**。 // - `update_time`:**业务**——最后修改时间(仅 `t_courses`,DDL 本身就是 DEFAULT NULL)。 // 补默认只解决"插入漏传则报错",**不解决"取到当前时间就错了"**,所以必须配 create_time 断言 // (见 run_marker.go 的 countRowsAfter)。 // // 纪律(不可妥协): // 1. **只 ALTER ... MODIFY**,绝不 DROP 重建(重建会把 GORM 建的索引/字符集一起改掉,产生新漂移); // 2. MODIFY 子句**用实库现有属性**(type / NULL 性 / 注释),只把 DEFAULT 与 ON UPDATE 换成 DDL 口径 // —— `FixNullable` 打开时才额外按 DDL 改 NULL 性; // 3. NULL 性、类型、注释等差异**默认只报告不执行**,必须显式开 `FixNullable` 才动; // 4. 每条 ALTER 先在会话临时表上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一条 MODIFY)。 // // 关于 ALGORITHM:默认在语句末尾带 `, ALGORITHM=INSTANT`(显式声明"只改元数据")。 // 但 **MySQL 8.0.23 不支持对 "MODIFY 改默认值" 用 INSTANT/INPLACE**(实测 Error 1845), // 要 8.0.29+ 才支持;在旧版本上必须显式降级为 `Algorithm=none` 才能执行(见报告)。 // defaultModelDir 是 DDL 目录。 const defaultModelDir = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model` // ColumnsAudit 是审计类时间列(取默认值合理)。 var ColumnsAudit = []string{"created_at", "updated_at"} // ColumnsBusiness 是业务类时间列(取默认值 = 静默写错,必须配断言)。 var ColumnsBusiness = []string{"create_time"} // AlignOptions 是对齐参数。 type AlignOptions struct { ModelDir string // DDL 目录 Columns []string // 参与对齐的列名(默认 created_at/updated_at) Algorithm string // instant / inplace / copy / none FixNullable bool // 是否按 DDL 修正 NULL 性(默认只报告) OnlyTables []string // 只处理这些表(空=全部) } // ddlColumn 是从 DDL 文件里解析出来的一列。 type ddlColumn struct { Table string Column string RawDef string Type string NotNull bool HasDefault bool OnUpdate bool Comment string } // liveColumn 是实库的一列(取自 information_schema)。 type liveColumn struct { Table string Column string ColumnType string Nullable bool Default sql.NullString Extra string Comment string } // AlignAction 是一张表一列的对齐判定。 type AlignAction struct { Table string Column string Kind string // ok / alignable / alignable-nullable / drift-nullable / ddl-no-default / ddl-missing LiveDesc string DDLDefault string SQL string Note string } // AlignReport 是整轮比对结果。 type AlignReport struct { ModelDir string Options AlignOptions Actions []AlignAction DryRunOK map[int]bool Applied int Failed int Errors []string } // Alignable 返回需要执行 ALTER 的条目下标。 func (r *AlignReport) Alignable() []int { var out []int for i, a := range r.Actions { if a.Kind == "alignable" || a.Kind == "alignable-nullable" { out = append(out, i) } } return out } // Problems 返回「只报告不执行」的条目。 func (r *AlignReport) Problems() []AlignAction { var out []AlignAction for _, a := range r.Actions { if strings.HasPrefix(a.Kind, "drift") || a.Kind == "ddl-missing" { out = append(out, a) } } return out } var ( createTableLine = regexp.MustCompile("^\\s*CREATE\\s+TABLE\\s+`([A-Za-z0-9_]+)`") columnLine = regexp.MustCompile("^\\s*`([A-Za-z0-9_]+)`\\s+(.+?),?\\s*$") defaultExpr = regexp.MustCompile(`(?i)\bDEFAULT\s+(CURRENT_TIMESTAMP(?:\(\d*\))?|'[^']*'|[^\s]+)`) commentExpr = regexp.MustCompile(`(?i)\bCOMMENT\s+'((?:[^']|'')*)'`) firstToken = regexp.MustCompile(`^[A-Za-z]+(?:\([0-9,]+\))?`) ) // ParseDDLFile 解析一个 DDL 文件,返回指定列的列定义。 func ParseDDLFile(path string, want []string) ([]ddlColumn, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("读 DDL 失败(%s):%w", path, err) } var out []ddlColumn curTable := "" for _, line := range strings.Split(string(raw), "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "--") { continue } if m := createTableLine.FindStringSubmatch(trimmed); m != nil { curTable = m[1] continue } if curTable == "" { continue } if strings.HasPrefix(trimmed, ")") { curTable = "" continue } m := columnLine.FindStringSubmatch(line) if m == nil { continue } col := m[1] if !containsString(want, col) { continue } def := strings.TrimSuffix(strings.TrimSpace(m[2]), ",") c := ddlColumn{Table: curTable, Column: col, RawDef: def} if t := firstToken.FindString(def); t != "" { c.Type = t } c.NotNull = strings.Contains(strings.ToUpper(def), "NOT NULL") if dm := defaultExpr.FindStringSubmatch(def); dm != nil { c.HasDefault = true } c.OnUpdate = strings.Contains(strings.ToUpper(def), "ON UPDATE CURRENT_TIMESTAMP") if cm := commentExpr.FindStringSubmatch(def); cm != nil { c.Comment = strings.ReplaceAll(cm[1], "''", "'") } out = append(out, c) } return out, nil } func containsString(ss []string, s string) bool { for _, v := range ss { if v == s { return true } } return false } // loadLiveColumns 从 information_schema 读实库指定列的属性。 func loadLiveColumns(ctx context.Context, db *sql.DB, schema string, columns []string) (map[string]liveColumn, error) { // 关掉 information_schema 统计缓存,拿即时值(同 batch2 的 AUTO_INCREMENT 坑)。 if _, err := db.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil { return nil, err } ph := make([]string, len(columns)) args := []any{schema} for i, c := range columns { ph[i] = "?" args = append(args, c) } q := fmt.Sprintf(` SELECT table_name, column_name, column_type, is_nullable, column_default, extra, column_comment FROM information_schema.columns WHERE table_schema = ? AND column_name IN (%s)`, strings.Join(ph, ",")) rows, err := db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() out := map[string]liveColumn{} for rows.Next() { var c liveColumn var nullable string if err := rows.Scan(&c.Table, &c.Column, &c.ColumnType, &nullable, &c.Default, &c.Extra, &c.Comment); err != nil { return nil, err } c.Nullable = strings.EqualFold(nullable, "YES") out[c.Table+"."+c.Column] = c } return out, rows.Err() } // algorithmSuffix 生成 ALGORITHM 子句(none 时返回空串)。 func algorithmSuffix(algorithm string) string { switch strings.ToLower(strings.TrimSpace(algorithm)) { case "instant": return ", ALGORITHM=INSTANT" case "inplace": return ", ALGORITHM=INPLACE, LOCK=NONE" case "copy": return ", ALGORITHM=COPY" default: // "", "none" return "" } } // buildModifySQL 用**实库现有属性** + DDL 的 DEFAULT 拼出 MODIFY 语句。 func buildModifySQL(live liveColumn, ddl ddlColumn, fixNullable bool, algorithm string) string { var b strings.Builder b.WriteString(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `%s` %s", live.Table, live.Column, live.ColumnType)) notNull := !live.Nullable if fixNullable && ddl.NotNull { notNull = true } if notNull { b.WriteString(" NOT NULL") } b.WriteString(" DEFAULT CURRENT_TIMESTAMP(3)") if ddl.OnUpdate { b.WriteString(" ON UPDATE CURRENT_TIMESTAMP(3)") } if live.Comment != "" { b.WriteString(" COMMENT '" + strings.ReplaceAll(live.Comment, "'", "''") + "'") } b.WriteString(algorithmSuffix(algorithm)) return b.String() } func describeLive(l liveColumn) string { def := "NULL(无默认值)" if l.Default.Valid { def = l.Default.String } null := "NOT NULL" if l.Nullable { null = "NULL" } extra := "" if l.Extra != "" { extra = fmt.Sprintf(" extra=%q", l.Extra) } return fmt.Sprintf("%s %s DEFAULT=%s%s", l.ColumnType, null, def, extra) } // BuildAlignReport 逐表比对 DDL 与实库,产出对齐清单(不执行)。 func BuildAlignReport(ctx context.Context, cfg Config, opts AlignOptions) (*AlignReport, error) { if opts.ModelDir == "" { opts.ModelDir = os.Getenv("AC15_MODEL_DIR") } if opts.ModelDir == "" { opts.ModelDir = defaultModelDir } if len(opts.Columns) == 0 { opts.Columns = ColumnsAudit } files := []string{ filepath.Join(opts.ModelDir, "core-model-ddl.sql"), filepath.Join(opts.ModelDir, "academic-model-ddl.sql"), } var specs []ddlColumn for _, f := range files { cols, err := ParseDDLFile(f, opts.Columns) if err != nil { return nil, err } specs = append(specs, cols...) } if len(specs) == 0 { return nil, fmt.Errorf("DDL 里没有解析到 %v 的定义(modelDir=%s)", opts.Columns, opts.ModelDir) } db, err := cfg.OpenTarget() if err != nil { return nil, err } defer db.Close() live, err := loadLiveColumns(ctx, db, cfg.TargetDB, opts.Columns) if err != nil { return nil, err } rep := &AlignReport{ModelDir: opts.ModelDir, Options: opts, DryRunOK: map[int]bool{}} for _, sp := range specs { if len(opts.OnlyTables) > 0 && !containsString(opts.OnlyTables, sp.Table) { continue } key := sp.Table + "." + sp.Column lv, ok := live[key] if !ok { rep.Actions = append(rep.Actions, AlignAction{ Table: sp.Table, Column: sp.Column, Kind: "ddl-missing", LiveDesc: "<实库无此列>", Note: "DDL 有、实库没有 —— 属缺列,本工具不处理", }) continue } a := AlignAction{Table: sp.Table, Column: sp.Column, LiveDesc: describeLive(lv), DDLDefault: "CURRENT_TIMESTAMP(3)"} ddlNull := "NOT NULL" if !sp.NotNull { ddlNull = "NULL" } switch { case lv.Nullable != !sp.NotNull: if opts.FixNullable && sp.NotNull && sp.HasDefault { a.Kind = "alignable-nullable" a.SQL = buildModifySQL(lv, sp, true, opts.Algorithm) a.Note = fmt.Sprintf("NULL 性不一致(实库可空、DDL %s)→ 按 DDL 一并修正 + 补 DEFAULT", ddlNull) } else { a.Kind = "drift-nullable" a.Note = fmt.Sprintf("NULL 性不一致:实库可空,DDL %s —— 未开 FixNullable,只报告不改", ddlNull) } case !sp.HasDefault: a.Kind = "ddl-no-default" a.Note = "DDL 该列本身没有 DEFAULT CURRENT_TIMESTAMP(3),无需对齐" case lv.Default.Valid: a.Kind = "ok" a.Note = "已有默认值(已对齐)" default: a.Kind = "alignable" a.SQL = buildModifySQL(lv, sp, false, opts.Algorithm) a.Note = "实库 DEFAULT 为 NULL → 补 DEFAULT CURRENT_TIMESTAMP(3)" + map[bool]string{true: ",并补 ON UPDATE CURRENT_TIMESTAMP(3)", false: ""}[sp.OnUpdate] } rep.Actions = append(rep.Actions, a) } sort.Slice(rep.Actions, func(i, j int) bool { if rep.Actions[i].Table != rep.Actions[j].Table { return rep.Actions[i].Table < rep.Actions[j].Table } return rep.Actions[i].Column < rep.Actions[j].Column }) return rep, nil } // DryRunAlign 在**会话临时表**上验证每条 ALTER 是否合法且达到预期效果。 func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error { db, err := cfg.OpenTarget() if err != nil { return err } defer db.Close() conn, err := db.Conn(ctx) if err != nil { return err } defer conn.Close() for i := range rep.Actions { a := &rep.Actions[i] if a.Kind != "alignable" && a.Kind != "alignable-nullable" { continue } tmp := "tmp_align_" + a.Table if _, err := conn.ExecContext(ctx, fmt.Sprintf("CREATE TEMPORARY TABLE IF NOT EXISTS `%s` LIKE `%s`", tmp, a.Table)); err != nil { return fmt.Errorf("dry-run 建临时表失败(%s):%w", a.Table, err) } stmt := strings.Replace(a.SQL, "ALTER TABLE `"+a.Table+"`", "ALTER TABLE `"+tmp+"`", 1) if _, err := conn.ExecContext(ctx, stmt); err != nil { rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 失败 %s.%s:%v", a.Table, a.Column, err)) rep.DryRunOK[i] = false continue } var def sql.NullString var extra string if err := conn.QueryRowContext(ctx, ` SELECT column_default, extra FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, tmp, a.Column).Scan(&def, &extra); err != nil { rows, err2 := conn.QueryContext(ctx, fmt.Sprintf("SHOW COLUMNS FROM `%s` LIKE '%s'", tmp, a.Column)) if err2 != nil { rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 复查失败 %s.%s:%v", a.Table, a.Column, err2)) rep.DryRunOK[i] = false continue } for rows.Next() { var field, typ, null, key, d, ex sql.NullString if err := rows.Scan(&field, &typ, &null, &key, &d, &ex); err != nil { rows.Close() return err } def, extra = d, ex.String } rows.Close() } rep.DryRunOK[i] = def.Valid && strings.Contains(strings.ToLower(def.String), "current_timestamp") if !rep.DryRunOK[i] { rep.Errors = append(rep.Errors, fmt.Sprintf( "dry-run 复查不符 %s.%s:default=%v extra=%q(期望 CURRENT_TIMESTAMP)", a.Table, a.Column, def, extra)) } } return nil } // ApplyAlign 执行对齐(只在 dry-run 全部通过时执行)。 func ApplyAlign(ctx context.Context, cfg Config, rep *AlignReport) error { for i := range rep.Actions { k := rep.Actions[i].Kind if (k == "alignable" || k == "alignable-nullable") && !rep.DryRunOK[i] { return fmt.Errorf("存在未通过 dry-run 的语句,拒绝执行(%s.%s)", rep.Actions[i].Table, rep.Actions[i].Column) } } db, err := cfg.OpenTarget() if err != nil { return err } defer db.Close() for i := range rep.Actions { a := &rep.Actions[i] if a.Kind != "alignable" && a.Kind != "alignable-nullable" { continue } if _, err := db.ExecContext(ctx, a.SQL); err != nil { rep.Failed++ rep.Errors = append(rep.Errors, fmt.Sprintf("执行失败 %s.%s:%v", a.Table, a.Column, err)) continue } rep.Applied++ } return nil } // VerifyAlign 复核:指定列的 COLUMN_DEFAULT 必须全部非 NULL。 func VerifyAlign(ctx context.Context, cfg Config, columns []string) (total int, missing []string, err error) { if len(columns) == 0 { columns = ColumnsAudit } db, err := cfg.OpenTarget() if err != nil { return 0, nil, err } defer db.Close() live, err := loadLiveColumns(ctx, db, cfg.TargetDB, columns) if err != nil { return 0, nil, err } for key, c := range live { total++ if !c.Default.Valid { missing = append(missing, fmt.Sprintf("%s(%s)", key, c.ColumnType)) } } sort.Strings(missing) return total, missing, nil } // PrintAlignReport 打印对齐清单。 func PrintAlignReport(w io.Writer, r *AlignReport) { fmt.Fprintf(w, "== 结构对齐清单(基准:DDL 文件)==\n") fmt.Fprintf(w, "DDL 目录:%s\n", r.ModelDir) algo := r.Options.Algorithm if algo == "" { algo = "none" } fmt.Fprintf(w, "列范围:%v 算法子句:%s FixNullable=%v\n\n", r.Options.Columns, algo, r.Options.FixNullable) alignable := r.Alignable() fmt.Fprintf(w, "【待执行】%d 条\n", len(alignable)) for _, i := range alignable { a := r.Actions[i] dry := "未验证" if ok, has := r.DryRunOK[i]; has { if ok { dry = "dry-run ✔" } else { dry = "dry-run ✘" } } fmt.Fprintf(w, " %-32s [%s] %s\n %s\n", a.Table+"."+a.Column, a.Kind, dry, a.SQL) } okCount := 0 for _, a := range r.Actions { if a.Kind == "ok" { okCount++ } } fmt.Fprintf(w, "\n【已对齐】%d 条\n", okCount) for _, a := range r.Actions { if a.Kind == "ok" { fmt.Fprintf(w, " %-32s %s\n", a.Table+"."+a.Column, a.LiveDesc) } } probs := r.Problems() fmt.Fprintf(w, "\n【只报告、不执行】%d 条\n", len(probs)) for _, a := range probs { fmt.Fprintf(w, " %-32s [%s] %s\n 实库:%s\n", a.Table+"."+a.Column, a.Kind, a.Note, a.LiveDesc) } skipped := 0 for _, a := range r.Actions { if a.Kind == "ddl-no-default" { skipped++ } } if skipped > 0 { fmt.Fprintf(w, "\n【DDL 无默认值、无需动】%d 条\n", skipped) } if len(r.Errors) > 0 { fmt.Fprintf(w, "\n【错误/告警】%d 条\n", len(r.Errors)) for _, e := range r.Errors { fmt.Fprintf(w, " - %s\n", e) } } if r.Applied > 0 || r.Failed > 0 { fmt.Fprintf(w, "\n执行结果:成功 %d 条,失败 %d 条\n", r.Applied, r.Failed) } }