Selaa lähdekoodia

feat(ac15): 结构对齐工具 align(created_at/updated_at 只 MODIFY 补 DEFAULT)+ 两条口径裁决落 README

一、align 子命令(internal/academic/dualrun/fixture/schema_align.go)
- 基准 = core-model-ddl.sql + academic-model-ddl.sql,逐表解析 created_at/updated_at 定义
- 与 information_schema 比对,分四类:alignable / ok / drift-nullable / ddl-missing
- MODIFY 子句用【实库现有属性】(column_type/is_nullable/column_comment),
  只把 DEFAULT 与 ON UPDATE 换成 DDL 口径 —— 其它属性一个字节不动
- 每条 ALTER 先在会话临时表 dry-run(CREATE TEMPORARY TABLE ... LIKE + 同一句 MODIFY)
- 分类为 drift-* 的一律只报告不执行(t_campus.created_at/updated_at 实库可空、DDL NOT NULL)
- 绝不 DROP 重建(会连带改掉 GORM 建的索引/字符集)
- align --apply / align --verify 两个开关;默认只打印清单

当前清单(14 条待补,dry-run 全通过;17 条已对齐;2 条只报告)

二、README 落两条裁决
- 字典 t_expenditure 的 D1 是临时手段:只在「两边都从同一份 legacy 快照起步」时成立,
  割接后新增字典项必须走 biz_no 或业务键(否则两边各涨各的 id,按 id 对齐失效)
- t_user_divide_into.created_at/updated_at 是 epoch 占位值:对分账表无业务含义,
  双跑不参与比对(列矩阵标 N/A);将来要真实时间须新建列并回填,不能拿 epoch 当时间用
Q 9 tuntia sitten
vanhempi
sitoutus
4b9ef94c38

+ 84 - 0
cmd/ac15fixture/main.go

@@ -19,6 +19,11 @@
 //	  clean2                  清理并复核 7 张表回 0 行
 //	  count2                  打印第二批涉及表当前行数
 //
+//	结构对齐(created_at / updated_at 补 DEFAULT):
+//	  align                   逐表比对 DDL 与实库,打印 ALTER 清单并做 dry-run(不执行)
+//	  align --apply           清单经人工确认后执行
+//	  align --verify          只复核两列的 COLUMN_DEFAULT 是否全部非 NULL
+//
 // 环境变量(口令不落盘、不打印):
 //
 //	AC15_DB_HOST / AC15_DB_PORT / AC15_DB_USER / AC15_DB_PASS / AC15_TARGET_DB
@@ -69,6 +74,8 @@ func main() {
 		os.Exit(runClean2(args))
 	case "count2":
 		os.Exit(runCount2(args))
+	case "align":
+		os.Exit(runAlign(args))
 	case "-h", "--help", "help":
 		usage()
 		os.Exit(0)
@@ -98,6 +105,11 @@ func usage() {
   clean2    清理并复核 7 张表回 0 行
   count2    打印第二批涉及表当前行数
 
+结构对齐(created_at / updated_at 补 DEFAULT,只 MODIFY 不重建):
+  align             逐表比对 DDL 与实库,打印 ALTER 清单 + dry-run(不执行)
+  align --apply     清单确认后执行
+  align --verify    只复核 COLUMN_DEFAULT 是否全部非 NULL
+
 第一批参数:--user <legacy userid>(默认 53691), --ddl <path>
 第二批参数:--root <分账树根 userid,默认 395>, --buser <充值工单用户,默认 31348>,
             --euser <收支记录创建人,默认 395>, --dict <是否迁移字典,默认 true>
@@ -327,6 +339,78 @@ func runClean2(args []string) int {
 	return 0
 }
 
+func runAlign(args []string) int {
+	fs := flag.NewFlagSet("align", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	apply := fs.Bool("apply", false, "执行清单里的 ALTER(默认只打印 + dry-run)")
+	verifyOnly := fs.Bool("verify", false, "只复核两列默认值")
+	modelDir := fs.String("model", "", "DDL 目录(默认 AC15_MODEL_DIR 或内置路径)")
+	if err := fs.Parse(args); err != nil {
+		return 2
+	}
+	cfg, code := loadConfig()
+	if code != 0 {
+		return code
+	}
+	ctx := context.Background()
+
+	if *verifyOnly {
+		total, missing, err := fixture.VerifyAlign(ctx, cfg)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+			return 3
+		}
+		fmt.Printf("== 复核:%d 个 created_at/updated_at 列 ==\n", total)
+		if len(missing) == 0 {
+			fmt.Printf("COLUMN_DEFAULT 全部非 NULL:PASS\n")
+			return 0
+		}
+		fmt.Printf("仍有 %d 个列 DEFAULT 为 NULL:\n", len(missing))
+		for _, m := range missing {
+			fmt.Printf("  - %s\n", m)
+		}
+		return 1
+	}
+
+	rep, err := fixture.BuildAlignReport(ctx, cfg, *modelDir)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	if err := fixture.DryRunAlign(ctx, cfg, rep); err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: dry-run 失败:%v\n", err)
+		return 3
+	}
+	if *apply {
+		if err := fixture.ApplyAlign(ctx, cfg, rep); err != nil {
+			fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+			return 3
+		}
+	}
+	fixture.PrintAlignReport(os.Stdout, rep)
+
+	if *apply {
+		total, missing, err := fixture.VerifyAlign(ctx, cfg)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+			return 3
+		}
+		fmt.Printf("\n== 执行后复核:%d 列 ==\n", total)
+		if len(missing) == 0 {
+			fmt.Printf("COLUMN_DEFAULT 全部非 NULL:PASS\n")
+		} else {
+			fmt.Printf("仍有 %d 列为 NULL(含 drift-nullable 等未处理项):\n", len(missing))
+			for _, m := range missing {
+				fmt.Printf("  - %s\n", m)
+			}
+		}
+		if rep.Failed > 0 {
+			return 3
+		}
+	}
+	return 0
+}
+
 func runCount2(args []string) int {
 	cfg, _, code := bootstrap2(args)
 	if code != 0 {

+ 34 - 2
internal/academic/dualrun/fixture/README.md

@@ -133,7 +133,12 @@ biz_no 的四条硬约束(`NormalizeBizNo`):非空、长度 == 32、32 位
 
 `t_expenditure` 走 D1 的理由:`t_expenditure_log.expenditureid` 指向它,而它既无旧 UUID
 也无业务唯一键(`name` 不唯一、`parentid` 自引用)。要让 `expenditure_id` 在双跑里可对齐,
-只有「字典 id 原样保留」一条路。**这超出本批 4 表范围,属必要补充,已在报告中待拍板。**
+只有「字典 id 原样保留」一条路。
+
+> ⚠ **字典的 D1 是"临时手段",与流水表的 D1 不同性质**:
+> 流水表只增不改,历史行的 id 不会再变,D1 永久成立;
+> 而**字典项会被业务新增**——新增项在 legacy 与 target 两边各涨各的 id,一旦不齐,"按 id 对齐"立刻失效。
+> 因此:**字典的 D1 只在「两边都从同一份 legacy 快照起步」时成立;割接后新增字典项必须走 biz_no 或业务键。**
 
 ## 7. 第二批:空值与时间口径(逐列判断)
 
@@ -149,6 +154,12 @@ biz_no 的四条硬约束(`NormalizeBizNo`):非空、长度 == 32、32 位
 |`created_at` / `updated_at`(balance_request)|无此列|`request_time` / `response_time ?? request_time`|由真实时间派生|
 |`created_at` / `updated_at`(divide_into)|无此列|`1970-01-01 00:00:00`(epoch 哨兵)|legacy 无任何时间信息,**绝不用 NOW()**|
 
+> ⚠ **epoch 哨兵的适用边界(与上一条同等重要)**:
+>  1. 这两列对分账表**没有任何业务含义,是纯占位值**(legacy 连时间列都没有);
+>  2. **双跑时 `created_at` / `updated_at` 不参与比对**(列矩阵里标 `N/A`);
+>  3. 如果将来分账表真需要时间,**必须新建列并回填**,不能把 epoch 当真实时间用;
+>     也不要把「1970-01-01」当成"早期数据"去统计——它是哨兵,不是时间点。
+
 ## 8. 第二批:float(10,3) 的精度真相(本批最重要的实测结论)
 
 `t_expenditure_log.cost` 是 **`FLOAT(10,3)` = 4 字节单精度**(约 7 位有效数字),大金额存不住 3 位小数:
@@ -185,7 +196,28 @@ biz_no 的四条硬约束(`NormalizeBizNo`):非空、长度 == 32、32 位
    处理(`ALTER` 只作用于会话临时表,不碰任何真实表)。
 3. **`tinyint(4) unsigned zerofill`** 读出来是 `0001`/`0002` 字样,必须 `CAST(type AS SIGNED)` 取整数。
 
-## 11. 已知事实(不是缺陷,是数据现状)
+## 11. 结构对齐工具(`align`)
+
+dev 库 `yuansiwei_2026` 是**"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体**。
+GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表 `created_at` / `updated_at`
+是 `NOT NULL` 且 **无默认值** —— 裸 SQL 插入会在**随机某几张表**上直接 `Error 1364`。
+
+```bash
+go run ./cmd/ac15fixture align            # 逐表比对 DDL 与实库 → 打印 ALTER 清单 + dry-run(不执行)
+go run ./cmd/ac15fixture align --apply    # 清单人工确认后执行
+go run ./cmd/ac15fixture align --verify   # 只复核两列 COLUMN_DEFAULT 是否全部非 NULL
+```
+
+设计上刻意的四条约束:
+
+1. **只 `ALTER TABLE ... MODIFY`,绝不 DROP 重建** —— 重建会把 GORM 建的索引/字符集一起改掉,产生新漂移;
+2. MODIFY 子句**用实库现有属性**(`column_type` / `is_nullable` / `column_comment`),
+   只把 `DEFAULT` 与 `ON UPDATE` 换成 DDL 口径 —— 除这两项外一个字节都不动;
+3. **NULL 性 / 类型 / 注释等其它差异只报告不执行**(`drift-nullable` 等分类),等人工定夺;
+4. 每条 ALTER 先在**会话临时表**上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一句 MODIFY),
+   语法或属性不合法的语句在执行前就暴露。
+
+## 12. 已知事实(不是缺陷,是数据现状)
 
 - legacy `t_user_expense_log` 里 `create_userid = 0` 或 NULL 的行数 = **0**(90007 行全都有真实创建人)。
   所以「0 → NULL」这条规则**无法用真实数据证明**,只能由 `mapping_test.go` 单测 + 变异实验 M3 证明。

+ 474 - 0
internal/academic/dualrun/fixture/schema_align.go

@@ -0,0 +1,474 @@
+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)
+	}
+}