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
+129 -14
View File
@@ -1187,37 +1187,109 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**权限**:运营 **Query**:`keyword`、`status`、`expireBefore`、分页
`expireBefore`(`yyyy-MM-dd`)**含当天** —— `expire_time` 存的是当天 `23:59:59`,
所以比较要补到当天末尾,否则"今天到期"的租户查不出来。格式不对返回 `40001`
(**不静默忽略筛选**:这个值只由界面按当前日期算出来,非法值只可能来自手搓请求)。
**响应**:`{ list: TenantVO[], total }`
⚠️ 列表里的 `contactPhone` **恒为 `null`** —— 联系电话以密文入库,列表接口不能成为
批量拉取联系方式的通道。脱敏值只有 6.9 给。
`packageName` 与内嵌 `usage{}`(§3.12 的简表里画了)**不实现**:200 行内嵌用量会变成 N+1 统计。
### 6.6 `POST /api/v1/tenants`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantName` | string | ✅ | — |
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd` |
| `tenantType` | int | ✅ | `1` 学校 / `2` 机构 / `3` 企业 |
| `contactName` / `contactPhone` | string | ✅ | 联系人 |
| `packageId` | int64 | ✅ | 套餐 |
| `expireTime` | string | ✅ | 到期时间 |
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd`;**入库时小写化** |
| `tenantType` | int | ✅ | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
| `contactName` / `contactPhone` | string | ✅ | 联系人;电话**加密入库** |
| `packageId` | int64 | | 套餐(可选,无外键) |
| `expireTime` | string | ✅ | 到期时间;只给日期时补成当天 `23:59:59` |
| `adminUsername` / `adminRealName` / `adminPhone` | string | ✅ | **同时创建首个管理员** |
**响应**:`{ "id": 1002, "adminInitialPassword": "Kp9#vR4t", "miniProgramScene": "t1002" }`
**副作用**:切到新租户的作用域发放 6 个内置角色、建管理员并绑定 `SCHOOL_ADMIN`;
写一条操作日志(动作 `CREATE_TENANT`,`target_type=TENANT`)。
### 6.7 `GET /api/v1/tenants/{id}/usage`
**Query**:`period`(`2026-09` 或 `2026Q3`)
**Query**:`period`(`2026-09` 或 `2026Q3`;不传 = 当月)
```json
{
"code": 0,
"data": {
"studentCount": 1240, "storageMb": 8420, "ocrCount": 3105, "smsCount": 420,
"tenantId": 1, "tenantName": "示范职业学校", "period": "2026-09",
"studentCount": 1240, "storageMb": 8420, "ocrCount": 0, "smsCount": 0,
"quota": { "studentLimit": 2000, "storageLimitMb": 20480, "ocrLimit": 10000, "smsLimit": 2000 },
"alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%" } ]
"alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%", "used": 8420, "limit": 20480 } ],
"notes": [
"ocrCount 恒为 0:M4 的识别通道还没接(等 OCR 选型),不是「没用量」",
"smsCount 恒为 0:短信通道还没有商务结论(降级链只实现到站内信)"
]
}
}
```
⚠️ 三条口径(都不是"实现细节",是**看数的人必须知道的**):
- `studentCount` / `storageMb` 是**实时**值(它们决定"还能不能再收人"),
`period` **只影响计费项**(`ocrCount` / `smsCount`)—— 界面必须写明,
否则会以为"2026Q3 的学生数"是历史值;
- `ocrCount` / `smsCount` 一期**恒为 0 并带 `notes` 说明**:
两条通道都还没接,编一个数字会让运营拿着假数据去谈续费;
- 统计**只算这一个租户**。这条不是废话:调用方一定是平台运营(`tenant:view` 是平台专属码),
而平台身份的 `tenantId` 是 `0`,租户拦截器对它**整张表关掉过滤** ——
实现里必须显式切到目标租户的上下文再统计,否则返回的是**全平台**的数
(库里只有一所学校有数据时,它与正确值完全相等,看不出来)。
### 6.8 `PUT /api/v1/tenants/{id}`
**权限**:运营
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantName` | string | | 不能是空白串 |
| `tenantType` | int | | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
| `contactName` | string | | — |
| `contactPhone` | string | | **留空 = 不修改**;非空才写,且写入的是**密文** |
| `packageId` | int64 | | — |
| `expireTime` | string | | `2027-12-31` 或 `2027-12-31T23:59:59` |
| `status` | int | | `0` 停用 / `1` 正常 / `2` 试用 |
**不传的字段不修改**(不是"清空")。**`tenantCode` 不可修改**:它出现在小程序扫码
落地页、域名解析与对接文档里,改了会让已经发出去的东西失效,而我们通知不到客户那边。
**留痕**(本接口必须留痕,因为它是这套系统里影响面最大的一类动作):
| 变更 | 动作码 |
|---|---|
| `status` → `0` | `DISABLE_TENANT`(该校所有学生与老师**立刻登不进来**,单独一个码) |
| `status` → `1` | `ENABLE_TENANT` |
| `status` → `2` | `UPDATE_TENANT` |
| 其它字段有变化 | `UPDATE_TENANT`(`before` / `after` 是 `k=v; k=v` 摘要) |
⚠️ 这些日志行的 `tenant_id` 是 **`0`**(写日志用的是**操作者**的上下文,平台运营的租户号是 0),
不是被操作的那所学校。要查"这所学校被谁停过",用 `target_type=TENANT` + `target_id=<租户 id>`,
**不能用 `tenantId` 筛**。
**响应**:`TenantVO`(同 6.9)
### 6.9 `GET /api/v1/tenants/{id}`
**权限**:运营 **响应**:`TenantVO`,其中 `contactPhone` 是**脱敏值**(形如 `139****1111`)
这是**唯一**能拿到联系电话的接口。为什么不给明文:这是全系统唯一一处"加密存储的联系方式",
若在详情里可读且不留痕,就与「看证件号明文必须留痕」(`VIEW_IDCARD_FULL`)这条既有规矩
自相矛盾;而脱敏值足够确认"档里有号",要改直接填新号(6.8 留空 = 不修改)。
⚠️ 库里存在**历史明文行**(早期版本的写入路径没有加密)。服务端对解密失败
按明文处理并记一条 WARN —— 否则一所学校的详情页会**永远打不开**,
而那种故障看起来像"系统坏了",不像"数据里有一行是旧的"。
---
## §7 任务与配置
@@ -2477,7 +2549,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
#### `GET /api/v1/logs/operations`
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetType`、`targetId`、`from` / `to`、分页
> `targetType` + `targetId` 是**最重要的查询方式**:靠关键字全文匹配会把
> 「改了它」和「顺带提到它」混在一起,而合规结论不能建立在这种匹配上。
>
> `from` / `to` 只给 10 位日期时,`to` 会补成当天 `23:59:59` —— 不补的话
> 「查 9 月 30 日」查不到当天的记录。
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
@@ -2496,28 +2574,55 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
{ "id": 70001, "operatorId": 10086, "operatorName": "王老师", "module": "ROSTER",
"action": "UPDATE_IDCARD", "targetType": "ROSTER", "targetId": 9012,
"beforeVal": "3610292009******10", "afterVal": "3610292009******99",
"ip": "10.1.2.3", "userAgent": "Chrome/120", "createTime": "2026-09-28T16:20:00+08:00" }
"ip": "10.1.2.3", "userAgent": "Chrome/120",
"requestMethod": "PUT", "requestUri": "/api/v1/roster/9012",
"extraJson": "{\"orgId\":43,\"taskId\":487}",
"bizCode": 0, "traceId": "3f9c…", "createTime": "2026-09-28T16:20:00+08:00" }
],
"total": 1
}
}
```
**必记的敏感操作**(PRD FR-506 / FR-1202)
**动作码全集**(`action`,权威来源是后端 `OperationLogService` 的 `ACTION_*` 常量,
PC 端 `apis/xt/log.ts` 的 `OPERATION_ACTIONS` 是它的文案表 —— 两端由
`scripts/check-wiring.py` 第 ⑩ 项**逐字核对**,缺一个界面上就会原样回显英文码)
| `action` | 场景 |
|---|---|
| `UPDATE_IDCARD` | **改名册证件号**(留痕强制) |
| `UPDATE_ROSTER_NAME` | 改名 |
| `DELETE_ROSTER` | 删名册项 |
| `PUBLISH_TASK` / `PAUSE_TASK` / `ARCHIVE_TASK` | 任务状态流转 |
| `PUBLISH_TASK` | 发布任务 |
| `PAUSE_TASK` / `RESUME_TASK` | 暂停 / 恢复("恢复"意味着曾经暂停过,必须与"发布"分开记) |
| `CLOSE_TASK` / `AUTO_CLOSE` | 手动截止 / **定时任务自动截止**(出争议时第一个要看的就是这个区别) |
| `STATUS_CHANGE` | 任务状态变更(兜底名:调用方说不出是哪个动作时才用) |
| `ARCHIVE_TASK` / `DELETE_TASK` | 归档 / 删除已归档任务 |
| `REVOKE_AUDIT` | **撤回审核** |
| `EXPORT_CREATE` | 创建导出 |
| `EXPORT_CREATE` / `EXPORT_DOWNLOAD` | 创建导出 / 下载导出文件 |
| `PROXY_UPLOAD` | 代传材料 |
| `VIEW_IDCARD_FULL` | **查看完整身份证号**(单独留痕) |
| `CONFIG_COMPRESS` | 改压缩配置 |
| `CREATE_ROLE` / `UPDATE_ROLE` / `DELETE_ROLE` | 角色的增删改 |
| `GRANT_ROLE` / `UPDATE_ROLE_MENU` / `TOGGLE_ROLE` | **改授权** / 改可见菜单 / 停用启用角色 |
| `CREATE_TENANT` / `UPDATE_TENANT` | 开通租户 / 改租户信息·套餐 |
| `DISABLE_TENANT` / `ENABLE_TENANT` | **停用租户**(该校所有人立刻登不进来)/ 恢复启用 |
**模块 `module`**:`TASK` / `ROSTER` / `FILE` / `ROLE` / `TENANT`。
(`AUDIT` / `EXPORT` / `USER` / `JOIN` 四个常量**当前没有写入点** —— 界面上的模块下拉
刻意不列它们:选中只会得到一张空表,让人以为"这段时间没有审核动作"。)
**脱敏**:`beforeVal` / `afterVal` 中的证件号**一律脱敏存储**,即使日志泄露也不暴露明文。
服务层还会再兜底脱敏一次并截断到 512 字符(两层都做,是因为"忘记脱敏"的后果不可逆)。
**两列刻意不采集**:`request_param`(请求参数)与 `duration_ms`(耗时)**没有任何写入点**
(全库 0 行有值)。要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,
而合规留痕本来就不需要它们 —— 界面上不展示、也不做筛选。
> **一期只做查询,不做日志导出**(`backend/.../module/log/controller/LogController.java` 的类注释):
> 导出等于把「谁在什么时候看了谁的身份证」这份名单落到本地磁盘上,
> 而"有人导出过这份日志"本身也要留痕 —— 一个追着自己尾巴的循环。
> 这条口径**只写在代码注释里**过一段时间,所以在这里也记一笔。
#### `GET /api/v1/logs/logins`
@@ -2525,7 +2630,17 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
**响应**:`{ list: [{ id, tenantId, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
⚠️ 三条口径:
- `tenantId`(`0` = 平台)**必须有**:平台运营看到的是跨单位的登录日志,
没有这一列就分不清一行是哪所学校的;
- `userType` 是 **`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生**
(权威来源是 `BuiltinRole.UserType` 与 `UserService.USER_TYPE_NAMES`,库列注释也一致)
—— **不要**按某些模板里那套「1 教师 / 2 学生」去映射;
- `location`(IP 归属地)**一期恒为 `null`**:没接 IP 库,留 null 而不是编一个。
界面**不要显示这一列**(否则整列空白,看起来像功能坏了)。
| `loginType` | 含义 |
|---|---|