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:
+50
-13
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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>
|
||||
|
||||
+17
-2
@@ -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>
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
+12
-5
@@ -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));
|
||||
|
||||
+10
@@ -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));
|
||||
|
||||
|
||||
+10
-2
@@ -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");
|
||||
}
|
||||
|
||||
// ── 详情 ──────────────────────────────────────
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user