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 = "登录日志",
|
||||
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} */
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+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.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
|
||||
|
||||
+36
-1
@@ -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";
|
||||
|
||||
/**
|
||||
* 记一条操作日志(**不抛异常**)。
|
||||
|
||||
+5
-2
@@ -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));
|
||||
|
||||
+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.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());
|
||||
|
||||
+100
-15
@@ -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();
|
||||
}
|
||||
|
||||
// ── 周期解析 ──
|
||||
|
||||
Reference in New Issue
Block a user