Преглед изворни кода

test(model): 落地设计 v0.1.3 §2.6 指纹口径约束 + §2.4-B A3(self_support→bool)

§2.6 L2 指纹口径(sha256-lf-v1)三条约束全部落地:
① 口径自描述:锁文件改为「# algo=sha256-lf-v1 + 说明注释 + digest 行」,
   解析器要求 algo 声明存在且识别,缺失/未知一律显式报错(防"静默变红且原因不明")
② 负例入测(8 例,标注不得删减):hash(LF)==hash(CRLF);而 行尾追加空格、末尾增删换行、
   单字节替换、单字节删除、大小写变化、BOM 前缀、孤立 CR 插入 **均必须改变摘要**
③ 报告侧只比第一字段:归一范围严格限定为 `\r\n → \n` 一行 ReplaceAll,
   单 CR / 末尾换行 / BOM / 行尾空白 / 大小写一律不归一(ddl_hash_test.go 内有专项断言)
另补解析器正/负例共 14 例(CRLF 检出、注释顺序、无末尾换行、algo 带空白;
缺声明、algo 未识别、双 digest、长度/大小写/非 hex、带 sha256sum 模式标记均须拒绝)。
指纹值不变(fixture 无 CR 字节,严格归一与旧口径等价:7a25c918…75935)。

§2.4-B A3 模型口径(类型由语义定,不由对账定):
- internal/org/model/models.go:Campus.SelfSupport int8 → bool(语义确为布尔;同表
  login_allowed 与 org 包自述约定「tinyint(1) 用 bool」一致;DDL 不变,仍精确匹配 TINYINT(1))
- internal/academic/port/orgadapter.go:campusRow.SelfSupport int8 → bool,
  `row.SelfSupport == 1` 的手写转换随之删除(少一处易错的隐式约定)
- academic 域 4 个既有 bool 列(actived/teacher_cert)**未动**(本就符合语义规则)
- 注:本仓无 org 侧 DDL fixture(self_support 仅出现在 port_test.go 的内联建表),
  故不涉及 fixture/指纹联动;旧 bit(1) 迁移的 `[]byte → bool` 风险已由 architect 记入 §2.4-B

验证:go build ./... && go vet ./... && go test ./... -short 全绿;A3 未引起任何既有用例失败
Alex пре 18 часа
родитељ
комит
7f0a9dc598

+ 199 - 52
internal/academic/model/ddl_hash_test.go

@@ -1,30 +1,36 @@
-// DDL 指纹锁单测(AC-17 验收⑥的前置门禁)——设计 v0.1.2 §2.4「L2 DDL 哈希校验」。
+// DDL 指纹锁单测(AC-17 验收⑥的前置门禁)——设计 **v0.1.3 §2.6「sha256-lf-v1 口径」**。
 //
-// 目的:目标态 DDL 是**跨仓双份**存在的(文档 `unified/03-migration/academic-model-ddl.sql`
-// 与代码仓 fixture `internal/academic/model/testdata/academic-model-ddl.sql`)。文档不在代码仓,
-// 跨仓字节相等无法由 Go 测试直接判定,因此本仓这一侧做「指纹锁」:
+// 目标态 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 的内容
+//	sha256(lf-normalize(fixture)) == testdata/academic-model-ddl.sha256 的 digest 行
 //
-// 配合 AC-17 的人工/报告流程:对「文档副本」与「fixture」各跑一次 sha256 并与锁文件比对,
-// 三者一致才视为"文档与代码未漂移",结果写进一致性报告留证。
+// 配合 AC-17 人工/报告流程:对「文档副本」与「fixture」各跑一次 sha256(用 `tr -d '\r'` 复现同一
+// 归一)后与锁文件比对,三者一致才视为未漂移,结论写进一致性报告留证。
 //
 // 纪律(任一 DDL 变更必须**同一提交**内更新三处):
 //  1. 文档 `unified/03-migration/academic-model-ddl.sql`;
 //  2. 本仓 fixture `testdata/academic-model-ddl.sql`;
-//  3. 本仓锁文件 `testdata/academic-model-ddl.sha256`(单行 64 位小写 hex)。
+//  3. 本仓锁文件 `testdata/academic-model-ddl.sha256`。
 //
-// 为什么按「LF 归一」取指纹(不是裸字节):
+// ── sha256-lf-v1 口径定义(§2.6 约束①,**本文件是实现,改动须先改设计**)──
 //
-//	开发者机器常见 `core.autocrlf=true`(Windows 检出为 CRLF),同一内容在 Linux CI 会得到
-//	不同 sha256 → 裸字节指纹必然产生"跨平台假红"。归一掉换行风格后,指纹只对**内容**敏感:
-//	任何字符级修改都会改变它,而"纯换行风格"这种无意义差异不会误报。
-//	(实测:Windows 工作区与 git blob 均为 LF,两者指纹一致 = 7a25c918…75935。)
+//	归一:**仅** `\r\n → \n`(一行 ReplaceAll)。
+//	      单 CR、末尾换行增删、BOM、行尾空白、大小写 **一律不归一**(它们是内容差异,必须红)。
+//	摘要:sha256(归一字节) → 64 位小写 hex。
+//	自描述:锁文件允许 `#` 注释行,**必须**含 `# algo=sha256-lf-v1`;digest 为第一个非注释行。
+//	        目的:将来若换口径(如 eol=lf 落地后改裸字节),不会出现"静默变红且原因不明"。
+//
+// 为什么需要 LF 归一(而非裸字节):本仓 `core.autocrlf=true`,同一内容 Windows 检出为 CRLF、
+// Linux CI 为 LF;裸字节指纹必然跨平台假红,而这种红是纯噪声。归一只放过"换行风格"这一种
+// 无意义差异,任何字符级修改仍会改变摘要(负例见 TestDDLHashAlgoContract)。
 package model
 
 import (
 	"crypto/sha256"
 	"encoding/hex"
+	"fmt"
 	"os"
 	"strings"
 	"testing"
@@ -33,86 +39,227 @@ import (
 const (
 	// ddlFixturePath DDL fixture 相对路径(go test 以包目录为工作目录)。
 	ddlFixturePath = "testdata/academic-model-ddl.sql"
-	// ddlHashPath DDL 指纹锁文件路径(单行 64 位小写 hex,可被 awk '{print $1}' 直接取用)。
+	// ddlHashPath DDL 指纹锁文件路径。
 	ddlHashPath = "testdata/academic-model-ddl.sha256"
 	// ddlHashHexLen sha256 十六进制串长度。
 	ddlHashHexLen = 64
+	// ddlHashAlgo 口径标识(§2.6 约束①:锁文件必须自描述,换口径须同步改本常量与设计)。
+	ddlHashAlgo = "sha256-lf-v1"
+	// ddlHashAlgoMarker 锁文件内的口径声明行(必须是合法注释行)。
+	ddlHashAlgoMarker = "# algo=" + ddlHashAlgo
 )
 
-// normalizeEOL 把 CRLF / 孤立 CR 统一为 LF(跨平台指纹稳定的唯一前提)。
+// lfNormalize 按 sha256-lf-v1 口径归一:**仅** CRLF → LF。
+//
+// 入参:b []byte——文件原始字节。出参:[]byte——归一字节(其余字符一字不动)。
 //
-// 入参: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)
+// 反例(必须保持原样,不得"顺手"归一):孤立 CR、末尾换行、BOM、行尾空格、大小写。
+func lfNormalize(b []byte) []byte {
+	return []byte(strings.ReplaceAll(string(b), "\r\n", "\n"))
 }
 
-// sha256Hex 计算内容的 sha256 十六进制摘要(入参应已归一)。
-//
-// 入参:b []byte。出参:64 位小写 hex。
+// sha256Hex 计算内容 sha256 的 64 位小写 hex 摘要。
 func sha256Hex(b []byte) string {
 	sum := sha256.Sum256(b)
 	return hex.EncodeToString(sum[:])
 }
 
-// readDDLHashLock 读取并校验锁文件格式(单行 64 位 hex;多余空白容忍,注释/多行拒绝)。
+// ddlHashLock 锁文件解析结果。
+type ddlHashLock struct {
+	// Algo 口径标识(必须等于 ddlHashAlgo)。
+	Algo string
+	// Digest 期望摘要(64 位小写 hex)。
+	Digest string
+}
+
+// parseDDLHashLock 解析锁文件内容(纯函数,便于负例单测覆盖格式校验)。
 //
-// 出参:锁文件中的 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)
+// 入参:raw []byte——锁文件原始字节(容忍 CRLF 检出)。
+// 出参:
+//   - ddlHashLock:Algo + Digest;
+//   - error:缺 algo 声明 / algo 未识别 / digest 缺失或多行 / 非 64 位小写 hex。
+//
+// 格式(§2.6 约束①):
+//
+//	# algo=sha256-lf-v1          ← 必须存在(注释行与顺序无关,但必须是 # 开头)
+//	# 任意其它说明               ← 可选
+//	<64 位小写 hex>              ← 第一个非注释行,且必须唯一
+func parseDDLHashLock(raw []byte) (ddlHashLock, error) {
+	var lock ddlHashLock
+	digestLines := make([]string, 0, 2)
+	for _, line := range strings.Split(string(lfNormalize(raw)), "\n") {
+		trimmed := strings.TrimSpace(line)
+		if trimmed == "" {
+			continue
+		}
+		if strings.HasPrefix(trimmed, "#") {
+			// 注释体去空白后再匹配(容忍 `#   algo = sha256-lf-v1` 这类手写形态)
+			body := strings.ReplaceAll(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")), " ", "")
+			if algo, ok := strings.CutPrefix(body, "algo="); ok {
+				lock.Algo = algo
+			}
+			continue
 		}
+		digestLines = append(digestLines, trimmed)
 	}
-	if len(lines) != 1 {
-		t.Fatalf("锁文件 %s 应只含 1 行(64 位小写 hex),实得 %d 行: %v", ddlHashPath, len(lines), lines)
+
+	if lock.Algo != ddlHashAlgo {
+		return lock, fmt.Errorf("锁文件口径声明不符:期望 %q,实得 %q(缺 `%s` 行?)",
+			ddlHashAlgo, lock.Algo, ddlHashAlgoMarker)
 	}
-	got := lines[0]
-	if len(got) != ddlHashHexLen {
-		t.Fatalf("锁文件 %s 内容长度 = %d,期望 %d(纯 hex,勿加注释/文件名)", ddlHashPath, len(got), ddlHashHexLen)
+	if len(digestLines) != 1 {
+		return lock, fmt.Errorf("锁文件应只含 1 个 digest 行,实得 %d 个: %v", len(digestLines), digestLines)
 	}
-	if strings.ToLower(got) != got {
-		t.Fatalf("锁文件 %s 必须使用小写 hex,实得 %q", ddlHashPath, got)
+	digest := digestLines[0]
+	if len(digest) != ddlHashHexLen {
+		return lock, fmt.Errorf("digest 长度 = %d,期望 %d(纯 hex,勿带文件名/模式标记)", len(digest), ddlHashHexLen)
 	}
-	for _, r := range got {
+	if strings.ToLower(digest) != digest {
+		return lock, fmt.Errorf("digest 必须小写 hex,实得 %q", digest)
+	}
+	for _, r := range digest {
 		if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
-			t.Fatalf("锁文件 %s 含非 hex 字符: %q", ddlHashPath, got)
+			return lock, fmt.Errorf("digest 含非 hex 字符: %q", digest)
 		}
 	}
-	return got
+	lock.Digest = digest
+	return lock, nil
+}
+
+// readDDLHashLock 读取真实锁文件(解析失败即 fail-fast)。
+func readDDLHashLock(t *testing.T) ddlHashLock {
+	t.Helper()
+	raw, err := os.ReadFile(ddlHashPath)
+	if err != nil {
+		t.Fatalf("读取 DDL 指纹锁失败(%s): %v", ddlHashPath, err)
+	}
+	lock, err := parseDDLHashLock(raw)
+	if err != nil {
+		t.Fatalf("解析 DDL 指纹锁失败(%s): %v", ddlHashPath, err)
+	}
+	return lock
 }
 
 // TestDDLFixtureHashLock fixture 内容必须与指纹锁一致(DDL 漂移的硬门禁)。
 func TestDDLFixtureHashLock(t *testing.T) {
-	want := readDDLHashLock(t)
+	lock := 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"+
+	got := sha256Hex(lfNormalize(raw))
+	if got != lock.Digest {
+		t.Fatalf("DDL fixture 指纹漂移:\n  实际 = %s\n  锁定 = %s(algo=%s)\n"+
 			"若为有意的 DDL 变更,必须**同一提交**内同步三处:文档副本 / fixture / testdata/academic-model-ddl.sha256",
-			got, want)
+			got, lock.Digest, lock.Algo)
 	}
 }
 
 // TestDDLFixtureHashMatchesEmbedded embed 进测试二进制的 DDL 与磁盘 fixture 内容一致
-// (防"改了 fixture 但 embed 走缓存/路径不一致"这类隐蔽漂移)。
+// (防"改了 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 {
+	if got, want := sha256Hex(lfNormalize([]byte(ddlSQL))), sha256Hex(lfNormalize(raw)); got != want {
 		t.Fatalf("go:embed 的 DDL 与磁盘 fixture 不一致:embed=%s file=%s", got, want)
 	}
 }
+
+// TestDDLHashAlgoContract 口径契约(§2.6 约束②,**负例不得删减**):
+// 归一只放过"换行风格",任何字符级差异必须改变摘要。
+func TestDDLHashAlgoContract(t *testing.T) {
+	content := []byte("CREATE TABLE `t_x` (\n  `a` INT NOT NULL\n) ENGINE=InnoDB;\n")
+
+	t.Run("LF 与 CRLF 同摘要(归一目的)", func(t *testing.T) {
+		lf := content
+		crlf := []byte(strings.ReplaceAll(string(content), "\n", "\r\n"))
+		if got, want := sha256Hex(lfNormalize(crlf)), sha256Hex(lfNormalize(lf)); got != want {
+			t.Fatalf("CRLF 检出应归一到同一摘要:crlf=%s lf=%s", got, want)
+		}
+	})
+
+	negative := []struct {
+		name string
+		mod  []byte
+	}{
+		{"末尾追加空格(行尾空白是内容差异)", append([]byte(string(content)+" "), '\n')},
+		{"末尾追加换行(末尾换行增删是内容差异)", append([]byte(string(content)), '\n')},
+		{"单字节替换(a→b)", []byte(strings.Replace(string(content), "`a`", "`b`", 1))},
+		{"单字节删除", []byte(strings.Replace(string(content), "\n", "", 1))},
+		{"大小写变化(INT→int)", []byte(strings.Replace(string(content), "INT", "int", 1))},
+		{"BOM 前缀(BOM 不归一)", append([]byte{0xEF, 0xBB, 0xBF}, content...)},
+		{"孤立 CR 插入(单 CR 不归一)", []byte(strings.Replace(string(content), "\n", "\r", 1))},
+	}
+	base := sha256Hex(lfNormalize(content))
+	for _, tc := range negative {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := sha256Hex(lfNormalize(tc.mod)); got == base {
+				t.Fatalf("该修改必须改变摘要(口径只放过 CRLF→LF),实得与基线相同: %s", got)
+			}
+		})
+	}
+
+	t.Run("孤立 CR 不被归一掉", func(t *testing.T) {
+		if got, want := string(lfNormalize([]byte("a\rb"))), "a\rb"; got != want {
+			t.Fatalf("单 CR 不应被归一:期望 %q,实得 %q", want, got)
+		}
+		if got, want := string(lfNormalize([]byte("a\r\nb"))), "a\nb"; got != want {
+			t.Fatalf("CRLF 应归一为 LF:期望 %q,实得 %q", want, got)
+		}
+	})
+}
+
+// TestDDLHashLockParserAccepts 锁文件格式正例(含 CRLF 检出、注释顺序变化、多注释行)。
+func TestDDLHashLockParserAccepts(t *testing.T) {
+	const digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+	cases := []struct {
+		name string
+		raw  string
+	}{
+		{"最简形态", "# algo=sha256-lf-v1\n" + digest + "\n"},
+		{"CRLF 检出", "# algo=sha256-lf-v1\r\n" + digest + "\r\n"},
+		{"多注释 + 空行", "# 说明一\n\n# algo=sha256-lf-v1\n# 说明二\n\n" + digest + "\n"},
+		{"注释在 digest 之后", "# algo=sha256-lf-v1\n" + digest + "\n# 尾注\n"},
+		{"无末尾换行", "# algo=sha256-lf-v1\n" + digest},
+		{"algo 行带多余空白", "#   algo = sha256-lf-v1  \n" + digest + "\n"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			lock, err := parseDDLHashLock([]byte(tc.raw))
+			if err != nil {
+				t.Fatalf("应解析成功,实得错误: %v", err)
+			}
+			if lock.Algo != ddlHashAlgo || lock.Digest != digest {
+				t.Fatalf("解析结果不符:algo=%q digest=%q", lock.Algo, lock.Digest)
+			}
+		})
+	}
+}
+
+// TestDDLHashLockParserRejects 锁文件格式负例(防"静默变红且原因不明":缺声明必须显式报错)。
+func TestDDLHashLockParserRejects(t *testing.T) {
+	const digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+	cases := []struct {
+		name string
+		raw  string
+	}{
+		{"缺少 algo 声明", digest + "\n"},
+		{"algo 未识别", "# algo=sha256-raw-v1\n" + digest + "\n"},
+		{"无 digest", "# algo=sha256-lf-v1\n"},
+		{"两个 digest 行", "# algo=sha256-lf-v1\n" + digest + "\n" + digest + "\n"},
+		{"digest 长度不足", "# algo=sha256-lf-v1\n" + digest[:63] + "\n"},
+		{"digest 大写", "# algo=sha256-lf-v1\n" + strings.ToUpper(digest) + "\n"},
+		{"digest 含非 hex", "# algo=sha256-lf-v1\n" + digest[:63] + "z\n"},
+		{"digest 带 sha256sum 模式标记", "# algo=sha256-lf-v1\n" + digest + "  testdata/x.sql\n"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := parseDDLHashLock([]byte(tc.raw)); err == nil {
+				t.Fatal("该锁文件格式应被拒绝,实得 nil error")
+			}
+		})
+	}
+}

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

@@ -1 +1,5 @@
+# algo=sha256-lf-v1
+# target=testdata/academic-model-ddl.sql(academic 27 表目标态 DDL fixture)
+# 归一规则:仅 \r\n -> \n(单 CR / 末尾换行 / BOM / 行尾空白一律不归一)
+# 纪律:DDL 变更须同一提交内同步「文档副本 / fixture / 本文件」三处
 7a25c9183840e566a4022ec1c207a711d86d1dc9bdeef0ea00486e06ed975935

+ 2 - 2
internal/academic/port/orgadapter.go

@@ -36,7 +36,7 @@ import (
 type campusRow struct {
 	CampusID      int64  `gorm:"column:campus_id;primaryKey"`
 	InstitutionID int64  `gorm:"column:institution_id"`
-	SelfSupport   int8   `gorm:"column:self_support"`
+	SelfSupport   bool   `gorm:"column:self_support"`
 	Status        string `gorm:"column:status"`
 }
 
@@ -112,7 +112,7 @@ func (r *OrgCampusReader) GetByIDTx(ctx context.Context, tx *gorm.DB, campusID i
 	return &CampusInfo{
 		CampusID:    row.CampusID,
 		SchoolID:    row.InstitutionID,
-		SelfSupport: row.SelfSupport == 1, // 1 自营 / 0 加盟(DDL 默认 1)
+		SelfSupport: row.SelfSupport, // 直接赋值(tinyint(1)↔bool;不再手写 ==1 转换,v0.1.3 A3)
 		Status:      row.Status,
 	}, nil
 }

+ 1 - 1
internal/org/model/models.go

@@ -57,7 +57,7 @@ type Campus struct {
 	Address       *string   `gorm:"column:address;size:255" json:"address,omitempty"`
 	Phone         *string   `gorm:"column:phone;size:20" json:"phone,omitempty"`
 	Status        string    `gorm:"column:status;size:16;not null" json:"status"`
-	SelfSupport   int8      `gorm:"column:self_support;not null;default:1" json:"selfSupport"` // 是否自营校区(1 自营 / 0 加盟)
+	SelfSupport   bool      `gorm:"column:self_support;not null;default:1" json:"selfSupport"` // 是否自营校区(true 自营 / false 加盟;tinyint(1)↔bool,迁移源 bit(1))
 	CreatedAt     time.Time `gorm:"column:created_at" json:"createdAt"`
 	UpdatedAt     time.Time `gorm:"column:updated_at" json:"updatedAt"`
 }