Q
pushed to master at yuansiwei/DEV-MERGE-LEGACY-BACKEND-2026
970f7ad303 fix(academic): 契约 v1.5 对齐(端点 1 扁平 / 12 业务时间 / 21 分布)+ 端点 11 注释
## 端点 1:改扁平(契约 v1.5 —— 后端错,契约不变)
`{userId, institutionId, campusId, totalBalance, campusBalance, specialExpenses, cumulativeIncome}`
来源列(逐字段可追溯,契约 §3.4 ③ 要求):
- `totalBalance` ← `t_user_expense.balance`
- `campusBalance` ← `t_user_expense_school.balance`(未指定/无账户 → 0)
- `specialExpenses` / `cumulativeIncome` ← 对应表 `special_expenses` / `total_income`(有校区账户取校区列)
- `institutionId`/`campusId` 是**入参回显**,**不从账户行取**(`t_user_expense` 无作用域列)
顺带**删掉**已无人使用的 `AccountBalanceView` / `CampusAccountBalanceView` 与两个 builder ——
避免"嵌套与扁平两种形状并存"被下一个人误接。
**已知代价(写进注释)**:扁平形状无法用 `null` 表达"该校区未建户",`campusBalance=0` 与"未建户"不可区分;
要区分需另加标志位,本期不加(契约未要求)。
## 端点 12:时间字段改业务列(契约 v1.5 —— 契约错,实质缺陷)
- `requestTime` ← **`request_time`**(业务时间)
- 新增 `responseTime` ← **`response_time`**(未审批 → `null`)
- `applicantId` ← `request_user_id`、`approverId` ← `response_user_id`(**未审批 → null**,用指针保留语义)
- **下线 `createdAt`** —— 它是迁移期新加列 `created_at DEFAULT CURRENT_TIMESTAMP(3)`,
迁移后**所有历史工单的值都是迁移那一刻**,下发会把全部历史工单排在同一天。
### 横切断言 + 两组变异(team-lead 要求)
`TestRequestsUseBizTimeNotCreatedAt`:造 `request_time = 2020-01-01` 的工单,断言
① `requestTime == request_time`;② `requestTime != created_at`;③ **原始 JSON 无 `createdAt`/`createTime` 键**。
| 变异 | 实测 |
|---|---|
| `RequestTime` 改取 `row.CreatedAt` | `requestTime = 2026-11-09 11:07:15,期望 2020-01-01 08:00:00(← request_time 业务时间)` **红** |
| 把 `createdAt` 加回 json tag | `响应含非业务时间键 "createdAt" —— created_at 是迁移期入库时间…` **红**(并打印完整响应体) |
③ 用原始 JSON 断言的原因写进注释:结构体没有该字段时,结构体断言**恒真**,等于没测。
## 端点 21:补 `balanceDistribution`(契约 v1.5 唯一可算项)
- 新增 `balanceDistribution:[{campusId, balance}]` ← `t_user_expense_school` 按 `campus_id` 聚合
(该表**有**作用域列;流水表没有 ⇒ 这正是删 `periodIncome/periodExpense/net` 却保留本项的原因)。
- 新增仓储 `ListCampusBalances`(Go 侧定点解析,禁 `SUM(decimal)`)。
- 用例 `TestDashboardBalanceDistribution`:两个校区 → 分布 2 项、按 campusId 升序、金额字符串、合计 350.00。
## 端点 11:只补注释(契约删字段,代码本就没有)
写明「`t_user_expense_log` 无作用域列 ⇒ 响应不含 `institutionId`/`campusId`;也正因如此
非超管强制带 `userId`——不带等于跨用户查全机构流水,是真实越权而非过度收紧」。
## 端点 39:补 `quota` 默认值断言(team-lead 点名)
新节点 `quota` 必须是**金额零**(`"0.00"`),不是 null/空串:`0` 是"不分成"的合法配置值,
用 null 表达会把"不分成"与"未配置"混为一谈。断言按 `money.Amount` 比较(sqlite 会把 "0.00" 读回 "0"),
并单独断言"不能用空串表达"。
handler 26 用例全绿;go build / go vet / go test -short ./... 全绿。
49ad084f3b feat(academic): AC-13a 分账 6 端点(38~43)+ 两树隔离/树不变式断言
## 范围(13a 全量)
| 端点 | 方法 | 说明 |
|---|---|---|
| 38 | `GetDivideTree` | + `rootUserId?` 过滤;Nested Set 不变式 I1~I4 |
| 39 | `AddDivideMember` | 转调 AC-09 `AddSubordinate`,返回 `{ancestorIds:[]}`(新增 `ListAncestorIDs` 原语,**root 限定不可省**) |
| 40 | `SetDivideQuota` | 转调 AC-09 `UpdateQuota`(3111 = **新额度 > 父额度**),返回 `{affectedUserIds:[]}` |
| 41 | `CreateDivideLogRecord` | 转调 AC-09 `AddLog`;**无金额入参**(分成额取节点 quota) |
| 42 | `DeleteDivideLogRecord` | 定位方式是 **`studentId`+`orderNo`**(不是 `:id`,契约 §2.3);转调 `DeleteLog`,注入真实 `FirstChargedRecordID` |
| 43 | `ListDivideLogs` | query 参数名按契约改 **`userId`**(响应行字段仍是 `findUserId`),右开窗口 |
AC-09 的 service **未改逻辑**:新增方法都在我自己的 `divide_tree_service.go` 里做**编排**,
`AddSubordinate/UpdateQuota/AddLog/DeleteLog` 一行未动(符合"不该大改 service"的要求)。
## 量纲
`quota` = **金额字符串**(`"100.00"`),端点 40 的 3111 判据是 **「新额度 > 父额度」**(不是 `quota > 1`)。
## 两组关键变异(全部实测,原文)
| 变异 | 结果 |
|---|---|
| 插入点 `parentRight + 1`(off-by-one,D-3) | `插入后 A 树不变式被破坏(返回 5001)—— 检查插入点是否取了**位移前**的 parentRight(D-3)` **红** |
| shift 去掉 `root_user_id` 限定(D5) | `B 树节点 01HROOTB1… 区间被平移:(left,right,layer) 期望 (1,4,1),实得 (1,6,1) —— 说明位移漏了 root_user_id 过滤(D5)` **红** |
⚠ 第二组我**调整了断言顺序**才发现价值:原先"插入后校验 A 树不变式"排在前面,
D5 变异会先被 A 树不变式 5001 拦下 ⇒ 看不出"逐值比对 B 树"这条断言是否真的有效。
现在**先逐值比对 B 树、再校验 A 树不变式**,两种失败形态各命中各的文案
(D5 → 逐值文案;D-3 → 不变式文案)。这正是"断言顺序会影响可诊断性"的一个实例。
## 其它判定(按你的四段要求)
- 错误码分流:2002(quota/orderNo 非法)、3111(子超父)、2001(缺参)、1001(无作用域)各有用例;
- 端点 43 的 `total` 与 `list` 同口径(右开:`to` 时刻那笔不进);
- `quota`/`cumulativeIncome` 直接断言原始 JSON 片段 `"100.00"`(number/比率两种错法都拦得住)。
## 顺手更正(你点名的一行)
`models_test.go` 门禁说明由「**唯一**防线」改为「**第一道**防线(管"只改一侧");
"两侧一起改回"由 `TestPayrollTeacherExpPrecisionLocked` 兜」——
留"唯一"会和下一段"它抓不住两边一起改"自相矛盾,容易被后人删掉后半段。
## 状态
handler 24 用例全绿;go build / go vet / go test -short ./... 全绿;工作区干净;未 push。
86cc4afd54 feat(academic): AC-13 第 1 批(端点 38 分账树 + 43 分账流水)+ Nested Set 不变式校验
按 team-lead 指定顺序**先做 38/39/40(分账树)**:本批交付 38 + 43(两条读端点),
39/40(额度)与 41/42(记/删流水,写路径)明确留到下批(文件末列了范围表,防"以为做完了")。
## 新增
- `service/divide_tree_service.go`:
· `GetTree` 组装森林 + **校验 Nested Set 四条不变式**(I1 区间值恰为 1..2N 无重复无空洞 /
I2 任意两节点嵌套或相离 / I3 父区间严格包含子 / I4 `layer = parent.layer + 1`),
不成立即 **5001**(不返回"看起来对"的树——位移错位无法从数据推断原值,猜测修复会把确定性错误变成随机正确);
· `ListLogs`(端点 43,右开窗口、total 与 list 同 WHERE)。
- `repository/divide_repo.go`(纯新增):`ListAllNodes`(Nested Set 先序)、`ListLogsFiltered`。
- `handler/divide_handler.go`:`GetDivideTree` / `ListDivideLogs` + 路径常量(**不含 `/api`**)。
- `handler/divide_handler_test.go`:6 个用例。
## 量纲红线(mock 曾错)
`quota` = **金额字符串**(`"100.00"`,`DECIMAL(12,2)`),**不是** 0~1 比率、不是 number。
`TestDivideTreeQuotaIsMoneyString` 直接断言原始 JSON 片段 `"100.00"`(带引号两位小数)。
## 三组变异(team-lead 要求的"写错会变红",全部实测)
| 变异 | 结果(原文) |
|---|---|
| 关掉 `assertNestedSetInvariants` | `坏树应返回 5001(数据损坏),实得 200 —— 说明不变式校验没生效` **红** |
| `Quota` 改 float64 且输出 0~1 比率 | `quota = 0.01,期望 "100.00"(金额字符串…不是 0~1 比率、不是 number)` **红** |
| 流水窗口 `create_time < to` 改 `<=` | `total/list = 2/2,期望 1/1(右开窗口:to 时刻那笔应被排除)` **红** |
三组均"还原即绿"。
## 另一件事(**必须你裁决,未擅自改动**)
契约 v1.4 §3.4 **A 表**(端点 1/11/12/21)的字段名,与我 AC-12 的实现**不一致**:
| 端点 | 契约 v1.4(〔mock〕) | AC-12 现状 |
|---|---|---|
| 1 | `userId,institutionId,campusId,totalBalance,campusBalance,specialExpenses,cumulativeIncome` | `{userId, global:{balance,spend,totalIncome,…}, campus:{balance,…}}` |
| 11 | `id,userId,institutionId,campusId,type,money,balanceAfter,createTime,remark` | `{id,userId,type,typeTitle,logId,money,balanceAfter,remark,createTime}`(**无 institutionId/campusId** —— 流水表没有这两列,契约字段在数据层不可得) |
| 12 | `…,applicantId,approverId,createTime` | `…,requestUserId,responseUserId,createdAt` |
| 21 | `periodIncome,periodExpense,net,balanceDistribution[]` | `{balance,totalIncome,totalExpenditure,netReceivable,accounts,flowsByCampusSupported,…}` |
契约总原则是「**名字听前端的**」⇒ 按契约我需要改 4 个端点的 DTO;但:
- 端点 11 的 `institutionId/campusId` **在 `t_user_expense_log` 里不存在**(§3.8-f 约束 2),
改名字也变不出数据 ⇒ 需要契约侧删字段或改由 user→membership 反查(有性能与语义代价);
- 其余三个是纯改名(有前端读取路径影响)。
**按你的纪律"实现与文档冲突先报",我没有静默改。** 等你裁决后我一次性改 DTO(含 handler 用例同步)。
## 状态
go build / go vet / go test -short ./... 全绿;工作区干净;未 push。
0e7728215a 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 ./... 全绿。
ec174ee1ec 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 ./... 全绿。
- View comparison for these 34 commits »
13 hours ago