// 教龄列精度口径锁(P0 回归门禁)——锁住 `teacher_exp_min/max = DECIMAL(6,3)` 这个**业务决定**。 // // --------------------------------------------------------------------------- // 为什么需要一把专门的"口径锁"(而不是靠 `TestModelsColumnByColumn`) // --------------------------------------------------------------------------- // // `TestModelsColumnByColumn` 断言的是「struct tag ↔ DDL fixture **一致**」。 // 它抓得住"只改了一边"(改 DDL 忘改 tag),但抓不住"**两边一起改回 (6,2)**"—— // 那时两边仍然一致,门禁**依然是绿的**,而精度已经悄悄收敛了。 // // 而精度在这里**不是格式偏好,是语义**(P0,架构师-2 AC-10 审计): // - 源列 `teacher_exp_min/max` = `float(10,3)`,dump 首行实测 `0.000 / 0.999`; // - 收敛到 2 位后 `0.999 → 1.00` ⇒ 区间 `[0.000, 1.000]` **开始包含教龄 1.0**; // - 下一档 `[1.000, 1.999]` 也含 1.0 ⇒ **两档并列命中** ⇒ 按 `ORDER BY grade_id DESC, // subject_id DESC, id ASC` 取到**低档** ⇒ 课酬按错档算(D7 双跑必红,代码却"看起来没错")。 // // 所以本文件把"3 位"这一决定**钉死在两个独立位置**(DDL 文本 + struct tag), // 任何一边被改回 2 位都会红;两边一起改也**照样红**(这正是 `TestModelsColumnByColumn` 的盲区)。 // // 自证失败(变异实测,见交付报告): // - 只改 tag → 本文件红 + TestModelsColumnByColumn 红; // - 只改 DDL fixture → 本文件红 + TestModelsColumnByColumn 红 + 指纹门禁红; // - **tag 与 DDL 一起改成 (6,2)** → TestModelsColumnByColumn **绿**(盲区),而**本文件红**。 package model import ( "os" "regexp" "strings" "testing" ) // teacherExpPrecisionPattern DDL 里两列声明的精确形态(列名 + DECIMAL 精度)。 // // 用"声明行"而不是全局搜 `DECIMAL(6,3)`:避免把注释里提到 `decimal(6,3)` 的说明文字也算命中 // (否则把列改回 (6,2)、只在注释里留一句 "6,3" 就能骗过门禁)。 var teacherExpPrecisionPattern = map[string]*regexp.Regexp{ "teacher_exp_min": regexp.MustCompile("(?i)^\\s*`teacher_exp_min`\\s+DECIMAL\\(6,3\\)"), "teacher_exp_max": regexp.MustCompile("(?i)^\\s*`teacher_exp_max`\\s+DECIMAL\\(6,3\\)"), } // TestPayrollTeacherExpPrecisionLocked 教龄两列必须是 `DECIMAL(6,3)`(DDL 文本 + struct tag 双锁)。 func TestPayrollTeacherExpPrecisionLocked(t *testing.T) { raw, err := os.ReadFile(ddlFixturePath) if err != nil { t.Fatalf("读 DDL fixture 失败: %v", err) } lines := strings.Split(string(lfNormalize(raw)), "\n") for column, pattern := range teacherExpPrecisionPattern { found := false for _, line := range lines { if pattern.MatchString(line) { found = true break } } if !found { t.Errorf("DDL fixture 中 `%s` 不是 DECIMAL(6,3):精度收敛会把 0.999 舍成 1.00,"+ "使相邻教龄档并列命中、课酬按错档算(P0,见本文件头)", column) } } // struct tag 侧同样锁死(DDL 与 tag 必须**都**是 3 位;只锁 DDL 会漏掉"tag 单独被改回"的路径) tag := string(mustReadFile(t, "models_payroll.go")) for _, col := range []string{"teacher_exp_min", "teacher_exp_max"} { want := "column:" + col + ";type:decimal(6,3)" if !strings.Contains(tag, want) { t.Errorf("models_payroll.go 缺少 `%s`(教龄列 tag 必须是 decimal(6,3))", want) } } } // mustReadFile 读包目录下的源码文件(仅本文件用)。 func mustReadFile(t *testing.T, name string) []byte { t.Helper() raw, err := os.ReadFile(name) if err != nil { t.Fatalf("读 %s 失败: %v", name, err) } return raw }