Forráskód Böngészése

test(academic): 【P0 守护】教龄精度口径锁 + 账单 pages 字段(P1 明细窗口已在 86172ad)

## 一、P0 守护补强:`TestPayrollTeacherExpPrecisionLocked`(新增)

**为什么 P0 的修复还缺一道门禁**:P0(`a10707b`,教龄 `decimal(6,2)`→`(6,3)`)依赖的既有门禁
`TestModelsColumnByColumn` 断言的是「struct tag ↔ DDL fixture **一致**」——
它抓得住"只改了一边",但**抓不住"两边一起改回 (6,2)"**:那时两边仍一致,门禁**照样绿**,
而精度已悄悄收敛(0.999 舍成 1.00 ⇒ 相邻教龄档并列命中 ⇒ 课酬按错档算)。
这正是"守卫本身要能被证伪"(§10.3)的同类问题:门禁的失败形态恰恰是「**恒过**」。

新增口径锁把"3 位"钉死在**两个独立位置**(DDL 声明行 + struct tag 串),任一边被改回都红,
**两边一起改也红**。用"列声明行"正则匹配而非全文搜 `DECIMAL(6,3)`:否则只在注释里留一句"6,3"
就能骗过门禁。

### 「改回红 / 还原绿」实测(变异方式:tag + DDL fixture **一起**回退 (6,2),并同步重算指纹
### 以排除哈希门禁干扰 —— 这样只剩"语义守护"在起作用)

① 既有门禁 `TestModelsColumnByColumn`:**ok(绿)** ← 盲区实证
② 新增 `TestPayrollTeacherExpPrecisionLocked`:**FAIL(红)**
```
--- FAIL: TestPayrollTeacherExpPrecisionLocked (0.00s)
    DDL fixture 中 `teacher_exp_min` 不是 DECIMAL(6,3):精度收敛会把 0.999 舍成 1.00,使相邻教龄档并列命中、课酬按错档算(P0,见本文件头)
    DDL fixture 中 `teacher_exp_max` 不是 DECIMAL(6,3):…(同上)
    models_payroll.go 缺少 `column:teacher_exp_min;type:decimal(6,3)`(教龄列 tag 必须是 decimal(6,3))
    models_payroll.go 缺少 `column:teacher_exp_max;type:decimal(6,3)`(同上)
```
③ 语义用例 `TestPayrollRepoTeacherExpThreeDecimalScale`:**ok(绿)** —— sqlite 的 NUMERIC 亲和性
   **不强制 decimal scale**,所以"(6,2) 把 0.999 舍成 1.00"这一形态在本仓单测栈**物理上无法复现**。
   这就是为什么精度守护必须落在 ② 这种 schema/口径锁上,而不是靠语义用例。

还原 (6,3) 后:① ② ③ 全绿。**结论:P0 现在有了能自证失败的门禁。**

## 二、`pages` 字段(契约 v1.4 rev.2:端点 17 `data` 平铺五字段齐全)

- `BillResult` 新增 `Pages int \`json:"pages"\``;新增纯函数 `billPages(total int64, pageSize int) int`:
  `pageSize <= 0 → 0`(防除零)、`total <= 0 → 0`、否则**整数向上取整** `(total+size-1)/size`
  (**不用 `math.Ceil(float64)`**:1e16 量级会丢精度,且多一条 import)。
- **入参用"窗口内 total + 钳制后 pageSize"**,不是请求原始值与历史全量。
- handler 改为直接用 `res.Pages`,**删掉本地重复取整**(同一份分页元信息若有两处算法,
  迟早出现"某处四舍五入、某处向上取整");顺带把明细数组键名对齐为 **`details`**
  (以 `BillResult` 的 json tag 为准;契约此前两版对"嵌套/键名"各错一半,本次按代码定稿)。
- 用例:`TestBillPagesBoundaries`(`0/10→0`、`25/10→3`、`10/10→1`、`11/10→2`、`5/0→0`、`-1/10→0`);
  `TestGetBillPagesFollowsWindowedTotal`(窗口内 2 条 / pageSize 2 → 1 页;请求 pageSize=5000
  被钳到 200 后 pages 仍按 200 算);handler 侧断言 `total=1 → pages=1` 且 `total != 历史 3 笔`
  (后者专门抓"只改 PageLogs 不改 CountLogs"的改一半形态)。

go build / go vet / go test -short ./... 全绿。
Alex 15 órája
szülő
commit
ec174ee1ec

+ 9 - 8
internal/academic/handler/bill_handler.go

@@ -45,7 +45,8 @@ var billSelfRoles = map[string]struct{}{
 //   - `from`/`to` 时间窗(**`to` 不含上界**:要含当日必须传次日或 23:59:59);
 //   - `page`/`pageSize` 分页(钳制上限 200)。
 //
-// 出参(成功):`{summary{9 列 + 2 个对账辅助列 + campusIds}, list[], total, page, pageSize, pages, from, to}`。
+// 出参(成功):`{summary{9 列 + 2 个对账辅助列 + campusIds}, details[], total, page, pageSize, pages, from, to}`。
+// (`details` 是顶层数组、不嵌套;`pages` 由 Service 的 `billPages` 一次算定,handler 不重复取整。)
 // 出参(失败):1001(无作用域)/2001(缺 from/to、from>to)/2002(campusIds 或时间格式非法)/1003/1004/5002。
 //
 // **两个对账字段名由前端契约 v1.3 端点 17 注 ① 定死**(契约定名、后端照做,不是实现产出后回填):
@@ -59,7 +60,7 @@ var billSelfRoles = map[string]struct{}{
 // 且`inclusive − exclusive` 恰好等于该笔金额。谁把 tag 改名,该用例立刻红(解出零值 ⇒ 差值断言失败)。
 //
 // 响应形状说明:账单是"汇总 + 明细"两段,**不能**用 `response.Page`(那会把 summary 挤掉),
-// 因此这里用 `response.OK` 手工拼 `PageData` 的 5 个键(list/total/page/pageSize/pages),
+// 因此这里用 `response.OK` 手工拼分页元信息(total/page/pageSize/pages)+ `details` 数组,
 // 保证前端 `useListPage` 的读法一致(分页元信息仍在 data 内,不散落顶层)。
 func (h *Handler) GetBill(c *fiber.Ctx) error {
 	sc, err := mustScope(c)
@@ -97,17 +98,17 @@ func (h *Handler) GetBill(c *fiber.Ctx) error {
 	if err != nil {
 		return response.Fail(c, err)
 	}
-	pages := int64(0)
-	if res.PageSize > 0 {
-		pages = (res.Total + int64(res.PageSize) - 1) / int64(res.PageSize)
-	}
+	// 分页五字段**由 Service 一次算定**(`res.Pages`),handler 不再自己取整:
+	// 同一份分页元信息若有两处算法,迟早出现"某处四舍五入、某处向上取整"的偏差。
+	// 注意:明细数组键名是 **`details`**(不是 `list`,也不是嵌套对象)—— 以 `BillResult` 的
+	// json tag 为准(契约 v1.4 rev.2 已按代码定稿:`data` 平铺)。
 	return response.OK(c, fiber.Map{
 		"summary":  res.Summary,
-		"list":     res.Details,
+		"details":  res.Details,
 		"total":    res.Total,
 		"page":     res.Page,
 		"pageSize": res.PageSize,
-		"pages":    pages,
+		"pages":    res.Pages,
 		"from":     res.From,
 		"to":       res.To,
 	})

+ 13 - 8
internal/academic/handler/fund_handler_test.go

@@ -656,11 +656,12 @@ func TestBillEndpointIdentity(t *testing.T) {
 			FlowInclusiveEnd string  `json:"flowInclusiveEnd"`
 			CampusIDs        []int64 `json:"campusIds"`
 		} `json:"summary"`
-		List     []map[string]any `json:"list"`
+		// 明细数组键名 = **details**(契约 v1.4 rev.2:以结构体 json tag 为准,平铺不嵌套)
+		Details  []map[string]any `json:"details"`
 		Total    int64            `json:"total"`
 		Page     int              `json:"page"`
 		PageSize int              `json:"pageSize"`
-		Pages    int64            `json:"pages"`
+		Pages    int              `json:"pages"`
 	}
 	if err := json.Unmarshal(data, &bill); err != nil {
 		t.Fatalf("账单体解码失败: %v", err)
@@ -685,13 +686,17 @@ func TestBillEndpointIdentity(t *testing.T) {
 		t.Fatalf("flowInclusiveEnd = %s,期望 %s(含 to 当天那笔)", got, want)
 	}
 	// v1.4 起明细按窗口过滤(右开):3 笔历史流水里只有 03-05 那笔在窗口内
-	// (02-20 在窗口前、04-01 恰在 `to` 右端点)→ total=1、pages=1。
-	if bill.Total != 1 || bill.PageSize != 10 || bill.Pages != 1 || bill.List == nil {
-		t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d list=%v(明细应受窗口约束)",
-			bill.Total, bill.PageSize, bill.Pages, bill.List)
+	// (02-20 在窗口前、04-01 恰在 `to` 右端点)→ total=1、pages=1(ceil(1/10))。
+	if bill.Total != 1 || bill.PageSize != 10 || bill.Pages != 1 || bill.Details == nil {
+		t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d details=%v(明细应受窗口约束)",
+			bill.Total, bill.PageSize, bill.Pages, bill.Details)
 	}
-	if len(bill.List) != 1 {
-		t.Fatalf("窗口内明细应为 1 条,实得 %d 条", len(bill.List))
+	if len(bill.Details) != 1 {
+		t.Fatalf("窗口内明细应为 1 条,实得 %d 条", len(bill.Details))
+	}
+	// Total 必须**不等于**历史总数(3 笔)——只改 PageLogs 不改 CountLogs 的实现会在这里露馅
+	if bill.Total == 3 {
+		t.Fatal("Total = 历史总数 3:说明 CountLogs 没跟 PageLogs 用同一个窗口条件(改一半)")
 	}
 	if bill.Summary.CampusIDs == nil {
 		t.Fatal("campusIds 回显必须是 [](空时也不能是 null)")

+ 82 - 0
internal/academic/model/payroll_precision_test.go

@@ -0,0 +1,82 @@
+// 教龄列精度口径锁(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
+}

+ 29 - 1
internal/academic/service/bill_service.go

@@ -198,8 +198,15 @@ type BillResult struct {
 	Total int64 `json:"total"`
 	// Page 当前页码(已钳制)。
 	Page int `json:"page"`
-	// PageSize 每页条数(已钳制)。
+	// PageSize 每页条数(已钳制,∈[1,200])。
 	PageSize int `json:"pageSize"`
+	// Pages 总页数 = ceil(Total / PageSize)(分页契约要求五字段齐全:list/total/page/pageSize/pages)。
+	//
+	// 算法纪律(**别用 `math.Ceil(float64)`**):整数向上取整 `(Total + PageSize - 1) / PageSize`;
+	// `PageSize <= 0` 时恒 `0`(防除零——虽然 PageSize 已被 ClampPage 钳到 ≥1,这道守卫是给
+	// "将来有人绕过 Service 直接构造 BillResult" 用的)。
+	// 入参用**钳制后**的 PageSize 与**窗口内**的 Total,不是请求原始值。
+	Pages int `json:"pages"`
 	// From / To 回显查询窗口(审计用;语义见 GetBillInput)。
 	From time.Time `json:"from"`
 	To   time.Time `json:"to"`
@@ -313,11 +320,32 @@ func (s *BillService) GetBill(ctx context.Context, in GetBillInput) (*BillResult
 		Total:    total,
 		Page:     pageNo,
 		PageSize: pageSize,
+		Pages:    billPages(total, pageSize),
 		From:     in.From,
 		To:       in.To,
 	}, nil
 }
 
+// billPages 计算总页数(整数向上取整;纯函数,便于边界单测)。
+//
+// 入参:total 窗口内流水条数(已按查询窗口统计);pageSize 每页条数(**已钳制**,通常 ∈[1,200])。
+// 出参:总页数;`pageSize <= 0` 恒 0(防除零)。
+//
+// 边界(两个最容易写错的地方,用例逐个钉住):
+//   - `total = 0` → **0** 页(不是 1 页:空结果集不该显示"第 1/1 页");
+//   - `total = 25, pageSize = 10` → **3** 页(向上取整;用整数运算 `(25+10-1)/10`,
+//     **不要** `math.Ceil(2.5)` 再转 int——浮点在 1e16 量级的 total 上会丢精度,且多一条 import)。
+func billPages(total int64, pageSize int) int {
+	if pageSize <= 0 {
+		return 0
+	}
+	if total <= 0 {
+		return 0
+	}
+	size := int64(pageSize)
+	return int((total + size - 1) / size)
+}
+
 // buildBillSummary 由 4 份内存结构算出 dump 的 9 个汇总列 + 3 个对账辅助列(纯函数,便于单测)。
 //
 // 入参:

+ 56 - 0
internal/academic/service/bill_service_test.go

@@ -450,3 +450,59 @@ func TestBillServiceErrorCodeContract(t *testing.T) {
 	})
 	wantAppErr(t, err, infraerrors.CodeInternal, "DB 未装配")
 }
+
+// TestBillPagesBoundaries 分页总页数的两个边界(`pages` 字段,契约 v1.4 rev.2)。
+//
+// 契约要求:`PageSize <= 0` → 0(防除零);其余 `ceil(Total/PageSize)`。
+// 两个最容易写错的点,逐个钉住:
+//   - `Total = 0` → **0** 页(不是 1 页;空结果集不该显示"第 1/1 页");
+//   - `Total = 25, PageSize = 10` → **3** 页(向上取整;写成整除会得 2)。
+func TestBillPagesBoundaries(t *testing.T) {
+	cases := []struct {
+		total    int64
+		pageSize int
+		want     int
+	}{
+		{0, 10, 0},  // 空结果集 → 0 页
+		{25, 10, 3}, // 向上取整(整除会得 2)
+		{10, 10, 1}, // 正好整页
+		{11, 10, 2}, // 多一条即多一页
+		{1, 200, 1}, // 单条
+		{5, 0, 0},   // 防除零
+		{-1, 10, 0}, // 脏数据不产生负页数
+	}
+	for _, tc := range cases {
+		if got := billPages(tc.total, tc.pageSize); got != tc.want {
+			t.Errorf("billPages(total=%d, pageSize=%d) = %d,期望 %d", tc.total, tc.pageSize, got, tc.want)
+		}
+	}
+}
+
+// TestGetBillPagesFollowsWindowedTotal `pages` 必须由**窗口内** total 与**钳制后** pageSize 算出。
+//
+// 若 pages 用历史全量 total(4)算,会得 1 页(pageSize=2 → 2 页);窗口内 total=2、pageSize=2 → 1 页。
+// 本用例把两种错法都排除:断言 Pages == 1(窗口内 2 条 / 每页 2 条),
+// 并且 Total 明确等于窗口内条数而非历史总数。
+func TestGetBillPagesFollowsWindowedTotal(t *testing.T) {
+	_, svc, userID, from, to := billFixture(t)
+	res, err := svc.GetBill(context.Background(), GetBillInput{
+		UserID: userID, From: from, To: to, Page: 1, PageSize: 2,
+	})
+	if err != nil {
+		t.Fatalf("GetBill 失败: %v", err)
+	}
+	if res.Total != 2 || res.PageSize != 2 || res.Pages != 1 {
+		t.Fatalf("total/pageSize/pages = %d/%d/%d,期望 2/2/1(pages 用窗口内 total + 钳制后 pageSize)",
+			res.Total, res.PageSize, res.Pages)
+	}
+	// 超大 pageSize 被钳制到 200 后,pages 也必须按 200 算(而不是按请求值)
+	big, err := svc.GetBill(context.Background(), GetBillInput{
+		UserID: userID, From: from, To: to, Page: 1, PageSize: 5000,
+	})
+	if err != nil {
+		t.Fatalf("GetBill(pageSize=5000) 失败: %v", err)
+	}
+	if big.PageSize != 200 || big.Pages != 1 {
+		t.Fatalf("pageSize/pages = %d/%d,期望 200/1(按钳制后的 200 算,不是请求的 5000)", big.PageSize, big.Pages)
+	}
+}