package fixture import ( "context" "database/sql" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strings" ) // 本文件是**结构对齐工具**:把 dev 库 `yuansiwei_2026` 的 created_at / updated_at // 对齐到 DDL 文件的口径(补 DEFAULT CURRENT_TIMESTAMP(3),updated_at 另有 ON UPDATE)。 // // 为什么需要它:目标库是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。 // GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表这两列 **NOT NULL 且无默认值**, // 裸 SQL 插入会在随机某几张表上直接 `Error 1364`。 // // 纪律(不可妥协): // 1. **只 ALTER ... MODIFY**,绝不 DROP 重建(重建会把 GORM 建的索引/字符集一起改掉,产生新漂移); // 2. MODIFY 子句**用实库现有属性**(type / NULL 性 / 注释),只把 DEFAULT 与 ON UPDATE 换成 DDL 口径 // —— 这样除了这两个属性,其它一个字节都不动; // 3. NULL 性、类型、注释等**其它**差异只报告不执行,等人工定夺; // 4. 每条 ALTER 先在会话临时表上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一条 MODIFY), // 语法或属性不合法的语句在真正执行前就会暴露。 // DDL 目录(与 core DDL 同一目录)。 const defaultModelDir = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model` // 本工具只处理这两列。 var alignedColumns = []string{"created_at", "updated_at"} // ddlColumn 是从 DDL 文件里解析出来的一列。 type ddlColumn struct { Table string Column string RawDef string // DDL 里的原始定义(不含列名、不含逗号) Type string // 如 DATETIME(3) NotNull bool HasDefault bool OnUpdate bool Comment string } // liveColumn 是实库的一列(取自 information_schema)。 type liveColumn struct { Table string Column string ColumnType string // 如 datetime(3) Nullable bool Default sql.NullString Extra string Comment string } // AlignAction 是一张表一列的对齐判定。 type AlignAction struct { Table string Column string Kind string // ok / alignable / drift-nullable / drift-type / ddl-missing LiveDesc string DDLDefault string SQL string // Kind=alignable 时的 ALTER 语句 Note string } // AlignReport 是整轮比对结果。 type AlignReport struct { ModelDir string Actions []AlignAction DryRunOK map[int]bool // Actions 下标 → dry-run 是否通过 Applied int Failed int Errors []string } // Alignable 返回需要补 DEFAULT 的条目下标。 func (r *AlignReport) Alignable() []int { var out []int for i, a := range r.Actions { if a.Kind == "alignable" { 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 文件,返回其中 created_at / updated_at 的列定义。 func ParseDDLFile(path 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 len(alignedColumns) > 0 && !contains(alignedColumns, 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 contains(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) (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 } rows, err := db.QueryContext(ctx, ` 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 ('created_at','updated_at')`, schema) 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() } // buildModifySQL 用**实库现有属性** + DDL 的 DEFAULT 拼出 MODIFY 子句。 // // 只动两处:DEFAULT CURRENT_TIMESTAMP(3)(两列都有)、ON UPDATE CURRENT_TIMESTAMP(3)(仅 updated_at)。 // type / NOT NULL / COMMENT 全部照抄实库,避免顺手改掉别的属性。 func buildModifySQL(live liveColumn, ddl ddlColumn) string { var b strings.Builder b.WriteString(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `%s` %s", live.Table, live.Column, live.ColumnType)) if !live.Nullable { 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, "'", "''") + "'") } return b.String() } // describeLive 生成实库列的可读描述(报告用)。 func describeLive(l liveColumn) string { def := "NULL(无默认值)" if l.Default.Valid { def = l.Default.String } null := "NULL" if !l.Nullable { null = "NOT NULL" } return fmt.Sprintf("%s %s DEFAULT=%s extra=%q", l.ColumnType, null, def, l.Extra) } // BuildAlignReport 逐表比对 DDL 与实库,产出对齐清单(不执行)。 func BuildAlignReport(ctx context.Context, cfg Config, modelDir string) (*AlignReport, error) { if modelDir == "" { modelDir = os.Getenv("AC15_MODEL_DIR") } if modelDir == "" { modelDir = defaultModelDir } files := []string{ filepath.Join(modelDir, "core-model-ddl.sql"), filepath.Join(modelDir, "academic-model-ddl.sql"), } var specs []ddlColumn for _, f := range files { cols, err := ParseDDLFile(f) if err != nil { return nil, err } specs = append(specs, cols...) } if len(specs) == 0 { return nil, fmt.Errorf("DDL 里没有解析到 created_at/updated_at 定义(modelDir=%s)", modelDir) } db, err := cfg.OpenTarget() if err != nil { return nil, err } defer db.Close() live, err := loadLiveColumns(ctx, db, cfg.TargetDB) if err != nil { return nil, err } rep := &AlignReport{ModelDir: modelDir, DryRunOK: map[int]bool{}} for _, sp := range specs { 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 有、实库没有 —— 属缺列,本工具不处理(需要 ALTER ADD COLUMN,另有风险)", }) continue } a := AlignAction{Table: sp.Table, Column: sp.Column, LiveDesc: describeLive(lv), DDLDefault: "CURRENT_TIMESTAMP(3)"} switch { case lv.Nullable != !sp.NotNull: // NULL 性不一致 —— 只报告,等人工定夺(团队要求不顺手改) a.Kind = "drift-nullable" liveNull := "NOT NULL" if lv.Nullable { liveNull = "NULL" } ddlNull := "NOT NULL" if !sp.NotNull { ddlNull = "NULL" } a.Note = fmt.Sprintf("NULL 性不一致:实库 %s,DDL %s —— 只报告不改", liveNull, 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) 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 是否合法且达到预期效果。 // // 不动真实表:`CREATE TEMPORARY TABLE ... LIKE 真表` → 跑同一条 MODIFY → 复查默认值。 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" { 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) } // 把临时表名替换进 ALTER 语句(其余部分逐字符相同) 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 { // 临时表不出现在 information_schema 的情况(部分版本),退化为 SHOW COLUMNS 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 { if rep.Actions[i].Kind == "alignable" && !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" { 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(drift-nullable 的除外)。 func VerifyAlign(ctx context.Context, cfg Config) (total int, missing []string, err error) { db, err := cfg.OpenTarget() if err != nil { return 0, nil, err } defer db.Close() live, err := loadLiveColumns(ctx, db, cfg.TargetDB) 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\n", r.ModelDir) alignable := r.Alignable() fmt.Fprintf(w, "【待补 DEFAULT】%d 条(只 MODIFY 这两列,其余属性照抄实库)\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, " %-30s 实库:%s\n %s [%s]\n", a.Table+"."+a.Column, a.LiveDesc, a.SQL, dry) } 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, " %-30s %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, " %-30s [%s] %s\n 实库:%s\n", a.Table+"."+a.Column, a.Kind, a.Note, a.LiveDesc) } 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) } }