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 = "登录日志",
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<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long userId,
@@ -49,6 +49,25 @@ public final class LogDtos {
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 String traceId;
@@ -74,11 +93,28 @@ public final class LogDtos {
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 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;
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
@@ -45,7 +45,7 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
@InterceptorIgnore(tenantLine = "true")
@Select("""
<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,
u.user_type AS userType,
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.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.request_method AS requestMethod, l.request_uri AS requestUri,
l.extra_json AS extraJson,
l.create_time AS createTime
FROM sys_operation_log l
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_USER = "USER";
public static final String MODULE_FILE = "FILE";
/** 角色与授权(二期)。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
/** 角色与授权。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
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_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_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";
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
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_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_TASK = "TASK";
@@ -110,6 +143,8 @@ public class OperationLogService {
public static final String TARGET_USER = "USER";
/** 角色({@code sys_role.id})。 */
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) {
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) {
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,
act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id,
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.exception.BizException;
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.TenantVO;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
@@ -48,6 +49,7 @@ public class TenantService {
private final UserMapper userMapper;
private final RoleProvisioner roleProvisioner;
private final SensitiveCodec codec;
private final OperationLogService operationLog;
private final PasswordEncoder passwordEncoder;
// ── 列表 ──────────────────────────────────────
@@ -65,7 +67,10 @@ public class TenantService {
.orderByDesc(SysTenant::getId);
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);
@@ -120,19 +125,76 @@ public class TenantService {
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);
}
/**
* 租户详情。
*
* <p>与列表的唯一区别:<b>这里返回联系电话的脱敏值</b>(列表恒为 {@code null},见 {@link #toVo})。
*
* <p>为什么给脱敏值而不是明文:这是全系统唯一一处「加密存储的联系方式」,
* 若在详情里可读且不留痕,就与「看证件号明文必须留痕」({@code VIEW_IDCARD_FULL})
* 这条既有规矩自相矛盾。脱敏号足够确认"档里有号",要改直接填新号 ——
* {@code PUT} 留空 = 不修改,所以界面不需要回填明文。
*/
public TenantVO detail(Long id) {
SysTenant t = tenantMapper.selectById(id);
if (t == null) {
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) {
try {
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
@@ -151,8 +213,8 @@ public class TenantService {
vo.setTenantName(t.getTenantName());
vo.setTenantType(t.getTenantType());
vo.setContactName(t.getContactName());
// 联系电话以密文入库,列表页要求脱敏展示时的处理在导出/详情里做,
// 这里不返回明文,避免列表接口成为批量拉取联系方式的通道
// 联系电话以密文入库:**列表恒不返回**(脱敏值也不行),
// 避免列表接口成为批量拉取联系方式的通道。脱敏值只有 detail() 给。
vo.setContactPhone(null);
vo.setPackageId(t.getPackageId());
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.module.file.entity.CollectFile;
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.entity.CollectRoster;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -18,6 +21,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 租户用量统计(《03-接口设计》§6.7)。
@@ -45,6 +49,8 @@ public class TenantUsageService {
private final TenantMapper tenantMapper;
private final CollectRosterMapper rosterMapper;
private final FileMapper fileMapper;
private final SensitiveCodec codec;
private final OperationLogService operationLog;
/** 默认套餐额度(没有配套餐时的兜底)。 */
private static final long DEFAULT_STUDENT_LIMIT = 2000L;
@@ -64,15 +70,22 @@ public class TenantUsageService {
vo.setTenantName(t.getTenantName());
vo.setPeriod(p.text());
// 学生数:**按证件号去重**(同一个人可能在多个任务里各有一行名册)
vo.setStudentCount(distinctStudents());
// 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
.select(CollectFile::getFinalSize))
.stream().map(CollectFile::getFinalSize)
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
vo.setStorageMb(bytes / 1024 / 1024);
// ⚠️ 这两项必须**切到目标租户的上下文里**再查,不能直接用当前请求的身份查。
//
// 原因:本接口只有平台运营能调(@RequirePerm(TENANT_VIEW),那个码在
// PermissionDef.platformOnlyCodes() 里),而平台运营的 tenantId 是 0 ——
// FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true,
// **整张表的租户过滤被关掉**。于是"学生数 / 存储量"会算成**全平台**的数,
// 却挂在某一所学校的名下:运营拿着它去谈续费,而数字是假的。
//
// 这个 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(见类注释)
vo.setOcrCount(0);
@@ -94,6 +107,19 @@ public class TenantUsageService {
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` 去重(同一个人在多任务里只算一次)。 */
private long distinctStudents() {
// 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法:
@@ -152,6 +178,7 @@ public class TenantUsageService {
if (req == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空");
}
Integer oldStatus = exist.getStatus();
SysTenant upd = new SysTenant();
upd.setId(id);
if (req.getTenantName() != null) {
@@ -161,21 +188,34 @@ public class TenantUsageService {
upd.setTenantName(req.getTenantName().trim());
}
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,
"租户类型只能是 1(学校)/ 2(机构)/ 3(企业)");
"租户类型只能是 1(学校)/ 2(认定机构)/ 3(报名点)/ 4(企业)");
}
upd.setTenantType(req.getTenantType());
}
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.setExpireTime(req.getExpireTime());
upd.setLogoUrl(req.getLogoUrl());
upd.setConfigJson(req.getConfigJson());
if (req.getStatus() != null) {
if (req.getStatus() != 0 && req.getStatus() != 1) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(启用)");
// 取值以 SysTenant.status 的注释为准(0 停用 / 1 正常 / 2 试用)。
// ⚠️ 这里曾经只放行 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());
if (req.getStatus() == 0) {
@@ -187,7 +227,52 @@ public class TenantUsageService {
tenantMapper.updateById(upd);
log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(),
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();
}
// ── 周期解析 ──