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:
+2
-3
@@ -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} */
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+2
@@ -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
|
||||||
|
|||||||
+36
-1
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记一条操作日志(**不抛异常**)。
|
* 记一条操作日志(**不抛异常**)。
|
||||||
|
|||||||
+5
-2
@@ -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));
|
||||||
|
|||||||
+66
-4
@@ -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());
|
||||||
|
|||||||
+100
-15
@@ -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
@@ -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
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user