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:
+129
-14
@@ -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` | 含义 |
|
||||
|---|---|
|
||||
|
||||
+14
-1
@@ -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`,没按评审意见里那句错的口径写),所以这处只影响读代码的人 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-3
@@ -8,17 +8,21 @@
|
||||
|
||||
---
|
||||
|
||||
## §1 三种角色,三个入口
|
||||
## §1 四种角色,四个入口
|
||||
|
||||
| 角色 | 入口 | 设备 | 主要做什么 |
|
||||
|---|---|---|---|
|
||||
| **学生** | 扫学校/班主任发的**二维码** | 手机(微信小程序 / H5) | 核对身份 → 填表 → 上传材料 → 提交 |
|
||||
| **班主任** | 微信小程序(同一套码,登录后进教师端) | 手机 | 看本班进度、审本班材料、代传、催报 |
|
||||
| **学校管理员** | 电脑浏览器打开管理端 | PC | 建任务、配表单与材料、导名册、终审、导出交付包 |
|
||||
| **平台运营** | 电脑浏览器打开管理端(账号**不用填单位编码**) | PC | 开通学校、改到期与联系人、停用学校、查全平台日志 |
|
||||
|
||||
**⚠️ 学生与老师的小程序是同一个**,靠账号角色区分。学生第一次要"核验身份"(填姓名+证件号),
|
||||
这一步就是**门禁**:名册里没有的人进不去(这是产品铁律,见 §5.1)。
|
||||
|
||||
**⚠️ 平台运营是唯一跨单位的角色**:它看的是**所有学校**的数据,所以在"组织与账号""收集管理"
|
||||
这类本来就是单位内的页面上,它必须先选"**当前学校**"(顶栏那个切换),选之前看到的是混在一起的。
|
||||
|
||||
---
|
||||
|
||||
## §2 学生操作手册
|
||||
@@ -199,7 +203,9 @@
|
||||
| 改学校/院系/班级结构 | 组织与账号 → 组织架构(也支持 Excel 导入) |
|
||||
| 加一个下拉选项(如多了种学历写法) | 配置 → 字典管理(**内置项不可改名**,要加就追加自定义项) |
|
||||
| 给某类人配一套能力范围 | 组织与账号 → 角色与授权(见 §4.5) |
|
||||
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 操作日志 |
|
||||
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 日志(「操作日志」页签,点某一行看详情) |
|
||||
| 查"某个账号什么时候登进来的、从哪登的" | 平台运维 → 日志 → 「登录日志」页签 |
|
||||
| 开通一所新学校 / 改到期日 / 停用学校 | 平台运维 → 租户管理(**仅平台运营可见**;停用前会二次确认) |
|
||||
| 查"通知到底发出去没有" | 收集管理 → 通知记录 |
|
||||
| 看某个任务的分班进度 | 工作台 → 数据看板(选一个任务) |
|
||||
|
||||
@@ -261,7 +267,8 @@ A:系统上限是 3 次(防反复折腾)。到上限后由班主任/管理
|
||||
3. **屏幕上的完整提示文字**(照抄,不要转述 —— 提示文案与错误码是一一对应的);
|
||||
4. **发生时间**(精确到分钟)。
|
||||
|
||||
有了这四样,技术同学可以直接去「操作日志」与「通知记录」里定位。
|
||||
有了这四样,技术同学可以直接去「日志」(平台运维菜单,含操作日志与登录日志两个页签)
|
||||
与「通知记录」里定位 —— 日志详情里能拿到 `traceId`,那是后端排查时最有用的一个线索。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+145
-2
@@ -2847,7 +2847,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
| 手段 | 结果 |
|
||||
|---|---|
|
||||
| `scripts/e2e-m10-role.py`(新增) | **77/77**:改授权后**同一个 token** 立刻生效 / 停用立刻失效 / 内置角色只读 / 越权与自锁 / 删除守卫 `40045` / 内置角色权限以代码为准 + 同步回填幂等 / 跨租户 |
|
||||
| `python scripts/verify-all.py --with-e2e` | **9 项全过 / 0 失败 / 0 跳过**;**37 个脚本 / 1595 条断言 / 0 失败**(2026-10 收敛删掉 2 个脚本后已重跑填数) |
|
||||
| `python scripts/verify-all.py --with-e2e` | **9 项全过 / 0 失败 / 0 跳过**;**37 个脚本 / 1617 条断言 / 0 失败**(2026-10 收敛删掉 2 个脚本后已重跑填数) |
|
||||
| 浏览器实测(真点) | 新建自定义角色 → 账号页角色候选出现它(且平台角色已不在候选里)→ 改权限落库(`sys_role_permission` 逐字核对)→ 删除(软删 + 授权/菜单行一并软删);截图留证 |
|
||||
| 机械校验 | `check-api-parity`(契约 **119** / 已实现 118 / 缺口 1)、`verify-docs` 22/22(原型 **29** 屏)、`check-wiring` 未接线 0、`check-entity-columns` 28 实体一致、`check-manual` 0 错 |
|
||||
| 前端 | `pnpm typecheck` 0 错、`eslint` 0 错(`views/role/**`、`apis/xt/role.ts` 与改动过的账号页) |
|
||||
@@ -2892,7 +2892,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
| PC 业务菜单 | 12 | **10** |
|
||||
| 小程序页面 | 10 | **9** |
|
||||
| 原型屏 | 32 | **29**(学生 10 / 班主任 9 / 管理端 10) |
|
||||
| E2E 脚本 | 39 | **37**(删 `e2e-m2-join.py`、`e2e-m7-cleanup.py`);断言 1698 → **1595 条** |
|
||||
| E2E 脚本 | 39 | **37**(删 `e2e-m2-join.py`、`e2e-m7-cleanup.py`);断言 1698 → **1617 条** |
|
||||
|
||||
> ⚠️ **两个容易算错的数**(都踩过):
|
||||
> ① **契约是 119,不是 116**。116 是"删 13 个**代码端点**"(cleanup 8 + join 5),
|
||||
@@ -3183,4 +3183,147 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
根 `AGENTS.md` 的接线检查说明补上第 ⑨ 项。
|
||||
|
||||
|
||||
## §一·三十九 平台运营的租户管理 + 日志详情(2026-10)
|
||||
|
||||
**起因**:用户要求「设计开发平台运营人员对租户的管理功能,还有日志的详情查看功能」。
|
||||
|
||||
### 一、口径变更(**这一节最先要看的东西**)
|
||||
|
||||
`frontend-pc/src/views/tenant/index.vue` 原先头部写着:
|
||||
|
||||
> ⚠️ 列表**没有操作列**:不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)
|
||||
|
||||
**这条口径本轮被用户推翻**(选的是「全量:详情 + 用量 + 编辑 + 停用/启用」)。
|
||||
所以列表现在有操作列(详情 / 编辑 / 停用·启用),并且新增了详情抽屉与编辑弹窗。
|
||||
|
||||
⚠️ **变更理由要一起记下来,否则下一个人读到旧注释会以为是 rebase 丢的**:
|
||||
这条旧口径的动机是"少一个误操作入口",而运营真正需要的是**改到期日**(续费)
|
||||
与**停用学校**(欠费 / 退租)—— 这两个动作没有入口时,只能改数据库。
|
||||
|
||||
另外三条已确认的口径(都是"不做什么",比做什么更容易被顺手破坏):
|
||||
**套餐只读**(全库没有 `sys_package` 表,`package_id` 是无外键裸 bigint)、
|
||||
**不补原型屏**(`docs/prototype/admin-web.html` 保持 10 屏,`合计屏数 : 29` 不动)、
|
||||
**不新增后端接口**(契约 119 不变)。
|
||||
|
||||
### 二、本轮最关键的一件事:用量接口算的是「全平台」的数(F-112)
|
||||
|
||||
**这不是顺手发现的,是审查计划时逐条对照代码挖出来的**,而且它比原计划里所有 UI 改动都严重:
|
||||
|
||||
- 调用方一定是平台运营(`/tenants/{id}/usage` 挂 `tenant:view`,而它在
|
||||
`PermissionDef.platformOnlyCodes()` 里);平台运营的 `tenantId` 是 **0**;
|
||||
- 而 `MybatisPlusConfig.FillTenantLineHandler.ignoreTable()` 对平台身份**直接返回 true**
|
||||
—— **整张表的租户过滤被关掉**;
|
||||
- 于是 `TenantUsageService.distinctStudents()`(查 `collect_roster`)与存储量统计
|
||||
(查 `collect_file`)都**不带租户条件** → 返回全平台的学生数与存储占用,
|
||||
却标着某一所学校的名字。
|
||||
|
||||
**⚠️ 它在本地完全看不出来**:实测库里 `collect_roster` 34 行、`collect_file` 22 行
|
||||
**全在 `tenant_id=1`** —— 只有一所学校有数据时,错误值与正确值**完全相等**。
|
||||
`ActingTenantResolver` 的类注释描述的正是这个陷阱("当只有一所学校有数据时,
|
||||
它和学校管理员看到的一模一样")。
|
||||
|
||||
**修法与验证**:
|
||||
- 统计段包进 `TenantScope.callAs(tenantId, 0L, Set.of(), ...)`(本仓既有机制,
|
||||
`OpenDataService` / `OpenPushService` 在用)。**没有**手写 `tenant_id` 条件 ——
|
||||
那等于放弃拦截器的保护;切走上下文之后 `isPlatformAdmin()` 变 false,过滤自动恢复。
|
||||
- E2E **必须有夹具**才证伪得了:`e2e-m8-orgfile.py` 往新建的 E2E 租户**直插一行
|
||||
`collect_roster`**(该表 NOT NULL 且无默认值的列只有 5 个,不需要走导入夹具),
|
||||
再断言"接口值 == 库里 `where tenant_id=1` 的去重数",并**额外断言
|
||||
`全平台去重数 > 一号学校去重数`** —— 否则这条断言是空转的(这次实测两值确实不等)。
|
||||
结果 **74/74**。
|
||||
|
||||
### 三、另外四条后端缺陷(都在同一次改动里修掉)
|
||||
|
||||
| # | 缺陷 | 后果 |
|
||||
|---|---|---|
|
||||
| F-113 | `PUT /tenants/{id}` 把手机号**明文**写进加密列(`create` 是加密的) | `AesGcmCipher.decrypt` 解不开 Base64 直接抛 → **存过一次电话,这所学校详情页永远打不开**。实测 `id=1` 存的就是明文 `13800138000` |
|
||||
| F-114 | `tenantType` 只放行 1~3(实体/DDL/契约/前端都是 **4 值**);`status` 只放行 0/1(实体有 **2 试用**,前端筛选按钮里就有) | 这两类值**能建不能改**,选了「试用」永远是空列表 |
|
||||
| F-115 | 开通 / 修改 / 停用租户**一条日志都不写**(只有给运维看的 `log.warn`) | 停用一所学校是这套系统里影响面最大的单个动作,却查不到是谁干的 |
|
||||
| F-116 | `expireBefore` 用 `atStartOfDay()` 比 → **差一天**,「今天到期」查不出来 | 续费提醒那类用法正好踩中 |
|
||||
|
||||
对 F-113 的读路径要特别说一句:**必须容错**(解密失败按明文处理 + `log.warn`),
|
||||
否则故障形态是"详情页永久 500",看起来像系统坏了而不是"库里有一行旧数据"。
|
||||
**历史明文行不做数据迁移** —— 迁移里拿不到 AES 密钥。
|
||||
|
||||
留痕口径(**容易记错**):新增 `MODULE_TENANT` 与 4 个动作码,
|
||||
`DISABLE_TENANT` / `ENABLE_TENANT` **不并进** `UPDATE_TENANT`(出争议时第一个要看的就是这个区别)。
|
||||
⚠️ 这些日志行的 `tenant_id` 是 **0**(写日志用的是**操作者**的上下文),
|
||||
查"这所学校被谁停过"要用 `target_type=TENANT` + `target_id`,
|
||||
**不能用 `tenantId` 筛** —— 这条已经写进常量注释与契约 §6.8,因为反直觉。
|
||||
|
||||
### 四、日志详情:为什么不新增接口
|
||||
|
||||
详情要的 `request_method` / `request_uri` / `extra_json` 都是**轻字段**
|
||||
(实测 `max(length(extra_json))=45`、`max(length(request_uri))=31`、`max(length(user_agent))=125`,
|
||||
一页 20 行约 2 KB),所以直接把三列补进 `listOperations` 的 SELECT 就行。
|
||||
|
||||
⚠️ **"别让重字段驮在列表上"这个理由在这里不成立**:真正重的 `request_param` /
|
||||
`duration_ms` **全库 1758 行里 0 行有值**(从来没有 `setXxx` 过)。
|
||||
新增 `GET /logs/operations/{id}` 反而要改契约 119 与 4 份文档。**所以没加。**
|
||||
|
||||
### 五、`AUTO_CLOSE` 与新的检查 ⑩(**这一族检查的价值取决于被检查的那一侧**)
|
||||
|
||||
日志页的 `actionText()` **查不到就原样回显那个码**。而 `AUTO_CLOSE`(定时任务自动截止)
|
||||
**根本不在常量表里** —— 它在 `TaskService.autoClose()` 里是**字符串字面量**写的。
|
||||
也就是说"后端常量 ↔ 前端清单"这种检查**本来就扫不到它**,而它正是量最大的那类任务日志。
|
||||
|
||||
所以这件事要做两步:① 把字面量提成常量(连带 `STATUS_CHANGE` 兜底名);
|
||||
② 新增 `check-wiring.py` 第 ⑩ 项(27 个动作码两端逐字核对)。
|
||||
**已反向验证**:删掉前端一条文案 → 报红 + exit 1。
|
||||
|
||||
⚠️ 写这个检查时踩到的坑:同文件里 `MODULE_OPTIONS` / `TARGET_TYPE_OPTIONS` 也是
|
||||
`{ value: 'TASK' }` 的形状,**整文件扫会把模块名当成动作码收进来** —— 所以只从
|
||||
`OPERATION_ACTIONS` 这个数组里取。
|
||||
|
||||
### 六、顺带修掉的五处
|
||||
|
||||
- **F-117**:上面的 `AUTO_CLOSE` 字面量。
|
||||
- **F-118**:日志页「模块」下拉里 `AUDIT` / `EXPORT` **后端一个写入点都没有**,
|
||||
而真会写的 `FILE` / `ROLE` **不在下拉里** —— 换成了导出的 `MODULE_OPTIONS`
|
||||
(口径:**只列后端真有写入点的**,否则选中死项只会得到空表,让人以为"这段时间没有审核动作")。
|
||||
- **F-119**:`LogController` 的 `/logins` 描述与事实相反(还写着"目前不写这张表(见 F-31)",
|
||||
而 F-31 已闭环)。
|
||||
- **F-123**:**登录日志没有租户列** —— 平台运营看到的日志是跨单位的(`log:viewAll`),
|
||||
而 `LoginLogVO` 里没有 `tenantId`,于是筛得了单位却看不出每行属于哪个单位。
|
||||
`sys_login_log.tenant_id` 从 V2 建表起就有,只是 VO 没带出来。
|
||||
已修(VO + mapper SELECT + 契约 + 界面加「单位」列,**仅平台身份显示**)。
|
||||
- **F-124**:`LoginLogVO.userType` 的 Javadoc 写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,
|
||||
与权威口径(`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生)错位 ——
|
||||
**这是同一个坑的第三次出现**(该错位枚举曾导致"把班主任当成社会人员拒掉")。
|
||||
已修,并在注释里写明权威来源。⚠️ 前端当时**没有**按评审意见里那句错的口径写、
|
||||
而是复用了 `USER_TYPE_NAMES` —— 这处只影响读代码的人。
|
||||
|
||||
### 七、前端一处刻意的选择:页面内 Tab 用**分段按钮**而不是 `NTabs`
|
||||
|
||||
日志页要做「操作日志 / 登录日志」两个页签,选了工具条上的分段按钮组,原因有两条:
|
||||
① `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
|
||||
否则表格高度算成 0、**一行都看不见**(本仓踩过);
|
||||
② naive 2.45.3 的 `NTabs` 用 `tab="..."` prop 只渲染注释节点、必须用 `#tab` 插槽。
|
||||
按钮组在视觉上就是两个页签,且不动高度链。**这条已写进 `frontend-pc/AGENTS.md`。**
|
||||
|
||||
### 八、验证
|
||||
|
||||
| 手段 | 结果 |
|
||||
|---|---|
|
||||
| `e2e-m8-orgfile.py` | **74/74**(租户口径断言现共 74 条:脱敏 / 密文 / 留痕 / 状态流转 / 4 值类型 / 到期日边界 / 用量越界) |
|
||||
| `e2e-m5-logs.py` | **44/44**(回归:日志留痕、脱敏、`targetType`+`targetId`、`from`/`to` 当天边界、越权 403) |
|
||||
| `check-wiring.py` | ⑦⑧⑨⑩ 全绿;**⑩ 反向验证过**(删一条文案 → 红 + exit 1) |
|
||||
| `check-api-parity.py` | 契约 119 / 已实现 118 / 缺口 1(**没变**,符合"不加接口"的决定) |
|
||||
| `verify-docs.py` | 22/22 |
|
||||
| `pnpm typecheck` / `pnpm lint` | 0 错 |
|
||||
|
||||
⚠️ **后端改了必须重新打包重启再验**:本轮有两次"改完忘了重打包"的空转风险
|
||||
(`LoginLogVO` 加字段那次、以及 `AUTO_CLOSE` 提常量那次)。
|
||||
判据是**看返回体里有没有新字段**,而不是"接口通了就算"——
|
||||
`/logs/logins` 返回 `tenantId`、`/tenants/1` 返回 `138****8000`(而不是 500),
|
||||
这两条同时成立才说明跑的是新 jar。
|
||||
|
||||
### 九、约定同步
|
||||
|
||||
`frontend-pc/AGENTS.md` 与 `.agents/skills/code-vue/SKILL.md` 新增两条:
|
||||
「详情用 `NDrawer` + `NDescriptions`,**不要为详情新开路由**」、
|
||||
「页面内页签用工具条分段按钮,写明高度链的原因」;
|
||||
根 `AGENTS.md` 的接线检查说明补第 ⑩ 项。
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user