docs: 同步契约 §6.5~6.9 / §13.4、问题清单 F-112~F-124、交接摘要 §一·三十九

1) docs/03-接口设计.md:
   - §6.5 补 expireBefore **含当天**的语义与"格式不对返回 40001,不静默忽略筛选";补
     "列表 contactPhone 恒为 null"与"packageName 与内嵌 usage{} 不实现(200 行内嵌用量会变成
     N+1 统计)"。
   - §6.6 的 tenantType 由 3 值改回真实的 **4 值**(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)、
     packageId 由 ✅ 必填改为可选(DTO 里本来就是可选),并补副作用(发放 6 个内置角色 + 建管理员
     + 写 CREATE_TENANT 日志)。
   - §6.7 的响应示例原来写着 ocrCount=3105 / smsCount=420,**与实现恒 0 矛盾** —— 改成 0 并补齐
     契约没写的 notes 与 alerts.used/limit,再加三条口径:实时值 vs 周期、恒 0 的原因(编数字会让
     运营拿假数据去谈续费)、**只算这一个租户**(含"库里只有一所学校时看不出"这一点)。
   - **新增 §6.8 / §6.9**(PUT /tenants/{id} 与 GET /tenants/{id}):PUT 此前只在 §4.2 的总览表里
     出现过一次、全文**没有详情节**;GET 更是只登记在 §17.1 的"清单漏列"组里。§6.8 含字段表、
     "不传的字段不修改"、"tenantCode 不可修改"的理由,以及留痕对照表(status→0 是 DISABLE_TENANT,
     不是 UPDATE_TENANT)+ "这些日志行的 tenant_id 是 0,要用 target_type/target_id 查"的警告。
   - §13.4 的 Query 补 targetType(并说明为什么 targetType+targetId 才是最重要的查询方式:
     关键字全文匹配会把"改了它"和"顺带提到它"混在一起,而合规结论不能建立在这种匹配上);
     响应补 request_method / request_uri / extraJson;动作表由 9 行扩到**全集 27 个**并按来源分组
     (任务 / 名册 / 导出 / 角色 / 租户);写明 request_param / duration_ms 刻意不采集;
     并记下"一期只做查询、不做日志导出"这条**此前只写在 LogController 类注释里**的口径
     (导出等于把"谁在什么时候看了谁的身份证"这份名单落到本地磁盘,而导出行为本身也要留痕)。
   - 登录日志响应补 tenantId,并写明 userType 的权威枚举与 location 恒为 null。

2) docs/08-完成度盘点与问题清单.md:新增 **F-112~F-124** —— 用量越界(含"为什么本地完全看不出来")、
   PUT 明文写入加密列、两个枚举能建不能改、租户零留痕、expireBefore 差一天、AUTO_CLOSE 字面量、
   模块下拉里有死项、LogController 描述与事实相反、request_param/duration_ms 有列无写入点、
   schema-postgresql.sql 缺三张表、**"平台专属权限码 + 查带 tenant_id 的表"这个风险面**、
   登录日志缺租户列、userType 注释错位。F-112 标注为本轮最严重的一条。

3) docs/10-用户手册与客服话术.md:「三种角色,三个入口」→ **四种角色**(补平台运营,并写明它是
   唯一跨单位的角色、用"组织与账号/收集管理"这类单位内页面之前要先选"当前学校");
   §4 速查表的菜单名与路径同步(平台运维 → 日志,含两个页签;新增租户管理一行);
   附录的排查话术补 traceId("日志详情里能拿到,那是后端排查时最有用的一个线索")。

4) docs/交接摘要.md:新增 **§一·三十九**,九节 —— 一、口径变更(旧注释原文 + 推翻理由);
   二、用量越界那个 bug 的完整来龙去脉(权限码 → ignoreTable → 实测数据分布 → 修法与夹具);
   三、另外四条后端缺陷表;四、日志详情为什么不新增接口(轻字段 vs 全库 0 行有值的重字段);
   五、AUTO_CLOSE 与检查 ⑩ 的关系(**"这一族检查的价值取决于被检查的那一侧有没有被规范化"**);
   六、顺带修掉的五处;七、前端刻意用分段按钮而不是 NTabs 的两条原因;八、验证表 +
   "改了后端必须重新打包重启、判据是看返回体里有没有新字段"的提醒;九、约定同步。
   另把 E2E 断言总数 1595 → **1617**(四处)。

5) 根 AGENTS.md:接线检查说明补第 ⑩ 项,并写明做这一项时才发现 AUTO_CLOSE 是字符串字面量写的
   —— "提成常量"是让这个检查真正生效的前提。

验证:python scripts/verify-docs.py **22/22**;python scripts/check-wiring.py 未接线 **0 条**
(⑦ 11 个权限码 / ⑧ 0 处漏 import / ⑨ 3 个 SVG 全合法 / ⑩ 27 个动作码两端逐字一致);
python scripts/check-api-parity.py 契约 **119** / 已实现 118 / 缺口 1 —— **没变**,符合本轮
"不新增后端接口"的决定。
This commit is contained in:
2026-10-13 14:04:59 +08:00
parent 624a05aa66
commit 31e7483464
5 changed files with 303 additions and 22 deletions
+14 -1
View File
@@ -17,7 +17,7 @@
> | PC 业务菜单 | 12 | **10** |
> | 小程序页面 | 10 | **9** |
> | 原型屏 | 32 | **29** |
> | E2E 脚本 | 39 | **37**(断言 1698 → **1595 条**,0 失败 0 跳过) |
> | E2E 脚本 | 39 | **37**(断言 1698 → **1617 条**,0 失败 0 跳过) |
>
> **删除的三块**:① 内置角色「副班主任 `DEPUTY_TEACHER`」「审核员 `AUDITOR`」;
> ② **历史整理器**(`module/cleanup`,5 接口 + `cleanup:run` + `collect_cleanup_job` 表 + PC 页 + 原型屏);
@@ -485,6 +485,19 @@
| F-109 | ⚠️ **`Map.of(...)` 拼模板变量 → 一条通知都没发**:`Map.of` **不接受 null 值**,而"这个学生查不到姓名"完全正常(学生账号是扫码核验时建的,`real_name` 可能还没填)→ 抛 NPE → 被外层"通知失败不影响业务"的 try/catch 吞成**一行 warn**,于是**驳回通知全丢,而驳回本身照样成功**。这是 F-25「被吞掉的异常」的第二次:**降级逻辑越完善,这类错越安静** | 新增 `NotifyRequest.vars(...)`(null 值直接跳过 —— 模板渲染对"没有值"的处理本来就是渲染成空);姓名改为**优先取名册上的名字**(那才是"他在这个任务里叫什么")。发现方式:`e2e-m5-notify-wiring.py` 第一次跑就红了(标题对、正文里姓名是空的) |
| F-110 | ⚠️ **一条断言写成了"全库计数",于是它会在几天后突然变红**:`e2e-m7-schedule.py` 的"刚提交的不该被提醒"数的是**全库**的「审核超时」消息,而超时扫描是**跨租户**的 —— 库里只要存在别的老填报单(几天前别的脚本留下的),它就会照样提醒。本轮真的红了,而**代码一行都没改**(那些单子的 `submit_time` 终于超过 48 小时了) | 断言**限定到本次夹具的收件人与任务**(`user_id` + 正文里的任务名)。**教训:跨租户扫描类功能的断言必须"限定到自己的夹具"** —— 否则它测的是"库里干不干净",而那是另一件事(与 F-42「cleanup 静默失败污染后续脚本」同一族) |
| F-111 | ⚠️ **`ON CONFLICT (id) DO UPDATE` 不更新用户名 → 看起来像密码错**:E2E 夹具按 id upsert 测试账号,只更新了 `real_name`,于是上一轮残留的行保留着**旧用户名**,而脚本按新用户名登录 → `40012 账号或密码错误`。第一反应会去查密码哈希(而它是对的) | 夹具改为**固定用户名 + `username = EXCLUDED.username`**(幂等);已写进 `e2e-m5-notify-wiring.py` 的注释 |
| F-112 | 🔴 **`GET /tenants/{id}/usage` 在平台账号下算的是「全平台」的数**(做租户管理时发现,是本轮最严重的一条)。平台运营的 `tenantId` 是 `0`,而 `MybatisPlusConfig.FillTenantLineHandler.ignoreTable()` 对平台身份**直接返回 true —— 整张表的租户过滤被关掉**。于是 `TenantUsageService.distinctStudents()`(查 `collect_roster`)与存储量统计(查 `collect_file`)都**不带租户条件**,返回的是全平台的学生数与存储占用,却标着某一所学校的名字 —— 运营会拿着它去谈续费。⚠️ **而这个接口的调用者一定是平台运营**(`tenant:view` 在 `PermissionDef.platformOnlyCodes()` 里),所以它**从来只在"过滤被关掉"的上下文里跑过**。⚠️ 更麻烦的是它**在本地完全看不出来**:实测库里 `collect_roster` 34 行、`collect_file` 22 行**全在 `tenant_id=1`**,只有一所学校有数据时,错误值与正确值**完全相等**(`ActingTenantResolver` 的类注释描述的正是这个陷阱) | 已修:统计段包进 `TenantScope.callAs(tenantId, 0L, Set.of(), ...)` —— 切走之后 `isPlatformAdmin()` 变 false,租户过滤自动恢复(本仓既有机制,`OpenDataService` / `OpenPushService` 在用)。**没有**给查询手写 `tenant_id` 条件(那等于放弃拦截器的保护)。E2E 新增夹具(往另一个租户直插一行 `collect_roster`)+ 断言"接口值 == 库里 `where tenant_id=1` 的去重数",`e2e-m8-orgfile.py` 74/74 |
| F-113 | 🔴 **`PUT /tenants/{id}` 把手机号明文写进加密列 → 详情接口必然 500**:`create` 走 `codec.encrypt(...)`,而 `update` 是 `upd.setContactPhone(req.getContactPhone())`;DB 列注释写着「联系电话(AES加密存储)」。`AesGcmCipher.decrypt` 对非 Base64 输入直接抛(该类**没有 dev 旁路**),于是**存过一次电话,这所学校的详情页就永远打不开**。实测 `sys_tenant.id=1` 存的就是明文 `13800138000`(来源不可考:全仓库没有任何脚本 PUT 过租户电话,3 处 `contactPhone` 都是 `POST /tenants`) | 已修:非空才 `codec.encrypt(...)` 写入(**空 = 不修改**,不是清空);详情返回**脱敏值**(不给明文:这是全系统唯一一处加密存储的联系方式,可读且不留痕会与「看证件号明文必须留痕」自相矛盾)。**读路径必须容错**:解密失败按明文处理 + `log.warn`(历史行不做数据迁移 —— 迁移里拿不到 AES 密钥),否则详情页是"永久 500"这种看起来像系统坏了的故障 |
| F-114 | ⚠️ **租户的两个枚举值"能建不能改"**:`tenantType` 在 `update()` 里只放行 `1~3`,而 DDL / 实体注释 / 契约 / 前端下拉都是**4 值**(`1` 学校 `2` 认定机构 `3` 报名点 `4` 企业);`status` 只放行 `0/1`,而实体与 DDL 是 `0` 停用 / `1` 正常 / **`2` 试用** —— 前端的状态筛选按钮组里就有「试用」,选了永远是空列表 | 已修:`tenantType` 1~4、`status` 0/1/2,报错文案与实体注释逐字对齐;E2E 断言 `status=2` 与 `tenantType=4` 都能改 |
| F-115 | ⚠️ **租户的开通 / 修改 / 停用原先一条日志都不写**:`update()` 只有 `log.warn` / `log.info`(那是给运维看系统日志的,不是合规留痕)。F-1 的修改建议里明确点了「租户开通」是优先留痕点 —— 停用一所学校是这套系统里**影响面最大的单个动作** | 已修:新增模块 `TENANT`、动作 `CREATE_TENANT` / `UPDATE_TENANT` / `DISABLE_TENANT` / `ENABLE_TENANT`(停用与启用不并进 `UPDATE_TENANT`:出争议时第一个要看的就是这个区别);`before/after` 是 `k=v` 摘要(手机号只记"有没有设置"—— 密文写进日志既没用又难看,所以"换了一个号"改用请求里带没带它来补一条)。⚠️ 这些行的 `tenant_id` 是 **0**(平台身份),查"这所学校被谁停过"要用 `target_type=TENANT` + `target_id`,**不能用 `tenantId` 筛** —— 已写进 `MODULE_TENANT` 常量注释与契约 §6.8 |
| F-116 | ⚠️ **`expireBefore` 差一天**:`LocalDate.parse(...).atStartOfDay()` 与存进去的当天 `23:59:59` 比 → 「今天到期」的租户**查不出来**。同型的坑在 `LogController.parseTo` 上已经修过一次(那次是"查 9 月 30 日查不到当天")。另外格式不对时原先直接抛 50000 | 已修:`atTime(23,59,59)`(含当天);格式不对返回 `40001` 并说明格式 —— **刻意不学** `LogController.parseTo` 的"当没传":那两个是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";而这个值只由界面按当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了" |
| F-117 | ⚠️ **`AUTO_CLOSE`(定时任务自动截止)是字符串字面量,不在常量表里**:`TaskService.autoClose()` 直接传 `"AUTO_CLOSE"`。所以"后端 `ACTION_*` 常量 ↔ 前端文案"这种检查**本来就扫不到它** —— 而它正是量最大的那类任务日志(库里 TASK 1548 行里的大头),界面上会**原样回显这个英文码**。(`STATUS_CHANGE` 兜底名同样是字面量) | 已修:提成 `OperationLogService.ACTION_AUTO_CLOSE` / `ACTION_STATUS_CHANGE` 并在 `TaskService` 用常量;新增检查 `check-wiring.py` 第 ⑩ 项(27 个动作码两端逐字核对),**已反向验证**(删掉前端一条文案 → 报红并 exit 1)。**教训**:这一族检查的价值取决于被检查的那一侧有没有被规范化 |
| F-118 | ⚠️ **日志页的「模块」下拉里有两个永远筛不到的项**:`moduleOptions` 只有 4 项(ROSTER/TASK/AUDIT/EXPORT),其中 `AUDIT` / `EXPORT` **后端一个写入点都没有**,而真正会写的 `FILE`(代传材料)与 `ROLE`(6 个角色动作)**不在下拉里** —— 选中死项只会得到空表,让人以为"这段时间没有审核动作" | 已修:`apis/xt/log.ts` 导出 `MODULE_OPTIONS`(TASK/ROSTER/FILE/ROLE/TENANT)与 `TARGET_TYPE_OPTIONS`,口径写明"只列后端真有写入点的模块"。⚠️ 顺带登记:`MODULE_AUDIT` / `MODULE_EXPORT` / `MODULE_USER` / `MODULE_JOIN` 四个常量当前**零调用点**,其中 `MODULE_JOIN` 是需求收敛后遗留的死常量 |
| F-119 | ⚠️ **`LogController` 的 `/logins` 描述与事实相反**:`@Operation` 里写着「一期的登录日志只有 PC 账号密码登录…目前不写这张表(见 F-31)」,而 **F-31 已闭环**(`WechatLoginLogger` 会写 `login_type=2`)。这是"注释从正确的当时留到了不正确之后"那类问题 | 已修:描述改为按 `loginType`(`PASSWORD` / `WECHAT`)说明,并写明 `location` **恒为 null**(没接 IP 库) |
| F-120 | 📌 **`request_param` / `duration_ms` 有列、无写入点**:全库 1758 行里两列都是 **0 行有值**(`max(length(extra_json))=45`、`max(length(user_agent))=125`,而这两列从来没被 `setXxx` 过)。它们也不在 `OperationLogMapper.listOperations` 的 SELECT 里 | **不做**(已写进契约 §13.4):要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,而 `OperationLogService` 的类注释已论证过"合规留痕不用 AOP";界面上不展示、也不做筛选。本轮只把**轻字段** `request_method` / `request_uri` / `extra_json` 补进列表 SELECT(实测一页 20 行约 2 KB),因此**不需要**为详情新开接口 |
| F-121 | 📌 **`docs/db/schema-postgresql.sql` 缺三张表**:只含 `sys_tenant`,没有 `sys_operation_log` / `sys_login_log` / `sys_tenant_usage_snapshot`(这三张只在 Flyway 迁移 `V2` / `V9` / `V17` 里)。按它建库会缺日志与用量快照 | **本轮不动**(登记待办):Flyway 才是权威,那份 DDL 是给 DBA 看的快照,补齐它属独立一件事 |
| F-122 | 📌 **只在平台身份下可达、且查业务表的接口 = 租户越界的风险面**:F-112 暴露的是一个**模式**,不是一处笔误 —— 凡是"平台专属权限码 + 查带 `tenant_id` 的表",都会因为 `ignoreTable()` 对平台返回 true 而**静默跨租户** | 本轮把 `tenant` 模块逐接口过了一遍(`list` / `detail` / `create` / `update` / `usage`),**只有 `usage` 统计业务表**,已修;`logs` 模块自己显式处理了范围(`logScopeTenantId`)。**留下的排查线索**:以后新增平台专属接口时,先问一句"它查的是不是带 tenant_id 的表" |
| F-123 | ⚠️ **登录日志没有租户列 → 平台视角下这些行"不属于任何学校"**:`LoginLogVO` 11 个字段里没有 `tenantId`,而平台运营看到的日志是**跨单位**的(`log:viewAll`)。于是"某个账号什么时候登进来的"这个问题答不完整 —— 筛得了单位,却看不出每行属于哪个单位。`sys_login_log.tenant_id` 从 V2 建表起就有(`NOT NULL DEFAULT 0`),只是 VO 没带出来 | 已修:`LoginLogVO` 加 `tenantId`、`LoginLogMapper.listLogins` 的 SELECT 加 `l.tenant_id AS tenantId`、契约 §13.4 同步;界面上登录日志表在「时间」后加「单位」列(`0` → 平台),**仅平台身份显示**(学校管理员自己就是那个单位,加了是噪音) |
| F-124 | ⚠️ **`LoginLogVO.userType` 的 Javadoc 与真实枚举错位**:写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,而权威口径(`BuiltinRole.UserType`、`UserService.USER_TYPE_NAMES`、库列注释、《03》§2.7)是 **1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生**。实测库里 `user_type=1` 的行就是 `platform` 账号。这是**同一个坑的第三次出现**(该错位枚举曾导致"把班主任当成社会人员拒掉") | 已修:Javadoc 改为正确枚举 + 写明"权威来源是 `BuiltinRole.UserType`,别按模板那套映射"。前端本来就用对了(复用 `USER_TYPE_NAMES`,没按评审意见里那句错的口径写),所以这处只影响读代码的人 |
---