b516a7c47b
做「平台运营的租户管理」时挖出来的,其中第 1 条与那个功能本身无关、但严重得多。 1) 🔴 GET /tenants/{id}/usage 一直算的是**全平台**的数。平台运营的 tenantId 是 0,而 FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true —— 整张表的租户过滤被关掉, 于是 distinctStudents()(查 collect_roster)与存储量(查 collect_file)都不带租户条件, 返回全平台数字却标着某一所学校的名字,运营会拿它去谈续费。而这个接口的调用者**一定是** 平台运营(tenant:view 在 platformOnlyCodes 里),所以它从来只在"过滤被关掉"的上下文里跑过。 ⚠️ 本地完全看不出来:实测 34 行 collect_roster、22 行 collect_file 全在 tenant_id=1, 只有一所学校有数据时错误值与正确值**完全相等**。修法:统计段包进既有的 TenantScope.callAs, 切走后 isPlatformAdmin() 变 false、过滤自动恢复;**没有**手写 tenant_id 条件 —— 那等于 放弃拦截器的保护。 2) PUT /tenants/{id} 把手机号**明文**写进加密列(create 走 codec.encrypt,update 是裸赋值)。 AesGcmCipher.decrypt 对非 Base64 直接抛(该类没有 dev 旁路)→ 存过一次电话,这所学校的 详情页就永远打不开。库里 id=1 存的就是明文 13800138000。改为非空才加密写入(空 = 不修改), 详情返回脱敏值;**读路径必须容错**(解密失败按明文处理 + WARN),否则故障形态是"永久 500", 看起来像系统坏了、而不是"库里有一行旧数据"。历史明文行不做迁移(迁移里拿不到密钥)。 3) 租户两个枚举"能建不能改":tenantType 只放行 1~3(实体/DDL/契约/前端都是 4 值)、 status 只放行 0/1(实体有 2 试用,而前端的状态筛选按钮里就有「试用」,选了永远是空列表)。 已按真实枚举放开,报错文案与实体注释逐字对齐。 4) 开通/修改/停用租户原先一条日志都不写(只有给运维看的 log.warn/log.info)。新增 MODULE_TENANT 与 CREATE_TENANT/UPDATE_TENANT/DISABLE_TENANT/ENABLE_TENANT —— 停用与启用 不并进 UPDATE_TENANT:停用是这套系统里影响面最大的单个动作,出争议时第一个要看的就是这个 区别。⚠️ 这些行的 tenant_id 是 0(写日志用的是**操作者**的上下文),查"这所学校被谁停过" 要用 target_type=TENANT + target_id,**不能**用 tenantId 筛(反直觉,已写进常量注释)。 5) expireBefore 用 atStartOfDay() 与存进去的当天 23:59:59 比 → **差一天**,「今天到期」的租户 查不出来(同型的坑在 LogController.parseTo 上修过一次,那次是"查 9 月 30 日查不到当天")。 改为补到当天末尾。格式不对返回 40001 并说明格式 —— **刻意不学** parseTo 的"当没传": 那侧是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";这侧的值只由界面按 当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了"。 6) 顺带: - AUTO_CLOSE(定时任务自动截止)在 TaskService 里是**字符串字面量**、不在常量表里 —— 也就是说"后端常量 ↔ 前端文案"这类检查**本来就扫不到它**,而它正是量最大的那类任务日志, 界面上会原样回显英文码。提成常量(连带 STATUS_CHANGE 兜底名)。 - 日志列表 SELECT 补 request_method/request_uri/extra_json 三个**轻字段**(实测一页 20 行 约 2 KB),日志详情因此不需要新开接口;真正重的 request_param/duration_ms 全库 1758 行里 **0 行有值**(从来没有 setXxx 过),不采集。 - LoginLogVO 补 tenantId:平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是 哪所学校的,而"某个账号什么时候登进来的"一定得连着"哪个单位"一起回答。 - 修正 LoginLogVO.userType 的 Javadoc:原来写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」, 与权威口径(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生)错位 —— **同一个坑第三次出现**, 该错位枚举曾导致"把班主任当成社会人员拒掉"。 新增检查 scripts/check-wiring.py 第 ⑩ 项:后端会写的动作码 ↔ PC 端 OPERATION_ACTIONS 的文案, 逐字核对(缺一个文案,日志页就会原样回显英文码)。**已反向验证**:删掉一条文案 → 报红 + exit 1。 ⚠️ 写这个检查时踩到的坑:同文件里 MODULE_OPTIONS / TARGET_TYPE_OPTIONS 也是 { value: 'TASK' } 的形状,整文件扫会把模块名当成动作码收进来 —— 所以只从 OPERATION_ACTIONS 那个数组里取。 验证:python scripts/verify-all.py --with-e2e → **9 项全过 / 0 失败 / 0 跳过**(37 脚本 / 1617 断言)。e2e-m8-orgfile.py **74/74** —— 其中"用量只算这一所学校"这条**必须造夹具**才 证伪得了:它往新建的 E2E 租户**直插一行 collect_roster**(该表 NOT NULL 且无默认值的列只有 5 个, 不需要走导入夹具),再断言"接口值 == 库里 where tenant_id=1 的去重数",并**额外断言 "全平台去重数 > 一号学校去重数"**,否则这条断言是空转的。e2e-m5-logs.py 44/44(回归)。 重打包重启后再验一次:/logs/logins 返回 tenantId、/tenants/1 返回 138****8000(而不是 500)—— 判据是**看返回体里有没有新字段**,而不是"接口通了就算"。