Browse Source

docs(ac15): 实测 `to` 的格式敏感性 —— 发现 handler UTC 解析与本地口径差 8 小时

同一份种子只改 `to` 写法,两侧实测(只读探针,跑完即删):

legacy(from='2026-03-01'):
  '2026-03-31'          → end 60.000
  '2026-03-31 00:00:00' → end 60.000(与裸日期**同效**:`<` 严格比较)
  '2026-03-31 23:59:59' → end 110.000
  ⇒ "右端开"只对 `<` 成立,与格式无关;但**当期发生额**用 BETWEEN(含等号),
    若 t=to 落在消费/充值类型上,两种写法会差一笔(本种子 D 取 type=200,属已知盲区)。

target(handler 解析裸日期 = **UTC 00:00** vs 本地):
  UTC 00:00   → end 110.00 / exclusive 120.00 / inclusive 120.00
  本地 00:00  → end 60.00  / exclusive 70.00  / inclusive 120.00
  本地 23:59:59 → end 110.00 / exclusive 120.00 / inclusive 120.00

真分歧(未修,需定责):`parseTimeParam` 用 time.Parse(UTC),而 DB create_time 以
loc=Local 读出(本机 +08:00)⇒ 裸日期窗口边界晚 8 小时,legacy 是字符串比较(= 本地零点)。
整月窗口 from=2026-03-01&to=2026-04-01(文档推荐"含当日"写法)实测:
  handler:prev 90.00 / recharge 0.00 / exclusive 20.00 / total 2
  本地   :prev −10.00 / recharge 100.00 / exclusive 120.00 / total 3
不报错,只是数不同。三条可能处置(handler 改本地零点 / 契约只收 RFC3339 / service 归一)
需另定;未做 case(落在 handler 层,非 SP/service 语义)。
RS-5 覆盖的是 service 语义,不覆盖 handler 的 UTC 解析 —— 已在文档写明。

门禁:go build ./... | go vet ./internal/academic/... |
go test ./internal/academic/dualrun/stage2/ -count=1 | resultsets 5 PASS / 0 FAIL。
Q 5 hours ago
parent
commit
b20f9c8074
1 changed files with 50 additions and 0 deletions
  1. 50 0
      internal/academic/dualrun/stage2/README.md

+ 50 - 0
internal/academic/dualrun/stage2/README.md

@@ -1755,3 +1755,53 @@ target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报
    ⇒ **割接验收不能只比数字**:数值一致在这里恰恰是"错的理由碰巧撞对了数"。
    要判"合并还是丢弃",必须同时核**该活动的 signed 行数 / `activity_money` 合计**
    (合并 ⇒ 2000,丢弃 ⇒ 1000)。处置口径需迁移 owner 定,本文件只给数。
+
+### `to` 的格式敏感性:一次只读测量(RS-5 之后追加)
+
+**问题**:RS-5 里那句注释(dump 的 `create_time` 是 VARCHAR、`BETWEEN` 是字符串比较)会不会意味着
+「右端语义」其实只是**入参格式**的副产品?同一份种子,**只改 `to` 的写法**,两侧实测:
+
+**legacy**(`from='2026-03-01'`,种子同上:A −10 / B +100(03-01 00:00)/ C −30 / D +50(03-31 00:00)):
+
+| `to` | prev | income | spend | **end** | 其余 5 列 |
+| --- | --- | --- | --- | --- | --- |
+| `'2026-03-31'` | −10.000 | 100.000 | −30.000 | **60.000** | 0.000 |
+| `'2026-03-31 00:00:00'` | −10.000 | 100.000 | −30.000 | **60.000** | 0.000 |
+| `'2026-03-31 23:59:59'` | −10.000 | 100.000 | −30.000 | **110.000** | 0.000 |
+
+⇒ 三种写法里 `'2026-03-31'` 与 `'2026-03-31 00:00:00'` **对结余列完全同效**(`<` 是严格比较,
+`'…31 00:00:00' < '2026-03-31'` 与 `< '2026-03-31 00:00:00'` 都为 false);
+只有 `23:59:59` 会把 D 计入期末。**"右端开"这个说法在 legacy 侧只对 `<` 成立,与格式无关。**
+
+⚠ 但**当期发生额**用的是 `BETWEEN`(含等号):`'…00:00:00' <= '2026-03-31'` 为 false、
+`<= '2026-03-31 00:00:00'` 为 true ⇒ 若 `t=to` 那笔落在**消费/充值类型**上,两种写法会差一笔。
+本测的 D 取 type=200(不在 IN 列表),所以这一列**没被观察到** —— 这是本测量的已知盲区。
+
+**target**(同一份种子,直接调 `BillService.GetBill`,本地时区 +08:00):
+
+| `to` 来源 | prev | end | exclusive | inclusive |
+| --- | --- | --- | --- | --- |
+| handler 解析裸日期 `2026-03-31`(**UTC 00:00**) | −10.00 | **110.00** | **120.00** | 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 |
+
+### 由此发现的**真分歧**(未修,需定责)
+
+`handler.parseTimeParam` 用 `time.Parse("2006-01-02", …)` ⇒ **UTC 当日 00:00**,
+而 DB 的 `create_time` 以 `loc=Local` 读出(本机 +08:00)⇒ 同一个裸日期,target 的边界比本地口径**晚 8 小时**。
+legacy 是字符串比较(无时区),等价于本地零点。实测一个整月窗口(`from=2026-03-01 & to=2026-04-01`,
+即文档推荐的"含当日"写法):
+
+| 口径 | prev | end | recharge | exclusive | total |
+| --- | --- | --- | --- | --- | --- |
+| handler(UTC,裸日期) | **90.00** | 110.00 | **0.00** | **20.00** | **2** |
+| 本地(= legacy 的字符串口径) | −10.00 | 110.00 | 100.00 | 120.00 | 3 |
+
+差异机制:`from` 被解析成 03-01 **08:00 本地** ⇒ B(03-01 00:00)被挤进 `PrevBalance`、不计当期充值;
+`to` 被解析成 04-01 08:00 本地 ⇒ 多算 04-01 凌晨的流水。**不报错,只是数不同。**
+
+这一条**没有做成 case**(本轮边界:只测量 + 文档):它落在 handler 层(`parseTimeParam`),
+不是 SP/service 语义;要修得先定口径——三种可能:
+① handler 把裸日期解释为本地零点;② 契约只允许 RFC3339;③ service 侧统一归一。
+责任与处置需另定(候选缺陷,未入本文件结论)。RS-5 用的是**直接调 service + 本地 `to`**,
+所以 RS-5 覆盖的是 service 语义,**不覆盖 handler 的 UTC 解析**。