ソースを参照

test(academic): 落地设计 v0.1.2 §2.4 的 L1/L2 裁定(类型归一 + DDL 指纹锁)

L1 TINYINT(1) 假阳性 → 不动 model,改对账归一:
- 新增 internal/infra/dbtest(跨域共用测试口径):NormalizeColumnType / SameColumnType
  规则顺序敏感:ToUpper+去空白 → 仅整数族剥离 "(N)"(INTEGER→INT)→ BOOL/BOOLEAN→TINYINT
  → 其余类型(decimal(p,s)/varchar(N)/datetime(3))**保留参数**
- migrate_dev_check_test.go 类型比对改为两侧归一(原来逐字串比对会误报 4 个
  actived/teacher_cert;MySQL 8.0.19+ 省略整数显示宽度,actual 可能是 tinyint 而 DDL 写 TINYINT(1))
- 单测 31 例:正例(显示宽度/同义词等价)+ **负例必须判不一致**(decimal(12,2)vs(12,3)、
  varchar(32)vs(26)、datetime vs datetime(3)、有/无符号、tinyint vs smallint)+ 幂等

L2 DDL 哈希校验(AC-17 ⑥前置门禁):
- 新增 testdata/academic-model-ddl.sha256(单行 64 位小写 hex)
- ddl_hash_test.go 断言 sha256(fixture) == 锁文件;另断言 go:embed 内容与磁盘 fixture 一致
- 指纹按 **LF 归一**计算(core.autocrlf=true 下 CRLF 检出会让裸字节指纹跨平台假红);
  已实测:篡改 fixture 立即红灯,恢复后绿
- 纪律落地:DDL 变更须同一提交内同步「文档副本 / fixture / sha256」三处(测试失败文案已写入该提示)

AC-17 侧配合:对「文档副本」与「fixture」各跑 sha256 后与锁文件比对,结论写入一致性报告。
L3(AC-03 order_pay_repo 不继承 BaseRepo)、L4、L7 已确认口径,本包无代码改动。

验证:go build ./... && go vet ./... && go test ./... -short 全绿
Alex 19 時間 前
親
コミット
f667bd1230

+ 118 - 0
internal/academic/model/ddl_hash_test.go

@@ -0,0 +1,118 @@
+// DDL 指纹锁单测(AC-17 验收⑥的前置门禁)——设计 v0.1.2 §2.4「L2 DDL 哈希校验」。
+//
+// 目的:目标态 DDL 是**跨仓双份**存在的(文档 `unified/03-migration/academic-model-ddl.sql`
+// 与代码仓 fixture `internal/academic/model/testdata/academic-model-ddl.sql`)。文档不在代码仓,
+// 跨仓字节相等无法由 Go 测试直接判定,因此本仓这一侧做「指纹锁」:
+//
+//	sha256(fixture, LF 归一) == testdata/academic-model-ddl.sha256 的内容
+//
+// 配合 AC-17 的人工/报告流程:对「文档副本」与「fixture」各跑一次 sha256 并与锁文件比对,
+// 三者一致才视为"文档与代码未漂移",结果写进一致性报告留证。
+//
+// 纪律(任一 DDL 变更必须**同一提交**内更新三处):
+//  1. 文档 `unified/03-migration/academic-model-ddl.sql`;
+//  2. 本仓 fixture `testdata/academic-model-ddl.sql`;
+//  3. 本仓锁文件 `testdata/academic-model-ddl.sha256`(单行 64 位小写 hex)。
+//
+// 为什么按「LF 归一」取指纹(不是裸字节):
+//
+//	开发者机器常见 `core.autocrlf=true`(Windows 检出为 CRLF),同一内容在 Linux CI 会得到
+//	不同 sha256 → 裸字节指纹必然产生"跨平台假红"。归一掉换行风格后,指纹只对**内容**敏感:
+//	任何字符级修改都会改变它,而"纯换行风格"这种无意义差异不会误报。
+//	(实测:Windows 工作区与 git blob 均为 LF,两者指纹一致 = 7a25c918…75935。)
+package model
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"os"
+	"strings"
+	"testing"
+)
+
+const (
+	// ddlFixturePath DDL fixture 相对路径(go test 以包目录为工作目录)。
+	ddlFixturePath = "testdata/academic-model-ddl.sql"
+	// ddlHashPath DDL 指纹锁文件路径(单行 64 位小写 hex,可被 awk '{print $1}' 直接取用)。
+	ddlHashPath = "testdata/academic-model-ddl.sha256"
+	// ddlHashHexLen sha256 十六进制串长度。
+	ddlHashHexLen = 64
+)
+
+// normalizeEOL 把 CRLF / 孤立 CR 统一为 LF(跨平台指纹稳定的唯一前提)。
+//
+// 入参:b []byte——文件原始字节。出参:[]byte——换行归一后的字节(不改其它任何字符)。
+func normalizeEOL(b []byte) []byte {
+	s := strings.ReplaceAll(string(b), "\r\n", "\n")
+	s = strings.ReplaceAll(s, "\r", "\n")
+	return []byte(s)
+}
+
+// sha256Hex 计算内容的 sha256 十六进制摘要(入参应已归一)。
+//
+// 入参:b []byte。出参:64 位小写 hex。
+func sha256Hex(b []byte) string {
+	sum := sha256.Sum256(b)
+	return hex.EncodeToString(sum[:])
+}
+
+// readDDLHashLock 读取并校验锁文件格式(单行 64 位 hex;多余空白容忍,注释/多行拒绝)。
+//
+// 出参:锁文件中的 hex 摘要(小写)。
+func readDDLHashLock(t *testing.T) string {
+	t.Helper()
+	raw, err := os.ReadFile(ddlHashPath)
+	if err != nil {
+		t.Fatalf("读取 DDL 指纹锁失败(%s): %v", ddlHashPath, err)
+	}
+	lines := make([]string, 0, 2)
+	for _, line := range strings.Split(string(normalizeEOL(raw)), "\n") {
+		if trimmed := strings.TrimSpace(line); trimmed != "" {
+			lines = append(lines, trimmed)
+		}
+	}
+	if len(lines) != 1 {
+		t.Fatalf("锁文件 %s 应只含 1 行(64 位小写 hex),实得 %d 行: %v", ddlHashPath, len(lines), lines)
+	}
+	got := lines[0]
+	if len(got) != ddlHashHexLen {
+		t.Fatalf("锁文件 %s 内容长度 = %d,期望 %d(纯 hex,勿加注释/文件名)", ddlHashPath, len(got), ddlHashHexLen)
+	}
+	if strings.ToLower(got) != got {
+		t.Fatalf("锁文件 %s 必须使用小写 hex,实得 %q", ddlHashPath, got)
+	}
+	for _, r := range got {
+		if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
+			t.Fatalf("锁文件 %s 含非 hex 字符: %q", ddlHashPath, got)
+		}
+	}
+	return got
+}
+
+// TestDDLFixtureHashLock fixture 内容必须与指纹锁一致(DDL 漂移的硬门禁)。
+func TestDDLFixtureHashLock(t *testing.T) {
+	want := readDDLHashLock(t)
+
+	raw, err := os.ReadFile(ddlFixturePath)
+	if err != nil {
+		t.Fatalf("读取 DDL fixture 失败(%s): %v", ddlFixturePath, err)
+	}
+	got := sha256Hex(normalizeEOL(raw))
+	if got != want {
+		t.Fatalf("DDL fixture 指纹漂移:\n  实际 = %s\n  锁定 = %s\n"+
+			"若为有意的 DDL 变更,必须**同一提交**内同步三处:文档副本 / fixture / testdata/academic-model-ddl.sha256",
+			got, want)
+	}
+}
+
+// TestDDLFixtureHashMatchesEmbedded embed 进测试二进制的 DDL 与磁盘 fixture 内容一致
+// (防"改了 fixture 但 embed 走缓存/路径不一致"这类隐蔽漂移)。
+func TestDDLFixtureHashMatchesEmbedded(t *testing.T) {
+	raw, err := os.ReadFile(ddlFixturePath)
+	if err != nil {
+		t.Fatalf("读取 DDL fixture 失败: %v", err)
+	}
+	if got, want := sha256Hex(normalizeEOL([]byte(ddlSQL))), sha256Hex(normalizeEOL(raw)); got != want {
+		t.Fatalf("go:embed 的 DDL 与磁盘 fixture 不一致:embed=%s file=%s", got, want)
+	}
+}

+ 10 - 2
internal/academic/model/migrate_dev_check_test.go

@@ -19,6 +19,7 @@ import (
 
 	"yuansiwei/unified-backend/internal/infra/config"
 	infradb "yuansiwei/unified-backend/internal/infra/db"
+	"yuansiwei/unified-backend/internal/infra/dbtest"
 )
 
 // TestDevDBAutoMigrate 对 dev 库执行 AutoMigrate 并用 information_schema 逐列核对(需显式开启)。
@@ -85,8 +86,15 @@ func TestDevDBAutoMigrate(t *testing.T) {
 			if !ok {
 				t.Fatalf("表 %s 缺少列 %s", table, want.Name)
 			}
-			if gotType := strings.ToUpper(r.ColumnType); gotType != want.Type {
-				t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", table, want.Name, gotType, want.Type)
+			// 类型比对**两侧归一**(设计 v0.1.2 §2.4「L1 TINYINT(1) 假阳性」裁定):
+			// information_schema 对 bool/int8 列可能返回 "tinyint(1)" 或 "tinyint"
+			// (MySQL 8.0.19+ 省略整数显示宽度),DDL 侧写法亦不统一 →
+			// 归一只放过"显示宽度/同义词"差异;decimal(p,s)/varchar(N)/datetime(3) 的参数
+			// 仍参与严格比对(负例见 internal/infra/dbtest/columntype_test.go)。
+			if !dbtest.SameColumnType(r.ColumnType, want.Type) {
+				t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s(归一后 %q vs %q)",
+					table, want.Name, r.ColumnType, want.Type,
+					dbtest.NormalizeColumnType(r.ColumnType), dbtest.NormalizeColumnType(want.Type))
 			}
 			wantNullable := "YES"
 			if want.NotNull {

+ 1 - 0
internal/academic/model/testdata/academic-model-ddl.sha256

@@ -0,0 +1 @@
+7a25c9183840e566a4022ec1c207a711d86d1dc9bdeef0ea00486e06ed975935

+ 120 - 0
internal/infra/dbtest/columntype.go

@@ -0,0 +1,120 @@
+// Package dbtest 跨域共用的**测试期**数据库口径工具(非生产代码,不参与业务逻辑)。
+//
+// 依据:unified/03-migration/academic-domain-design.md v0.1.2 §2.4「L1 TINYINT(1) 假阳性裁定」:
+// 取「不动 model,改对账归一」——model 保持 int8/bool 原样,由对账侧把
+//
+//	`tinyint(1)`(information_schema 返回的 bool 物理形态,MySQL 8.0.19+ 可能省略显示宽度)
+//	与 DDL 侧书写的 `TINYINT(1)` / `TINYINT`
+//
+// 归一到同一签名后再比对,避免"类型其实一致、只因显示宽度写法不同而误报"。
+//
+// 为什么放共享包(而不是 academic 包内私有函数):
+//
+//	org 域的 `t_campus.self_support` / `verified` / `login_allowed` 是同一类 bit(1)→tinyint 迁移列,
+//	将来做真库核对必须复用**同一口径**,否则两域对"类型是否一致"的判定会漂移。
+//
+// 归一规则的顺序敏感性(误用即引入漏检,评审重点):
+//  1. ToUpper + TrimSpace + 空白折叠("tinyint ( 1 )" 不会出现,但多空格出现过);
+//  2. **仅整数族**(TINYINT/SMALLINT/MEDIUMINT/INT/INTEGER/BIGINT)剥离 "(N)" 显示宽度;
+//     INTEGER 归一为 INT(同义词);
+//  3. BOOL/BOOLEAN 归一为 TINYINT(MySQL 里就是 tinyint(1) 的别名);
+//  4. **其余类型一律保留参数**:decimal(12,2) / varchar(N) / datetime(3) / double(p,s)
+//     的参数是精度语义,归一掉就再也发现不了 decimal(12,3) 这类真缺陷。
+package dbtest
+
+import (
+	"strings"
+	"unicode"
+)
+
+// integerFamily 可安全剥离显示宽度的整数族(MySQL 中 "(N)" 只影响显示,不影响存储与语义)。
+//
+// 注意:**不包含** DECIMAL/NUMERIC/FLOAT/DOUBLE(那里的括号是精度)、也不包含
+// VARCHAR/CHAR/BINARY(那里的括号是长度)——它们的参数必须参与比对。
+var integerFamily = map[string]struct{}{
+	"TINYINT":   {},
+	"SMALLINT":  {},
+	"MEDIUMINT": {},
+	"INT":       {},
+	"INTEGER":   {},
+	"BIGINT":    {},
+}
+
+// NormalizeColumnType 把 DDL/信息模式里的列类型归一为可比较签名。
+//
+// 入参:
+//   - raw string:原始列类型(可含大小写/空白差异,如 "tinyint(1)"、"TINYINT"、"boolean"、
+//     "decimal(12,2)"、"varchar(26)"、"datetime(3)"、"bigint unsigned")。
+//
+// 出参:
+//   - string:归一签名(大写)。空入参 → 空串(由调用方判为不合法,不在此处造错误)。
+//
+// 归一样例:
+//
+//	"tinyint(1)"  → "TINYINT"     "TINYINT"      → "TINYINT"
+//	"boolean"     → "TINYINT"     "bool"         → "TINYINT"
+//	"int(11)"     → "INT"         "integer"      → "INT"
+//	"bigint unsigned" → "BIGINT UNSIGNED"(属性保留,只有显示宽度被剥离)
+//	"decimal(12,2)"   → "DECIMAL(12,2)"(**精度保留**)
+//	"varchar(26)"     → "VARCHAR(26)"(**长度保留**)
+//	"datetime(3)"     → "DATETIME(3)"(**小数秒精度保留**)
+func NormalizeColumnType(raw string) string {
+	// ① 去空白:information_schema 不会插空格,但手工/日志粘贴的字符串常带
+	//    "  tinyint ( 1 )  " 这种形态,折叠成紧凑形态后再解析,避免无意义的误报。
+	compact := make([]rune, 0, len(raw))
+	for _, r := range strings.ToUpper(raw) {
+		if unicode.IsSpace(r) {
+			continue
+		}
+		compact = append(compact, r)
+	}
+	s := string(compact)
+	if s == "" {
+		return ""
+	}
+
+	// ② 剥离尾部存储属性(UNSIGNED / ZEROFILL 是存储语义,**不**归一掉,只重排到最后)
+	attrs := make([]string, 0, 2)
+	for {
+		switch {
+		case strings.HasSuffix(s, "UNSIGNED"):
+			attrs = append([]string{"UNSIGNED"}, attrs...)
+			s = strings.TrimSuffix(s, "UNSIGNED")
+			continue
+		case strings.HasSuffix(s, "ZEROFILL"):
+			attrs = append([]string{"ZEROFILL"}, attrs...)
+			s = strings.TrimSuffix(s, "ZEROFILL")
+			continue
+		}
+		break
+	}
+
+	name := s
+	if idx := strings.IndexByte(s, '('); idx >= 0 {
+		name = s[:idx] // 用于判别族,参数仍保留在 base 中
+	}
+
+	base := s
+	if _, ok := integerFamily[name]; ok {
+		if name == "INTEGER" {
+			name = "INT" // 同义词收敛
+		}
+		base = name // 整数族:剥离 "(N)" 显示宽度
+	} else if name == "BOOL" || name == "BOOLEAN" {
+		base = "TINYINT" // MySQL 把 bool/boolean 落成 tinyint(1)
+	}
+	// 非整数族:base 保持原样(decimal(12,2)/varchar(26)/datetime(3) 的参数是精度/长度语义)
+
+	if len(attrs) > 0 {
+		base += " " + strings.Join(attrs, " ")
+	}
+	return base
+}
+
+// SameColumnType 判定两个列类型是否等价(两侧各归一后逐字相等)。
+//
+// 入参:a/b string——来自 DDL 与 information_schema 的列类型字符串(顺序无关)。
+// 出参:true 表示等价。
+func SameColumnType(a, b string) bool {
+	return NormalizeColumnType(a) == NormalizeColumnType(b)
+}

+ 91 - 0
internal/infra/dbtest/columntype_test.go

@@ -0,0 +1,91 @@
+// 列类型归一单测(AC 对账工具口径的回归护栏)。
+//
+// 设计约束(v0.1.2 §2.4):归一必须"只放过显示宽度差异",**绝不能**把精度/长度差异一并放过,
+// 否则 decimal(12,3)、varchar(26)、datetime(无毫秒)等真缺陷会被静默吞掉。
+// 因此本文件的正例(必须等价)与负例(必须不等价)同等重要,负例不得删减。
+package dbtest
+
+import "testing"
+
+// TestNormalizeColumnTypePositive 正例:显示宽度/同义词差异必须归一到同一签名。
+func TestNormalizeColumnTypePositive(t *testing.T) {
+	cases := []struct {
+		name string
+		a    string
+		b    string
+		want string // 归一后的期望签名(同时验证归一结果本身)
+	}{
+		{"TINYINT(1) ≡ tinyint(核心假阳性场景)", "TINYINT(1)", "tinyint", "TINYINT"},
+		{"TINYINT(1) ≡ TINYINT(1)", "TINYINT(1)", "tinyint(1)", "TINYINT"},
+		{"BOOLEAN ≡ tinyint(bool 别名)", "BOOLEAN", "tinyint", "TINYINT"},
+		{"bool ≡ TINYINT(1)", "bool", "TINYINT(1)", "TINYINT"},
+		{"INT(11) ≡ int", "INT(11)", "int", "INT"},
+		{"INTEGER ≡ INT(同义词)", "INTEGER", "INT(10)", "INT"},
+		{"BIGINT(20) ≡ bigint", "BIGINT(20)", "bigint", "BIGINT"},
+		{"SMALLINT(6) ≡ smallint", "SMALLINT(6)", "smallint", "SMALLINT"},
+		{"MEDIUMINT(9) ≡ mediumint", "MEDIUMINT(9)", "mediumint", "MEDIUMINT"},
+		{"空白折叠", "  tinyint ( 1 )  ", "TINYINT", "TINYINT"},
+		{"属性保留但仍等价", "BIGINT(20) UNSIGNED", "bigint   unsigned", "BIGINT UNSIGNED"},
+		{"decimal 相同参数", "DECIMAL(12,2)", "decimal(12,2)", "DECIMAL(12,2)"},
+		{"varchar 相同长度", "VARCHAR(26)", "varchar(26)", "VARCHAR(26)"},
+		{"datetime(3) 相同精度", "DATETIME(3)", "datetime(3)", "DATETIME(3)"},
+		{"DATE 无参数", "DATE", "date", "DATE"},
+		{"LONGTEXT", "LONGTEXT", "longtext", "LONGTEXT"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := NormalizeColumnType(tc.a); got != tc.want {
+				t.Fatalf("NormalizeColumnType(%q) = %q,期望 %q", tc.a, got, tc.want)
+			}
+			if !SameColumnType(tc.a, tc.b) {
+				t.Fatalf("%q 与 %q 应判定等价(归一后 %q vs %q)",
+					tc.a, tc.b, NormalizeColumnType(tc.a), NormalizeColumnType(tc.b))
+			}
+		})
+	}
+}
+
+// TestNormalizeColumnTypeNegative 负例(**不得删减**):精度/长度/族差异必须判为不一致。
+//
+// 这些是 DDL 与真库之间真正会出现的偏差类型;若归一规则把参数也吃掉,这里会全绿但线上漏检。
+func TestNormalizeColumnTypeNegative(t *testing.T) {
+	cases := []struct {
+		name string
+		a    string
+		b    string
+	}{
+		{"decimal 精度不同", "DECIMAL(12,2)", "DECIMAL(12,3)"},
+		{"decimal 位数不同", "DECIMAL(12,2)", "DECIMAL(10,2)"},
+		{"varchar 长度不同", "VARCHAR(32)", "VARCHAR(26)"},
+		{"char vs varchar", "CHAR(26)", "VARCHAR(26)"},
+		{"datetime 无毫秒 vs 有毫秒", "DATETIME", "DATETIME(3)"},
+		{"datetime 精度不同", "DATETIME(3)", "DATETIME(6)"},
+		{"整数族不同", "TINYINT", "SMALLINT"},
+		{"整数族不同(2)", "INT", "BIGINT"},
+		{"有符号 vs 无符号", "BIGINT", "BIGINT UNSIGNED"},
+		{"文本族不同", "LONGTEXT", "TEXT"},
+		{"double 精度不同", "DOUBLE(10,3)", "DOUBLE(10,2)"},
+		{"空值 vs 有值", "", "TINYINT"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if SameColumnType(tc.a, tc.b) {
+				t.Fatalf("%q 与 %q 必须判为不一致(归一后 %q vs %q)",
+					tc.a, tc.b, NormalizeColumnType(tc.a), NormalizeColumnType(tc.b))
+			}
+		})
+	}
+}
+
+// TestNormalizeColumnTypeIdempotent 幂等:归一结果再归一必须不变(防止二次归一把精度吃掉)。
+func TestNormalizeColumnTypeIdempotent(t *testing.T) {
+	for _, raw := range []string{
+		"tinyint(1)", "TINYINT", "boolean", "int(11)", "integer", "decimal(12,2)",
+		"varchar(26)", "datetime(3)", "datetime", "bigint unsigned", "longtext", "", "  ",
+	} {
+		once := NormalizeColumnType(raw)
+		if twice := NormalizeColumnType(once); twice != once {
+			t.Fatalf("归一不幂等:%q → %q → %q", raw, once, twice)
+		}
+	}
+}