diff --git a/AGENTS.md b/AGENTS.md index 83dd269..b177db3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具) python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑 ``` -- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**37 个脚本 / 1595 条断言**,以最近一次 +- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**37 个脚本 / 1617 条断言**,以最近一次 `verify-all --with-e2e` 的输出为准;2026-10 需求收敛删掉了 `e2e-m2-join.py` 与 `e2e-m7-cleanup.py`), 或一条命令 `python scripts/verify-all.py --with-e2e` - 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` + @@ -122,7 +122,10 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑 **模板里的 `` 组件有没有漏 import**(第 ⑧ 项 —— 漏了会被当未知元素渲染,样式整块丢、 编译与 lint 都不红)、 **`.svg` 资产是不是合法 XML**(第 ⑨ 项 —— SVG 注释里出现连续两个减号就 not well-formed, - 图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次))。 + 图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次)、 + **后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案**(第 ⑩ 项 —— 缺一个文案, + 日志页会**原样回显英文码**;做这一项时才发现 `AUTO_CLOSE` 是字符串字面量写的, + 所以"提成常量"是让这个检查真正生效的前提)。 ⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚), 只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)。 ⚠️ **检查器自己也会失效**:PC 端重搭后权限码的写法从 `permissions.includes` / `v-hasPerm` diff --git a/docs/03-接口设计.md b/docs/03-接口设计.md index c0706eb..cf09da6 100644 --- a/docs/03-接口设计.md +++ b/docs/03-接口设计.md @@ -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` | 含义 | |---|---| diff --git a/docs/08-完成度盘点与问题清单.md b/docs/08-完成度盘点与问题清单.md index cd8242c..1812de6 100644 --- a/docs/08-完成度盘点与问题清单.md +++ b/docs/08-完成度盘点与问题清单.md @@ -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`,没按评审意见里那句错的口径写),所以这处只影响读代码的人 | --- diff --git a/docs/10-用户手册与客服话术.md b/docs/10-用户手册与客服话术.md index 523877d..65c4565 100644 --- a/docs/10-用户手册与客服话术.md +++ b/docs/10-用户手册与客服话术.md @@ -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`,那是后端排查时最有用的一个线索。 --- diff --git a/docs/交接摘要.md b/docs/交接摘要.md index a31dc89..d98abe5 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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` 的接线检查说明补第 ⑩ 项。 + +