From b516a7c47b6daf3a24d4326e3d85df115897b6d9 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 13 Oct 2026 14:04:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(tenant,log)!:=20=E5=B9=B3=E5=8F=B0=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E4=B8=8B=E7=94=A8=E9=87=8F=E7=BB=9F=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E6=88=90=E5=85=A8=E5=B9=B3=E5=8F=B0=20+=205=20=E5=A4=84?= =?UTF-8?q?=E7=A7=9F=E6=88=B7/=E6=97=A5=E5=BF=97=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 做「平台运营的租户管理」时挖出来的,其中第 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)—— 判据是**看返回体里有没有新字段**,而不是"接口通了就算"。 --- .../module/log/controller/LogController.java | 5 +- .../fill/module/log/dto/LogDtos.java | 38 ++++- .../module/log/mapper/LoginLogMapper.java | 2 +- .../module/log/mapper/OperationLogMapper.java | 2 + .../log/service/OperationLogService.java | 37 ++++- .../fill/module/task/service/TaskService.java | 7 +- .../module/tenant/service/TenantService.java | 70 ++++++++- .../tenant/service/TenantUsageService.java | 115 ++++++++++++-- scripts/check-wiring.py | 72 ++++++++- scripts/e2e-m8-orgfile.py | 145 +++++++++++++++++- 10 files changed, 463 insertions(+), 30 deletions(-) diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java index 93a9a39..3b28e62 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java @@ -147,9 +147,8 @@ public class LogController { @Operation(summary = "登录日志", description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。" + "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。" - + "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— " - + "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。" - + "所以「学生登录记录为空」是已知情况,不是查询条件写错了") + + "登录方式 `loginType`:`PASSWORD`(PC 账号密码)/ `WECHAT`(学生从小程序、H5 走微信授权)。" + + "⚠️ `location`(IP 归属地)一期**恒为 null** —— 没接 IP 库,返回 null 而不是编一个") public ApiResult> logins( @RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long userId, diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/dto/LogDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/dto/LogDtos.java index 2df49a3..929dcf6 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/dto/LogDtos.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/dto/LogDtos.java @@ -49,6 +49,25 @@ public final class LogDtos { private String userAgent; + /** + * 请求方法({@code GET} / {@code POST} / {@code PUT} / {@code DELETE})。 + * + *

与 {@link #requestUri}、{@link #extraJson} 一起是「日志详情」要用的三列 —— + * 它们都是**轻字段**(实测最大 45 / 125 字节级),所以列表直接带上, + * 不需要为详情再开一个按 id 查的接口。 + */ + private String requestMethod; + + /** 请求路径(不含 query string)。 */ + private String requestUri; + + /** + * 补充上下文(JSON 串),如批量操作命中的 id 列表 / 停用组织时的 {@code deactivated}。 + * + *

⚠️ 不是每一条都有(一期只有一部分动作带它)。 + */ + private String extraJson; + private Integer bizCode; private String traceId; @@ -74,11 +93,28 @@ public final class LogDtos { private Long id; + /** + * 登录者所属租户({@code 0} = 平台)。 + * + *

平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是哪所学校的 —— + * 而"某个账号什么时候登进来的"这个问题,在平台视角下**一定**要连着"哪个单位"一起回答。 + * {@code sys_login_log.tenant_id} 本来就有(V2 建表时的 {@code NOT NULL DEFAULT 0}), + * 只是这个 VO 此前没把它带出来。 + */ + private Long tenantId; + private Long userId; private String userName; - /** 用户类型:1 超管 / 2 管理员 / 3 教师 / 4 学生 */ + /** + * 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。 + * + *

⚠️ 这里以前写的是「1 超管 / 2 管理员 / 3 教师 / 4 学生」,**是错的**。 + * 权威来源是 {@code BuiltinRole.UserType} 与 {@code UserService.USER_TYPE_NAMES}, + * 库列注释({@code sys_user.user_type})也与它们一致 —— + * 别按 `教师/学生` 那套去映射,本仓库曾因此把班主任当成社会人员拒掉过一次。 + */ private Integer userType; /** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */ diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java index 9b34f4b..d570d53 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java @@ -45,7 +45,7 @@ public interface LoginLogMapper extends BaseMapper { @InterceptorIgnore(tenantLine = "true") @Select("""