Переглянути джерело

docs(academic): AC-12 收尾——A21 注释改三条 + 补 A21 去重用例 + T-TEST-1 门禁说明

## 一、`bill_service.go` 注释修正(**这是会引来回归的一类注释**)

原注释写「RIGHT JOIN 的**两个**后果(**必须照抄,不得"修好"**)」,只列了 2 条,
而实现恰好"修好"了第三条(扇出)——注释在说"必须照抄",代码却没照抄,
设计 §3.8-**A21** 已把这个"修好"登记为正确处置(`DUMP-BUGFIX`)。
**若有人读注释把第三条补回去,等于主动引入 A21 已排除的缺陷。**

改为**三条 + 逐条标注处置**:
1. 活动从未产生 used_log ⇒ 其 signed 额完全不计入结余 —— **复刻**;
2. 只有 used_log 无 signed 行 ⇒ signed 侧 NULL,SUM 按 0 计 —— **复刻**;
3. 同活动 k 条 signed ⇒ `activity_money_used` 汇总被**重复计入 k 次**(SQL 扇出)
   —— **刻意不复刻**(A21 / `DUMP-BUGFIX`,按 `activity_id` 去重各计一次);
   并写明真实数据仅 1 例(`user 32596 / activity 1`,两条各 1000.000,
   cutoff 2020-01-01 时 dump=737.022 / Go=1737.022,差 1000.000)与
   **⚠ 不要照抄第 3 条**。文件头的 dump 骨架段也加了同一指向,避免两处表述冲突。

## 二、补 A21 用例(设计侧标注"必须补用例,现有 36 例零覆盖")

`TestGetBillGiveBalanceDeduplicatesSignedRows`:造"1 活动 2 条 signed(各 1000.00)+ used −100.00",
断言 `giveEndBalance = 1900.00`(去重)而**不是 1800.00**(扇出),并显式排除 1800.00 这个值。
目标态有 UK(user_id, activity_id) 造不出重复行,故用例先 `DROP INDEX uk_user_activity`
模拟**旧库形态**(老库无此 UK,正是 A21 那 1 例脏数据的成因)—— 这一点写进用例注释。

**变异实测**:把 `total.Add(usedSum)` 改成 `usedSum.MulInt(2)`(复刻 k=2 扇出)→
`期末赠送结余 = 1800.00,期望 1900.00(…若得 1800.00 说明复刻了 A21 的扇出缺陷)` **红**;还原即绿。

## 三、`models_test.go`:把 T-TEST-1 写进门禁上方

在 `TestModelsColumnByColumn` 上加说明:**它是精度类问题的唯一防线**,不得跳过、
不得放宽为"仅名字比对"。原因(`tech-debt-backlog.md` **T-TEST-1**):sqlite 的 NUMERIC 亲和性
**不强制 DECIMAL 标度** ⇒ 把 `(6,3)` 改回 `(6,2)` 时插入 `0.999` 依然原样存下,
"0.999 舍成 1.00 ⇒ 相邻区间并列命中"这类**精度语义差异在单测里复现不出来**。
由此三条纪律:① 精度差异只能靠真库 e2e / D7 双跑证明;② 防精度回退的断言对象必须是**列类型**
(不是计算结果,否则两种精度都绿);③ 本用例必须保持对 `type` 的严格比对。
并注明它抓不住"两边一起改回",后者由 `TestPayrollTeacherExpPrecisionLocked` 兜。

## 四、`pages` 字段:**已在 `ec174ee` 落地**(本条消息里再确认一次)
`BillResult.Pages int \`json:"pages"\`` + `billPages` 纯函数(`PageSize<=0 → 0`;否则整数向上取整);
handler 直接透传 `res.Pages`;边界用例覆盖 `0/10→0`、`25/10→3`、`5/0→0`、`11/10→2` 等。

go build / go vet / go test -short ./... 全绿。
Alex 15 годин тому
батько
коміт
0e7728215a

+ 18 - 0
internal/academic/model/models_test.go

@@ -100,6 +100,24 @@ func TestDDLTableCoverage(t *testing.T) {
 }
 
 // TestModelsColumnByColumn 27 表逐列比对(列名/类型/可空性)。
+//
+// ---------------------------------------------------------------------------
+// ⚠ 本用例是**精度类问题的唯一防线**,不得跳过、不得放宽为"仅名字比对"
+// ---------------------------------------------------------------------------
+//
+// 原因(`tech-debt-backlog.md` **T-TEST-1**):本仓单测栈是 sqlite,它的 NUMERIC 亲和性
+// **不强制 `DECIMAL` 的标度**——把 `decimal(6,3)` 改回 `(6,2)`,插入 `0.999` 依然原样存下,
+// 于是"0.999 被舍成 1.00 ⇒ 相邻区间并列命中"这类**精度导致的语义差异在单测里根本复现不出来**。
+//
+// 由此三条纪律:
+//  1. 精度敏感的语义差异,只能靠**真库 e2e**(MySQL)或 **D7 双跑**证明,sqlite 单测不能替代;
+//  2. 写"防精度回退"用例时,**断言对象必须是列类型(schema)**,不是计算结果 ——
+//     拿计算结果做断言等于没写(它对两种精度都绿);
+//  3. 因此本用例(tag ↔ DDL 逐列比对)必须**保持对 `type` 的严格比对**:
+//     一旦被简化成"只比列名",`decimal(6,2)`/`(6,3)` 收敛就再没有任何门禁能拦。
+//
+// 另注:本用例抓得住"只改一边"(改 DDL 忘改 tag),但**抓不住"两边一起改回"**(一致地错)。
+// 后者的守护是 `payroll_precision_test.go`(`TestPayrollTeacherExpPrecisionLocked`,业务口径锁)。
 func TestModelsColumnByColumn(t *testing.T) {
 	ddl := parseDDL(t, ddlSQL)
 	checkedColumns := 0

+ 14 - 2
internal/academic/service/bill_service.go

@@ -19,6 +19,8 @@
 //	764177  SELECT SUM(money) FROM temp_user_expense_log as log1    WHERE create_time<inEndTime;    -- **期末余额**
 //	764179  SELECT SUM(money) FROM temp_user_expense_log as log2    WHERE create_time<inStartTime;  -- **期初余额**
 //	764182-764189  赠送额结余(当前 764182-764184 / 上一次 764187-764189)
+//	               ⚠ 其中 `SUM(...) OVER signed RIGHT JOIN (... GROUP BY activityid)` 存在**扇出缺陷**,
+//	                 已登记 §3.8-A21,Go **刻意不复刻**(详见 giveBalanceBefore 注释第 3 条)
 //	               → 764193 当期使用赠送 = 当期赠送 + 上期结余 − 当前结余(`SET currentUsedGiveCost = ...`)
 //
 // ---------------------------------------------------------------------------
@@ -467,9 +469,19 @@ func buildBillSummary(
 //	→ 结果 = Σ(该 user 在"有 used_log(< cutoff)的活动"上的全部 signed.activity_money)
 //	       + Σ(used_log.activity_money_used where create_time < cutoff)
 //
-// **RIGHT JOIN 的两个后果(必须照抄,不得"修好")**:
+// **RIGHT JOIN 的三个后果**(前两条**复刻**、第三条**刻意不复刻** —— 别把三条一视同仁):
 //  1. 某活动若从未产生 used_log(< cutoff),其 signed.activity_money **完全不计入**结余;
-//  2. 某活动只有 used_log、没有 signed 行时,signed 侧为 NULL,SUM 按 0 计(不报错)。
+//     —— **复刻**(dump 的语义事实,不是缺陷)。
+//  2. 某活动只有 used_log、没有 signed 行时,signed 侧为 NULL,SUM 按 0 计(不报错);
+//     —— **复刻**(同上)。
+//  3. 同一活动有 **k 条 signed** 时,该活动的 `activity_money_used` 汇总会被**重复计入 k 次**
+//     (`SUM` 建在 `signed RIGHT JOIN (… GROUP BY activityid)` 上 ⇒ SQL 扇出 / fan-out)。
+//     —— **刻意不复刻**(设计 §3.8-**A21**,归因 `DUMP-BUGFIX`):本函数按 `activity_id` 先去重、
+//     每个活动的 signed 额与 used 额**各只计一次**。
+//     真实数据**仅 1 例**(`user 32596 / activity 1`,两条各 `1000.000`);cutoff `2020-01-01` 时
+//     dump 得 `737.022`、Go 得 `1737.022`,**差 1000.000**;跨该活动的窗口 `GiveUsed` 同样差。
+//     **⚠ 不要"照抄"第 3 条** —— 那是 dump 的缺陷;补回去等于主动引入 A21 已排除的错误。
+//     用例:`TestGetBillGiveBalanceDeduplicatesSignedRows`(造"同活动 2 条 signed",断言只计一次)。
 //
 // 入参:
 //   - used 全量 used_log;

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

@@ -506,3 +506,68 @@ func TestGetBillPagesFollowsWindowedTotal(t *testing.T) {
 		t.Fatalf("pageSize/pages = %d/%d,期望 200/1(按钳制后的 200 算,不是请求的 5000)", big.PageSize, big.Pages)
 	}
 }
+
+// TestGetBillGiveBalanceDeduplicatesSignedRows 赠送结余必须按活动**去重**(设计 §3.8-A21,DUMP-BUGFIX)。
+//
+// ---------------------------------------------------------------------------
+// dump 的缺陷(**刻意不复刻**,见 giveBalanceBefore 注释第 3 条)
+// ---------------------------------------------------------------------------
+//
+// dump 764182-764189 的结余是 `SUM(activity_money_used)` 建在
+// `signed RIGHT JOIN (SELECT … GROUP BY activityid)` 上:同一活动有 **k 条 signed** 时,
+// 那条 used 汇总值会被 JOIN 扇出成 k 行 ⇒ 被加 k 次(`activity_money` 因 GROUP BY 只出现一次,
+// used 却被放大)⇒ `giveEndBalance`/`givePrevBalance` 双计 ⇒ `giveUsed` 虚增。
+//
+// 真实数据仅 1 例(`user 32596 / activity 1`,两条各 1000.000;cutoff 2020-01-01 时
+// dump=737.022 / Go=1737.022,差 1000.000)。本用例用"1 活动 2 条 signed"把这个形态造出来:
+//
+//	expected(Go,去重):giveEnd = signed(1000+1000) + used(−100)            = 1900.00
+//	dump(扇出,错误)  :giveEnd = signed(1000+1000) + used(−100 × 2 条 signed) = 1800.00
+//
+// 断言两者**不相等**,并锁定 1900.00 —— 若有人"按注释把第三条补回去",本用例立刻红。
+//
+// 注:目标态 `t_courses_activity_signed` 有 UK(user_id, activity_id),造不出重复行,
+// 故本用例先 DROP 该唯一索引来模拟**旧库形态**(老库无此 UK,正是 A21 那 1 例脏数据的成因)。
+func TestGetBillGiveBalanceDeduplicatesSignedRows(t *testing.T) {
+	db := newBillServiceDB(t)
+	userID := fundUserID(950)
+	from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
+	to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
+
+	// 去掉 UK 以复现旧库形态(老库允许同 (user, activity) 多行 → A21 的来源)
+	if err := db.Exec("DROP INDEX IF EXISTS uk_user_activity").Error; err != nil {
+		t.Fatalf("DROP INDEX uk_user_activity 失败: %v", err)
+	}
+	no := "REQ-DUP"
+	for i := 0; i < 2; i++ { // 同一活动 2 条 signed,各 1000.00
+		if err := db.Create(&model.CoursesActivitySigned{
+			ActivityID: 9002, UserID: userID, ActivityMoney: "1000.00", RequestNo: &no,
+		}).Error; err != nil {
+			t.Fatalf("seed 重复报名(第 %d 条) 失败: %v", i+1, err)
+		}
+	}
+	if err := db.Create(&model.CoursesActivityUsedLog{
+		ActivityID: 9002, UserID: userID, ActivityMoneyUsed: "-100.00",
+		CreateTime: time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC),
+	}).Error; err != nil {
+		t.Fatalf("seed used_log 失败: %v", err)
+	}
+
+	svc := NewBillService(db, fundSuperScope())
+	res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
+	if err != nil {
+		t.Fatalf("GetBill 失败: %v", err)
+	}
+	// 去重口径:signed 2000 + used 只计一次 −100 = 1900
+	if got, want := res.Summary.GiveEndBalance.String(), "1900.00"; got != want {
+		t.Fatalf("期末赠送结余 = %s,期望 %s(used 只计一次;若得 1800.00 说明复刻了 A21 的扇出缺陷)",
+			got, want)
+	}
+	if res.Summary.GiveEndBalance.String() == "1800.00" {
+		t.Fatal("复刻了 dump 的 RIGHT JOIN 扇出(A21,DUMP-BUGFIX)——该缺陷已明确不复刻")
+	}
+	// giveUsed = Gift(0) + GivePrev(0) − GiveEnd(1900) = −1900(本用例只关心去重,不构造 gift 链)
+	if got, want := res.Summary.GiveUsed.String(), "-1900.00"; got != want {
+		t.Fatalf("当期使用赠送 = %s,期望 %s", got, want)
+	}
+}