fix(tenant,log)!: 平台身份下用量统计算成全平台 + 5 处租户/日志缺陷

做「平台运营的租户管理」时挖出来的,其中第 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)——
判据是**看返回体里有没有新字段**,而不是"接口通了就算"。
This commit is contained in:
2026-10-13 14:04:48 +08:00
parent 9f6d26c650
commit b516a7c47b
10 changed files with 463 additions and 30 deletions
+71 -1
View File
@@ -21,7 +21,7 @@
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
而不是靠下次再想一遍)。
## 查九件事
## 查十件事
| # | 查什么 | 没接上的后果 |
|:--:|---|---|
@@ -34,6 +34,7 @@
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
| 10 | **后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案** | 日志页原样回显英文码(见下) |
### 第 7 项是 2026-10-09 补的(自己踩了一次)
@@ -58,6 +59,20 @@
后两项严格说不是"声明没接线",而是**同一族:坏了但没有任何工具会红**。
放这里是因为它想要的就是"一条命令把这一族扫一遍"。
### 第 10 项是 2026-10 补的(做租户管理与日志详情时)
日志页的 `actionText()` **查不到就原样回显那个码**:界面上出现 `AUTO_CLOSE`
这种没翻译的英文,编译 / lint / typecheck / review 全都不红。
加这一项时顺带发现了一件更值得记的事:`AUTO_CLOSE` **根本不在常量表里** ——
它在 `TaskService.autoClose()` 里是用字符串字面量写的。也就是说,
"后端常量 ↔ 前端清单"这种检查**本来就扫不到它**;写这个检查的同时
得先把字面量提成常量(`ACTION_AUTO_CLOSE`),检查才真的有覆盖。
**这一族检查的价值,取决于被检查的那一侧有没有被规范化。**
⚠️ 与第 7 项同一个教训:**加完必须反向验证**(删掉前端一条文案,它得红),
否则又是一个"扫不到却判为通过"。
## 它**不查**什么(免得被当成"全绿就没事")
@@ -108,6 +123,10 @@ PERM_NO_ROLE = {}
# PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
FE_PERM_EXCEPTION = {}
# 第 ⑩ 项:后端会写的日志动作码,却**有意**不在 PC 端给中文文案的。
# 现在没有例外 —— 少一个文案就会在日志页上原样回显英文码,没有"故意不翻译"的理由。
ACTION_TEXT_EXCEPTION = {}
ERROR_CODE_UNUSED = {
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
@@ -642,6 +661,57 @@ def main():
if not svg_bad:
say(' ✓ 全部能被解析为合法 XML')
# ── ⑩ 后端会写的日志动作码 ↔ PC 端的中文文案 ──────────────
#
# ⚠️ 为什么属于这一族:PC 端的 `actionText()` **查不到就原样回显那个码**
# (不是空白,也不报错)。于是日志页上会出现一个没翻译的英文码,
# 而编译 / lint / typecheck / review **全都不红** —— 只有人盯着那一列才看得出来。
#
# ⚠️ 加这一项时顺带发现的:`AUTO_CLOSE`(定时任务自动截止)**根本不在常量表里**,
# 是字符串字面量写的 —— 所以"常量 ↔ 前端清单"这种检查本来就扫不到它。
# 已提成 `OperationLogService.ACTION_AUTO_CLOSE`,这条检查才真的有覆盖。
#
# ⚠️ 刻意**只从 `OPERATION_ACTIONS` 这个数组里取**前端清单:同文件里
# `MODULE_OPTIONS` / `TARGET_TYPE_OPTIONS` 也是 `{ value: 'TASK' }` 的形状,
# 整文件扫会把 `FILE` / `TENANT` 这些模块名当成动作码收进来(假阳性)。
LOG_SERVICE = ('backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/'
'service/OperationLogService.java')
PC_LOG_API = 'frontend-pc/src/apis/xt/log.ts'
be_actions = set(re.findall(r'public static final String ACTION_\w+ = "([A-Z_]+)";',
strip_java_comments(read(LOG_SERVICE))))
blk = re.search(r'export const OPERATION_ACTIONS[^=]*=\s*\[(.*?)\n\]',
strip_js_comments(read(PC_LOG_API)), re.S)
fe_actions = set(re.findall(r"value:\s*'([A-Z_]+)'", blk.group(1) if blk else ''))
say('\n【⑩】日志动作码:后端常量 %d 个 / PC 端文案 %d 个'
% (len(be_actions), len(fe_actions)))
if not be_actions or not fe_actions:
# 解析失败必须**报红**,不能静默通过 —— 第 ⑦ 项就是栽在这里
# (重搭后写法变了,它"一个码都扫不到却判为通过")。
problems.append(('日志动作码', '解析失败',
'后端解析出 %d 个 / PC 端解析出 %d 个 —— 0 就是检查器自己失效了,'
'不是"没问题"' % (len(be_actions), len(fe_actions))))
say(' ✗ 解析失败(后端 %d / PC %d):0 表示检查器失效,先修检查器'
% (len(be_actions), len(fe_actions)))
else:
for code in sorted(be_actions - fe_actions):
if code in ACTION_TEXT_EXCEPTION:
notes.append('日志动作码 %s —— 已知例外:%s' % (code, ACTION_TEXT_EXCEPTION[code]))
say(' — %-18s %s' % (code, ACTION_TEXT_EXCEPTION[code]))
else:
problems.append(('日志动作码', code,
'后端会写它,但 PC 端 OPERATION_ACTIONS 没有文案'
'(日志页会原样显示这个英文码)'))
say(' ✗ %-18s 后端会写,PC 端没文案 → 界面回显英文码' % code)
for code in sorted(fe_actions - be_actions):
problems.append(('日志动作码', code,
'PC 端有文案,但后端没有任何 ACTION_* 常量等于它'
'(选中它永远筛不到东西)'))
say(' ✗ %-18s PC 端有文案,后端没这个码 → 选了永远筛不到' % code)
if be_actions == fe_actions:
say(' ✓ %d 个动作码两端逐字一致:%s'
% (len(be_actions), '、'.join(sorted(be_actions))))
# ── 汇总 ────────────────────────────────────────────────
print('\n' + '=' * 88)
if notes: