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
+143 -2
View File
@@ -14,6 +14,11 @@
断言要看的是"**它承认自己没接**",而不是"字段存在"。
③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。
返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。
④ **租户用量必须只算"这一所学校"**。平台运营的 `tenantId` 是 0,而租户拦截器
对它**整张表关掉过滤**(`FillTenantLineHandler.ignoreTable`)—— 于是
`/tenants/{id}/usage` 会算成**全平台**的学生数与存储量,挂在某一所学校的名下。
⚠️ 这个 bug 在"库里只有一所学校有数据"时**与正确值完全相等**,光断言
"有值、非 null"永远发现不了 —— 所以本脚本要**先造出别的租户的数据**再断言。
## 还有一条不能省的边界
@@ -24,6 +29,7 @@
跑法:python scripts/e2e-m8-orgfile.py
"""
import argparse
import datetime
import importlib.util
import json
import os
@@ -38,6 +44,8 @@ PASSED = []
FAILED = []
CREATED_TASK_IDS = []
CREATED_ORG_NAMES = []
CREATED_TENANT_IDS = []
USAGE_FIXTURE_HASH_PREFIX = "e2e-usage-scope-"
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-of-roster.xlsx")
TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx")
TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-of.jpg")
@@ -250,6 +258,103 @@ def main():
check("学生看租户用量 → 403", b.get("code") in (40101, 40301, 403),
"code=%s" % b.get("code"))
# ── 2b. 详情脱敏 / 用量口径 / 变更留痕 / 到期日边界 ──
print("\n[2b] 租户:详情脱敏 / 用量口径 / 变更留痕 / 到期日边界")
# ① 详情接口必须可用,且联系电话是**脱敏值**。
# ⚠️ 修复前这条会 500:`update()` 把手机号**明文**写进了加密列,
# 而 `AesGcmCipher.decrypt` 对非 Base64 的输入直接抛 —— 库里 id=1 就是那种行。
det = body_of("GET", "/api/v1/tenants/1", ops_token)
check("⭐ 租户详情可用(历史明文手机号不能让详情页打不开)",
det.get("code") == 0, json.dumps(det, ensure_ascii=False)[:250])
check("详情给出**脱敏**后的联系电话(形如 139****1111)",
"****" in str((det.get("data") or {}).get("contactPhone")),
"contactPhone=%r" % ((det.get("data") or {}).get("contactPhone"),))
# ② 改手机号:落库必须是密文,并且能读回脱敏值(写得进 + 解得开)
upd = body_of("PUT", "/api/v1/tenants/1", ops_token, {"contactPhone": "13712345678"})
check("改联系电话成功", upd.get("code") == 0, json.dumps(upd, ensure_ascii=False)[:250])
stored_phone = sql("select contact_phone from sys_tenant where id=1;").strip()
check("⭐ 联系电话以**密文**落库(明文落库会让详情页从此永远打不开)",
stored_phone != "13712345678" and len(stored_phone) > 20, "stored=%r" % stored_phone)
det2 = body_of("GET", "/api/v1/tenants/1", ops_token)
check("⭐ 新写的号能读回脱敏值(证明密文与解密对得上,不是「写进去就算了」)",
(det2.get("data") or {}).get("contactPhone") == "137****5678",
"contactPhone=%r" % ((det2.get("data") or {}).get("contactPhone"),))
# ③ 到期日筛选:格式不对要**明确报错**,不能静默忽略
bad_exp = body_of("GET", "/api/v1/tenants?expireBefore=" + urllib.parse.quote("去年"), ops_token)
check("⭐ expireBefore 写错 → 明确报错(静默忽略会让人以为「我按到期日筛过了」)",
bad_exp.get("code") != 0, "code=%s msg=%s" % (bad_exp.get("code"), bad_exp.get("msg")))
# ④ 后面几条用一个**新建的**租户(不动 demo,免得影响别的用例)
today = datetime.date.today().isoformat()
_, created = R.call("POST", BASE + "/api/v1/tenants", ops_token, {
"tenantName": "E2E用量学校 " + suffix,
"tenantCode": "e2eusage" + suffix, # ⚠️ 必须以 e2e 开头,dev-clean-e2e.py 按它清
"tenantType": 1, "contactName": "E2E用量联系人", "contactPhone": "13900139009",
"expireTime": today, "adminUsername": "admin",
"adminRealName": "E2E用量管理员", "adminPhone": "13900139010",
})
ntid = ((created or {}).get("data") or {}).get("id")
check("建一所 E2E 租户(后面的断言靠它,收尾会清掉)", isinstance(ntid, int),
json.dumps(created, ensure_ascii=False)[:250])
if isinstance(ntid, int):
CREATED_TENANT_IDS.append(ntid)
# 开通就要留痕(F-1 点名「租户开通」是优先留痕点,而本模块原先一条都不写)
row = sql("select action || '|' || tenant_id from sys_operation_log "
"where target_type='TENANT' and target_id=%d order by id desc limit 1;" % ntid).strip()
check("⭐ 开通租户留痕(target_type=TENANT & target_id=租户 id)",
row.startswith("CREATE_TENANT|"), "最新一行=%r" % row)
check("⭐ 留痕的 tenant_id 是 **0**(平台身份),不是被操作的那所学校 —— "
"所以查「这所学校是谁开通的」要用 target,不能用 tenant_id 筛",
row.split("|")[-1] == "0", "row=%r" % row)
# 状态流转:2 试用 / 0 停用 / 1 启用,各自留痕(停用与启用不能并成一个动作)
for st, act in ((2, "UPDATE_TENANT"), (0, "DISABLE_TENANT"), (1, "ENABLE_TENANT")):
r = body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"status": st})
check("status=%d 能改(⚠️ status=2「试用」原先被拒:只放行 0/1,而前端有「试用」)" % st,
r.get("code") == 0, json.dumps(r, ensure_ascii=False)[:200])
got = sql("select action from sys_operation_log where target_type='TENANT' "
"and target_id=%d order by id desc limit 1;" % ntid).strip()
check("status→%d 的留痕动作是 %s" % (st, act), got == act, "实际=%r" % got)
r4 = body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"tenantType": 4})
check("⭐ tenantType=4(企业)能改(原先只放行 1~3,而前端下拉有 4 项)",
r4.get("code") == 0, json.dumps(r4, ensure_ascii=False)[:200])
# 到期日**含当天**:把到期日设成今天,`expireBefore=今天` 必须能查到它
body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"expireTime": today})
lst = body_of("GET", "/api/v1/tenants?expireBefore=%s&pageSize=200" % today, ops_token)
hit = [t.get("id") for t in ((lst.get("data") or {}).get("list") or [])]
check("⭐ expireBefore=今天 能查到「今天到期」的租户"
"(原先用 atStartOfDay() 比,差一天,查不到)",
ntid in hit, "今天=%s 命中 id=%s" % (today, hit[:10]))
# ⭐ 用量口径 —— 本段最重要的一条。
# 平台身份会让租户拦截器**整张表关掉过滤**,所以 `/tenants/{id}/usage`
# 曾经返回的是**全平台**的学生数与存储量。库里只有一所学校有数据时
# 它与正确值完全相等,所以这里必须先往**另一个租户**塞一行名册。
R.sql("insert into collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
"values (%d, -1, 'E2E用量范围', 'x', '%s%s');"
% (ntid, USAGE_FIXTURE_HASH_PREFIX, suffix))
own = int(sql("select count(distinct id_card_hash) from collect_roster "
"where tenant_id=1 and deleted=0;").strip() or 0)
allc = int(sql("select count(distinct id_card_hash) from collect_roster "
"where deleted=0;").strip() or 0)
u1 = (body_of("GET", "/api/v1/tenants/1/usage", ops_token).get("data") or {})
check("⭐ 平台看到的「1 号学校学生数」= 库里 tenant_id=1 的去重人数,**不是全平台**",
u1.get("studentCount") == own,
"接口=%s 库里 tenant_id=1 去重=%s 全平台去重=%s(接口等于后者就说明过滤被算丢了)"
% (u1.get("studentCount"), own, allc))
un = (body_of("GET", "/api/v1/tenants/%d/usage" % ntid, ops_token).get("data") or {})
check("⭐ 新租户看到的是**它自己**那一行(顺带证明不是恒 0)",
un.get("studentCount") == 1, "新租户 studentCount=%s" % un.get("studentCount"))
check("全平台确实不止 1 个(否则上面那条断言是空转的)", allc > own,
"全平台=%s 一号学校=%s" % (allc, own))
# ── 3. 文件夹具 ──
print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)")
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
@@ -476,6 +581,24 @@ def _make_jpeg():
f.write(base64.b64decode(b64))
_TENANT_TABLES = None
def _tables_with_tenant_id():
"""
带 `tenant_id` 的表 —— **动态枚举**,不手抄表名。
手抄的清单在"新增了一张业务表"那天就开始漏,而漏掉的那张表恰好很难被注意到
(租户没了、行还在)。`dev-clean-e2e.py` 是同一口径。
"""
global _TENANT_TABLES
if _TENANT_TABLES is None:
out = sql("SELECT table_name FROM information_schema.columns "
"WHERE table_schema='public' AND column_name='tenant_id'")
_TENANT_TABLES = [t.strip() for t in out.splitlines() if t.strip()]
return _TENANT_TABLES
def cleanup():
for tid in CREATED_TASK_IDS:
R.sql("delete from collect_validation_issue where task_id=%d;" % tid)
@@ -489,12 +612,30 @@ def cleanup():
R.sql("delete from collect_task where id=%d;" % tid)
for name in CREATED_ORG_NAMES:
R.sql("delete from sys_org where org_name = '%s';" % name)
R.sql("update sys_tenant set contact_name='张校长' where id=1;")
# 用量夹具(本脚本往 E2E 租户里插的那一行名册)
R.sql("delete from collect_roster where id_card_hash like '%s%%';" % USAGE_FIXTURE_HASH_PREFIX)
# 本脚本建的 E2E 租户:连带它的用户 / 角色 / 组织一起删。
# ⚠️ 日志行要单独删 —— 它们的 `tenant_id` 是 **0**(平台身份),
# 用 tenant_id 清是清不掉的,得按 target 清。
for tid in CREATED_TENANT_IDS:
R.sql("delete from sys_operation_log where target_type='TENANT' and target_id=%d;" % tid)
for table in _tables_with_tenant_id():
R.sql("delete from %s where tenant_id=%d;" % (table, tid))
R.sql("delete from sys_tenant where id=%d;" % tid)
del CREATED_TENANT_IDS[:]
# 联系人 / 联系电话复原:本条用例改过它们(手机号改完是**密文**,
# 所以复原成改动前那个明文值 —— 顺带保留"库里有一行历史明文"这个现状,
# 那正是详情接口的容错分支要处理的输入)
R.sql("update sys_tenant set contact_name='张校长', contact_phone='13800138000' where id=1;")
R.sql("delete from collect_message where user_id in (997201, 997202, 997299);")
for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG):
if os.path.exists(p):
os.remove(p)
print(" 已清理:任务 / 材料文件 / 测试组织 / 租户联系人复原 / 临时文件")
print(" 已清理:任务 / 材料文件 / 测试组织 / E2E 租户与用量夹具 / "
"租户联系人复原 / 临时文件")
if __name__ == "__main__":