feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)

客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤,
  于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所
  学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。
  现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户:
  只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。
- OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对
  平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() ——
  isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通
  用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补
  TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。
- 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。
  按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校;
  学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。
- 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn
  吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。
  E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。

二、PC 端走查体验
- 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口,
  菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。
- 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。
  403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。
- 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在
  卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据
  铃铛;配色按客户要求还原。

顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于
PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false,
而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义
竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。

验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过;
e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、
e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。
详见 docs/交接摘要.md §一·三十三 与 §一·三十四。
This commit is contained in:
2026-10-09 17:29:20 +08:00
parent db8aecc30a
commit 0bbac7d228
41 changed files with 1387 additions and 372 deletions
@@ -1,12 +1,15 @@
package cn.tianbaotong.fill.module.log.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.log.dto.LogDtos;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper;
import cn.tianbaotong.fill.security.PermissionChecker;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
@@ -25,8 +28,8 @@ import java.util.List;
/**
* 日志查询(《03-接口设计》§13.4)。
*
* <p>权限用 {@code log:viewTenant}(本租户操作日志)。
* 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。
* <p>权限是**两个码的任一个**:{@code log:viewTenant}(本租户操作日志,学校管理员/教务处)
* 或 {@code log:viewAll}(全量,平台运营)。范围由 {@link #logScopeTenantId} 决定。
* 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
*
* <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel
@@ -41,15 +44,19 @@ public class LogController {
private final OperationLogMapper operationLogMapper;
private final LoginLogMapper loginLogMapper;
private final PermissionChecker permissionChecker;
@GetMapping("/operations")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT)
@RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "操作日志",
description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
description = "Query:`tenantId` `operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
+ "**按 targetType+targetId 查**是这个接口最重要的用法 —— "
+ "「这张名册项的证件号被谁从什么改成了什么」要靠它回答,"
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)")
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)。"
+ "⚠️ 范围:只有 `log:viewAll`(平台运营)能跨租户看**全量**,"
+ "也只有它能用 `tenantId` 指定某所学校;学校管理员恒为本租户")
public ApiResult<PageResult<LogDtos.OperationLogVO>> operations(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long operatorId,
@RequestParam(required = false) String module,
@RequestParam(required = false) String action,
@@ -59,20 +66,48 @@ public class LogController {
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require();
Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from);
LocalDateTime t = parseTo(to);
List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations(
me.tenantId(), operatorId, module, action, targetType, targetId,
scope, operatorId, module, action, targetType, targetId,
f, t, ps, (pn - 1) * ps);
long total = operationLogMapper.countOperations(
me.tenantId(), operatorId, module, action, targetType, targetId, f, t);
scope, operatorId, module, action, targetType, targetId, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps));
}
/**
* 日志查询的<b>租户范围</b>:{@code null} = 全量(跨租户)。
*
* <p>这是"平台运营看不到任何日志"那个 bug 的修法(2026-10-09):
* 以前控制器恒传 {@code me.tenantId()},而平台运营的 tenantId 是 <b>0</b>,
* 日志却都挂在各租户下 → 列表永远空白、且不报错。
* 而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
*
* <p>规则三条:
* <ol>
* <li>有 {@code log:viewAll}(平台运营):<b>默认全量</b>;显式传 {@code tenantId} 时只看那一所;</li>
* <li>只有 {@code log:viewTenant}(学校管理员、教务处):<b>恒为本租户</b>;</li>
* <li>学校管理员传了**别的**租户 id → 直接 {@code 40301}。
* <b>不静默忽略</b>:静默忽略会让人以为"我按这个单位筛过了",其实没筛 ——
* 而这一屏是拿去回答合规问题的,不能有这种"看起来筛了"的错觉。</li>
* </ol>
*/
private Long logScopeTenantId(Long requested) {
LoginUser me = UserContext.require();
if (permissionChecker.has(PermissionDef.LOG_VIEW_ALL)) {
return requested;
}
if (requested != null && !requested.equals(me.tenantId())) {
throw new BizException(ErrorCode.FORBIDDEN, "只能查看本单位的日志");
}
return me.tenantId();
}
/**
* 日期入参解析。
*
@@ -108,13 +143,15 @@ public class LogController {
}
@GetMapping("/logins")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT)
@RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "登录日志",
description = "Query:`userId` `userType` `result` `from` `to` + 分页。"
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Integer userType,
@RequestParam(required = false) Boolean result,
@@ -122,7 +159,7 @@ public class LogController {
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require();
Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from);
@@ -131,8 +168,8 @@ public class LogController {
Integer ok = result == null ? null : (result ? 1 : 0);
List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins(
me.tenantId(), userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(me.tenantId(), userId, userType, ok, f, t);
scope, userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(scope, userId, userType, ok, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps));
}
}
@@ -60,7 +60,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
l.create_time AS loginTime
FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="userId != null">
AND l.user_id = #{userId}
</if>
@@ -96,7 +99,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
SELECT COUNT(*)
FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="userId != null">
AND l.user_id = #{userId}
</if>
@@ -18,6 +18,15 @@ import java.util.List;
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
* 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
* 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
*
* <p>⚠️ {@code tenantId} 是<b>可选</b>条件({@code <if test="tenantId != null">}):
* 契约 §13.4 本来就把 {@code tenantId} 定义成查询参数。不传 = <b>全量</b>,
* 只有平台运营({@code log:viewAll})能走到这一支;学校管理员由
* {@code LogController} 恒填本租户。
*
* <p>以前这里写死 {@code l.tenant_id = #{tenantId}}、控制器恒传 {@code me.tenantId()} ——
* 而平台运营的 tenantId 是 0,于是它<b>一条日志都看不到</b>(列表空白,
* 没有任何报错),而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
*/
public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
@@ -57,7 +66,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
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
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="operatorId != null">
AND l.user_id = #{operatorId}
</if>
@@ -100,7 +112,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
<script>
SELECT COUNT(*)
FROM sys_operation_log l
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="operatorId != null">
AND l.user_id = #{operatorId}
</if>
@@ -11,7 +11,21 @@ import java.util.List;
/** 推送记录(契约 §14.4)。 */
public interface OpenPushLogMapper extends BaseMapper<CollectOpenPushLog> {
/** 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。 */
/**
* 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。
*
* <p>⚠️ {@code @InterceptorIgnore(tenantLine = "true")} <b>不能省</b>:
* 定时任务**没有租户上下文**(它是"没有请求也会发生的事"),而租户拦截器的姿态是
* <b>默认拒绝</b> —— 少了这个注解,这条查询每次都抛 {@code 40101},
* 被 {@code OpenPushService.retryDue} 的 {@code catch (RuntimeException)} 吞成一行 warn。
* 表现是:**"安排了下次重试"却从来没有重试过**,而日志里只有每 10 秒一次的噪音。
*
* <p>这与 {@link #findBySubmissionIgnoreTenant} 是同一个规律:
* <b>"先找归属、再摆上下文"里,找归属那一步必须能无租户执行</b>。
* 跨租户的候选扫描集中在这一个方法里,逐条处理时再由
* {@code TenantScope.runAs} 摆好目标租户的上下文 —— 保护没有被放弃。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_open_push_log
WHERE deleted = 0 AND status = 2
@@ -212,11 +212,18 @@ public class OpenPushService {
// 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处)
CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId());
if (app == null || !app.enabled()) {
// 接入方没了/停了:不再重试,标成最终失败并说明原因
row.setStatus(CollectOpenPushLog.STATUS_FAILED);
row.setLastError("接入方不存在或已停用,停止重试");
row.setNextRetryAt(null);
pushLogMapper.updateById(row);
// 接入方没了/停了:不再重试,标成最终失败并说明原因。
//
// ⚠️ 这一步**也必须摆租户上下文**:`updateById` 会走租户拦截器,
// 而在"接入方已不存在"的情况下我们不能从 app 拿租户 —— 所以用
// **推送记录自己的 tenant_id**。少了它,这条 update 会抛 40101,
// 被下面的 catch 吞成一行 warn,于是这条记录**每 10 秒重试一次、永远停不下来**。
TenantScope.runAs(row.getTenantId(), null, Set.of(), () -> {
row.setStatus(CollectOpenPushLog.STATUS_FAILED);
row.setLastError("接入方不存在或已停用,停止重试");
row.setNextRetryAt(null);
pushLogMapper.updateById(row);
});
continue;
}
TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row));
@@ -42,7 +42,17 @@ public class OrgService {
* 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。
*/
public List<OrgTreeVO> tree(boolean withCount, Long rootId) {
// ★ 显式带上租户条件,不依赖拦截器。
//
// 拦截器对**平台运营**(tenantId=0)整个关掉了过滤(它是唯一跨租户的身份),
// 于是这里会返回"所有租户的组织混在一棵树里" —— 而只有一所学校有数据时,
// 它看起来和学校管理员看到的完全一样,**看不出错**。
// 组织树本来就是租户内的东西:平台要看某所学校的组织,得先在页面上选定学校
// (请求头 X-Tenant-Id → 见 ActingTenantResolver),那时 tenantId 就是那所学校。
// 没选学校时这里返回空树,而不是"把所有人的组织端出来"。
long tenantId = UserContext.tenantId();
List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
.eq(SysOrg::getTenantId, tenantId)
.orderByAsc(SysOrg::getSortNo)
.orderByAsc(SysOrg::getId));
@@ -215,10 +215,18 @@ public class TaskService {
return o instanceof Number n ? n.longValue() : null;
}
/** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */
/**
* 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。
*
* <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
* tenantId 被换成了那所学校,{@code isPlatformAdmin()} 就是 false 了 ——
* 那时会掉进下面"只看自己带的班"的分支,而平台在该校没有班级,
* 于是工作台的任务下拉变成**空列表**,看起来像"这所学校没有任务"。
*/
private boolean seesWholeTenant() {
LoginUser me = UserContext.require();
return me.isPlatformAdmin() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
return me.isPlatformStaff() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
}
// ── 详情 ──────────────────────────────────────
@@ -164,7 +164,11 @@ public class UserService {
final String kw = (raw == null || raw.isEmpty()) ? null : raw;
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery();
q.eq(orgId != null, SysUser::getOrgId, orgId)
// ★ 显式租户条件(与 OrgService.tree 同一个理由):拦截器对平台运营
// (tenantId=0)整个关掉了过滤,不加这一条就会把**所有租户的账号**混在一起返回。
// 平台要看某所学校的账号,先在页面上选定学校(X-Tenant-Id),那时 tenantId 就是它。
q.eq(SysUser::getTenantId, UserContext.tenantId())
.eq(orgId != null, SysUser::getOrgId, orgId)
.eq(userType != null, SysUser::getUserType, userType)
.eq(status != null, SysUser::getStatus, status);
@@ -31,6 +31,7 @@ public class AuthInterceptor implements HandlerInterceptor {
private final TokenBlacklist blacklist;
private final PermissionChecker permissionChecker;
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
private final cn.tianbaotong.fill.support.ActingTenantResolver actingTenantResolver;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
@@ -52,6 +53,21 @@ public class AuthInterceptor implements HandlerInterceptor {
LoginUser user = jwtService.parse(token);
// ★ 平台运营的「当前学校」(请求头 X-Tenant-Id)。
//
// 平台是唯一跨租户的身份,租户拦截器因此对它关掉了过滤 —— 于是「组织架构」
// 「账号管理」这类租户内的页面会看到**所有租户混在一起**的数据,而且看不出错
// (只有一所学校有数据时,它和学校管理员看到的一模一样)。
// 所以要代管某所学校时必须显式指明:换掉 tenantId 之后 isPlatformAdmin() 变 false
// → 租户过滤自动生效,权限仍按 PLATFORM_ADMIN 那套判。
//
// ⚠️ 必须在下面的一致性校验**之前**:切换后要拿"生效租户"去和域名比,
// 否则在 t2 域名上代管 t1 会被放过去。
Long actingTenant = actingTenantResolver.resolve(request, user);
if (actingTenant != null) {
user = new LoginUser(user.uid(), actingTenant, user.orgIds(), user.roles(), user.userType());
}
// ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
//
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
@@ -24,6 +24,15 @@ public enum BuiltinRole {
PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL,
// ⚠️ TASK_VIEW 是**读**权限(任务列表 / 详情 / 覆盖班级 / 规则模板 / 任务统计),
// PRD §3.2 的「查看填报进度看板」对平台运营是 ✅,而工作台的数据看板
// 要拿任务列表才能选任务 —— 少了它,平台点工作台会拿到 `/tasks` 40301。
//
// 它以前被"平台=超管"这个捷径掩盖着(`PermissionChecker.has` 对平台恒真),
// 直到平台开始**代管某所学校**(`X-Tenant-Id` → 不再是 isPlatformAdmin)
// 才暴露出来:那时只认角色声明的权限,于是任务列表直接 403。
// 只补读权限:TASK_MANAGE / TASK_CLONE 依旧不给(PRD 里平台运营是「—」)。
PermissionDef.TASK_VIEW,
PermissionDef.LOG_VIEW_ALL),
Set.of("dashboard", "tenant", "org", "user", "log")),
@@ -41,8 +50,16 @@ public enum BuiltinRole {
PermissionDef.FILE_PROXY_UPLOAD,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task", "form", "material",
"roster", "join", "audit", "export", "cleanup", "log",
Set.of("dashboard", "org", "user", "role", "task",
"join", "audit", "export", "cleanup", "log",
// ⚠️ 顶层菜单里**没有**「名单管理 / 表单设计 / 材料规则」这三条。
// 它们的功能早就做好了,但都是**按任务**的:入口在「收集任务」
// 每一行的链接里(名单管理 / 表单设计 / 材料规则)。
// 以前菜单里挂着三条"功能在别处"的占位项,点进去只有一句指路 ——
// 那既占了导航,又让人以为"这里应该有事可做"。现在直接去掉。
// ⚠️ 这与权限无关:task/form/material/roster 相关权限码照旧保留,
// 只是不再有对应的菜单键。
//
// ⚠️ 这两个菜单键以前**没有任何角色持有** —— 而 PC 端的菜单可见性
// 就是拿这里拼出来的集合过滤的(`buildXtRoutes` 按 menuKeys 过滤),
// 于是「字典管理」与「通知记录」两个页面**做得再好也点不到**:
@@ -56,7 +73,7 @@ public enum BuiltinRole {
PermissionDef.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")),
Set.of("dashboard", "task", "audit")),
/** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */
DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
@@ -64,14 +81,14 @@ public enum BuiltinRole {
PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")),
Set.of("dashboard", "task", "audit")),
/** 审核员:专做审核,不给任何管理权限(最小权限原则)。 */
AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40,
Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_ALL,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL),
Set.of("dashboard", "task", "roster", "audit")),
Set.of("dashboard", "task", "audit")),
/** 学生:只能操作本人那一份。 */
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
@@ -0,0 +1,88 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 平台运营的「当前学校」(请求头 {@code X-Tenant-Id})。
*
* <h3>它解决什么</h3>
*
* <p>平台运营({@code tenantId = 0})是唯一跨租户的身份,而租户拦截器对它
* <b>整个关掉了租户过滤</b>(见 {@code MybatisPlusConfig.FillTenantLineHandler})。
* 于是「组织架构」「账号管理」这类<b>本来就是租户内</b>的页面,在平台账号下看到的是
* <b>所有租户混在一起</b>的数据 —— 而这<b>看不出错</b>:当只有一所学校有数据时,
* 它和学校管理员看到的一模一样。
*
* <p>所以平台要操作某所学校的组织/账号时,必须<b>显式指明是哪一所</b>:
* 请求头带上目标租户 id,本类把它解析出来,{@code AuthInterceptor} 用它把
* {@link LoginUser} 的 tenantId 换成目标租户 —— 之后租户过滤、上下文写入、
* 权限判定全部按"我就是那所学校"走,<b>不需要每个接口各加一个参数</b>。
*
* <h3>四条约束(安全相关,不要松)</h3>
*
* <ol>
* <li><b>只认平台身份</b>:非平台用户带这个头一律忽略 —— 否则改一个数字就能越租户,
* 那正是本系统最不能出的一类漏洞;</li>
* <li>目标租户必须<b>存在且启用</b>,否则 {@code 40001},且不区分"不存在"与"被停用";</li>
* <li>换租户之后 {@code isPlatformAdmin()} 变成 false → 租户过滤自动生效。
* 这是<b>关键机制</b>(不是副作用):无需在别处再加一个"是否已切换"的判断;</li>
* <li><b>不带这个头时行为完全不变</b> —— 平台照旧可以跨租户看租户列表、全量日志。</li>
* </ol>
*
* <p>与 {@link TenantDomainResolver} 的分工:那个是"这条请求落在谁的域名上"(定位,匿名可用),
* 这个是"平台此刻要代管哪所学校"(显式选择,仅平台可用)。两者都会改变租户上下文,
* 所以都在 {@code AuthInterceptor} 里、<b>与 token 的租户一致性校验一起</b>完成。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ActingTenantResolver {
/** 平台运营指定"当前学校"的请求头。 */
public static final String HEADER = "X-Tenant-Id";
private final TenantMapper tenantMapper;
/**
* 解析本次请求的目标租户。
*
* @return 目标租户 id;<b>不是平台身份 / 没带头 / 头就是平台自身</b> 时返回 {@code null}
* (表示"不切换",按原有语义走)
*/
public Long resolve(HttpServletRequest req, LoginUser user) {
if (user == null || !user.isPlatformAdmin()) {
// 非平台身份带头一律忽略:这一条是安全边界,不是"顺手支持一下"
return null;
}
String raw = req == null ? null : req.getHeader(HEADER);
if (raw == null || raw.isBlank()) {
return null;
}
long target;
try {
target = Long.parseLong(raw.trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
HEADER + " 不是合法的租户号:" + raw);
}
if (target == LoginUser.PLATFORM_TENANT_ID) {
// 指着平台自己 = 不切换(而不是报错:前端清空选择时可能发 0)
return null;
}
SysTenant t = tenantMapper.selectById(target);
if (t == null || t.getStatus() == null || t.getStatus() != 1) {
// 两种原因给同一句话:不额外透露"这个租户存在但被停用了"
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "目标单位不存在或已停用");
}
log.info("平台运营切换租户:uid={} → tenantId={}({})", user.uid(), target, t.getTenantName());
return target;
}
}