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
@@ -147,9 +147,8 @@ public class LogController {
@Operation(summary = "登录日志", @Operation(summary = "登录日志",
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。" description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。" + "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— " + "登录方式 `loginType`:`PASSWORD`(PC 账号密码)/ `WECHAT`(学生从小程序、H5 走微信授权)。"
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。" + "⚠️ `location`(IP 归属地)一期**恒为 null** —— 没接 IP 库,返回 null 而不是编一个")
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins( public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long userId, @RequestParam(required = false) Long userId,
@@ -49,6 +49,25 @@ public final class LogDtos {
private String userAgent; private String userAgent;
/**
* 请求方法({@code GET} / {@code POST} / {@code PUT} / {@code DELETE})。
*
* <p>与 {@link #requestUri}、{@link #extraJson} 一起是「日志详情」要用的三列 ——
* 它们都是**轻字段**(实测最大 45 / 125 字节级),所以列表直接带上,
* 不需要为详情再开一个按 id 查的接口。
*/
private String requestMethod;
/** 请求路径(不含 query string)。 */
private String requestUri;
/**
* 补充上下文(JSON 串),如批量操作命中的 id 列表 / 停用组织时的 {@code deactivated}。
*
* <p>⚠️ 不是每一条都有(一期只有一部分动作带它)。
*/
private String extraJson;
private Integer bizCode; private Integer bizCode;
private String traceId; private String traceId;
@@ -74,11 +93,28 @@ public final class LogDtos {
private Long id; private Long id;
/**
* 登录者所属租户({@code 0} = 平台)。
*
* <p>平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是哪所学校的 ——
* 而"某个账号什么时候登进来的"这个问题,在平台视角下**一定**要连着"哪个单位"一起回答。
* {@code sys_login_log.tenant_id} 本来就有(V2 建表时的 {@code NOT NULL DEFAULT 0}),
* 只是这个 VO 此前没把它带出来。
*/
private Long tenantId;
private Long userId; private Long userId;
private String userName; private String userName;
/** 用户类型:1 超管 / 2 管理员 / 3 教师 / 4 学生 */ /**
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
*
* <p>⚠️ 这里以前写的是「1 超管 / 2 管理员 / 3 教师 / 4 学生」,**是错的**。
* 权威来源是 {@code BuiltinRole.UserType} 与 {@code UserService.USER_TYPE_NAMES},
* 库列注释({@code sys_user.user_type})也与它们一致 ——
* 别按 `教师/学生` 那套去映射,本仓库曾因此把班主任当成社会人员拒掉过一次。
*/
private Integer userType; private Integer userType;
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */ /** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
@@ -45,7 +45,7 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")
@Select(""" @Select("""
<script> <script>
SELECT l.id, l.user_id AS userId, SELECT l.id, l.tenant_id AS tenantId, l.user_id AS userId,
COALESCE(u.real_name, l.username) AS userName, COALESCE(u.real_name, l.username) AS userName,
u.user_type AS userType, u.user_type AS userType,
CASE l.login_type CASE l.login_type
@@ -63,6 +63,8 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
l.module, l.action, l.target_type AS targetType, l.target_id AS targetId, l.module, l.action, l.target_type AS targetType, l.target_id AS targetId,
l.before_val AS beforeVal, l.after_val AS afterVal, l.before_val AS beforeVal, l.after_val AS afterVal,
l.ip, l.user_agent AS userAgent, l.biz_code AS bizCode, l.trace_id AS traceId, l.ip, l.user_agent AS userAgent, l.biz_code AS bizCode, l.trace_id AS traceId,
l.request_method AS requestMethod, l.request_uri AS requestUri,
l.extra_json AS extraJson,
l.create_time AS createTime l.create_time AS createTime
FROM sys_operation_log l FROM sys_operation_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
@@ -67,8 +67,16 @@ public class OperationLogService {
public static final String MODULE_JOIN = "JOIN"; public static final String MODULE_JOIN = "JOIN";
public static final String MODULE_USER = "USER"; public static final String MODULE_USER = "USER";
public static final String MODULE_FILE = "FILE"; public static final String MODULE_FILE = "FILE";
/** 角色与授权(二期)。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */ /** 角色与授权。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
public static final String MODULE_ROLE = "ROLE"; public static final String MODULE_ROLE = "ROLE";
/**
* 租户(平台运营的「开通 / 修改 / 停用学校」)。
*
* <p>⚠️ 这类日志的 {@code tenant_id} 是 <b>0</b>(平台身份),不是被操作的学校 ——
* 因为写日志用的是<b>操作者</b>的上下文。查"这所学校被谁停过"要用
* {@code target_type=TENANT & target_id=<租户 id>},不能用 tenant_id 筛。
*/
public static final String MODULE_TENANT = "TENANT";
public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD"; public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME"; public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
@@ -80,6 +88,20 @@ public class OperationLogService {
public static final String ACTION_RESUME_TASK = "RESUME_TASK"; public static final String ACTION_RESUME_TASK = "RESUME_TASK";
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */ /** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK"; public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
/**
* 定时任务**自动**截止。
*
* <p>⚠️ 必须与 {@link #ACTION_CLOSE_TASK} 分开:出了争议时第一个要看的就是
* 「这个任务是到点自己关的,还是管理员手动关的」。
*/
public static final String ACTION_AUTO_CLOSE = "AUTO_CLOSE";
/**
* 任务状态变更(兜底动作名)。
*
* <p>只有在调用方**说不出**是哪个动作时才会用到;`patchStatus` 的注释解释了
* 为什么"发布"与"恢复"不能共用它(两者都是 →收集中,靠状态推会记成同一个动作)。
*/
public static final String ACTION_STATUS_CHANGE = "STATUS_CHANGE";
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK"; public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */ /** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
public static final String ACTION_DELETE_TASK = "DELETE_TASK"; public static final String ACTION_DELETE_TASK = "DELETE_TASK";
@@ -101,6 +123,17 @@ public class OperationLogService {
public static final String ACTION_UPDATE_ROLE_MENU = "UPDATE_ROLE_MENU"; public static final String ACTION_UPDATE_ROLE_MENU = "UPDATE_ROLE_MENU";
/** 停用 / 启用角色。 */ /** 停用 / 启用角色。 */
public static final String ACTION_TOGGLE_ROLE = "TOGGLE_ROLE"; public static final String ACTION_TOGGLE_ROLE = "TOGGLE_ROLE";
/** 开通租户(新建一所学校 + 它的内置角色与管理员账号)。 */
public static final String ACTION_CREATE_TENANT = "CREATE_TENANT";
/** 改租户信息与套餐(名称 / 类型 / 联系人 / 到期)。 */
public static final String ACTION_UPDATE_TENANT = "UPDATE_TENANT";
/**
* 停用租户 —— <b>该校所有学生与老师立刻登不进来</b>,
* 是这套系统里单个动作影响面最大的一类,所以单独一个动作码而不是并进 UPDATE。
*/
public static final String ACTION_DISABLE_TENANT = "DISABLE_TENANT";
/** 恢复启用租户。 */
public static final String ACTION_ENABLE_TENANT = "ENABLE_TENANT";
public static final String TARGET_ROSTER = "ROSTER"; public static final String TARGET_ROSTER = "ROSTER";
public static final String TARGET_TASK = "TASK"; public static final String TARGET_TASK = "TASK";
@@ -110,6 +143,8 @@ public class OperationLogService {
public static final String TARGET_USER = "USER"; public static final String TARGET_USER = "USER";
/** 角色({@code sys_role.id})。 */ /** 角色({@code sys_role.id})。 */
public static final String TARGET_ROLE = "ROLE"; public static final String TARGET_ROLE = "ROLE";
/** 租户({@code sys_tenant.id})。平台级动作的 target,见 {@link #MODULE_TENANT}。 */
public static final String TARGET_TENANT = "TENANT";
/** /**
* 记一条操作日志(**不抛异常**)。 * 记一条操作日志(**不抛异常**)。
@@ -688,7 +688,8 @@ public class TaskService {
* **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。 * **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。
*/ */
public void autoClose(Long taskId) { public void autoClose(Long taskId) {
patchStatus(taskId, CollectTask.STATUS_CLOSED, "AUTO_CLOSE"); patchStatus(taskId, CollectTask.STATUS_CLOSED,
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_AUTO_CLOSE);
} }
/** /**
@@ -718,7 +719,9 @@ public class TaskService {
if (from == status) { if (from == status) {
return; return;
} }
String act = action == null ? "STATUS_CHANGE" : action; String act = action == null
? cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_STATUS_CHANGE
: action;
operationLog.record(cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK, operationLog.record(cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id, act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id,
String.valueOf(from), String.valueOf(status)); String.valueOf(from), String.valueOf(status));
@@ -4,6 +4,7 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.PageResult; import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.exception.BizException; import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil; import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.tenant.dto.TenantCreateReq; import cn.tianbaotong.fill.module.tenant.dto.TenantCreateReq;
import cn.tianbaotong.fill.module.tenant.dto.TenantVO; import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant; import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
@@ -48,6 +49,7 @@ public class TenantService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final RoleProvisioner roleProvisioner; private final RoleProvisioner roleProvisioner;
private final SensitiveCodec codec; private final SensitiveCodec codec;
private final OperationLogService operationLog;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
// ── 列表 ────────────────────────────────────── // ── 列表 ──────────────────────────────────────
@@ -65,7 +67,10 @@ public class TenantService {
.orderByDesc(SysTenant::getId); .orderByDesc(SysTenant::getId);
if (expireBefore != null && !expireBefore.isBlank()) { if (expireBefore != null && !expireBefore.isBlank()) {
query.le(SysTenant::getExpireTime, LocalDate.parse(expireBefore).atStartOfDay()); // ⚠️ **含当天**:expire_time 存的是当天 23:59:59(见 parseExpire),
// 曾经用 atStartOfDay() 比 —— 于是"今天到期"的租户查不出来(差一天)。
// 同型的坑在 LogController.parseTo 上已经修过一次。
query.le(SysTenant::getExpireTime, parseExpireBefore(expireBefore));
} }
IPage<SysTenant> page = tenantMapper.selectPage(new Page<>(num, size), query); IPage<SysTenant> page = tenantMapper.selectPage(new Page<>(num, size), query);
@@ -120,19 +125,76 @@ public class TenantService {
log.info("已开通租户 {}(id={}),管理员账号 {}", code, tid, req.getAdminUsername()); log.info("已开通租户 {}(id={}),管理员账号 {}", code, tid, req.getAdminUsername());
// 留痕(F-1 点名「租户开通」是优先留痕点)。
// ⚠️ 这行的 tenant_id 是 **0**(平台身份,不是刚建的这所学校)—— 写日志用的是
// 操作者的上下文,而 TenantScope.runAs 已在 finally 里恢复了。
// 要查"这所学校是谁开通的",用 target_type=TENANT & target_id=tid。
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_CREATE_TENANT,
OperationLogService.TARGET_TENANT, tid, null,
"tenantCode=" + code + "; name=" + t.getTenantName() + "; type=" + t.getTenantType());
return new CreateResult(tid, initialPassword, "t" + tid); return new CreateResult(tid, initialPassword, "t" + tid);
} }
/**
* 租户详情。
*
* <p>与列表的唯一区别:<b>这里返回联系电话的脱敏值</b>(列表恒为 {@code null},见 {@link #toVo})。
*
* <p>为什么给脱敏值而不是明文:这是全系统唯一一处「加密存储的联系方式」,
* 若在详情里可读且不留痕,就与「看证件号明文必须留痕」({@code VIEW_IDCARD_FULL})
* 这条既有规矩自相矛盾。脱敏号足够确认"档里有号",要改直接填新号 ——
* {@code PUT} 留空 = 不修改,所以界面不需要回填明文。
*/
public TenantVO detail(Long id) { public TenantVO detail(Long id) {
SysTenant t = tenantMapper.selectById(id); SysTenant t = tenantMapper.selectById(id);
if (t == null) { if (t == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在");
} }
return toVo(t); TenantVO vo = toVo(t);
String plain = plainContactPhone(t.getContactPhone());
vo.setContactPhone(plain == null ? null : MaskUtil.maskPhone(plain));
return vo;
} }
// ── 内部 ────────────────────────────────────── // ── 内部 ──────────────────────────────────────
/**
* 解析 {@code expireBefore}({@code yyyy-MM-dd},**含当天**)。
*
* <p>⚠️ 格式不对时**明确报错**,不学 {@code LogController.parseTo} 的"当没传":
* 那两个是界面上的文本框自由输入,静默忽略是为了"输错了也别让人看不到日志";
* 而这里的值只由界面按当前日期算出来,非法值只可能来自手搓请求 ——
* 静默忽略筛选会让人以为"我按到期日筛过了",那正是本仓反复批评的模式。
*/
private static LocalDateTime parseExpireBefore(String s) {
try {
return LocalDate.parse(s.trim()).atTime(23, 59, 59);
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "到期日期格式不对,应为 2026-12-31");
}
}
/**
* 取联系电话明文(**只给详情用**)。
*
* <p>这一列是加密列,但库里存在历史明文行(本机 id=1 就是),直接 {@code decrypt}
* 会在 GCM 的 Base64 解码处抛 —— 而"解密失败"在这里不该变成 500:
* 那会让一所学校的详情页**永远打不开**。所以失败时按明文返回,
* 并记一条 WARN 让"还剩多少明文行"可见(历史行不做数据迁移:迁移里拿不到 AES 密钥)。
*/
private String plainContactPhone(String stored) {
if (stored == null || stored.isBlank()) {
return null;
}
try {
return codec.decrypt(stored);
} catch (RuntimeException e) {
log.warn("租户联系电话不是密文(历史明文行或密钥变更过),按明文返回:{}", e.toString());
return stored;
}
}
private static LocalDateTime parseExpire(String s) { private static LocalDateTime parseExpire(String s) {
try { try {
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59" // 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
@@ -151,8 +213,8 @@ public class TenantService {
vo.setTenantName(t.getTenantName()); vo.setTenantName(t.getTenantName());
vo.setTenantType(t.getTenantType()); vo.setTenantType(t.getTenantType());
vo.setContactName(t.getContactName()); vo.setContactName(t.getContactName());
// 联系电话以密文入库,列表页要求脱敏展示时的处理在导出/详情里做, // 联系电话以密文入库:**列表恒不返回**(脱敏值也不行),
// 这里不返回明文,避免列表接口成为批量拉取联系方式的通道 // 避免列表接口成为批量拉取联系方式的通道。脱敏值只有 detail() 给。
vo.setContactPhone(null); vo.setContactPhone(null);
vo.setPackageId(t.getPackageId()); vo.setPackageId(t.getPackageId());
vo.setExpireTime(t.getExpireTime()); vo.setExpireTime(t.getExpireTime());
@@ -4,10 +4,13 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException; import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.file.entity.CollectFile; import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.mapper.FileMapper; import cn.tianbaotong.fill.module.file.mapper.FileMapper;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster; import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant; import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper; import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.tianbaotong.fill.support.TenantScope;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -18,6 +21,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* 租户用量统计(《03-接口设计》§6.7)。 * 租户用量统计(《03-接口设计》§6.7)。
@@ -45,6 +49,8 @@ public class TenantUsageService {
private final TenantMapper tenantMapper; private final TenantMapper tenantMapper;
private final CollectRosterMapper rosterMapper; private final CollectRosterMapper rosterMapper;
private final FileMapper fileMapper; private final FileMapper fileMapper;
private final SensitiveCodec codec;
private final OperationLogService operationLog;
/** 默认套餐额度(没有配套餐时的兜底)。 */ /** 默认套餐额度(没有配套餐时的兜底)。 */
private static final long DEFAULT_STUDENT_LIMIT = 2000L; private static final long DEFAULT_STUDENT_LIMIT = 2000L;
@@ -64,15 +70,22 @@ public class TenantUsageService {
vo.setTenantName(t.getTenantName()); vo.setTenantName(t.getTenantName());
vo.setPeriod(p.text()); vo.setPeriod(p.text());
// 学生数:**按证件号去重**(同一个人可能在多个任务里各有一行名册) // ⚠️ 这两项必须**切到目标租户的上下文里**再查,不能直接用当前请求的身份查。
vo.setStudentCount(distinctStudents()); //
// 原因:本接口只有平台运营能调(@RequirePerm(TENANT_VIEW),那个码在
// 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用) // PermissionDef.platformOnlyCodes() 里),而平台运营的 tenantId 是 0 ——
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery() // FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true,
.select(CollectFile::getFinalSize)) // **整张表的租户过滤被关掉**。于是"学生数 / 存储量"会算成**全平台**的数,
.stream().map(CollectFile::getFinalSize) // 却挂在某一所学校的名下:运营拿着它去谈续费,而数字是假的。
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum(); //
vo.setStorageMb(bytes / 1024 / 1024); // 这个 bug 在本地看不见 —— 库里只有一所学校有数据时,它与正确值完全相等
// (ActingTenantResolver 的注释里描述的是同一个陷阱)。
// 切成目标租户之后 isPlatformAdmin() 变 false,租户过滤自动恢复,
// 不需要给这些查询手写 tenant_id 条件(那样等于放弃拦截器的保护)。
Scope scope = TenantScope.callAs(tenantId, 0L, Set.of(),
() -> new Scope(distinctStudents(), storageMb()));
vo.setStudentCount(scope.students());
vo.setStorageMb(scope.storageMb());
// OCR / 短信:一期没有通道,如实返回 0(见类注释) // OCR / 短信:一期没有通道,如实返回 0(见类注释)
vo.setOcrCount(0); vo.setOcrCount(0);
@@ -94,6 +107,19 @@ public class TenantUsageService {
return vo; return vo;
} }
/** 一次上下文切换里取到的两个「本来就是租户内」的数字。 */
private record Scope(long students, long storageMb) {
}
/** 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)。 */
private long storageMb() {
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
.select(CollectFile::getFinalSize))
.stream().map(CollectFile::getFinalSize)
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
return bytes / 1024 / 1024;
}
/** 学生数:按 `id_card_hash` 去重(同一个人在多任务里只算一次)。 */ /** 学生数:按 `id_card_hash` 去重(同一个人在多任务里只算一次)。 */
private long distinctStudents() { private long distinctStudents() {
// 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法: // 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法:
@@ -152,6 +178,7 @@ public class TenantUsageService {
if (req == null) { if (req == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空");
} }
Integer oldStatus = exist.getStatus();
SysTenant upd = new SysTenant(); SysTenant upd = new SysTenant();
upd.setId(id); upd.setId(id);
if (req.getTenantName() != null) { if (req.getTenantName() != null) {
@@ -161,21 +188,34 @@ public class TenantUsageService {
upd.setTenantName(req.getTenantName().trim()); upd.setTenantName(req.getTenantName().trim());
} }
if (req.getTenantType() != null) { if (req.getTenantType() != null) {
if (req.getTenantType() < 1 || req.getTenantType() > 3) { // 取值以 SysTenant.tenantType 的注释为准(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)。
// ⚠️ 这里曾经只放行 1~3,而 DDL 默认值与实体注释都是 4 值 ——
// 于是"认定机构 / 报名点 / 企业"在前端下拉里选得到,保存却报错。
if (req.getTenantType() < 1 || req.getTenantType() > 4) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"租户类型只能是 1(学校)/ 2(机构)/ 3(企业)"); "租户类型只能是 1(学校)/ 2(认定机构)/ 3(报名点)/ 4(企业)");
} }
upd.setTenantType(req.getTenantType()); upd.setTenantType(req.getTenantType());
} }
upd.setContactName(req.getContactName()); upd.setContactName(req.getContactName());
upd.setContactPhone(req.getContactPhone()); // ⚠️ contact_phone 是**加密列**(见 V1__business_tables.sql 的列注释)。
// 这里曾经是 `upd.setContactPhone(req.getContactPhone())` —— 明文直接落库,
// 而读回来的路径会在 AesGcmCipher.decrypt 里 Base64 解不开直接抛,
// 表现是「保存成功,但详情页从此打不开」。空值 = **不修改**(不是清空)。
if (req.getContactPhone() != null && !req.getContactPhone().isBlank()) {
upd.setContactPhone(codec.encrypt(req.getContactPhone().trim()));
}
upd.setPackageId(req.getPackageId()); upd.setPackageId(req.getPackageId());
upd.setExpireTime(req.getExpireTime()); upd.setExpireTime(req.getExpireTime());
upd.setLogoUrl(req.getLogoUrl()); upd.setLogoUrl(req.getLogoUrl());
upd.setConfigJson(req.getConfigJson()); upd.setConfigJson(req.getConfigJson());
if (req.getStatus() != null) { if (req.getStatus() != null) {
if (req.getStatus() != 0 && req.getStatus() != 1) { // 取值以 SysTenant.status 的注释为准(0 停用 / 1 正常 / 2 试用)。
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(启用)"); // ⚠️ 这里曾经只放行 0/1 —— 而前端的状态筛选按钮组与 TENANT_STATUS 都有"试用",
// 结果是"试用"建不出来也改不出来。
if (req.getStatus() != 0 && req.getStatus() != 1 && req.getStatus() != 2) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"状态只能是 0(停用)/ 1(正常)/ 2(试用)");
} }
upd.setStatus(req.getStatus()); upd.setStatus(req.getStatus());
if (req.getStatus() == 0) { if (req.getStatus() == 0) {
@@ -187,7 +227,52 @@ public class TenantUsageService {
tenantMapper.updateById(upd); tenantMapper.updateById(upd);
log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(), log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(),
upd.getStatus(), upd.getExpireTime()); upd.getStatus(), upd.getExpireTime());
return tenantMapper.selectById(id);
SysTenant now = tenantMapper.selectById(id);
recordChange(id, oldStatus, exist, now, req);
return now;
}
/**
* 租户变更留痕。
*
* <p>《08-完成度盘点》的 F-1 点名「租户开通」是优先留痕点,而本模块此前
* <b>一条日志都不写</b> —— 开通一所学校、把到期日往后挪、停用一所学校,
* 都不留痕,这是整套系统里影响面最大的几类动作之一。
*/
private void recordChange(Long id, Integer oldStatus, SysTenant before, SysTenant after, SysTenant req) {
Integer newStatus = req.getStatus();
if (newStatus != null && !newStatus.equals(oldStatus)) {
String action = switch (newStatus) {
case 0 -> OperationLogService.ACTION_DISABLE_TENANT;
case 1 -> OperationLogService.ACTION_ENABLE_TENANT;
default -> OperationLogService.ACTION_UPDATE_TENANT;
};
operationLog.record(OperationLogService.MODULE_TENANT, action,
OperationLogService.TARGET_TENANT, id,
"status=" + oldStatus, "status=" + newStatus);
return;
}
// 手机号在库里是密文,摘要里只记「有没有设置」(写密文进日志既没用又难看),
// 所以"换了一个号"这件事比较不出来 —— 用请求里有没有带它来补一条。
boolean phoneChanged = req.getContactPhone() != null && !req.getContactPhone().isBlank();
String b = summary(before);
String a = summary(after) + (phoneChanged ? "; phone=已更新" : "");
if (!b.equals(a)) {
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_UPDATE_TENANT,
OperationLogService.TARGET_TENANT, id, b, a);
}
}
/** 变更摘要(`k=v; k=v`)。`OperationLogService.mask` 会再兜底脱敏一次并截断到列宽。 */
private static String summary(SysTenant t) {
return "name=" + t.getTenantName()
+ "; type=" + t.getTenantType()
+ "; contact=" + t.getContactName()
+ "; phone=" + (t.getContactPhone() == null || t.getContactPhone().isBlank() ? "-" : "已设置")
+ "; package=" + t.getPackageId()
+ "; expire=" + t.getExpireTime()
+ "; status=" + t.getStatus();
} }
// ── 周期解析 ── // ── 周期解析 ──
+71 -1
View File
@@ -21,7 +21,7 @@
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表, 逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
而不是靠下次再想一遍)。 而不是靠下次再想一遍)。
## 查九件事 ## 查十件事
| # | 查什么 | 没接上的后果 | | # | 查什么 | 没接上的后果 |
|:--:|---|---| |:--:|---|---|
@@ -34,6 +34,7 @@
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) | | 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) | | 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) | | 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
| 10 | **后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案** | 日志页原样回显英文码(见下) |
### 第 7 项是 2026-10-09 补的(自己踩了一次) ### 第 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 里没有的(**应当保持为空** —— # PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项) # 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
FE_PERM_EXCEPTION = {} FE_PERM_EXCEPTION = {}
# 第 ⑩ 项:后端会写的日志动作码,却**有意**不在 PC 端给中文文案的。
# 现在没有例外 —— 少一个文案就会在日志页上原样回显英文码,没有"故意不翻译"的理由。
ACTION_TEXT_EXCEPTION = {}
ERROR_CODE_UNUSED = { ERROR_CODE_UNUSED = {
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)', 'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
'ID_CARD_MISMATCH_ROSTER': '同上(M4)', 'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
@@ -642,6 +661,57 @@ def main():
if not svg_bad: if not svg_bad:
say(' ✓ 全部能被解析为合法 XML') 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) print('\n' + '=' * 88)
if notes: if notes:
+143 -2
View File
@@ -14,6 +14,11 @@
断言要看的是"**它承认自己没接**",而不是"字段存在"。 断言要看的是"**它承认自己没接**",而不是"字段存在"。
③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。 ③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。
返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。 返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。
④ **租户用量必须只算"这一所学校"**。平台运营的 `tenantId` 是 0,而租户拦截器
对它**整张表关掉过滤**(`FillTenantLineHandler.ignoreTable`)—— 于是
`/tenants/{id}/usage` 会算成**全平台**的学生数与存储量,挂在某一所学校的名下。
⚠️ 这个 bug 在"库里只有一所学校有数据"时**与正确值完全相等**,光断言
"有值、非 null"永远发现不了 —— 所以本脚本要**先造出别的租户的数据**再断言。
## 还有一条不能省的边界 ## 还有一条不能省的边界
@@ -24,6 +29,7 @@
跑法:python scripts/e2e-m8-orgfile.py 跑法:python scripts/e2e-m8-orgfile.py
""" """
import argparse import argparse
import datetime
import importlib.util import importlib.util
import json import json
import os import os
@@ -38,6 +44,8 @@ PASSED = []
FAILED = [] FAILED = []
CREATED_TASK_IDS = [] CREATED_TASK_IDS = []
CREATED_ORG_NAMES = [] 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_ROSTER = os.path.join(ROOT, "scripts", "_e2e-of-roster.xlsx")
TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx") TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx")
TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-of.jpg") 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), check("学生看租户用量 → 403", b.get("code") in (40101, 40301, 403),
"code=%s" % b.get("code")) "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. 文件夹具 ── # ── 3. 文件夹具 ──
print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)") print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)")
_, b = R.call("POST", BASE + "/api/v1/tasks", at, { _, b = R.call("POST", BASE + "/api/v1/tasks", at, {
@@ -476,6 +581,24 @@ def _make_jpeg():
f.write(base64.b64decode(b64)) 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(): def cleanup():
for tid in CREATED_TASK_IDS: for tid in CREATED_TASK_IDS:
R.sql("delete from collect_validation_issue where task_id=%d;" % tid) 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) R.sql("delete from collect_task where id=%d;" % tid)
for name in CREATED_ORG_NAMES: for name in CREATED_ORG_NAMES:
R.sql("delete from sys_org where org_name = '%s';" % name) 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);") R.sql("delete from collect_message where user_id in (997201, 997202, 997299);")
for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG): for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG):
if os.path.exists(p): if os.path.exists(p):
os.remove(p) os.remove(p)
print(" 已清理:任务 / 材料文件 / 测试组织 / 租户联系人复原 / 临时文件") print(" 已清理:任务 / 材料文件 / 测试组织 / E2E 租户与用量夹具 / "
"租户联系人复原 / 临时文件")
if __name__ == "__main__": if __name__ == "__main__":