Răsfoiți Sursa

fix(academic): F31 修正 from/to 纯日期解析的 UTC 偏移 8 小时

`parseTimeParam` 用 `time.Parse("2006-01-02")` 得到 UTC 零点,而 DB 连接是
`loc=Asia/Shanghai` ⇒ 所有 from/to 窗口整体偏 8 小时:**数字不对但不报错**。
影响 24 处调用(账单 17 / 收支 60 / 课表 48·57 / 分账流水等)。

四步必须一起改,缺一则「含当天」会变成「完全不含当天」:
  ① parseTimeParam → time.ParseInLocation("2006-01-02", …, tz.Location())
  ② BillService.FlowInclusiveEnd 上界 t <= to → t < to 次日零点(右开)
  ③ 两条用例种子时区统一到 tz.Location(),期望按新口径重算
  ④ 新增 handler/timeparam_test.go:直接钉 +08:00 偏移,不依赖集成用例

③ 不是「放宽判据」:原期望 500/300 与 total=1 是**建立在缺陷之上**的期望,
不是契约。新期望 inclusive=650 / 差值=450 能同时抓住三种错误形态
(300 = 退化成 t<=to、1449 = 右端越到次日、其它 = 更离谱),旧期望一个都抓不住。

顺带补一个零区分力缺口:原用例只造 to 当天 00:00 一笔,而 `t<=to` 与
`t<to+1d` **同时**命中它 ⇒ 把 ② 回滚后用例仍然绿。已补两笔钉死口径两端:
  - 04-01 15:00(150.00)只有「含当天全天」抓得到 → 对抗 t<=to
  - 04-02 00:00(999.00)必须排除       → 对抗右端越界到次日

变异探针(三组,均实测红后还原并核对无 MUTATION 残留):
  1. ② 回滚 → TestBillEndpointIdentity 红:inclusive−exclusive=300.00,期望 450.00
  2. ① 回滚 → 账单用例红,**但分账流水用例仍绿** ⇒ 记 F33(SQLite 时间列按
     文本比较,时区错误以偶然形状出现,集成用例无法稳定检出此类缺陷)
  3. ① 回滚 → TestParseTimeParam* 红:5/5 子用例,差 28800 秒

回归(数字取自落盘输出):build / vet ✅;handler 整包 ✅(96.5s);
academic 其余 8 包 ✅ 全绿;dualrun/stage2 **70 PASS / 0 FAIL**。

文档:台账 F31 标记已修并附「F31 修复结果」;新增 F33;stage2/README §18
三处过时表述("没做成 case" / "time.Parse UTC" / "F30 两侧一致需按部署时区限定")
同步订正;bill_service.go 中 FlowInclusiveEnd 的过时注释(写成 `t <= To`)一并改掉。
Q 2 ore în urmă
părinte
comite
f74d16eb0e

+ 20 - 10
internal/academic/dualrun/stage2/README.md

@@ -1765,10 +1765,10 @@ target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报
    与 VARCHAR 入参做**字典序**比较 ⇒ 纯日期与 `00:00:00` 对结余列**同效**(都不含当天),`23:59:59` 才含;
    而「当期发生额」用 `BETWEEN`(含等号)⇒ `t=to` 落在消费/充值类型时,两种写法会差一笔。
    RS-5 的 D 取 `type=200` 正是为隔离这一点。
-4. ⚠ **时区前提**:target 的 `time.Parse("2006-01-02", …)` 得到 **UTC 零点**,而 DB 按 `loc=Local` 读。
-   仅当进程时区 = UTC 时,"两侧一致、都不含当天"才成立;dev 是 **+08:00**,
-   target 的边界实际晚 8 小时(实测 `to='2026-03-31'` 时 `endBalance` 110.00 vs legacy 60.00,
-   整月窗口 `recharge` 0.00 vs 100.00)。⇒ **F30 的"两侧一致"需按部署时区限定**,见下节实测。
+4. ✅ **时区前提(F31 已修,2026-11-10)**:target 的纯日期解析已改为 `tz.Location()`(+08:00),
+   与 DB 的 `loc` 同口径。**修复前**是 `time.Parse` ⇒ **UTC 零点**,边界比本地口径**晚 8 小时**
+   (实测 `to='2026-03-31'` 时 `endBalance` 110.00 vs legacy 60.00,整月窗口 `recharge` 0.00 vs 100.00)。
+   ⚠ 保留这段测量值是为了防回归:**谁把 `parseTimeParam` 改回 UTC,下面所有数字会重新变成"晚 8 小时"那一列**。
 
 ### `to` 的格式敏感性:一次只读测量(RS-5 之后追加)
 
@@ -1799,7 +1799,7 @@ target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报
 | 本地 `2026-03-31 00:00:00` | −10.00 | 60.00 | 70.00 | 120.00 |
 | 本地 `2026-03-31 23:59:59` | −10.00 | 110.00 | 120.00 | 120.00 |
 
-### 由此发现的**真分歧**(已登记 F31 / P1;修法见台账,本文只给证据)
+### 由此发现的**真分歧**(登记 F31 / P1;**✅ 2026-11-10 已修复**,本文保留证据)
 
 `handler.parseTimeParam` 用 `time.Parse("2006-01-02", …)` ⇒ **UTC 当日 00:00**,
 而 DB 的 `create_time` 以 `loc=Local` 读出(本机 +08:00)⇒ 同一个裸日期,target 的边界比本地口径**晚 8 小时**。
@@ -1814,13 +1814,23 @@ legacy 是字符串比较(无时区),等价于本地零点。实测一个
 差异机制:`from` 被解析成 03-01 **08:00 本地** ⇒ B(03-01 00:00)被挤进 `PrevBalance`、不计当期充值;
 `to` 被解析成 04-01 08:00 本地 ⇒ 多算 04-01 凌晨的流水。**不报错,只是数不同。**
 
-这一条**没有做成 case**:它落在 handler 层(`parseTimeParam`),不是 SP/service 语义。
-已登记 **F31**(P1 / `TARGET-DEFECT`,影响 `parseTimeParam` 的 24 处调用),修法三步在台账里定稿,
-排期由台账维护者定 —— 本文不写修复方案,只留证据。
+这一条**不进双跑 case 集**:它落在 handler 层(`parseTimeParam`),不是 SP/service 语义 ——
+双跑两侧都不经过 handler,做进 stage2 只会得到一条"恒等"的假 case。
+
+**✅ 2026-11-10 已修复**(F31 四步:① `time.ParseInLocation(…, tz.Location())` ② `FlowInclusiveEnd`
+上界改 `to` **次日零点**(右开)③ 两条用例种子时区归一并按新口径重算 ④ 新增 `handler/timeparam_test.go`
+直接钉 `+08:00` 偏移)。回归:`handler` / academic 其余 8 包 / 本包 **70 PASS** 全绿;
+三组变异探针实测红后还原。细节见台账「F31 修复结果」。
+
+⚠ **为什么判据必须落在纯函数层**:把 ① 回滚成 `time.Parse` 后,账单集成用例会红,
+但**分账流水用例仍然绿** —— 测试库 SQLite 的时间列按**文本**比较,时区错误会以各种偶然形状出现
+(同一绝对时刻写成 `…Z` 与 `…+08:00`,`<` 的命中结果相反)。即**集成用例无法稳定检出这类缺陷**。
+这是 F33 的由来:时区类判据宁可多写一条纯函数单测,也不要指望端到端用例兜住。
 
 ⚠ **RS-5 覆盖的是 service 语义,不能拿 RS-5 的绿证明端点 17 没问题**:
-RS-5 直接调 `BillService.GetBill` 并传**本地** `to`;端点走的是 handler → `time.Parse`(UTC)。
-两者的边界不在同一时刻。
+RS-5 直接调 `BillService.GetBill` 并传**本地** `to`;端点走的是 handler → `parseTimeParam`。
+**F31 修好后两者已同口径**(handler 也按 `tz.Location()` 解析),但"这两段可能分叉"这件事
+本身就是拒绝"用 RS-5 的绿代替端点 17 验收"的理由 —— 口径可以再次分叉,而 RS-5 看不见 handler。
 
 #### 最小复现(4 笔种子 + 两个调用;只读)
 

+ 10 - 3
internal/academic/handler/divide_handler_test.go

@@ -32,6 +32,7 @@ import (
 	"yuansiwei/unified-backend/internal/academic/money"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
+	"yuansiwei/unified-backend/internal/infra/tz"
 )
 
 // newDivideTestDB 分账测试库(两张分账表;其余表由 handler 其它用例各自建)。
@@ -305,10 +306,16 @@ func TestDivideLogsWindowAndTotals(t *testing.T) {
 	db := newDivideTestDB(t)
 	user := "01HFINDER00000000000000001"
 	stu := "01HSTUDENT0000000000000001"
+	// 种子时间必须与 `parseTimeParam` 用**同一个业务时区**(`tz.Location()`),
+	// **不能用 `time.UTC`**:测试库是 SQLite,时间列按**文本**比较 ——
+	// `time.UTC` 存成 `...T00:00:00Z`、`+08:00` 存成 `...T00:00:00+08:00`,
+	// 同一绝对时刻的字面量在 offset 段就分出了大小 ⇒ `create_time < to` 判错
+	//(实测:两种写法对同一时刻的命中结果相反)。生产 MySQL 的 `datetime` 是无时区
+	// 字面值、按 DSN `loc` 解释,不存在该现象。见台账 F31。
 	for _, at := range []time.Time{
-		time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), // 窗口前
-		time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC),  // 窗口内
-		time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),   // == to(右开:不得出现)
+		time.Date(2026, 2, 20, 10, 0, 0, 0, tz.Location()), // 窗口前
+		time.Date(2026, 3, 5, 10, 0, 0, 0, tz.Location()),  // 窗口内
+		time.Date(2026, 4, 1, 0, 0, 0, 0, tz.Location()),   // == to(右开:不得出现)
 	} {
 		if err := db.Create(&model.UserDivideIntoLog{
 			Type: 1, UserID: user, StudentID: stu, Money: "12.34", CreateTime: at,

+ 44 - 17
internal/academic/handler/fund_handler_test.go

@@ -34,6 +34,7 @@ import (
 	"yuansiwei/unified-backend/internal/academic/money"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
+	"yuansiwei/unified-backend/internal/infra/tz"
 )
 
 // ---------------------------------------------------------------- 测试基础设施
@@ -631,10 +632,28 @@ func TestRequestApproveTouchesFunds(t *testing.T) {
 // 为什么两个都要下发(不能只留一个):只留一个就没法让前端**看见**不对称,
 // 而契约 v1.2 那句"期末余额与当期发生额都按 `< to`"的错句,根因正是"看不见"。
 //
-// 本用例因此**刻意造一笔落在 `to` 当天的流水**(300.00),断言:
-//  1. 期末不含它(700.00)、`flowExclusiveEnd` 不含它(200.00);
-//  2. `flowInclusiveEnd` 含它(500.00);
-//  3. 二者之差 **恰好等于** 该笔金额(300.00)—— 也就是契约说的"落在 to 当天的流水"。
+// 本用例造 5 笔流水来同时守住"含 `to` 当天"的**两个端点**(台账 F31 / F30):
+//
+//	02-20 10:00  500.00  窗口前(期初基准)
+//	03-05 10:00  200.00  窗口内(唯一进明细的一笔)
+//	04-01 00:00  300.00  `to` 当天零点 —— 旧实现 `t <= to` 恰好能抓到这一刻
+//	04-01 15:00  150.00  `to` 当天下午 —— **只有**"含当天全天"才抓得到
+//	04-02 00:00  999.00  次日零点 —— 必须**排除**(守右端不得越到次日)
+//
+// 断言:
+//  1. 期末不含当天(700.00)、`flowExclusiveEnd` 不含当天(200.00);
+//  2. `flowInclusiveEnd` = 200 + 300 + 150 = 650.00;
+//  3. 二者之差 == 450.00(= 落在 `to` 当天的两笔之和),**不是** 300.00。
+//
+// ⚠ 为什么必须有 04-01 15:00 那笔:只造 00:00 一笔时,`t <= to` 与 `t < to+1d`
+// 会**同时**把它算进去 ⇒ 把 service 的 ② 回滚成 `!After(to)` 用例仍然是绿的
+// (零区分力)。加了下午那笔,两种实现才分得开:旧 500 / 新 650。
+//
+// ⚠ 种子时间必须用 `tz.Location()`(业务时区),**不能用 `time.UTC`**:
+// 测试库是 SQLite,时间列按**文本**比较,`time.UTC` 存成 `...T00:00:00Z`、
+// `+08:00` 存成 `...T00:00:00+08:00`,同一绝对时刻的字面量在 offset 段就分出了大小
+// ⇒ `create_time < to` 判错(实测过:同一时刻两种写法命中结果相反)。
+// 生产 MySQL 的 `datetime` 是无时区字面值、按 DSN `loc` 解释,不存在这个现象。
 //
 // 顺带守字段名:若有人把 service.BillSummary 的 json tag 改名,本用例立刻红
 // (`flowExclusiveEnd` 解出来是零值 ⇒ 差值断言必失败)。
@@ -650,9 +669,15 @@ func TestBillEndpointIdentity(t *testing.T) {
 			t.Fatalf("seed 流水失败: %v", err)
 		}
 	}
-	seedLog("500.00", time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC)) // 期初
-	seedLog("200.00", time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC))  // 窗口内
-	seedLog("300.00", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC))   // **恰好落在 to 当天**
+	// 用业务时区构造(理由见上方 ⚠);`bizAt` 只省重复的 `tz.Location()`。
+	bizAt := func(month time.Month, day, hour int) time.Time {
+		return time.Date(2026, month, day, hour, 0, 0, 0, tz.Location())
+	}
+	seedLog("500.00", bizAt(2, 20, 10)) // 期初
+	seedLog("200.00", bizAt(3, 5, 10))  // 窗口内
+	seedLog("300.00", bizAt(4, 1, 0))   // `to` 当天零点
+	seedLog("150.00", bizAt(4, 1, 15))  // `to` 当天下午(只有"含当天全天"能抓到)
+	seedLog("999.00", bizAt(4, 2, 0))   // 次日零点(必须排除)
 
 	code, msg, data := doJSON(t, app, http.MethodGet,
 		"/student/bill?userId="+user+"&from=2026-03-01&to=2026-04-01", nil)
@@ -688,16 +713,18 @@ func TestBillEndpointIdentity(t *testing.T) {
 	if end.Sub(prev) != exclusive {
 		t.Fatalf("恒等式破:期末−期初=%s,flowExclusiveEnd=%s", end.Sub(prev).String(), exclusive.String())
 	}
-	// 契约 v1.3:两口径差值 == 落在 to 当天的流水
-	if got, want := inclusive.Sub(exclusive), int64Money(t, "300.00"); got != want {
-		t.Fatalf("flowInclusiveEnd−flowExclusiveEnd = %s,期望 %s(= 落在 to 当天的流水)",
+	// 契约 v1.3:两口径差值 == 落在 `to` 当天的流水(300 + 150,**不含次日那笔 999**)
+	if got, want := inclusive.Sub(exclusive), int64Money(t, "450.00"); got != want {
+		t.Fatalf("flowInclusiveEnd−flowExclusiveEnd = %s,期望 %s(= 落在 to 当天的两笔之和)\n"+
+			"若实得 300.00 ⇒ 实现退化成 `t <= to`(只抓到当天零点那一刻,漏掉当天其余流水,台账 F31)\n"+
+			"若实得 1449.00 ⇒ 右端越到次日(把 04-02 那笔也算进来了)",
 			got.String(), want.String())
 	}
-	if got, want := inclusive.String(), "500.00"; got != want {
-		t.Fatalf("flowInclusiveEnd = %s,期望 %s(含 to 当天那笔)", got, want)
+	if got, want := inclusive.String(), "650.00"; got != want {
+		t.Fatalf("flowInclusiveEnd = %s,期望 %s(含 to 当天**全天**:200+300+150)", got, want)
 	}
-	// v1.4 起明细按窗口过滤(右开):3 笔历史流水里只有 03-05 那笔在窗口内
-	// (02-20 在窗口前、04-01 恰在 `to` 右端点)→ total=1、pages=1(ceil(1/10))。
+	// v1.4 起明细按窗口过滤(右开):5 笔历史流水里只有 03-05 那笔在窗口内
+	// (02-20 在窗口前;04-01 零点/下午与 04-02 零点都在 `to` 右端点之后或等于它)→ total=1、pages=1。
 	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)
@@ -705,9 +732,9 @@ func TestBillEndpointIdentity(t *testing.T) {
 	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 用同一个窗口条件(改一半)")
+	// Total 必须**不等于**历史总数(5 笔)——只改 PageLogs 不改 CountLogs 的实现会在这里露馅
+	if bill.Total == 5 {
+		t.Fatal("Total = 历史总数 5:说明 CountLogs 没跟 PageLogs 用同一个窗口条件(改一半)")
 	}
 	if bill.Summary.CampusIDs == nil {
 		t.Fatal("campusIds 回显必须是 [](空时也不能是 null)")

+ 3 - 1
internal/academic/handler/handler.go

@@ -54,6 +54,7 @@ import (
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	"yuansiwei/unified-backend/internal/infra/response"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
+	"yuansiwei/unified-backend/internal/infra/tz"
 	"yuansiwei/unified-backend/internal/infra/validator"
 )
 
@@ -315,7 +316,8 @@ func parseTimeParam(raw, field string) (time.Time, bool, error) {
 	if t, err := time.Parse(time.RFC3339, trimmed); err == nil {
 		return t, true, nil
 	}
-	if t, err := time.Parse("2006-01-02", trimmed); err == nil {
+	// 纯日期按**业务时区**解析(不能用 time.Parse = UTC,否则窗口整体偏 8 小时,台账 F31)
+	if t, err := time.ParseInLocation("2006-01-02", trimmed, tz.Location()); err == nil {
 		return t, true, nil
 	}
 	return time.Time{}, false, infraerrors.WithCause(infraerrors.ErrBadFormat,

+ 143 - 0
internal/academic/handler/timeparam_test.go

@@ -0,0 +1,143 @@
+// `parseTimeParam` 单测 —— 台账 F31 的**主判据**(不是集成用例的副产品)。
+//
+// 为什么必须单独测这个纯函数,而不是靠 handler 集成用例:
+//
+//	集成用例跑在 SQLite 上,时间列按**文本**比较(`...T00:00:00Z` vs `...T00:00:00+08:00`),
+//	时区写错时窗口会以**各种偶然形状**错乱 —— 实测把本函数回滚成 UTC 后,
+//	账单用例确实红了,但**分账流水用例仍绿**(碰巧落在文本比较的安全侧)。
+//	也就是说"集成用例红/绿"无法稳定反映本函数的偏移是否正确。
+//	本文件把判据钉在**返回值本身**:偏移必须是 +08:00、绝对时刻必须等于业务时区零点。
+//
+// 反向约束(防假绿):每条断言都要能在**回滚修复后变红**。
+//
+//	回滚方式:`time.ParseInLocation("2006-01-02", trimmed, tz.Location())` → `time.Parse(...)`。
+//	此时 "2026-04-01" → UTC 零点(Unix 少 28800 秒),下面三项断言全部红。
+package handler
+
+import (
+	"testing"
+	"time"
+
+	"yuansiwei/unified-backend/internal/infra/tz"
+)
+
+// bizOffsetSeconds 中国标准时间偏移(秒)。中国无夏令时,恒 +08:00。
+const bizOffsetSeconds = 8 * 60 * 60
+
+// TestParseTimeParamDateUsesBusinessTimezone 纯日期入参必须解析成**业务时区**的当日零点。
+//
+// 缺陷形态(F31):`time.Parse("2006-01-02", s)` 返回 **UTC 零点**,
+// 而 DB 连接是 `loc=Asia/Shanghai` —— 同一个日期字符串算出来的绝对时刻差 8 小时,
+// 窗口整体右移,`to` 当天的 0~8 点流水被算进"下一天"(或反之)。
+// 影响面:`parseTimeParam` 的 24 处调用(账单 / 分账流水 / 课酬 / 活动等所有 from-to 窗口)。
+func TestParseTimeParamDateUsesBusinessTimezone(t *testing.T) {
+	cases := []struct {
+		name string
+		raw  string
+		want time.Time
+	}{
+		{"普通日", "2026-04-01", time.Date(2026, 4, 1, 0, 0, 0, 0, tz.Location())},
+		{"首日", "2026-01-01", time.Date(2026, 1, 1, 0, 0, 0, 0, tz.Location())},
+		{"末日", "2026-12-31", time.Date(2026, 12, 31, 0, 0, 0, 0, tz.Location())},
+		// 闰日专测:若有人用"加 8 小时"之类的偏移实现,跨月/跨年边界会露馅
+		{"闰日", "2024-02-29", time.Date(2024, 2, 29, 0, 0, 0, 0, tz.Location())},
+		// 前后空白必须被 trim(Http query 常见 `from=2026-04-01%20`)
+		{"带空白", "  2026-07-15  ", time.Date(2026, 7, 15, 0, 0, 0, 0, tz.Location())},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, ok, err := parseTimeParam(c.raw, "to")
+			if err != nil || !ok {
+				t.Fatalf("解析 %q 失败:ok=%v err=%v", c.raw, ok, err)
+			}
+			// ① 绝对时刻一致(这条是主判据:UTC 零点会比业务时区零点少 28800 秒)
+			if !got.Equal(c.want) {
+				t.Fatalf("解析 %q = %s(Unix %d),期望 %s(Unix %d),差 %d 秒",
+					c.raw, got.Format(time.RFC3339), got.Unix(),
+					c.want.Format(time.RFC3339), c.want.Unix(), got.Unix()-c.want.Unix())
+			}
+			// ② 偏移必须是 +08:00(这条把"日期对了但时区丢了"单独揪出来)
+			if _, offset := got.Zone(); offset != bizOffsetSeconds {
+				t.Fatalf("解析 %q 的时区偏移 = %d 秒,期望 %d 秒(+08:00)。"+
+					"若为 0 说明用回了 time.Parse(UTC),即 F31 的 8 小时偏移",
+					c.raw, offset, bizOffsetSeconds)
+			}
+			// ③ 与 UTC 零点必须**不相等** —— 直接对准 F31 的缺陷形态
+			//    (①② 已覆盖,但这条的失败文案能让后来者立刻看懂是什么坏了)
+			utcZero := time.Date(c.want.Year(), c.want.Month(), c.want.Day(), 0, 0, 0, 0, time.UTC)
+			if got.Equal(utcZero) {
+				t.Fatalf("解析 %q 得到的是 UTC 零点(%s),这是 F31 的缺陷形态:窗口整体偏 8 小时",
+					c.raw, utcZero.Format(time.RFC3339))
+			}
+		})
+	}
+}
+
+// TestParseTimeParamRFC3339KeepsOwnOffset 显式带偏移的 RFC3339 入参**不得**被改写成本地时区。
+//
+// 为什么:`from=2026-03-01T00:00:00Z` 是调用方**明确声明**的时刻,
+// 再"归一"到 +08:00 会改变其绝对时刻(那是改数据,不是改表示)。
+// 本用例守的是"① 只影响纯日期分支"这条边界 —— 否则修 F31 时容易顺手把 RFC3339 也归一。
+func TestParseTimeParamRFC3339KeepsOwnOffset(t *testing.T) {
+	cases := []struct {
+		name       string
+		raw        string
+		wantUnix   int64
+		wantOffset int
+	}{
+		{
+			name: "UTC 显式", raw: "2026-03-01T00:00:00Z",
+			wantUnix: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC).Unix(), wantOffset: 0,
+		},
+		{
+			name: "东八显式", raw: "2026-03-01T00:00:00+08:00",
+			wantUnix: time.Date(2026, 3, 1, 0, 0, 0, 0, tz.Location()).Unix(), wantOffset: bizOffsetSeconds,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, ok, err := parseTimeParam(c.raw, "from")
+			if err != nil || !ok {
+				t.Fatalf("解析 %q 失败:ok=%v err=%v", c.raw, ok, err)
+			}
+			if got.Unix() != c.wantUnix {
+				t.Fatalf("解析 %q 的 Unix = %d,期望 %d(RFC3339 的绝对时刻不得被改写)",
+					c.raw, got.Unix(), c.wantUnix)
+			}
+			if _, offset := got.Zone(); offset != c.wantOffset {
+				t.Fatalf("解析 %q 的偏移 = %d 秒,期望 %d 秒(保留调用方声明)",
+					c.raw, offset, c.wantOffset)
+			}
+		})
+	}
+}
+
+// TestParseTimeParamRejectsBadInput 空串与非规范格式的分支语义。
+//
+// 「空串 → ok=false 且**无错**」与「非法 → 报错」必须分开:
+// 前者是"未提供"(由调用方决定必填与否,例如端点 43 的 from/to 可省略),
+// 后者的 2002 一旦被吞成"未提供",窗口就会静默变成全表。
+func TestParseTimeParamRejectsBadInput(t *testing.T) {
+	if got, ok, err := parseTimeParam("   ", "from"); ok || err != nil || !got.IsZero() {
+		t.Fatalf("空串应返回 (零值, false, nil),实得 (%v, %v, %v)", got, ok, err)
+	}
+	badInputs := []string{
+		"2026-4-1",            // 非补零(Go 布局严格要求两位)
+		"2026/04/01",          // 分隔符错
+		"20260401",            // 紧凑格式
+		"2026-13-01",          // 月份越界
+		"2026-02-30",          // 日期不存在
+		"not-a-time",          // 完全非法
+		"2026-04-01T00:00:00", // 缺偏移的 RFC3339(无时区信息,不接受)
+	}
+	for _, raw := range badInputs {
+		got, ok, err := parseTimeParam(raw, "from")
+		if err == nil {
+			t.Fatalf("非法输入 %q 应报错,实得 ok=%v got=%v(静默接受会让窗口条件失效)",
+				raw, ok, got)
+		}
+		if ok {
+			t.Fatalf("非法输入 %q 的 ok 应为 false,实得 true(调用方会把它当有效边界)", raw)
+		}
+	}
+}

+ 20 - 3
internal/academic/service/bill_service.go

@@ -167,8 +167,11 @@ type BillSummary struct {
 	// FlowExclusiveEnd 窗口内全类型流水合计(From <= t **<** To)——
 	// **恒等于 EndBalance − PrevBalance**(§5.1 第 4 条:账单恒等式的可判定形式)。
 	FlowExclusiveEnd money.Amount `json:"flowExclusiveEnd"`
-	// FlowInclusiveEnd 窗口内全类型流水合计(From <= t **<=** To)——
-	// 与「当期发生额」同口径(BETWEEN 含右端)。二者之差 == 落在 `t = To` 的那些流水。
+	// FlowInclusiveEnd 窗口内全类型流水合计(From <= t **<** To 的**次日零点**)——
+	// 即真正"含 `to` 当天",与 dump 的 `BETWEEN` 口径对齐(`to` 为纯日期时的等价形式)。
+	//
+	// 二者之差 == 落在 `to` **当天**的流水。⚠ 不是"恰好等于 `to` 那一瞬"的流水 ——
+	// 后者的判据是 `t <= To`,那正是台账 F31 的缺陷形态:当天 0 点后所有流水全部漏算。
 	FlowInclusiveEnd money.Amount `json:"flowInclusiveEnd"`
 	// CampusIDs dump 第二入参的解析结果(**仅回显**:dump 全程未引用该参数,本服务不过滤)。
 	CampusIDs []int64 `json:"campusIds"`
@@ -369,6 +372,20 @@ func buildBillSummary(
 ) (BillSummary, error) {
 	var out BillSummary
 
+	// inclusiveEnd = `to` **所在日期的次日零点** —— 「含 `to` 当天」的真正上界(右开)。
+	//
+	// 为什么不能直接写 `!t.After(to)`(旧实现):`to` 是**日粒度**入参(`from/to` 只传日期时,
+	// handler 解析成当日 00:00),`<= to` 只能命中"恰好午夜那一瞬"的流水 ⇒ 当天绝大多数流水都进不来,
+	// 「含当天」实际不成立(台账 F30/F31)。旧版之所以"看起来对",是因为它把日期解析成 **UTC 零点**
+	// ⇒ 边界落在本地 08:00,恰好把当天 0~8 点的流水带了进来 —— 一个 bug 掩盖了另一个 bug。
+	//
+	// 语义边界:本列只负责**展示口径**(前端"含最后一天"的那个数),不影响 `EndBalance` /
+	// `FlowExclusiveEnd`(两者仍是 `< to`,与 legacy 的 `< inEndTime` 对齐)。
+	inclusiveEnd := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, to.Location()).AddDate(0, 0, 1)
+	if to.IsZero() {
+		inclusiveEnd = to
+	}
+
 	// dump 764116 游标:充值工单参与集合(`temp_user_balance_request`,764128 装入)
 	rechargeReqNos := make(map[string]struct{}, len(reqNos))
 	for _, no := range reqNos {
@@ -399,7 +416,7 @@ func buildBillSummary(
 			if row.CreateTime.Before(to) {
 				out.FlowExclusiveEnd = out.FlowExclusiveEnd.Add(amount)
 			}
-			if !row.CreateTime.After(to) {
+			if row.CreateTime.Before(inclusiveEnd) {
 				out.FlowInclusiveEnd = out.FlowInclusiveEnd.Add(amount)
 			}
 		}