feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)

角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
This commit is contained in:
2026-10-10 16:48:54 +08:00
parent 5b877451eb
commit 1f3c485ceb
84 changed files with 2845 additions and 4904 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
<artifactId>fill-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>数据信息填报系统</name>
<name>深学在线</name>
<description>职业技能等级认定 · 报名材料收集 SaaS</description>
<modules>
@@ -5,7 +5,7 @@ import lombok.Getter;
/**
* 错误码全集。
*
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>58 个</b>(含成功码 {@code 0})。
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>59 个</b>(含成功码 {@code 0})。
* <b>改这里之前先改文档,不要只改代码。</b>
*
* <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如
@@ -101,22 +101,14 @@ public enum ErrorCode {
INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"),
/** 字典项有下级,不可停用 */
DICT_HAS_CHILDREN(40036, "字典项有下级,不可停用"),
/** 加入申请次数超限 */
JOIN_REQUEST_LIMIT(40037, "加入申请次数已达上限"),
/** 该证件号已有待审申请 */
JOIN_REQUEST_DUPLICATE(40038, "你已提交过申请,请耐心等待"),
/** 该证件号已在名册中 */
ALREADY_IN_ROSTER(40039, "你已在名单中,请返回重新核验"),
/** 申请状态已变更(非待审) */
JOIN_STATUS_CHANGED(40040, "申请状态已变更"),
/** 名册中该证件号已被占用 */
ROSTER_ID_CARD_TAKEN(40041, "名册中该证件号已被占用"),
/** 申请已通过,不可再驳回 */
JOIN_ALREADY_APPROVED(40042, "申请已通过,不可再驳回"),
/** 文件归属存疑,阻断提交且无「仍然继续」 */
FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"),
/** 一期不支持该收集模式 / 认定类型 */
SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"),
/** 角色还有账号在用,不可删除 */
ROLE_IN_USE(40045, "该角色还有账号在用,请先改掉这些账号的角色"),
// ── 认证与权限 ────────────────────────────────
/** 未登录 / token 失效(真 HTTP 401) */
@@ -146,7 +138,7 @@ public enum ErrorCode {
STORAGE_FAILED(50007, "存储写入失败,请重传"),
// ── 兜底 ──────────────────────────────────────
/** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */
/** 系统内部错误(不在文档的 59 个码内,仅用于未预期异常兜底) */
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
@@ -68,9 +68,24 @@ public record LoginUser(
return false;
}
/**
* 这个人是不是<b>租户级管理员</b>({@code user_type = 2},即学校 / 机构管理员)。
*
* <p>⚠️ 判 {@code user_type} 而<b>不判角色码</b>。上一版这里写的是
* {@code hasAnyRole("SCHOOL_ADMIN", "AUDITOR")},有两个问题:
* <ol>
* <li>「审核员」({@code AUDITOR})角色已删除;</li>
* <li>租户可以自建角色,而"自定义角色 + {@code user_type=2}"同样是租户管理员 ——
* 按角色码判会让这批人退化成普通用户(表现是"什么都点不动",且不报任何错)。</li>
* </ol>
*/
public boolean isTenantAdmin() {
return userType != null && userType == 2;
}
/** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */
public boolean coversOrg(Long orgId) {
if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) {
if (isPlatformAdmin() || isTenantAdmin()) {
return true;
}
return orgId != null && orgIds != null && orgIds.contains(orgId);
@@ -7,7 +7,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 数据信息填报系统 · 主应用。
* 深学在线 · 主应用。
*
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
@@ -26,7 +26,7 @@ public class OpenApiConfig {
public OpenAPI fillOpenApi() {
return new OpenAPI()
.info(new Info()
.title("数据信息填报系统 API")
.title("深学在线 API")
.version("1.0.0")
.description("""
职业技能等级认定 · 报名材料收集 SaaS
@@ -66,7 +66,7 @@ public class StorageConfig {
*/
private void selfCheck(QiniuStorageService svc) {
String path = "_selfcheck/" + System.currentTimeMillis() + ".txt";
byte[] payload = "填报通存储自检 ok".getBytes(StandardCharsets.UTF_8);
byte[] payload = "深学在线存储自检 ok".getBytes(StandardCharsets.UTF_8);
try {
svc.put(path, payload, "text/plain; charset=utf-8");
if (!svc.exists(path)) {
@@ -10,18 +10,17 @@ import java.util.List;
* 审核范围:<b>"这个人能审哪些班"</b>。
*
* <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
* 这里把它翻译成一次查询。三级回退的顺序是<b>刻意的</b>:
* 这里把它翻译成一次查询。两级回退的顺序是<b>刻意的</b>:
*
* <ol>
* <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li>
* <li>{@code deputy_user_ids} —— 副班主任 / 协办人</li>
* <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li>
* </ol>
*
* <p>⚠️ <b>{@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的</b>:
* 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17}
* 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。
* 写入端在 {@code TaskOrgService.joinIds},两处必须一致。
* <p>⚠️ 这里以前还有一级 {@code deputy_user_ids}(副班主任 / 协办人)。
* 该字段与副班主任角色已一并删除,所以**一个班只有一个初审人**;
* 需要多人协审时,正确做法是给其中一人指定为该班初审人,
* 而不是再引入一个"协办人"字段。
*
* <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b>
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
@@ -48,7 +47,6 @@ public interface AuditScopeMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
ORDER BY cto.org_id
@@ -72,7 +70,6 @@ public interface AuditScopeMapper {
AND cto.status = 1
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
""")
@@ -102,7 +102,6 @@ public interface AuditTodoMapper {
AND cto.status = 1
AND (
cto.head_user_id = #{auditorId}
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
)
)
@@ -193,7 +192,6 @@ public interface AuditTodoMapper {
AND cto.status = 1
AND (
cto.head_user_id = #{auditorId}
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
)
)
@@ -158,6 +158,13 @@ public class AuthService {
Set.copyOf(roleCodes),
user.getUserType());
// ⚠️ 把上下文**升级成完整的 principal**(上面那个是"只有租户、没有角色"的临时的)。
// 必须在这里做:下面的 buildProfile 会用 PermissionChecker 装配菜单与权限,
// 而它是**从上下文里的 roles 算的** —— 上下文还是空角色的话,
// 管理员登录响应里的 permissions 会是空数组,前端于是把所有按钮都藏起来
// (现象是"刚登录什么都没有,刷新一下又好了")。
UserContext.set(principal);
String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC);
SysUser touch = new SysUser();
@@ -183,9 +190,9 @@ public class AuthService {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
LoginVO vo = buildProfile(user, roleCodes);
vo.setPermissions(permissionChecker.currentPermissions());
return vo;
// 权限与菜单由 buildProfile 统一装配(含自定义角色的授权),这里不再单独覆盖一次 ——
// 两处各写一份的话,登录响应与 /auth/me 迟早会不一致,而那种不一致只在"刷新一下就好了"里表现
return buildProfile(user, roleCodes);
}
// ── 修改自己的密码 ─────────────────────────────
@@ -358,8 +365,13 @@ public class AuthService {
}
vo.setRoleCodes(roleCodes);
vo.setMenus(BuiltinRole.mergeMenus(roleCodes));
vo.setPermissions(BuiltinRole.mergePermissions(roleCodes));
// ⚠️ 菜单与权限都走 PermissionChecker,**不要**直接取 BuiltinRole:
// 二期的自定义角色,它的授权在库里(sys_role_permission / sys_role_menu),
// 直接取 BuiltinRole 会让"自定义角色的人登录后左侧菜单是空的"——
// 而登录响应里的这两项前端会直接落库用(省一次 /auth/me 往返),
// 所以漏在这里就等于"要刷新一次才看得见菜单"。
vo.setMenus(permissionChecker.currentMenus());
vo.setPermissions(permissionChecker.currentPermissions());
return vo;
}
@@ -1,155 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
import cn.tianbaotong.fill.module.cleanup.service.CleanupImportService;
import cn.tianbaotong.fill.module.cleanup.service.CleanupService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* 历史数据整理器(《03-接口设计》§13.2)—— 差异化获客功能。
*
* <p>权限用 {@code task:manage}:整理出来的东西最终会变成正式任务(名册 + 材料),
* 与"创建与配置收集任务"是同一批人的职责。
*/
@Tag(name = "历史数据整理器")
@RestController
@RequestMapping("/api/v1/cleanup/jobs")
@RequiredArgsConstructor
public class CleanupController {
private final CleanupService cleanupService;
private final CleanupImportService importService;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "创建整理任务(上传压缩包)",
description = "用**与正式收集完全相同的规则**做规范化。"
+ "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
+ "**有名单才能做证件号校验、疑似串档检测** —— 而「疑似串档 1 例」"
+ "正是报告里最有价值的一条。"
+ "一期只支持 zip(云盘链接与文件夹上传返回 40044)")
public ApiResult<CleanupDtos.CreateVO> create(
@RequestParam String jobName,
@RequestParam(defaultValue = "1") Integer sourceType,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam(required = false) String namingTemplate,
@RequestParam(required = false) Long rosterTaskId) throws IOException {
CleanupDtos.CreateReq req = new CleanupDtos.CreateReq();
req.setJobName(jobName);
req.setSourceType(sourceType);
req.setNamingTemplate(namingTemplate);
req.setRosterTaskId(rosterTaskId);
return ApiResult.ok(cleanupService.create(req,
file == null ? null : file.getBytes()));
}
@PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
+ "方便自动化脚本与排障,不必手搓 multipart")
public ApiResult<CleanupDtos.CreateVO> createJson(@RequestBody Map<String, Object> body)
throws IOException {
CleanupDtos.CreateReq req = new CleanupDtos.CreateReq();
req.setJobName(body.get("jobName") == null ? null : String.valueOf(body.get("jobName")));
req.setSourceType(body.get("sourceType") == null ? 1
: Integer.valueOf(String.valueOf(body.get("sourceType"))));
req.setNamingTemplate(body.get("namingTemplate") == null ? null
: String.valueOf(body.get("namingTemplate")));
req.setRosterTaskId(body.get("rosterTaskId") == null ? null
: Long.valueOf(String.valueOf(body.get("rosterTaskId"))));
String b64 = body.get("zipBase64") == null ? null : String.valueOf(body.get("zipBase64"));
byte[] bytes = b64 == null || b64.isBlank()
? null : java.util.Base64.getDecoder().decode(b64);
return ApiResult.ok(cleanupService.create(req, bytes));
}
@GetMapping
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "整理任务列表")
public ApiResult<CleanupDtos.JobPageVO> list(
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
return ApiResult.ok(cleanupService.list(status, keyword, pageNum, pageSize));
}
@GetMapping("/{id}")
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "整理进度",
description = "compressRatio 在没算出来时给「—」而不是 0% —— "
+ "0% 看起来像「一点没压缩」,而真相是「还没算」")
public ApiResult<CleanupDtos.JobVO> detail(@PathVariable Long id) {
return ApiResult.ok(cleanupService.detail(id));
}
@GetMapping("/{id}/report")
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "体检报告 ⭐",
description = "报告里最重要的两个数字是 **autoHandled / needManual** ——"
+ "「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。"
+ "它们由服务端算出来,不让前端去减(前端减出来的数字早晚会分叉)。"
+ "⚠️ 一期没有 OCR,所以 `idcardConflicts` 的判定一律是 `NEED_CONFIRM` ——"
+ "唯一的证据是文件名,而文件名本身正是这次要修的东西(用它改数据是循环论证)")
public ApiResult<CleanupDtos.ReportVO> report(@PathVariable Long id) {
return ApiResult.ok(cleanupService.report(id));
}
@GetMapping("/{id}/result")
@RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
@Operation(summary = "下载整理结果(zip 流)")
public ResponseEntity<byte[]> result(@PathVariable Long id) {
byte[] bytes = cleanupService.resultBytes(id);
String name = "cleanup-" + id + "-result.zip";
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded)
.body(bytes);
}
@PostMapping("/{id}/import")
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "把整理结果导入为正式任务",
description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
+ "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
public ApiResult<CleanupDtos.ImportVO> importJob(@PathVariable Long id,
@RequestBody CleanupDtos.ImportReq req) {
return ApiResult.ok(importService.importAsTask(id, req));
}
@PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "只解析不落库(排障用)",
description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
+ "不然只能看最终报告,而报告是**汇总后的结果**,看不出是哪一步错了")
public ApiResult<List<Map<String, Object>>> parsePreview(
@RequestParam("file") MultipartFile file) throws IOException {
return ApiResult.ok(cleanupService.dryRunParse(file.getBytes()));
}
}
@@ -1,282 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 历史数据整理器(《03-接口设计》§13.2)。
*/
public final class CleanupDtos {
private CleanupDtos() {
}
/** 创建整理任务。 */
@Getter
@Setter
@Schema(description = "创建历史整理任务")
public static class CreateReq {
private String jobName;
/** {@code 1} 压缩包 / {@code 2} 文件夹 / {@code 3} 云盘链接。 */
private Integer sourceType;
private String uploadToken;
private String objectKey;
private String sourceUrl;
private String namingTemplate;
private Object compressConfig;
/**
* 关联到**已有任务的名册**。
*
* <p>⚠️ 关键参数:没有名册就只能做文件名规范化;<b>有名单才能做证件号校验、
* 疑似串档检测</b> —— 而"疑似串档 1 例"正是报告里最有价值的一条。
*/
private Long rosterTaskId;
}
/** 创建结果。 */
@Getter
@Setter
public static class CreateVO {
private Long jobId;
private Integer status;
/** 估时(秒)。按文件数估:解析 + 压缩,经验值约每 10 个文件 1 秒 */
private Integer estimatedSeconds;
}
/** 整理任务(列表与详情共用)。 */
@Getter
@Setter
public static class JobVO {
private Long id;
private String jobName;
private Integer sourceType;
private Long rosterTaskId;
private Integer status;
private String statusText;
private Integer progress;
private Integer totalFiles;
private Integer processedFiles;
private Integer parsedStudents;
private Integer parsedMaterials;
private Long rawSizeBytes;
private Long finalSizeBytes;
/** 形如 {@code 91.5%};没算出来时给 {@code —} 而不是 0%(0% 看起来像"没压缩") */
private String compressRatio;
/** 磁盘上的整理结果路径(不是可下载 URL —— 下载走 `/jobs/{id}/result`)。 */
private String resultPath;
private String errorMsg;
private Long targetTaskId;
private LocalDateTime createTime;
private LocalDateTime finishTime;
}
/** 列表。 */
@Getter
@Setter
public static class JobPageVO {
private List<JobVO> list = new ArrayList<>();
private long total;
}
// ── 体检报告(契约 §13.2 的核心)──────────────
/** 报告汇总。 */
@Getter
@Setter
public static class ReportSummary {
private int totalFiles;
/** 文件名不规范(双扩展名、缺姓名/证件号等)。 */
private int nameInvalid;
private int sizeExceeded;
private int formatConverted;
/** 证件号与名册不一致的份数。 */
private int idcardMismatch;
/** 其中系统自动修正的。 */
private int idcardAutoFixed;
/** 其中需要人工确认的。 */
private int idcardNeedConfirm;
/**
* 疑似串档。
*
* <p>判据(契约 §13.2 的例子):某个学生的**多项**材料证件号与名册**均不同**,
* 且这些材料彼此一致 —— 那说明"整个文件夹可能被换过"。
* 单项不符只是录入笔误,多项一致才指向"人不对"。
*/
private int crossFileSuspected;
private int lowResolution;
/** 无法归位(既没匹配到名册,也没解析出姓名)。 */
private int orphanImages;
}
/** 报告里的一条重点。 */
@Getter
@Setter
public static class Highlight {
/** {@code CROSS_FILE} / {@code LOW_RESOLUTION} / {@code IDCARD_MISMATCH} / {@code ORPHAN}。 */
private String type;
/** {@code 1} 提示 / {@code 2} 警告 / {@code 3} 必须人工。 */
private Integer level;
private Long rosterId;
private String realName;
private String title;
private String materialCode;
private String detail;
private String suggestion;
}
/** 改名样例。 */
@Getter
@Setter
public static class NameFixSample {
private String original;
private String fixed;
private String rule;
}
/** 证件号冲突明细。 */
@Getter
@Setter
public static class IdcardConflict {
private Long rosterId;
private String realName;
/** 名册里的脱敏值。 */
private String rosterValue;
/** 从文件名里读到的脱敏值。 */
private String fileNameValue;
/** 按文件名修正 / 按名册修正 / 待人工。 */
private String verdict;
private String verdictText;
}
/** 体检报告。 */
@Getter
@Setter
public static class ReportVO {
private Long jobId;
private ReportSummary summary = new ReportSummary();
/**
* 系统全自动处理了多少个文件。
*
* <p>契约里写明:<b>报告里最重要的一句话是 {@code autoHandled / needManual}</b>
* ——「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。
* 所以这两个数字**必须由服务端算出来**,不能让前端自己减。
*/
private int autoHandled;
private int needManual;
private List<Highlight> highlights = new ArrayList<>();
private List<NameFixSample> nameFixedSamples = new ArrayList<>();
private List<IdcardConflict> idcardConflicts = new ArrayList<>();
}
/** 导入为正式任务。 */
@Getter
@Setter
@Schema(description = "把整理结果导入为正式任务")
public static class ImportReq {
private String taskName;
private Integer collectMode;
private String batchNo;
/**
* 专业名称(选填)。
*
* <p>契约 §13.2 的导入请求里没有这个字段,但 `TaskService.create` 要求它 ——
* 而"专业名"必须有人给。所以:给了就用它,没给就**继承关联名册任务的专业**
* (历史材料与那份名册本来就属于同一个专业),都没有时明确报错让用户填。
* 这条补字段记在 F-44。
*/
private String majorName;
private Boolean importRoster;
private Boolean importFiles;
private Boolean markAsSubmitted;
}
/** 导入结果。 */
@Getter
@Setter
public static class ImportVO {
private Long taskId;
private int rosterCount;
private int fileCount;
}
}
@@ -1,89 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.entity;
import cn.tianbaotong.fill.support.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 历史整理任务({@code collect_cleanup_job},《03-接口设计》§13.2)。
*
* <p>⚠️ V1 建这张表时漏了 {@code update_time} / {@code deleted},
* 而实体继承 {@code BaseEntity}(带 {@code @TableLogic} 与自动填充)——
* 少了这两列的表现是**运行时**才炸。V13 补齐,并被
* {@code scripts/check-entity-columns.py} 提前拦住过(F-24/F-35 的同类)。
*/
@Getter
@Setter
@TableName("collect_cleanup_job")
public class CollectCleanupJob extends BaseEntity {
/** {@code 0} 排队 / {@code 1} 解析中 / {@code 2} 处理中 / {@code 3} 已完成 / {@code 4} 失败。 */
public static final int STATUS_QUEUED = 0;
public static final int STATUS_PARSING = 1;
public static final int STATUS_PROCESSING = 2;
public static final int STATUS_DONE = 3;
public static final int STATUS_FAILED = 4;
/** 来源:{@code 1} 压缩包 / {@code 2} 文件夹 / {@code 3} 云盘链接。 */
public static final int SOURCE_ZIP = 1;
public static final int SOURCE_FOLDER = 2;
public static final int SOURCE_CLOUD = 3;
private String jobName;
private Integer sourceType;
/** 压缩包在存储里的相对路径({@code sourceType=1})。 */
private String sourcePath;
/** 云盘链接({@code sourceType=3},一期不启用)。 */
private String sourceUrl;
/**
* 关联的已有任务名册(契约 §13.2 的 {@code rosterTaskId})。
*
* <p>⚠️ **这是关键参数**:没有名册就只能做文件名规范化;
* <b>有名单才能做证件号校验、疑似串档检测</b> ——
* 而"疑似串档 1 例"正是报告里最有价值的那一条。
*/
private Long rosterTaskId;
private String namingTemplate;
/** 压缩参数(JSON,缺省见契约 §3.11)。 */
private String compressConfig;
private Integer status;
private Integer progress;
/** 体检报告(JSON)—— 契约 §13.2 的 report 结构整体存这里。 */
private String reportJson;
private Integer totalFiles;
private Integer processedFiles;
private Integer fixedFiles;
private Integer failedFiles;
private Integer parsedStudents;
private Integer parsedMaterials;
private Long rawSizeBytes;
private Long finalSizeBytes;
/** 整理结果的压缩包路径。 */
private String resultPath;
/** 导入成正式任务后的任务 id。 */
private Long targetTaskId;
private LocalDateTime finishTime;
}
@@ -1,13 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.mapper;
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 历史整理任务。
*
* <p>只需要 MyBatis-Plus 的默认方法 —— 列表用 `selectPage` + 条件构造器就够了,
* 不为一个查询再写一段手写 SQL(那会多一处要维护的口径)。
*/
public interface CleanupJobMapper extends BaseMapper<CollectCleanupJob> {
}
@@ -1,284 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
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.cleanup.dto.CleanupDtos;
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
import cn.tianbaotong.fill.module.cleanup.mapper.CleanupJobMapper;
import cn.tianbaotong.fill.module.file.service.FileProcessService;
import cn.tianbaotong.fill.module.file.storage.StorageService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.task.service.TaskService;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 把整理结果导入为正式任务(《03-接口设计》§13.2 的 `/jobs/{id}/import`)。
*
* <h3>这个动作的分量:它把"一堆历史文件"变成"一份可审核的交付"</h3>
*
* <p>导入之后,客户拿到的是一个真实的任务:名册、材料、填报单、状态齐备,
* 可以走审核、可以导出。所以这里做的是"按解析结果重建一套数据",
* 而重建最容易出的两类错必须在这里挡住:
*
* <ol>
* <li><b>重复导入</b>:客户手滑点两次是常事。多出来的任务很难被发现
* (它会安静地躺在任务列表里),而"同一批人出现两次"会让导出重复计人。
* 所以导入是**幂等**的(看 {@code target_task_id})。</li>
* <li><b>证件号缺失</b>:解析不出证件号的人**不能建名册行** ——
* `collect_roster.id_card_no` 是 NOT NULL 的密文列(F-19 那个坑),
* 而且没有证件号的人永远过不了门禁、也过不了名册校验。
* 所以这类人**跳过并计入报告**,而不是塞一条空号的行进去。</li>
* </ol>
*
* <h3>一期能做到哪一步(说清边界,不假装完整)</h3>
*
* <p>能建:任务、名册(仅"解析出姓名 **且** 证件号"的人)、材料定义(按解析出的材料码)。
* <b>不能建</b>:填报单与挂文件 —— 那需要把整理结果里的每个文件重新走一遍
* "压缩 + 按模板改名 + 挂到某个填报单",而一期还没有"把外部文件挂到指定填报单"
* 的服务端入口(代传接口要 multipart 逐个传)。
* 所以 {@code importFiles} 参数**会被如实拒绝**(而不是安静地忽略它)——
* 安静忽略会让客户以为文件已经进去了。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CleanupImportService {
private final CleanupJobMapper jobMapper;
private final TaskService taskService;
private final TaskMapper taskMapper;
private final CollectRosterMapper rosterMapper;
private final StorageService storageService;
private final SensitiveCodec codec;
private final FileProcessService processService;
private final CleanupService cleanupService;
@Transactional(rollbackFor = Exception.class)
public CleanupDtos.ImportVO importAsTask(Long jobId, CleanupDtos.ImportReq req) {
LoginUser me = UserContext.require();
CollectCleanupJob job = jobMapper.selectById(jobId);
if (job == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理任务不存在或已删除");
}
if (job.getStatus() == null || job.getStatus() != CollectCleanupJob.STATUS_DONE) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理还没完成,暂时不能导入");
}
// ★ 参数校验必须在**幂等判断之前**。
//
// 原来它排在幂等分支之后,于是同一个整理任务第二次导入时,
// 请求会先命中"已经导过了,直接返回原任务",参数校验压根没跑到 ——
// `importFiles=true`(一期做不到)因此被安静地接受。
// 一次不合法的请求不该因为"恰好之前导过一次"而变成成功。
if (req == null || req.getTaskName() == null || req.getTaskName().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写新任务名称");
}
if (req.getBatchNo() == null || req.getBatchNo().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写批次号");
}
if (Boolean.TRUE.equals(req.getImportFiles())) {
// 如实拒绝而不是安静忽略 —— 见类注释
throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
"一期导入整理结果时还不能自动挂文件(需要把每个文件按模板重新规范化后挂到填报单,"
+ "那条服务端入口还没做)。请先导入名册,再让学生/老师把文件传上来");
}
if (req.getCollectMode() != null && req.getCollectMode() != 1 && req.getCollectMode() != 2) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"collectMode 只能是 1(学校收集)或 2(自主认定)");
}
// ★ 幂等:已经导过就直接回原来的任务,不新建
if (job.getTargetTaskId() != null) {
var existed = taskMapper.selectById(job.getTargetTaskId());
if (existed != null) {
CleanupDtos.ImportVO vo = new CleanupDtos.ImportVO();
vo.setTaskId(existed.getId());
vo.setRosterCount((int) nz(rosterMapper.selectCount(
Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, existed.getId()))));
vo.setFileCount(0);
log.info("整理结果重复导入,返回已有任务 jobId={} taskId={}", jobId, existed.getId());
return vo;
}
}
// 一期范围:只做学校收集(契约 §13.2 的 collectMode 也是 1/2)
// 缺省给 1(学校收集):一期**只**支持它,而缺省值若是 2,
// 不传这个字段的调用方会收到一个"你传了二期功能"的 40044 —— 那读起来像调用方的错。
//
// mode=2 由 `TaskService.create` 的 assertScopeSupported 拦(40044),
// 不在这里再拦一遍:范围的权威定义只有一处,两处早晚会不一致。
int mode = req.getCollectMode() == null ? 1 : req.getCollectMode();
// 建任务(草稿状态:让用户先去核对名册与材料,确认后再发布)
//
// ⚠️ **必须走 TaskService.create,不能自己 insert**:
// `collect_task.task_code` 是 NOT NULL 且由它生成,绕过它插入会报
// `null value in column "task_code"`,而对外表现只是"系统繁忙"(50000)。
// 更不能自己再写一份编码生成逻辑 —— 两处各生成一次编码迟早会撞号。
TaskUpsertReq treq = new TaskUpsertReq();
treq.setTaskName(req.getTaskName().trim());
treq.setBatchNo(req.getBatchNo().trim());
treq.setCollectMode(mode);
// ⚠️ 一期范围是 collectMode=1(学校收集)+ **certType=1(自主认定)** ——
// 权威定义在 `TaskService.assertScopeSupported`。
// 我按"2=自主认定"的直觉写过 2,被 40044 拦下(拦得对)。
treq.setCertType(1);
treq.setMajorName(majorNameOf(job, req));
treq.setStartTime(LocalDate.now().toString());
treq.setEndTime(LocalDate.now().plusMonths(6).toString());
if (job.getNamingTemplate() != null && !job.getNamingTemplate().isBlank()) {
treq.setNamingTemplate(job.getNamingTemplate());
}
Long newTaskId = taskService.create(treq).getId();
// 名册:按整理结果里"能确定是谁"的人建
int rosterCount = 0;
int skipped = 0;
if (req.getImportRoster() == null || Boolean.TRUE.equals(req.getImportRoster())) {
Map<String, String[]> students = studentsOf(jobId, job);
Set<String> seen = new LinkedHashSet<>();
for (Map.Entry<String, String[]> en : students.entrySet()) {
String name = en.getKey();
String idCard = en.getValue()[0];
String orgHint = en.getValue()[1];
if (name == null || name.isBlank() || idCard == null || idCard.isBlank()) {
skipped++;
continue;
}
// 同一个人只建一行(历史包里同一个人常有多个文件夹)
if (!seen.add(name + "|" + idCard)) {
continue;
}
CollectRoster r = new CollectRoster();
r.setTaskId(newTaskId);
r.setRealName(name);
// 三列一起写:密文 + hash(门禁靠它查)+ 脱敏值(展示)
r.setIdCardNo(codec.encrypt(idCard));
r.setIdCardHash(codec.hash(idCard));
r.setIdCardMask(maskOf(idCard));
r.setSeqNo(rosterCount + 1);
r.setSubmitStatus(CollectRoster.SUBMIT_NONE);
rosterMapper.insert(r);
rosterCount++;
}
}
CollectCleanupJob upd = new CollectCleanupJob();
upd.setId(jobId);
upd.setTargetTaskId(newTaskId);
jobMapper.updateById(upd);
log.info("整理结果导入为任务:jobId={} taskId={} 名册 {} 人(跳过 {})经办人={}",
jobId, newTaskId, rosterCount, skipped, me.uid());
CleanupDtos.ImportVO vo = new CleanupDtos.ImportVO();
vo.setTaskId(newTaskId);
vo.setRosterCount(rosterCount);
vo.setFileCount(0);
return vo;
}
/**
* 从整理结果里取出「姓名 → (证件号, 班级线索)」。
*
* <p>⚠️ 直接从**整理结果包**里重新解析,而不是读报告 JSON:
* 报告里的证件号是**脱敏值**(那是给人看的),拿它建名册会建成一堆星号。
* 这条"报告不能当数据源"的界线要说清 —— 否则以后有人图省事去读报告。
*/
private Map<String, String[]> studentsOf(Long jobId, CollectCleanupJob job) {
Map<String, String[]> out = new LinkedHashMap<>();
byte[] zip = storageService.getBytes(job.getResultPath());
try (ZipInputStream zin = new ZipInputStream(new java.io.ByteArrayInputStream(zip),
StandardCharsets.UTF_8)) {
ZipEntry e;
while ((e = zin.getNextEntry()) != null) {
if (e.isDirectory()) {
continue;
}
// 结果包的目录结构是我们自己定的:班级/姓名/材料/规范文件名
// 而规范文件名里带证件号 → 从路径就能取回"姓名 + 证件号"
HistoryNameParser.Hint h = HistoryNameParser.parse(e.getName());
String name = h.studentName();
if (name == null || h.idCard() == null) {
continue;
}
out.putIfAbsent(name, new String[]{h.idCard(), h.orgHint()});
drain(zin);
}
} catch (IOException ex) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"读整理结果失败:" + ex.getMessage());
}
return out;
}
private void drain(java.io.InputStream in) throws IOException {
byte[] buf = new byte[8192];
while (in.read(buf) > 0) {
// 只为了推进 zip 流
}
}
private long nz(Long v) {
return v == null ? 0L : v;
}
/**
* 新任务的"专业名称"从哪来。
*
* <p>三级回退:
* <ol>
* <li>请求里显式给了就用它;</li>
* <li>否则**继承关联名册任务的专业** —— 历史材料与那份名册本来就属于同一个专业,
* 而这是最可信的来源(比让用户再填一遍更不容易填错);</li>
* <li>都没有就明确报错,让用户填。</li>
* </ol>
*
* <p>为什么不猜一个默认值(比如"未分类"):专业名会进任务名、进导出汇总、
* 也会被用来筛人。猜错的话错误会一路带到交付物里,而"让你现在填一下"只是一秒的事。
*/
private String majorNameOf(CollectCleanupJob job, CleanupDtos.ImportReq req) {
if (req.getMajorName() != null && !req.getMajorName().isBlank()) {
return req.getMajorName().trim();
}
if (job.getRosterTaskId() != null) {
var src = taskMapper.selectById(job.getRosterTaskId());
if (src != null && src.getMajorName() != null && !src.getMajorName().isBlank()) {
return src.getMajorName();
}
}
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"请填写专业名称(也可以给整理任务关联一份已有名册,专业名会从它继承)");
}
private String maskOf(String idCard) {
if (idCard == null || idCard.length() < 14) {
return idCard;
}
return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
}
}
@@ -1,765 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
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.common.util.IdCardUtil;
import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
import cn.tianbaotong.fill.module.cleanup.mapper.CleanupJobMapper;
import cn.tianbaotong.fill.module.file.service.FileProcessService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.TenantScope;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 历史数据整理(《03-接口设计》§13.2)—— 差异化获客功能。
*
* <h3>它解决的真实痛点</h3>
*
* <p>客户手里已经有几百个 G 的历史材料,形态是"人长出来的":
* 一个班一个文件夹、里面一个人一个文件夹、文件名五花八门。
* 要收进系统得先规范化,而**人工改名几百份是不可能的**。
*
* <h3>这一版做什么 / 不做什么(必须说清,否则会被当成"功能不完整")</h3>
*
* <p><b>做</b>:
* <ul>
* <li>解压 zip → 逐条路径解析(姓名 / 证件号 / 材料类型 / 班级线索);</li>
* <li>与名册比对:证件号一致 / 不一致 / 名册里没这个人;</li>
* <li><b>疑似串档检测</b>:某人的多项材料证件号与名册<b>均不同</b>且彼此一致 →
* "整个文件夹可能被换过"(这是报告里最有价值的一条);</li>
* <li>按模板生成规范文件名,产出整理后的 zip;</li>
* <li>体检报告(含 {@code autoHandled / needManual} 两个关键数字)。</li>
* </ul>
*
* <p><b>不做</b>(并且理由都不是"来不及"):
* <ul>
* <li><b>不读图片内容</b>(没有 OCR/VLM,M4 未做)——
* 所以报告里的 {@code ocrValue} 一律为空,判定只用"文件名 + 名册"两条证据。
* 编一个假的 OCR 结果会让 {@code idcardAutoFixed} 这个数字失去意义;</li>
* <li><b>不做云盘链接</b>({@code sourceType=3} → 40044)——
* 要接网盘 SDK 与各家授权,一期不做;</li>
* <li><b>不做文件夹上传</b>({@code sourceType=2} → 40044)——
* 浏览器上传文件夹要 {@code webkitdirectory} + 后端逐文件落盘,
* 而现在客户手上给的形态就是压缩包。**但列已经建好了**(V13),
* 以后接上不用再动表。</li>
* </ul>
*
* <h3>为什么"有名单 vs 没名单"是两种完全不同的结果</h3>
*
* <p>没有 {@code rosterTaskId} 时只能做文件名规范化,报告里
* {@code idcardMismatch / crossFileSuspected} 恒为 0 ——
* 而这恰恰是客户愿意为这个功能付钱的那条信息。所以创建接口要**主动提示**
* (见 {@code create} 里的 warnings),而不是安静地少做一半。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CleanupService {
/** 单个文件体积上限(解压炸弹防护):50MB。 */
private static final long MAX_ENTRY_BYTES = 50L * 1024 * 1024;
/** 整包解压后的总上限(解压炸弹防护):2GB。 */
private static final long MAX_TOTAL_BYTES = 2L * 1024 * 1024 * 1024;
/** 最多处理多少个文件(防止误传一个几万文件的包把服务拖死)。 */
private static final int MAX_FILES = 20000;
private final CleanupJobMapper jobMapper;
private final CollectRosterMapper rosterMapper;
private final SensitiveCodec codec;
private final FileProcessService processService;
private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
private final ExecutorService exportExecutor;
// ── 创建 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public CleanupDtos.CreateVO create(CleanupDtos.CreateReq req, byte[] zipBytes) {
LoginUser me = UserContext.require();
if (req == null || req.getJobName() == null || req.getJobName().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写整理任务名称");
}
int sourceType = req.getSourceType() == null ? CollectCleanupJob.SOURCE_ZIP : req.getSourceType();
if (sourceType == CollectCleanupJob.SOURCE_CLOUD || sourceType == CollectCleanupJob.SOURCE_FOLDER) {
// 一期只做压缩包 —— 理由见类注释。**不静默降级**:
// 安静地按"压缩包"处理一个云盘链接,用户会以为功能坏了
throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
sourceType == CollectCleanupJob.SOURCE_CLOUD
? "一期暂不支持云盘链接,请把材料打包成 zip 上传"
: "一期暂不支持直接上传文件夹,请压缩成 zip 再上传");
}
if (zipBytes == null || zipBytes.length == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请上传要整理的压缩包");
}
if (req.getRosterTaskId() != null) {
// 名册必须真的存在,否则报告会安静地少掉最关键的两项
Long n = rosterMapper.selectCount(Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, req.getRosterTaskId()));
if (n == null || n == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"指定的名册任务里一个人都没有,无法做证件号校验");
}
}
// 压缩包落到存储里(整理是异步的,请求线程里的字节数组活不到那时)
String rel = String.format("cleanup/%d/%d-src.zip", me.tenantId(), System.currentTimeMillis());
storageService.put(rel, zipBytes, "application/zip");
CollectCleanupJob job = new CollectCleanupJob();
job.setJobName(req.getJobName().trim().length() > 120
? req.getJobName().trim().substring(0, 120) : req.getJobName().trim());
job.setSourceType(sourceType);
job.setSourcePath(rel);
job.setRosterTaskId(req.getRosterTaskId());
job.setNamingTemplate(req.getNamingTemplate() == null || req.getNamingTemplate().isBlank()
? "{姓名}-{证件号}-{材料名}" : req.getNamingTemplate().trim());
job.setCompressConfig(req.getCompressConfig() == null
? null : JSONUtil.toJsonStr(req.getCompressConfig()));
job.setStatus(CollectCleanupJob.STATUS_QUEUED);
job.setProgress(0);
jobMapper.insert(job);
Long tenantId = me.tenantId();
Long uid = me.uid();
final Long jobId = job.getId();
// ★ 异步要把租户上下文带过去:拦截器从 ThreadLocal 取 tenantId,
// 新线程里没有 → 一句 SQL 都跑不了(表现为 40101 或"查不到数据")
//
// ⚠️ 并且要把 **job 对象本身**传进去,不要在异步线程里回查 ——
// 异步线程与请求线程是两个事务,而请求线程此刻**还没提交**,
// 回查必然查不到(表现是"整理任务不存在",整次整理白跑)。
// 这类时序问题不能用 sleep 解决(它只是把概率变小),
// 去掉"回查"这个动作才是真的解决。
exportExecutor.execute(() -> TenantScope.runAs(tenantId, uid, Set.of(), () -> {
try {
run(job);
} catch (Throwable e) {
log.error("历史整理失败 jobId={}", jobId, e);
markFailed(jobId, e);
}
}));
CleanupDtos.CreateVO vo = new CleanupDtos.CreateVO();
vo.setJobId(jobId);
vo.setStatus(CollectCleanupJob.STATUS_QUEUED);
// 估时:解压 + 解析 + 压缩,经验值约每 10 个文件 1 秒(下限 10 秒)
vo.setEstimatedSeconds(Math.max(10, zipBytes.length / 1024 / 100));
log.info("创建历史整理任务 jobId={} 名称={} 包体={}KB 名册任务={}",
jobId, job.getJobName(), zipBytes.length / 1024, req.getRosterTaskId());
return vo;
}
// ── 查询 ──────────────────────────────────────
public CleanupDtos.JobPageVO list(Integer status, String keyword, int pageNum, int pageSize) {
var q = Wrappers.<CollectCleanupJob>lambdaQuery()
.eq(status != null, CollectCleanupJob::getStatus, status)
.like(keyword != null && !keyword.isBlank(), CollectCleanupJob::getJobName, keyword)
.orderByDesc(CollectCleanupJob::getId);
var page = jobMapper.selectPage(
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100)), q);
CleanupDtos.JobPageVO vo = new CleanupDtos.JobPageVO();
vo.setList(page.getRecords().stream().map(this::toVo).toList());
vo.setTotal(page.getTotal());
return vo;
}
public CleanupDtos.JobVO detail(Long id) {
CollectCleanupJob j = requireJob(id);
return toVo(j);
}
public CleanupDtos.ReportVO report(Long id) {
CollectCleanupJob j = requireJob(id);
if (j.getStatus() == null || j.getStatus() != CollectCleanupJob.STATUS_DONE) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"整理还没完成(" + statusText(j.getStatus()) + "),暂时没有报告");
}
if (j.getReportJson() == null || j.getReportJson().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "这份整理没有留下报告");
}
try {
return JSONUtil.toBean(j.getReportJson(), CleanupDtos.ReportVO.class);
} catch (RuntimeException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "报告内容损坏,无法解析");
}
}
/** 整理结果的压缩包字节(下载用)。 */
public byte[] resultBytes(Long id) {
CollectCleanupJob j = requireJob(id);
if (j.getStatus() == null || j.getStatus() != CollectCleanupJob.STATUS_DONE
|| j.getResultPath() == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理结果还没生成");
}
return storageService.getBytes(j.getResultPath());
}
// ── 主体:解压 → 解析 → 比对 → 重打包 ────────────
private void run(CollectCleanupJob job) {
Long jobId = job.getId();
progress(jobId, CollectCleanupJob.STATUS_PARSING, 5);
byte[] zip = storageService.getBytes(job.getSourcePath());
List<Entry> entries = readZip(zip);
if (entries.isEmpty()) {
throw new IllegalStateException("压缩包里没有文件(是不是传了一个空包?)");
}
progress(jobId, CollectCleanupJob.STATUS_PARSING, 20);
// 名册:有 rosterTaskId 才有。按"姓名 → 名册行"和"证件号 hash → 名册行"两路索引,
// 因为解析出来的线索可能只有姓名(文件名里没号码)或只有号码
Map<String, CollectRoster> byName = new HashMap<>();
Map<String, CollectRoster> byHash = new HashMap<>();
if (job.getRosterTaskId() != null) {
for (CollectRoster r : rosterMapper.selectList(Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, job.getRosterTaskId()))) {
if (r.getRealName() != null) {
byName.putIfAbsent(r.getRealName().trim(), r);
}
if (r.getIdCardHash() != null) {
byHash.putIfAbsent(r.getIdCardHash(), r);
}
}
}
List<Resolved> resolved = new ArrayList<>();
Set<String> usedNames = new java.util.LinkedHashSet<>();
for (Entry e : entries) {
resolved.add(resolve(e, byName, byHash, job.getNamingTemplate(), usedNames));
}
progress(jobId, CollectCleanupJob.STATUS_PROCESSING, 60);
// 重打包(按解析出的"班级/姓名/材料"建目录,文件名用规范名)
String outRel = String.format("cleanup/%d/%d-result.zip", UserContext.tenantId(), jobId);
long finalBytes;
try {
finalBytes = writeResultZip(resolved);
} catch (IOException ex) {
throw new IllegalStateException("生成整理结果失败:" + ex.getMessage(), ex);
}
storageService.put(outRel, lastResult, "application/zip");
// 体检报告
CleanupDtos.ReportVO report = buildReport(jobId, resolved);
long rawBytes = entries.stream().mapToLong(x -> x.bytes.length).sum();
int students = (int) resolved.stream().map(Resolved::rosterId).filter(java.util.Objects::nonNull)
.distinct().count();
int materials = (int) resolved.stream().map(Resolved::materialCode)
.filter(java.util.Objects::nonNull).distinct().count();
CollectCleanupJob upd = new CollectCleanupJob();
upd.setId(jobId);
upd.setStatus(CollectCleanupJob.STATUS_DONE);
upd.setProgress(100);
upd.setTotalFiles(entries.size());
upd.setProcessedFiles(resolved.size());
upd.setFixedFiles((int) resolved.stream().filter(Resolved::renamed).count());
upd.setFailedFiles((int) resolved.stream().filter(x -> x.rosterId == null).count());
upd.setParsedStudents(students);
upd.setParsedMaterials(materials);
upd.setRawSizeBytes(rawBytes);
upd.setFinalSizeBytes(finalBytes);
upd.setResultPath(outRel);
upd.setReportJson(JSONUtil.toJsonStr(report));
upd.setFinishTime(java.time.LocalDateTime.now());
jobMapper.updateById(upd);
log.info("历史整理完成 jobId={} 文件={} 自动处理={} 待人工={} 包体 {}KB → {}KB",
jobId, entries.size(), report.getAutoHandled(), report.getNeedManual(),
rawBytes / 1024, finalBytes / 1024);
}
/** 一条 zip 条目。 */
private record Entry(String path, byte[] bytes) {
}
/**
* 读 zip 到内存。
*
* <p>为什么整包进内存而不是流式:整理要**两遍**(先解析全部、再按解析结果重打包),
* 而 zip 不能随机访问(除非落盘再读)。客户的历史包按 PRD 的规模是几百 MB,
* 内存够;但**必须有上限**,否则一个恶意的解压炸弹能把服务打死 ——
* 见 {@link #MAX_TOTAL_BYTES} 与 {@link #MAX_ENTRY_BYTES}。
*/
private List<Entry> readZip(byte[] zip) {
List<Entry> out = new ArrayList<>();
long total = 0;
try (ZipInputStream zin = new ZipInputStream(new java.io.ByteArrayInputStream(zip),
StandardCharsets.UTF_8)) {
ZipEntry e;
while ((e = zin.getNextEntry()) != null) {
if (e.isDirectory()) {
continue;
}
// 跳过 macOS 的元数据文件:客户用 Mac 压的包一定会带,混进来会让报告虚高
String name = e.getName();
if (name.startsWith("__MACOSX/") || name.endsWith(".DS_Store")
|| name.contains("/._")) {
continue;
}
long declared = e.getSize();
if (declared > MAX_ENTRY_BYTES) {
throw new IllegalStateException("包里的单个文件超过 " + (MAX_ENTRY_BYTES / 1024 / 1024)
+ "MB:" + name + "(请先在本地压缩一下)");
}
byte[] data = readEntry(zin);
if (data.length > MAX_ENTRY_BYTES) {
throw new IllegalStateException("解压后单个文件过大:" + name);
}
total += data.length;
if (total > MAX_TOTAL_BYTES) {
throw new IllegalStateException("解压后总量超过 "
+ (MAX_TOTAL_BYTES / 1024 / 1024 / 1024) + "GB,请分批整理");
}
if (out.size() >= MAX_FILES) {
throw new IllegalStateException("包里的文件超过 " + MAX_FILES + " 个,请分批整理");
}
out.add(new Entry(name, data));
}
} catch (IOException ex) {
throw new IllegalStateException("压缩包读不了(是不是坏了或不是 zip):" + ex.getMessage(), ex);
}
return out;
}
private byte[] readEntry(InputStream in) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
// 边读边判上限:不能等读完再看长度(那时内存已经吃掉了)
while ((n = in.read(buf)) > 0) {
bos.write(buf, 0, n);
if (bos.size() > MAX_ENTRY_BYTES) {
throw new IOException("条目解压后超过 " + (MAX_ENTRY_BYTES / 1024 / 1024) + "MB");
}
}
return bos.toByteArray();
}
/**
* 一个文件的解析与比对结果。
*
* <p>用"普通类 + 字段"而不是 record:这个对象在解析过程中是**逐步填**的
* (先填解析结果、再填名册匹配、最后填规范文件名),record 那种一次成型的形状不合适。
*
* <p>⚠️ 但有访问器:方法引用(`Resolved::rosterId`)需要它 ——
* 直接引用字段在 Java 里是要写 lambda 的,而 lambda 在这里读起来更啰嗦。
*/
private static final class Resolved {
String path;
byte[] bytes;
String studentName;
String idCard;
String materialCode;
Long rosterId;
String rosterName;
String orgHint;
String standardName;
boolean renamed;
String issue;
Long rosterId() {
return rosterId;
}
String materialCode() {
return materialCode;
}
boolean renamed() {
return renamed;
}
}
private Resolved resolve(Entry e, Map<String, CollectRoster> byName,
Map<String, CollectRoster> byHash, String template,
Set<String> usedNames) {
HistoryNameParser.Hint h = HistoryNameParser.parse(e.path());
Resolved r = new Resolved();
r.path = e.path();
r.bytes = e.bytes();
r.studentName = h.studentName();
r.idCard = h.idCard();
r.materialCode = h.materialCode();
r.orgHint = h.orgHint();
// 与名册比对:先用证件号(更可靠),再用姓名
CollectRoster matched = null;
if (r.idCard != null) {
matched = byHash.get(codec.hash(r.idCard));
}
if (matched == null && r.studentName != null) {
matched = byName.get(r.studentName);
}
if (matched != null) {
r.rosterId = matched.getId();
r.rosterName = matched.getRealName();
// 证件号冲突:文件名里的号码与名册不一致。
// ⚠️ 这里**只记冲突、不改数据** —— 改谁都需要证据(没有 OCR,
// 我们只有"文件名"一条证据,而文件名本身就是要修的东西)
if (r.idCard != null && matched.getIdCardHash() != null
&& !codec.hash(r.idCard).equals(matched.getIdCardHash())) {
r.issue = "IDCARD_MISMATCH";
}
} else if (r.studentName != null || r.idCard != null) {
r.issue = "ORPHAN";
} else {
r.issue = "ORPHAN";
}
// 规范文件名:优先用**名册**里的姓名与证件号(名册才是权威),
// 名册里没有这个人时退回文件名里解析到的
String name = r.rosterName != null ? r.rosterName : r.studentName;
String idc = r.idCard;
String matName = r.materialCode == null ? "待归位"
: HistoryNameParser.materialNameOf(r.materialCode);
String ext = extOf(e.path());
Map<String, String> vars = new LinkedHashMap<>();
vars.put("姓名", name == null ? "未知姓名" : name);
vars.put("证件号", idc == null ? "未知证件号" : idc);
vars.put("材料名", matName);
vars.put("班级", r.orgHint == null ? "" : r.orgHint);
// ⚠️ ext 必须自带点号:renderName 的既有调用点是 `"." + ext`,
// 传裸扩展名不会报错,只会产出"身份证jpg"这种打不开的文件
String rendered = processService.renderName(template, vars, "." + ext);
// 同名去重:历史包里"身份证(1).jpg"这种重复很常见,
// 直接覆盖会让客户少一份材料(而报告上看起来一切正常)
r.standardName = uniqueName(usedNames, rendered);
r.renamed = !r.standardName.equals(lastSegment(e.path()));
return r;
}
private static String lastSegment(String path) {
String p = path.replace('\\', '/');
int i = p.lastIndexOf('/');
return i < 0 ? p : p.substring(i + 1);
}
private static String extOf(String path) {
String s = lastSegment(path);
int dot = s.lastIndexOf('.');
return dot > 0 ? s.substring(dot + 1) : "bin";
}
private static String uniqueName(Set<String> used, String name) {
if (used.add(name)) {
return name;
}
int dot = name.lastIndexOf('.');
String base = dot > 0 ? name.substring(0, dot) : name;
String ext = dot > 0 ? name.substring(dot) : "";
for (int i = 2; i < 1000; i++) {
String cand = base + "(" + i + ")" + ext;
if (used.add(cand)) {
return cand;
}
}
return name;
}
/** 上一次生成的整理结果(供 put 用;避免在 record 里传引用的别扭写法)。 */
private byte[] lastResult;
private long writeResultZip(List<Resolved> resolved) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream zip =
new org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream(bos)) {
// ★ UTF-8 文件名:与导出包同口径。不设 EFS 标志位时 Windows 会按本地代码页猜,
// 那个 bug 只在客户机器上出现
zip.setEncoding(StandardCharsets.UTF_8.name());
zip.setUseZip64(org.apache.commons.compress.archivers.zip.Zip64Mode.AsNeeded);
for (Resolved r : resolved) {
// 目录:班级/姓名/材料 —— 与导出包同一形状,客户拿到就能直接用
String org = r.orgHint == null || r.orgHint.isBlank() ? "未分班" : r.orgHint;
String name = r.rosterName != null ? r.rosterName
: (r.studentName == null ? "未知姓名" : r.studentName);
String mat = r.materialCode == null ? "待归位"
: HistoryNameParser.materialNameOf(r.materialCode);
String entryName = org + "/" + name + "/" + mat + "/" + r.standardName;
var ze = new org.apache.commons.compress.archivers.zip.ZipArchiveEntry(entryName);
ze.setSize(r.bytes.length);
zip.putArchiveEntry(ze);
zip.write(r.bytes);
zip.closeArchiveEntry();
}
}
byte[] out = bos.toByteArray();
lastResult = out;
return out.length;
}
/**
* 体检报告。
*
* <p>两个关键数字必须算对:
* <ul>
* <li>{@code autoHandled}:系统全自动处理了 —— 即"规范化成功且能对上名册"的;
* <li>{@code needManual}:必须人工判断的 —— 串档 + 无法归位 + 待人工确认的证件号冲突。
* </ul>
* 契约里写明「报告里最重要的一句话是 autoHandled / needManual」,
* 所以它们**由服务端算**,不让前端去减(前端减出来的数字早晚会分叉)。
*/
private CleanupDtos.ReportVO buildReport(Long jobId, List<Resolved> resolved) {
CleanupDtos.ReportVO vo = new CleanupDtos.ReportVO();
vo.setJobId(jobId);
CleanupDtos.ReportSummary s = vo.getSummary();
s.setTotalFiles(resolved.size());
// 串档检测:同一个人有 ≥2 项材料,**全部**与名册证件号不一致,
// 且这些材料彼此一致(说明"整个文件夹被换过")
Map<Long, List<Resolved>> byRoster = new HashMap<>();
for (Resolved r : resolved) {
if (r.rosterId != null) {
byRoster.computeIfAbsent(r.rosterId, k -> new ArrayList<>()).add(r);
}
}
int auto = 0;
int manual = 0;
for (Resolved r : resolved) {
if (r.issue == null) {
auto++;
} else if ("IDCARD_MISMATCH".equals(r.issue)) {
s.setIdcardMismatch(s.getIdcardMismatch() + 1);
s.setIdcardNeedConfirm(s.getIdcardNeedConfirm() + 1);
manual++;
} else {
s.setOrphanImages(s.getOrphanImages() + 1);
manual++;
}
}
for (Map.Entry<Long, List<Resolved>> en : byRoster.entrySet()) {
List<Resolved> list = en.getValue();
long mismatched = list.stream().filter(x -> "IDCARD_MISMATCH".equals(x.issue)).count();
if (list.size() >= 2 && mismatched == list.size()) {
s.setCrossFileSuspected(s.getCrossFileSuspected() + 1);
CollectRoster ro = rosterMapper.selectById(en.getKey());
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
hl.setType("CROSS_FILE");
hl.setLevel(3);
hl.setRosterId(en.getKey());
hl.setRealName(ro == null ? null : ro.getRealName());
hl.setTitle("疑似串档");
hl.setDetail("该生 " + list.size() + " 项材料的证件号与名册均不同,且 "
+ list.size() + " 项彼此一致");
hl.setSuggestion("可能整个文件夹被换过,请核对学生本人");
vo.getHighlights().add(hl);
}
}
// 改名样例(给客户看"系统到底做了什么"):最多 5 条
for (Resolved r : resolved) {
if (vo.getNameFixedSamples().size() >= 5) {
break;
}
if (r.renamed) {
CleanupDtos.NameFixSample sample = new CleanupDtos.NameFixSample();
sample.setOriginal(lastSegment(r.path));
sample.setFixed(r.standardName);
sample.setRule("VAL-06");
vo.getNameFixedSamples().add(sample);
}
}
// 证件号冲突明细
for (Resolved r : resolved) {
if (!"IDCARD_MISMATCH".equals(r.issue)) {
continue;
}
CollectRoster ro = r.rosterId == null ? null : rosterMapper.selectById(r.rosterId);
CleanupDtos.IdcardConflict c = new CleanupDtos.IdcardConflict();
c.setRosterId(r.rosterId);
c.setRealName(ro == null ? r.studentName : ro.getRealName());
c.setRosterValue(ro == null ? null : ro.getIdCardMask());
c.setFileNameValue(maskOf(r.idCard));
// ⚠️ 判定只能是"待人工":没有 OCR,唯一的证据是文件名,
// 而文件名本身正是这次要修的东西 —— 用它去改数据是循环论证
c.setVerdict("NEED_CONFIRM");
c.setVerdictText("文件名里的证件号与名册不一致;没有识别结果可交叉验证,请人工确认");
vo.getIdcardConflicts().add(c);
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
hl.setType("IDCARD_MISMATCH");
hl.setLevel(2);
hl.setRosterId(r.rosterId);
hl.setRealName(c.getRealName());
hl.setTitle("证件号与名册不一致");
hl.setDetail("文件名:" + c.getFileNameValue() + ",名册:" + c.getRosterValue());
hl.setSuggestion("核对本人证件号后改名册或改文件名");
vo.getHighlights().add(hl);
}
// 无法归位
for (Resolved r : resolved) {
if (!"ORPHAN".equals(r.issue)) {
continue;
}
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
hl.setType("ORPHAN");
hl.setLevel(2);
hl.setRealName(r.studentName);
hl.setTitle("无法归位");
hl.setDetail("从路径「" + r.path + "」里没能确定这是谁的材料");
hl.setSuggestion("把文件名改成「姓名-证件号-材料名」或放进以姓名命名的文件夹");
vo.getHighlights().add(hl);
}
vo.setAutoHandled(auto);
vo.setNeedManual(manual);
return vo;
}
private String maskOf(String idCard) {
if (idCard == null || idCard.length() < 14) {
return null;
}
return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
}
// ── 内部 ──────────────────────────────────────
private CollectCleanupJob requireJob(Long id) {
CollectCleanupJob j = jobMapper.selectById(id);
if (j == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理任务不存在或已删除");
}
return j;
}
private void progress(Long jobId, int status, int pct) {
CollectCleanupJob upd = new CollectCleanupJob();
upd.setId(jobId);
upd.setStatus(status);
upd.setProgress(pct);
jobMapper.updateById(upd);
}
private void markFailed(Long jobId, Throwable e) {
try {
CollectCleanupJob upd = new CollectCleanupJob();
upd.setId(jobId);
upd.setStatus(CollectCleanupJob.STATUS_FAILED);
upd.setProgress(0);
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
upd.setReportJson(JSONUtil.createObj().set("error", clip(msg, 400)).toString());
upd.setFinishTime(java.time.LocalDateTime.now());
jobMapper.updateById(upd);
} catch (RuntimeException inner) {
log.error("标记整理失败时又出错 jobId={}", jobId, inner);
}
}
private static String clip(String s, int max) {
return s == null ? null : (s.length() <= max ? s : s.substring(0, max));
}
private CleanupDtos.JobVO toVo(CollectCleanupJob j) {
CleanupDtos.JobVO vo = new CleanupDtos.JobVO();
vo.setId(j.getId());
vo.setJobName(j.getJobName());
vo.setSourceType(j.getSourceType());
vo.setRosterTaskId(j.getRosterTaskId());
vo.setStatus(j.getStatus());
vo.setStatusText(statusText(j.getStatus()));
vo.setProgress(j.getProgress());
vo.setTotalFiles(j.getTotalFiles());
vo.setProcessedFiles(j.getProcessedFiles());
vo.setParsedStudents(j.getParsedStudents());
vo.setParsedMaterials(j.getParsedMaterials());
vo.setRawSizeBytes(j.getRawSizeBytes());
vo.setFinalSizeBytes(j.getFinalSizeBytes());
vo.setResultPath(j.getResultPath());
vo.setTargetTaskId(j.getTargetTaskId());
vo.setCreateTime(j.getCreateTime());
vo.setFinishTime(j.getFinishTime());
if (j.getRawSizeBytes() != null && j.getRawSizeBytes() > 0 && j.getFinalSizeBytes() != null) {
double ratio = 100.0 * (1 - (double) j.getFinalSizeBytes() / j.getRawSizeBytes());
if (ratio < 0) {
// ⚠️ **负数压缩率是没有意义的,不能就这么显示出去。**
//
// 什么时候会负:包里的文件**本身就已经是压缩过的**(jpg/zip 里的图片),
// 或者总量很小 —— 重新打一个 zip 的**包头开销**比省下来的还多。
// 实测一个 3 个小文件(各 40 字节)的包:raw=120B、final=802B → `-568.3%`。
//
// 显示 `-568.3%` 的问题不是"不好看",而是**客户会以为系统算错了**
// (而它没算错,"压缩"这个词在小包上本来就不适用)。
// 所以这里给 `—` —— 与"还没算"同一个符号:**都是"这个数说明不了什么"**。
vo.setCompressRatio("—");
} else {
vo.setCompressRatio(Math.round(ratio * 10) / 10.0 + "%");
}
} else {
// — 而不是 0%:0% 看起来像"一点没压缩",而真相是"还没算"
vo.setCompressRatio("—");
}
if (j.getStatus() != null && j.getStatus() == CollectCleanupJob.STATUS_FAILED
&& j.getReportJson() != null && j.getReportJson().contains("\"error\"")) {
vo.setErrorMsg(JSONUtil.parseObj(j.getReportJson()).getStr("error"));
}
return vo;
}
private String statusText(Integer status) {
return switch (status == null ? 0 : status) {
case CollectCleanupJob.STATUS_QUEUED -> "排队中";
case CollectCleanupJob.STATUS_PARSING -> "解析中";
case CollectCleanupJob.STATUS_PROCESSING -> "处理中";
case CollectCleanupJob.STATUS_DONE -> "已完成";
case CollectCleanupJob.STATUS_FAILED -> "失败";
default -> "未知";
};
}
/** 供测试/排查用:只跑解析这一步(不落库、不打包)。 */
public List<Map<String, Object>> dryRunParse(byte[] zip) {
List<Map<String, Object>> out = new ArrayList<>();
for (Entry e : readZip(zip)) {
HistoryNameParser.Hint h = HistoryNameParser.parse(e.path());
Map<String, Object> m = new LinkedHashMap<>();
m.put("path", e.path());
m.put("studentName", h.studentName());
m.put("idCard", h.idCard() == null ? null : maskOf(h.idCard()));
m.put("idCardValid", h.idCard() != null && IdCardUtil.isValid(h.idCard()));
m.put("materialCode", h.materialCode());
m.put("orgHint", h.orgHint());
out.add(m);
}
return out;
}
/** 保留:给"文件夹来源"分支留的口子(一期走不到)。 */
static String normalizePath(String p) {
return IdentityNormalizer.toHalfWidth(p).replace('\\', '/');
}
}
@@ -1,296 +0,0 @@
package cn.tianbaotong.fill.module.cleanup.service;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 从**历史文件名与目录名**里猜出"这是谁、哪项材料"。
*
* <h3>这个类的全部难点:客户的历史文件夹是"人长出来的",不是机器生成的</h3>
*
* <p>实测(PRD §5.9 的背景)见到的形态:
* <pre>
* 24中技数控1班/张三/身份证.jpg.jpg
* 24中技数控1班/张三-361029200907295110/一寸照片.png
* 24数控/张三 身份证/IMG_2034.jpg
* .\李四\学籍表\学籍表(1).JPG
* </pre>
* 所以解析必须**从右往左找最有信息量的那段**,而不是"按固定层级取"——
* 后者在换一个班之后一定失效。
*
* <h3>两条不准做的事</h3>
*
* <ol>
* <li><b>不猜身份证号</b>:只认**完整 18 位且校验位正确**的。文件名里出现
* 一串 18 位数字很可能是别的编号(学号、订单号),猜错会把材料挂到别人身上 ——
* 而"疑似串档"恰恰是这份报告最有价值的一条,不能自己先制造出来。</li>
* <li><b>不把"猜不出"当成失败</b>:猜不出就归到"待人工归位",
* 由报告里那一条 {@code needManual} 说清。硬塞一个分类会让报告撒谎。</li>
* </ol>
*/
public final class HistoryNameParser {
private HistoryNameParser() {
}
/** 18 位身份证号(末位可为 X)。 */
private static final Pattern ID_CARD = Pattern.compile("(\\d{17}[\\dXx])");
/** 材料关键词 → 材料名(客户的历史命名习惯,与内置材料的别名有重叠但不完全相同)。 */
private static final String[][] MATERIAL_HINTS = {
{"身份证", "IDCARD"}, {"正面", "IDCARD"}, {"反面", "IDCARD"}, {"人像面", "IDCARD"},
{"国徽面", "IDCARD"},
{"一寸", "PHOTO1"}, {"照片", "PHOTO1"}, {"证件照", "PHOTO1"}, {"免冠", "PHOTO1"},
{"学籍", "SCHOOL_ROLL"}, {"学籍表", "SCHOOL_ROLL"},
{"工作证明", "WORK_PROOF"}, {"在职", "WORK_PROOF"}, {"证明", "WORK_PROOF"},
{"备案", "XJW_BA"},
{"其他", "OTHER_PROOF"}, {"其它", "OTHER_PROOF"}, {"佐证", "OTHER_PROOF"},
};
/** 一个文件路径解析出来的线索。 */
public record Hint(String studentName, String idCard, String materialCode,
String materialName, String orgHint, String fileName) {
}
/**
* 解析一条相对路径。
*
* @param relativePath 形如 {@code 24中技数控1班/张三/身份证.jpg}
*/
public static Hint parse(String relativePath) {
String p = relativePath.replace('\\', '/');
String[] segs = p.split("/");
// 去掉空段(形如 `./李四/...` 会切出一个空串)
List<String> parts = new ArrayList<>();
for (String s : segs) {
if (s != null && !s.isBlank() && !".".equals(s.trim())) {
parts.add(s.trim());
}
}
if (parts.isEmpty()) {
return new Hint(null, null, null, null, null, null);
}
String fileName = parts.get(parts.size() - 1);
// 目录段(不含文件名)
List<String> dirs = parts.size() > 1 ? parts.subList(0, parts.size() - 1) : List.of();
String idCard = null;
String name = null;
String orgHint = null;
// ① 身份证号:从**文件名与所有目录段**里找第一个合法的 18 位
//
// 为什么目录段也要找:常见的形态是"张三-361029200907295110"当文件夹名,
// 此时文件名只是 `身份证.jpg`。
Set<String> pool = new LinkedHashSet<>();
pool.add(fileName);
pool.addAll(dirs);
for (String s : pool) {
Matcher m = ID_CARD.matcher(s);
while (m.find()) {
String cand = m.group(1);
if (cn.tianbaotong.fill.common.util.IdCardUtil.isValid(cand)) {
idCard = cand.toUpperCase();
break;
}
}
if (idCard != null) {
break;
}
}
// ② 姓名:优先"带身份证号的那一段"里、号码前面的中文
// 其次取最靠近文件的目录名(班级 → 姓名 → 文件 是最常见的结构)
for (String s : pool) {
if (idCard != null && s.contains(idCard)) {
String before = s.substring(0, s.indexOf(idCard));
name = chineseName(before);
if (name != null) {
break;
}
}
}
if (name == null && looksLikePersonSegment(stripExt(fileName))) {
// 从文件名里找(`张三-身份证.jpg`)
name = chineseName(stripExt(fileName));
}
if (name == null && !dirs.isEmpty()) {
// 从最深的目录往上找第一个像姓名的(2~4 个汉字)
for (int i = dirs.size() - 1; i >= 0; i--) {
if (!looksLikePersonSegment(dirs.get(i))) {
continue;
}
String cand = chineseName(dirs.get(i));
if (cand != null) {
name = cand;
break;
}
}
}
// ③ 班级:最浅的那一层目录(通常就是班级),但**排除**看起来是姓名的那一层
for (String s : dirs) {
if (name != null && s.contains(name)) {
continue;
}
if (s.contains("班") || s.contains("级") || s.contains("数控")
|| s.contains("机电") || s.contains("专业")) {
orgHint = s;
break;
}
}
// ④ 材料:在"文件名 + 各级目录名"里找关键词(**文件名优先**,它更具体)
String materialCode = null;
String materialName = null;
for (String s : pool) {
for (String[] kv : MATERIAL_HINTS) {
if (s.contains(kv[0])) {
materialCode = kv[1];
materialName = kv[0];
break;
}
}
if (materialCode != null) {
break;
}
}
return new Hint(name, idCard, materialCode, materialName, orgHint, fileName);
}
/** 这段文本是不是"材料文件夹名"(里面出现了材料关键词)。 */
private static boolean looksLikeMaterial(String s) {
if (s == null) {
return false;
}
for (String[] kv : MATERIAL_HINTS) {
if (s.contains(kv[0])) {
return true;
}
}
return false;
}
/**
/**
* 这个片段**整体**看起来像"一个人的名字"吗。
*
* <p>与 {@link #chineseName} 的分工:这里判断"这段文本是不是人名所在的片段",
* 那里从片段里抠出候选字。两条排除规则:
* <ol>
* <li>**含数字 → 不是人名**:"24中技数控1班" 会被抠出 "中技数控",
* 而那是班级不是人(真实人名里不会出现阿拉伯数字);</li>
* <li>**含班级/学校词 → 不是人名**("班"、"级"、"专业"、"学校"、"学院")。</li>
* </ol>
*/
static boolean looksLikePersonSegment(String s) {
if (s == null || s.isBlank()) {
return false;
}
if (s.matches(".*\\d.*")) {
return false;
}
for (String marker : new String[]{"班", "级", "专业", "学校", "学院", "系", "组"}) {
if (s.contains(marker)) {
return false;
}
}
return true;
}
/**
* 从一段文本里取"看起来是人名"的中文片段。
*
* <p>规则保守:**2~4 个连续汉字**,且不包含材料/班级关键词。
* 放宽到 1 个汉字会把"张"这种姓当成名字;
* 放宽到 5 个会把"数控加工中心"(专业名)当成名字。
*/
static String chineseName(String s) {
if (s == null || s.isBlank()) {
return null;
}
// 去掉常见的分隔符与噪声
String t = s.replaceAll("[-_()()【】\\[\\]\\s]+", "");
// ⚠️ 取**完整的**中文串,不要 {2,4} 截断 ——
// "新建文件夹"(5 字)被截成 "新建文件" 之后,噪声词表里查不到它,
// 于是那个文件被归到"新建文件"名下,而报告上看起来还挺像回事。
Matcher m = Pattern.compile("[\\u4e00-\\u9fa5]+").matcher(t);
while (m.find()) {
String cand = m.group();
// 人名是 2~4 个汉字;连续中文超过 4 个字说明这是个标签("新建文件夹"),不是人
if (cand.length() < 2 || cand.length() > 4) {
continue;
}
if (isNoiseWord(cand)) {
continue;
}
// 材料词("身份证"、"一寸照片"、"学籍表")出现在目录名里时,
// 它指的是"这是哪项材料",不是"这是谁" —— 由材料词表负责判断
if (looksLikeMaterial(s)) {
continue;
}
return cand;
}
return null;
}
/**
* 明显不是姓名的词(材料名 / 班级词 / 目录名)。
*
* <p>⚠️ <b>判据必须收得很紧,因为中文人名与业务词高度重叠</b>。
* 我第一版写的是 {@code w.contains(n)},于是「<b>整理</b>甲」这种名字里
* 只要含"整理"就被当噪声滤掉 —— 表现是**所有文件都归不了位**,
* 报告里冒出一堆 orphan,而串档检测也因此失去基准(全都对不上名册)。
*
* <p>⚠️ <b>只做精确匹配,不做包含</b>。我中间写过一版"长度 ≤2 的词被包含就算噪声",
* 而"整理"正好是 2 个字 —— 于是「<b>整理</b>甲」又被滤掉了。
* 教训是:<b>中文人名与业务短词无法用"包含"区分</b>,
* 这条规则只要存在,早晚会吃掉一个真名,而失败是静默的(文件变成"无法归位")。
*
* <p>"是不是材料/班级词"由 {@link #MATERIAL_HINTS} 与下面这份词表**各自**判断:
* 前者管材料目录名("身份证"、"一寸照片"),后者管纯噪声词。
* 职责分开之后,这边只需要回答"这个字面量本身是不是一个业务词"。
*/
private static boolean isNoiseWord(String w) {
String[] noise = {"身份证", "一寸", "照片", "学籍", "工作", "证明", "备案", "其他", "其它",
"佐证", "材料", "扫描", "复印", "副本", "正本", "原图", "最终", "新版", "旧版",
"中技", "数控", "机电", "专业", "班级", "学院", "学校", "高级", "中级", "初级",
"认定", "报名", "收集", "整理", "待整理", "已整理", "未整理", "补充", "补交",
// 目录名噪声:这些是"文件夹"而不是人,混进候选会让归位结果看起来可信但实际是错的
"新建", "文件夹", "新建文件夹", "未命名", "待定", "临时", "归档", "历史",
"材料汇总", "待整理材料", "杂项", "其它材料", "其他材料"};
for (String n : noise) {
if (w.equals(n)) {
return true;
}
}
return false;
}
private static String stripExt(String s) {
int dot = s.lastIndexOf('.');
return dot > 0 ? s.substring(0, dot) : s;
}
/** 材料码 → 中文名(写进规范文件名用)。 */
public static String materialNameOf(String code) {
for (String[] kv : MATERIAL_HINTS) {
if (kv[1].equals(code)) {
return kv[0];
}
}
return code;
}
/** 把解析到的证件号规范化(全角转半角 + 去空白),与其它入口同口径。 */
public static String normalizeIdCard(String raw) {
return IdentityNormalizer.idCard(raw);
}
}
@@ -1,72 +0,0 @@
package cn.tianbaotong.fill.module.join.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.service.JoinService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 管理侧的加入申请审批(《03-接口设计》§8.7.3 ~ §8.7.5)。
*
* <p>权限码是现成的 {@code join:audit}({@code PermissionDef.JOIN_REQUEST_AUDIT})——
* 不需要新加:它就是为这条通道定义的。
*
* <p>⚠️ <b>没有"批量通过"接口</b>,这是刻意的(契约 §8.7.5 的取舍):
* 批量通过要求管理员先逐条看过(列表已提供信息),而**驳回必须逐条填原因**;
* 且批量通过**不提供"全选"快捷键**。理由与"批量通过只带绿色项"同源 ——
* 身份认定环节的误操作代价极高。
*/
@Tag(name = "管理端 · 名单加入申请")
@RestController
@RequestMapping("/api/v1/roster/join-requests")
@RequiredArgsConstructor
public class JoinRequestController {
private final JoinService joinService;
@GetMapping
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "申请待办列表",
description = "默认只看待审(status=0)。每条带 waitHours(等最久的排最前)与 "
+ "duplicateHint(该证件号在**其他任务**的名册里出现过,帮判断是不是本校学生)")
public ApiResult<JoinDtos.AdminListVO> list(@RequestParam(required = false) Long taskId,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long orgId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long pageNum,
@RequestParam(required = false) Long pageSize) {
return ApiResult.ok(joinService.list(taskId, status, orgId, keyword, pageNum, pageSize));
}
@PostMapping("/{id}/approve")
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "通过(建名册项 + 自动绑定)",
description = "事务内做三件事:建名册项、把申请人绑上去(免他重输)、回写申请。"
+ "不传 orgId 时用学生的自述班级匹配组织名,匹配不上(或撞名)会要求手选。"
+ "notifySent 恒为 false(通知渠道未实现),需要人工告知学生")
public ApiResult<JoinDtos.ApproveVO> approve(@PathVariable Long id,
@RequestBody(required = false) JoinDtos.ApproveReq req) {
return ApiResult.ok(joinService.approve(id, req));
}
@PostMapping("/{id}/reject")
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "驳回(原因必填)",
description = "原因会原样给学生看,所以要说清「哪里不对、该怎么办」。"
+ "已通过的申请不能驳回(40042)—— 要撤销请删名册项")
public ApiResult<JoinDtos.RejectVO> reject(@PathVariable Long id,
@RequestBody JoinDtos.RejectReq req) {
return ApiResult.ok(joinService.reject(id, req));
}
}
@@ -1,48 +0,0 @@
package cn.tianbaotong.fill.module.join.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.service.JoinService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 学生侧的名单加入申请(《03-接口设计》§8.7.1 / §8.7.2)。
*
* <p>权限:登录即可(学生)。**不额外挂权限码** —— 这条通道就是给
* "门禁没匹配上、什么权限都没有"的人用的,要求权限码等于把门关上。
* 限次(同 openid 每任务 ≤2 次)在服务层做,那才是这条通道的闸门。
*/
@Tag(name = "填报端 · 名单加入申请")
@RestController
@RequestMapping("/api/v1/mp/roster/join-request")
@RequiredArgsConstructor
public class MpJoinRequestController {
private final JoinService joinService;
@PostMapping
@Operation(summary = "提交加入申请",
description = "**只解决「进名单」,不绕过门禁** —— 申请期间学生仍不能填报。"
+ "证件号立刻转 HMAC + 脱敏,明文不落库。"
+ "已在名册(40039)/ 已有待审申请(40038)/ 超过 2 次(40037)都会被拒")
public ApiResult<JoinDtos.SubmitVO> submit(@RequestBody JoinDtos.SubmitReq req) {
return ApiResult.ok(joinService.submit(req));
}
@GetMapping
@Operation(summary = "我的申请(含状态与驳回原因)",
description = "通过之后 canFillNow=true:说明已经自动绑定,学生返回重新核验就能直接进")
public ApiResult<Map<String, Object>> mine(@RequestParam(required = false) Long taskId) {
return ApiResult.ok(joinService.myRequests(taskId));
}
}
@@ -1,252 +0,0 @@
package cn.tianbaotong.fill.module.join.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 名单加入申请(《03-接口设计》§8.7.1 ~ §8.7.5)。
*
* <p>学生侧与管理侧各有一份形状:学生只看得到自己的申请与状态,
* 管理侧要看到"谁、什么证件号、等多久、有没有重复线索"。
*/
public final class JoinDtos {
private JoinDtos() {
}
// ── 学生侧 ────────────────────────────────────
/** 提交申请(§8.7.1)。 */
@Getter
@Setter
@Schema(description = "提交加入申请")
public static class SubmitReq {
private Long taskId;
/** 姓名(明文存储,业务必需)。 */
private String realName;
/** 身份证号 —— **服务端立刻转 HMAC + 脱敏,明文不落库**。 */
private String idCardNo;
private String phone;
/** 自述班级。 */
private String orgHint;
private String reason;
}
/** 提交结果。 */
@Getter
@Setter
public static class SubmitVO {
private Long requestId;
private Integer status;
private String statusText;
/** 预计多久处理(给个量级,不是承诺)。 */
private Integer estimatedHours;
/** 还剩几次申请机会 —— 说清上限,比等他撞上 40037 再报错友好。 */
private Integer remainAttempts;
}
/** 学生看自己的申请(§8.7.2)。 */
@Getter
@Setter
public static class MyRequestVO {
private Long requestId;
private Long taskId;
private String taskName;
private Integer status;
private String statusText;
private String realName;
private String idCardMask;
private String orgHint;
private LocalDateTime createTime;
private LocalDateTime auditTime;
/** 驳回原因(学生需要知道为什么) */
private String rejectReason;
/** 通过后写入的名册项 */
private Long rosterId;
/** 通过之后能不能马上填 —— 界面据此给"返回重新核验"的按钮 */
private Boolean canFillNow;
}
// ── 管理侧 ────────────────────────────────────
/** 待办列表里的一条。 */
@Getter
@Setter
public static class AdminItemVO {
private Long requestId;
private Long taskId;
private String taskName;
private String realName;
private String idCardMask;
private String phoneMask;
private String orgHint;
private String reason;
private Integer status;
private String statusText;
private LocalDateTime createTime;
/** 已等待小时数(保留 1 位)—— 列表按它倒序,等最久的在最前 */
private Double waitHours;
/** 该证件号在**其他任务**的名册里出现过(帮管理员判断是不是本校学生) */
private DupHint duplicateHint;
private Long rosterId;
}
/** 重复线索。 */
@Getter
@Setter
public static class DupHint {
/** 一期只有 {@code IN_OTHER_TASK} */
private String type;
private String taskName;
private String realName;
}
/** 待办列表。 */
@Getter
@Setter
public static class AdminListVO {
private List<AdminItemVO> list = new ArrayList<>();
private long total;
private Counts counts;
/** 待审太多时的提醒(FR-509c:单任务待审 > 50 条) */
private Alert alert;
}
/** 三个计数。 */
@Getter
@Setter
public static class Counts {
private long pending;
private long approved;
private long rejected;
}
/** 提醒。 */
@Getter
@Setter
public static class Alert {
/** {@code 2} = 警告级 */
private Integer level;
private String text;
}
/** 审批通过(§8.7.4)。 */
@Getter
@Setter
@Schema(description = "通过加入申请")
public static class ApproveReq {
/** 指定班级。不传则取学生的自述班级(匹配不上就报错让他手选)。 */
private Long orgId;
/** 修正姓名(管理员核对后可改) */
private String realName;
/** 修正证件号 */
private String idCardNo;
/** 序号(默认追加到末尾) */
private Integer seqNo;
}
/** 通过结果。 */
@Getter
@Setter
public static class ApproveVO {
private Long requestId;
private Integer status;
private Long rosterId;
private Boolean rosterCreated;
/** ⚠️ 一期恒为 false(通知渠道 M5-12 还没做)—— **不写 true** */
private Boolean notifySent;
/** true = 该 openid 已绑定,学生再次进入直接放行 */
private Boolean canFillNow;
}
/** 驳回(§8.7.5)。 */
@Getter
@Setter
@Schema(description = "驳回加入申请")
public static class RejectReq {
/** **必填** —— 会原样推送给学生 */
private String reason;
/** 快捷原因码:{@code NOT_THIS_SCHOOL} / {@code INFO_MISMATCH} / {@code NOT_IN_SCOPE} / {@code OTHER} */
private String reasonCode;
}
/** 驳回结果。 */
@Getter
@Setter
public static class RejectVO {
private Long requestId;
private Integer status;
/** 一期恒为 false,见 {@link ApproveVO#getNotifySent()} */
private Boolean notifySent;
}
}
@@ -1,100 +0,0 @@
package cn.tianbaotong.fill.module.join.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 名单加入申请(对应 {@code collect_join_request},FR-509 · 客户确认后新增)。
*
* <h3>这条通道解决什么、不解决什么</h3>
*
* <p>门禁不通过的人原先只能"联系老师",老师再手工加名单 —— 几百人的批次下老师负担极重。
* 这里给一条<b>受控通道</b>:学生自助申请 → 管理员审批 → 自动入册。
*
* <p>⚠️ <b>关键边界:它只解决"进名单",不绕过门禁。</b>
* 申请期间学生仍然不能填报 —— 否则门禁就不再是准入门槛,
* 而是"填个表就能过"的形式主义,那整个产品的根基(凭证件号认人)就没了。
*
* <h3>证件号与手机号都只有 HMAC 与脱敏值</h3>
*
* <p>与 {@code collect_roster} 同一口径:{@code id_card_hash} 用于等值查重与审批后写名册,
* {@code id_card_mask} 用于展示。<b>明文不落库</b> —— 申请来自未核验的人,
* 更不该把明文留在库里。
*
* <p>唯一索引 {@code uk_join_task_idcard} 的作用:一个任务下同一证件号
* 只能有一条"待审或已通过"的申请,避免同一个人反复堆申请。
*/
@Getter
@Setter
@TableName("collect_join_request")
public class CollectJoinRequest extends TenantEntity {
/** {@code 0} 待审核 / {@code 1} 已通过 / {@code 2} 已驳回。 */
public static final int STATUS_PENDING = 0;
public static final int STATUS_APPROVED = 1;
public static final int STATUS_REJECTED = 2;
/** 来源:{@code 1} 学生自助申请(一期只有这一种)。 */
public static final int SOURCE_STUDENT = 1;
/** 同任务最多申请几次(契约 §8.7.1:同 openid 每任务 ≤2 次)。 */
public static final int MAX_ATTEMPTS = 2;
private Long taskId;
private String realName;
/** HMAC 的证件号索引(无法反推,用于查重与写名册时定位)。 */
private String idCardHash;
/**
* 证件号 <b>AES-GCM 密文</b>(V7 迁移新增)。
*
* <p>为什么申请表也要存密文:审批通过要往 {@code collect_roster} 插一行,
* 而那一行的 {@code id_card_no} 是 <b>NOT NULL</b> 的密文列。
* 只存 hash 的话,审批那一步会直接撞
* {@code null value in column "id_card_no" violates not-null constraint}(500)。
*
* <p>三条替代路都被排除:让学生先过门禁(先有鸡还是先有蛋)、
* 让管理员重输(等于把学生写错一位的风险复制给管理员)、
* 不写这一列(DDL 不允许,且审核看完整号时正是解这一列)。
*
* <p>⚠️ <b>是密文,不是明文</b> —— 明文只在提交请求的内存里存在过。
*/
private String idCardCipher;
private String idCardMask;
private String phoneHash;
private String phoneMask;
/** 自述班级(老师核对时用)。 */
private String orgHint;
private String reason;
private Integer status;
/** 驳回原因:<b>会原样推送给学生</b>,所以要写得能看懂。 */
private String rejectReason;
/** 通过后写入的名册项 id。 */
private Long rosterId;
private Long auditBy;
private LocalDateTime auditTime;
/** 申请人 openid(这条通道按 openid 限次与去重)。 */
private String applicantOpenid;
/** 申请人 uid(通过后写入名册的 {@code bind_user_id})。 */
private Long applicantUid;
private Integer source;
}
@@ -1,149 +0,0 @@
package cn.tianbaotong.fill.module.join.mapper;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 加入申请。
*
* <p>带 SQL 的地方一律**显式写 {@code tenant_id}**:这些查询会在
* "还没登录"或"跨任务核对"的路径上用到,而多租户拦截器只能保证
* "有登录上下文时"注入条件 —— 显式写一遍是给自己留的证据。
* (对照 {@code MpSceneMapper}:匿名接口那一族也是这个口径。)
*/
public interface CollectJoinRequestMapper extends BaseMapper<CollectJoinRequest> {
/** 同一个人(openid)在这个任务下已经申请过几次 —— 含已驳回的(驳回后重填是允许的)。 */
@Select("""
SELECT COUNT(*) FROM collect_join_request
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND applicant_openid = #{openid} AND deleted = 0
""")
int countByApplicant(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("openid") String openid);
/** 某个证件号在本任务下有没有"待审或已通过"的申请(唯一索引同口径)。 */
@Select("""
SELECT COUNT(*) FROM collect_join_request
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND id_card_hash = #{hash} AND status IN (0, 1) AND deleted = 0
""")
int countActiveByIdCard(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("hash") String hash);
/**
* 这个证件号在**其他任务**的名册里出现过吗(帮管理员判断是不是本校学生)。
*
* <p>返回 taskId 与姓名;没有就返回空列表。**只查同一租户** ——
* 跨租户的"是不是本校学生"是别人的数据,不能拿来做判断依据。
*/
@Select("""
SELECT r.task_id AS taskId, r.real_name AS realName
FROM collect_roster r
WHERE r.tenant_id = #{tenantId} AND r.id_card_hash = #{hash}
AND r.task_id <> #{taskId} AND r.deleted = 0
ORDER BY r.task_id DESC
LIMIT 1
""")
List<DupHint> findInOtherTasks(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("hash") String hash);
/** 跨任务核对的返回行。 */
class DupHint {
private Long taskId;
private String realName;
public Long getTaskId() {
return taskId;
}
public void setTaskId(Long v) {
this.taskId = v;
}
public String getRealName() {
return realName;
}
public void setRealName(String v) {
this.realName = v;
}
}
/**
* 该通知谁去审批这条申请(《08》F-102:以前一个都不通知)。
*
* <p>三类人,去重后返回:
* <ol>
* <li>这个任务覆盖班级的**班主任**({@code collect_task_org.head_user_id});</li>
* <li>这些班级的**副班主任**({@code deputy_user_ids} 存的是
* {@code ,1,2,} 这种带逗号的串,所以要用 {@code LIKE '%,id,%'} 判);</li>
* <li>本租户的**学校管理员** —— 兜底:任务没配班主任时也得有人管这件事。</li>
* </ol>
*
* <p>⚠️ 不去查"谁有 join:audit 权限":权限是"能不能做",收件人是"该谁做"。
* 一个学校有 50 个班主任,把申请推给全部 50 个等于推给没人。
*
* <p>⚠️ {@code collect_task_org} **没有 {@code deleted} 列**(V1 建表就没给,
* 全项目只有 5 张表这样,见《08》F-14 的排查记录)—— 所以这里不能写
* {@code cto.deleted = 0},否则整条 SQL 直接报"列不存在"。
*/
@Select("""
SELECT DISTINCT uid FROM (
SELECT cto.head_user_id AS uid
FROM collect_task_org cto
WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId}
AND cto.status = 1
UNION
SELECT CAST(TRIM(pid) AS BIGINT) AS uid
FROM collect_task_org cto,
LATERAL regexp_split_to_table(COALESCE(cto.deputy_user_ids, ''), ',') AS pid
WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId}
AND cto.status = 1
AND TRIM(pid) <> ''
UNION
SELECT u.id AS uid
FROM sys_user u
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0
WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1
AND ro.role_code = 'SCHOOL_ADMIN'
) x
WHERE uid IS NOT NULL
""")
List<Long> auditUserIdsOfTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/**
* 审批通过后回写申请单。
*
* <p>条件里带 {@code status = 0}:**"查"与"写"之间可能有人已经处理过**,
* 影响行数为 0 就说明被别人抢先了(并发下两个管理员同时点通过)。
* 这比"先查后写"可靠 —— 后者在并发时会给同一个人建两条名册。
*/
@Update("""
UPDATE collect_join_request
SET status = 1, roster_id = #{rosterId}, audit_by = #{auditor},
audit_time = NOW(), update_time = NOW()
WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 0 AND deleted = 0
""")
int markApproved(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("rosterId") Long rosterId, @Param("auditor") Long auditor);
/** 驳回的回写(同样带状态条件)。 */
@Update("""
UPDATE collect_join_request
SET status = 2, reject_reason = #{reason}, audit_by = #{auditor},
audit_time = NOW(), update_time = NOW()
WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 0 AND deleted = 0
""")
int markRejected(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("reason") String reason, @Param("auditor") Long auditor);
}
@@ -1,547 +0,0 @@
package cn.tianbaotong.fill.module.join.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import cn.tianbaotong.fill.module.join.mapper.CollectJoinRequestMapper;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import cn.tianbaotong.fill.module.notify.service.NotificationService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 名单加入申请(FR-509,《03-接口设计》§8.7)。
*
* <h3>这条通道的四条边界(都是刻意的,不要"顺手放宽")</h3>
*
* <ol>
* <li><b>只解决"进名单",不绕过门禁。</b>申请期间学生仍然不能填报 ——
* 否则门禁就变成"填个表就能过"的形式主义,整套"凭证件号认人"的根基就没了。</li>
* <li><b>每人每任务最多 2 次</b>({@code 40037})。不设上限的话,
* 一个输错号码的学生能刷出几十条申请,管理员的待办里全是同一个人。</li>
* <li><b>证件号已在名册 → {@code 40039} 让他回去重新核验</b>,而不是受理申请。
* 这种情况说明他<b>只是姓名输错了</b>,受理申请等于让管理员去纠正一个
* 本来自己就能改对的问题。这一步不违反"统一失败提示":
* 学生已经主动提供了正确证件号,此时说"你已在名单中"**不泄露任何新信息**。</li>
* <li><b>审批通过时自动绑定</b>({@code bind_user_id} = 申请人)——
* 不然学生还得再输一遍姓名与证件号,而这两项刚刚才由他提供过。</li>
* </ol>
*
* <h3>明文只在内存里存在</h3>
*
* <p>证件号与手机号在这里<b>立刻</b>转成 HMAC + 脱敏值,
* 落库的只有 hash 与 mask(与 {@code collect_roster} 同一口径)。
* 申请来自**尚未核验**的人,更不该把明文留在库里。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class JoinService {
/** 与账号模块同一口径的手机号校验。 */
private static final Pattern PHONE = Pattern.compile("^1[3-9]\\d{9}$");
/** 预计处理时长(给个量级,不是承诺)。 */
private static final int ESTIMATED_HOURS = 24;
/** 待审超过这个数就在列表上提醒(FR-509c)。 */
private static final int ALERT_PENDING = 50;
private final CollectJoinRequestMapper joinMapper;
private final CollectRosterMapper rosterMapper;
private final TaskMapper taskMapper;
private final UserMapper userMapper;
private final SensitiveCodec codec;
private final NotificationService notificationService;
// ── 学生侧 ─────────────────────────────────────
/** 提交申请(§8.7.1)。 */
public JoinDtos.SubmitVO submit(JoinDtos.SubmitReq req) {
if (req == null || req.getTaskId() == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 taskId");
}
String name = IdentityNormalizer.name(req.getRealName());
if (name == null || name.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写姓名");
}
String idCard = IdentityNormalizer.idCard(req.getIdCardNo());
if (idCard == null || !idCard.matches("^\\d{17}[\\dXx]$")) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写 18 位身份证号");
}
String phone = req.getPhone() == null ? "" : req.getPhone().trim();
if (!PHONE.matcher(phone).matches()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写正确的手机号(老师要能联系到你)");
}
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
CollectTask task = taskMapper.selectById(req.getTaskId());
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
if (task.getStatus() == null || task.getStatus() != CollectTask.STATUS_COLLECTING) {
throw new BizException(ErrorCode.TASK_NOT_OPEN, "任务当前不在收集期,暂时不能申请加入");
}
SysUser me = userMapper.selectById(uid);
String openid = me == null || me.getOpenId() == null ? "" : me.getOpenId();
if (openid.isBlank()) {
// 没有 openid 就没法按"人"限次(契约是"同 openid 每任务 ≤2 次")。
// 与其放宽成"不限次",不如明确拒绝 —— 放宽的口子会被反复利用。
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"当前登录方式不支持提交申请,请从微信里重新进入");
}
int used = joinMapper.countByApplicant(tenantId, req.getTaskId(), openid);
if (used >= CollectJoinRequest.MAX_ATTEMPTS) {
throw new BizException(ErrorCode.JOIN_REQUEST_LIMIT);
}
String hash = codec.hash(idCard);
// ① 已在名册 → 让他回去重新核验(不是受理申请)
List<CollectRoster> inRoster = rosterMapper.selectByHash(tenantId, req.getTaskId(), hash);
if (!inRoster.isEmpty()) {
throw new BizException(ErrorCode.ALREADY_IN_ROSTER);
}
// ② 同证件号已有待审或已通过的申请
if (joinMapper.countActiveByIdCard(tenantId, req.getTaskId(), hash) > 0) {
throw new BizException(ErrorCode.JOIN_REQUEST_DUPLICATE);
}
CollectJoinRequest r = new CollectJoinRequest();
r.setTaskId(req.getTaskId());
r.setRealName(name);
r.setIdCardHash(hash);
// ★ 密文也要存:审批通过时要原样写进名册的 id_card_no(NOT NULL 的密文列)。
// 只存 hash 的话审批那一步会撞非空约束 —— 这正是本轮发现并修掉的 F-19。
r.setIdCardCipher(codec.encrypt(idCard));
r.setIdCardMask(MaskUtil.maskIdCard(idCard));
r.setPhoneHash(codec.hash(phone));
r.setPhoneMask(MaskUtil.maskPhone(phone));
r.setOrgHint(req.getOrgHint() == null ? null
// ⚠️ 自述班级**不能**用 IdentityNormalizer.name() 规范化:那个方法会去掉空格
// (姓名要"张 三"→"张三"),而班级名里的空格是**有意义**的
// ("24中技数控1班 645451" 去掉空格就再也匹配不上组织了)。
// 这里只做全角转半角 + 去首尾空白。
: IdentityNormalizer.toHalfWidth(req.getOrgHint()).trim());
r.setReason(req.getReason() == null ? null : req.getReason().trim());
r.setStatus(CollectJoinRequest.STATUS_PENDING);
r.setApplicantOpenid(openid);
r.setApplicantUid(uid);
r.setSource(CollectJoinRequest.SOURCE_STUDENT);
joinMapper.insert(r);
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
notifyAuditors(task, r, name);
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
vo.setRequestId(r.getId());
vo.setStatus(CollectJoinRequest.STATUS_PENDING);
vo.setStatusText("已提交,等待学校审核");
vo.setEstimatedHours(ESTIMATED_HOURS);
vo.setRemainAttempts(CollectJoinRequest.MAX_ATTEMPTS - used - 1);
return vo;
}
/** 学生看自己的申请(§8.7.2)。 */
public Map<String, Object> myRequests(Long taskId) {
Long uid = UserContext.uid();
var q = Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(CollectJoinRequest::getApplicantUid, uid)
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.orderByDesc(CollectJoinRequest::getId);
List<CollectJoinRequest> rows = joinMapper.selectList(q);
Map<Long, String> taskNames = new HashMap<>();
List<Long> taskIds = rows.stream().map(CollectJoinRequest::getTaskId).distinct().toList();
if (!taskIds.isEmpty()) {
for (CollectTask t : taskMapper.selectByIds(taskIds)) {
taskNames.put(t.getId(), t.getTaskName());
}
}
List<JoinDtos.MyRequestVO> list = new ArrayList<>();
for (CollectJoinRequest r : rows) {
JoinDtos.MyRequestVO vo = new JoinDtos.MyRequestVO();
vo.setRequestId(r.getId());
vo.setTaskId(r.getTaskId());
vo.setTaskName(taskNames.get(r.getTaskId()));
vo.setStatus(r.getStatus());
vo.setStatusText(statusText(r.getStatus()));
vo.setRealName(r.getRealName());
vo.setIdCardMask(r.getIdCardMask());
vo.setOrgHint(r.getOrgHint());
vo.setCreateTime(r.getCreateTime());
vo.setAuditTime(r.getAuditTime());
vo.setRejectReason(r.getRejectReason());
vo.setRosterId(r.getRosterId());
// 通过 = 已经绑好了 → 学生重新核验就能直接进
vo.setCanFillNow(r.getStatus() != null && r.getStatus() == CollectJoinRequest.STATUS_APPROVED
&& r.getRosterId() != null);
list.add(vo);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("list", list);
out.put("total", list.size());
return out;
}
private String statusText(Integer status) {
return switch (status == null ? 0 : status) {
case CollectJoinRequest.STATUS_PENDING -> "待审核";
case CollectJoinRequest.STATUS_APPROVED -> "已通过,可以开始填报了";
case CollectJoinRequest.STATUS_REJECTED -> "未通过";
default -> "未知";
};
}
// ── 管理侧 ─────────────────────────────────────
/** 待办列表(§8.7.3)。 */
public JoinDtos.AdminListVO list(Long taskId, Integer status, Long orgId, String keyword,
Long pageNum, Long pageSize) {
Long tenantId = UserContext.tenantId();
int st = status == null ? CollectJoinRequest.STATUS_PENDING : status;
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
long ps = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
// orgId 筛的是"自述班级":申请还没有正式班级,只能按学生自己写的筛
var q = Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(CollectJoinRequest::getStatus, st)
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.like(keyword != null && !keyword.isBlank(), CollectJoinRequest::getRealName,
keyword == null ? "" : keyword.trim())
.orderByAsc(CollectJoinRequest::getCreateTime);
var page = joinMapper.selectPage(
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pn, ps), q);
Map<Long, String> taskNames = new HashMap<>();
List<Long> taskIds = page.getRecords().stream()
.map(CollectJoinRequest::getTaskId).distinct().toList();
if (!taskIds.isEmpty()) {
for (CollectTask t : taskMapper.selectByIds(taskIds)) {
taskNames.put(t.getId(), t.getTaskName());
}
}
JoinDtos.AdminListVO out = new JoinDtos.AdminListVO();
List<JoinDtos.AdminItemVO> list = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (CollectJoinRequest r : page.getRecords()) {
JoinDtos.AdminItemVO vo = new JoinDtos.AdminItemVO();
vo.setRequestId(r.getId());
vo.setTaskId(r.getTaskId());
vo.setTaskName(taskNames.get(r.getTaskId()));
vo.setRealName(r.getRealName());
vo.setIdCardMask(r.getIdCardMask());
vo.setPhoneMask(r.getPhoneMask());
vo.setOrgHint(r.getOrgHint());
vo.setReason(r.getReason());
vo.setStatus(r.getStatus());
vo.setStatusText(statusText(r.getStatus()));
vo.setCreateTime(r.getCreateTime());
vo.setRosterId(r.getRosterId());
if (r.getCreateTime() != null) {
long mins = Duration.between(r.getCreateTime(), now).toMinutes();
vo.setWaitHours(Math.round(mins / 6.0) / 10.0);
}
// 重复线索:这个证件号在别的任务的名册里出现过吗
List<CollectJoinRequestMapper.DupHint> dup =
joinMapper.findInOtherTasks(tenantId, r.getTaskId(), r.getIdCardHash());
if (!dup.isEmpty()) {
JoinDtos.DupHint hint = new JoinDtos.DupHint();
hint.setType("IN_OTHER_TASK");
hint.setTaskName(taskNames.getOrDefault(dup.get(0).getTaskId(),
"任务 #" + dup.get(0).getTaskId()));
hint.setRealName(dup.get(0).getRealName());
vo.setDuplicateHint(hint);
}
list.add(vo);
}
out.setList(list);
out.setTotal(page.getTotal());
JoinDtos.Counts counts = new JoinDtos.Counts();
counts.setPending(countByStatus(taskId, CollectJoinRequest.STATUS_PENDING));
counts.setApproved(countByStatus(taskId, CollectJoinRequest.STATUS_APPROVED));
counts.setRejected(countByStatus(taskId, CollectJoinRequest.STATUS_REJECTED));
out.setCounts(counts);
if (counts.getPending() > ALERT_PENDING) {
JoinDtos.Alert alert = new JoinDtos.Alert();
alert.setLevel(2);
alert.setText("待审申请已达 " + counts.getPending() + " 条,建议集中处理");
out.setAlert(alert);
}
return out;
}
private long countByStatus(Long taskId, int status) {
return joinMapper.selectCount(Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.eq(CollectJoinRequest::getStatus, status));
}
/**
* 审批通过(§8.7.4):建名册项 + 自动绑定 + 回写申请。
*
* <p><b>事务</b>:名册建了而申请没回写(或反过来),学生会看到"申请还在待审"
* 但名册里已经有他了 —— 他接着再申请一次就撞 {@code 40038}。两件事必须同生同死。
*/
@Transactional(rollbackFor = Exception.class)
public JoinDtos.ApproveVO approve(Long id, JoinDtos.ApproveReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
CollectJoinRequest r = joinMapper.selectById(id);
if (r == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "申请不存在或已删除");
}
if (r.getStatus() == null || r.getStatus() != CollectJoinRequest.STATUS_PENDING) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
// 班级:优先用管理员指定的;没指定就用学生的自述班级去匹配组织名(匹配不上必须手选)
Long orgId = req == null ? null : req.getOrgId();
if (orgId == null) {
orgId = matchOrgByName(r.getOrgHint());
}
if (orgId == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"请指定这个学生要加入的班级"
+ (r.getOrgHint() == null ? "" : "(他自述是「" + r.getOrgHint() + "」)"));
}
if (rosterMapper.countOrg(tenantId, orgId) == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
}
// 证件号:允许管理员核对后修正(学生可能把号码写错了)
String idCard = req != null && req.getIdCardNo() != null && !req.getIdCardNo().isBlank()
? IdentityNormalizer.idCard(req.getIdCardNo()) : null;
String hash = r.getIdCardHash();
if (idCard != null) {
hash = codec.hash(idCard);
}
String name = req != null && req.getRealName() != null && !req.getRealName().isBlank()
? IdentityNormalizer.name(req.getRealName()) : r.getRealName();
// 名册里这个证件号被占了吗(可能刚被老师手工加进去)
if (!rosterMapper.selectByHash(tenantId, r.getTaskId(), hash).isEmpty()) {
throw new BizException(ErrorCode.ROSTER_ID_CARD_TAKEN);
}
CollectRoster row = new CollectRoster();
row.setTaskId(r.getTaskId());
row.setOrgId(orgId);
row.setSeqNo(req == null ? null : req.getSeqNo());
row.setRealName(name);
row.setIdCardMask(MaskUtil.maskIdCard(idCard != null ? idCard : r.getIdCardMask()));
row.setIdCardHash(hash);
// 名册的 id_card_no 是 **NOT NULL 的密文列**:管理员修正了就用新的,否则搬申请里那份
if (idCard != null) {
row.setIdCardNo(codec.encrypt(idCard));
} else if (r.getIdCardCipher() != null && !r.getIdCardCipher().isBlank()) {
row.setIdCardNo(r.getIdCardCipher());
} else {
// V7 之前提交的申请没有密文列。**不能静默建一条没有证件号的名册行** ——
// 那条行以后永远过不了名册校验,而且没人知道为什么。
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这条申请缺少证件号信息,请让申请人在小程序里重新提交一次");
}
row.setPhone(r.getPhoneMask());
row.setPhoneHash(r.getPhoneHash());
// 来源留痕:老师日后问"这个人是哪来的",能答得上来
row.setExtJson(JSONUtil.createObj()
.set("source", "JOIN_REQUEST")
.set("joinRequestId", r.getId())
.set("orgHint", r.getOrgHint())
.toString());
// ★ 自动绑定(FR-509g):学生刚提供过姓名与证件号,不该让他再输一遍
row.setBindUserId(r.getApplicantUid());
row.setBindTime(LocalDateTime.now());
// bind_type = 3(来自申请)—— 与契约 §8.7.4 一致;1 门禁 / 2 管理员指定
row.setBindType(3);
row.setSubmitStatus(CollectRoster.SUBMIT_NONE);
rosterMapper.insert(row);
int affected = joinMapper.markApproved(tenantId, id, row.getId(), uid);
if (affected == 0) {
// 并发下别人先处理了 —— 整笔回滚(名册那一行也不能留)
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
// ★ 通知学生(《08》F-102):以前这里只写一行 log.warn,
// 学生要自己反复进小程序看结果 —— 而这条链路的另一端(通知渠道)
// 早在 M5 就做好了,只是没接上。
boolean notifySent = false;
if (r.getApplicantUid() != null) {
try {
var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_APPROVE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(r.getTaskId()),
"姓名", name),
"加入名单申请已通过",
CollectMessage.LINK_TASK, r.getTaskId(),
NotificationService.TPL_JOIN_APPROVE)
.withExtra("你现在可以扫码核验身份,然后开始填报了。"));
notifySent = nr.pushSent();
} catch (RuntimeException e) {
log.warn("加入申请通过的通知失败(不影响审批结果):request={}", id, e);
}
}
JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
vo.setRosterId(row.getId());
vo.setRosterCreated(true);
// ⚠️ 语义与驳回接口一致:这是"**微信推送**发出去了没有"。
// 站内信一定写得进去,所以学生一定能在消息中心看到结果。
vo.setNotifySent(notifySent);
vo.setCanFillNow(r.getApplicantUid() != null);
return vo;
}
/**
* 通知该审批这条申请的人(班主任 / 副班主任 / 学校管理员兜底)。
*
* <p>《08》F-102:提交申请之后**一个通知都没有** —— 老师只能自己想到去翻审批页。
* 而"申请加入名单"本来就是一条"学生等着、老师不知道"的链路,
* 没有通知等于这条路走不通(学生会一直等,或者去问班主任"你看到我申请了吗")。
*/
private void notifyAuditors(CollectTask task, CollectJoinRequest r, String name) {
try {
var uids = joinMapper.auditUserIdsOfTask(UserContext.tenantId(), r.getTaskId());
if (uids.isEmpty()) {
log.warn("加入申请无人可通知(该任务没配班主任、租户也没有管理员):request={}", r.getId());
return;
}
for (Long to : uids) {
notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
to, CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_SUBMIT",
NotificationService.NotifyRequest.vars(
"任务名", task.getTaskName(),
"姓名", name,
"班级", r.getOrgHint()),
"有新的加入名单申请",
// 跳任务而不是跳申请单:审批页是按任务筛的(学生端 SCENE 也只带 taskId)
CollectMessage.LINK_TASK, r.getTaskId(), null));
}
log.info("加入申请已通知 {} 位审批人:request={}", uids.size(), r.getId());
} catch (RuntimeException e) {
log.warn("加入申请的通知失败(不影响学生提交):request={}", r.getId(), e);
}
}
private String taskNameOf(Long taskId) {
CollectTask t = taskId == null ? null : taskMapper.selectById(taskId);
return t == null ? null : t.getTaskName();
}
private String nz(String s) {
return s == null ? "" : s;
}
/** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */
private Long matchOrgByName(String orgHint) {
if (orgHint == null || orgHint.isBlank()) {
return null;
}
Long tenantId = UserContext.tenantId();
var rows = orgNameToId(tenantId, orgHint.trim());
return rows.size() == 1 ? rows.get(0) : null;
}
private List<Long> orgNameToId(Long tenantId, String name) {
// 直接查 sys_org:几十个节点,一次取回来在内存里匹配
return rosterMapper.selectOrgIdsByName(tenantId, name);
}
/** 驳回(§8.7.5)。 */
@Transactional(rollbackFor = Exception.class)
public JoinDtos.RejectVO reject(Long id, JoinDtos.RejectReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
if (req == null || req.getReason() == null || req.getReason().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写驳回原因(学生要知道为什么)");
}
CollectJoinRequest r = joinMapper.selectById(id);
if (r == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "申请不存在或已删除");
}
if (r.getStatus() != null && r.getStatus() == CollectJoinRequest.STATUS_APPROVED) {
// 已通过不能"驳回" —— 要撤销请删名册项(否则名册里那个人就成了没人认领的行)
throw new BizException(ErrorCode.JOIN_ALREADY_APPROVED);
}
if (r.getStatus() == null || r.getStatus() != CollectJoinRequest.STATUS_PENDING) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
String reason = req.getReasonCode() == null || req.getReasonCode().isBlank()
? req.getReason().trim()
: "[" + req.getReasonCode() + "] " + req.getReason().trim();
int affected = joinMapper.markRejected(tenantId, id, reason, uid);
if (affected == 0) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason);
// ★ 通知学生(《08》F-102):驳回原因学生必须看到,
// 否则他会以为"申请丢了"而反复重提(每人每任务只能提 2 次)。
boolean notifySent = false;
if (r.getApplicantUid() != null) {
try {
var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_APPROVE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(r.getTaskId()),
"姓名", r.getRealName(),
"驳回原因", reason),
"加入名单申请未通过",
CollectMessage.LINK_TASK, r.getTaskId(),
NotificationService.TPL_JOIN_APPROVE)
.withExtra("原因:" + reason));
notifySent = nr.pushSent();
} catch (RuntimeException e) {
log.warn("加入申请驳回的通知失败(不影响驳回结果):request={}", id, e);
}
}
JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
vo.setNotifySent(notifySent);
return vo;
}
}
@@ -67,6 +67,8 @@ 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";
public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
@@ -87,16 +89,27 @@ public class OperationLogService {
public static final String ACTION_PROXY_UPLOAD = "PROXY_UPLOAD";
public static final String ACTION_VIEW_IDCARD_FULL = "VIEW_IDCARD_FULL";
public static final String ACTION_CONFIG_COMPRESS = "CONFIG_COMPRESS";
public static final String ACTION_JOIN_APPROVE = "JOIN_APPROVE";
public static final String ACTION_JOIN_REJECT = "JOIN_REJECT";
/** 新建自定义角色。 */
public static final String ACTION_CREATE_ROLE = "CREATE_ROLE";
/** 改角色名称 / 用户类型 / 备注(角色码不可改)。 */
public static final String ACTION_UPDATE_ROLE = "UPDATE_ROLE";
/** 删除自定义角色(软删)。 */
public static final String ACTION_DELETE_ROLE = "DELETE_ROLE";
/** 改角色的权限码(授权变更 —— 留痕里记变更前后的码清单)。 */
public static final String ACTION_GRANT_ROLE = "GRANT_ROLE";
/** 改角色的可见菜单。 */
public static final String ACTION_UPDATE_ROLE_MENU = "UPDATE_ROLE_MENU";
/** 停用 / 启用角色。 */
public static final String ACTION_TOGGLE_ROLE = "TOGGLE_ROLE";
public static final String TARGET_ROSTER = "ROSTER";
public static final String TARGET_TASK = "TASK";
public static final String TARGET_SUBMISSION = "SUBMISSION";
public static final String TARGET_FILE = "FILE";
public static final String TARGET_EXPORT = "EXPORT";
public static final String TARGET_JOIN_REQUEST = "JOIN_REQUEST";
public static final String TARGET_USER = "USER";
/** 角色({@code sys_role.id})。 */
public static final String TARGET_ROLE = "ROLE";
/**
* 记一条操作日志(**不抛异常**)。
@@ -104,7 +104,9 @@ public class MaterialDefController {
Map<String, Object> out = new LinkedHashMap<>();
List<Map<String, Object>> codes = new java.util.ArrayList<>();
for (String code : MaterialCatalog.allCodes()) {
// ⚠️ 用 selectableCodes() 而不是 allCodes():学信网备案表 / 其它佐证材料
// 属已取消的二期,**读得到、选不到**(历史任务里配过的材料项仍能显示名字)。
for (String code : MaterialCatalog.selectableCodes()) {
MaterialCatalog.Kind kind = MaterialCatalog.of(code);
Map<String, Object> m = new LinkedHashMap<>();
m.put("code", code);
@@ -73,11 +73,29 @@ public final class MaterialCatalog {
}
}
/** 全部已知材料码,按 §2.1 的顺序。 */
/**
* 全部已知材料码,按 §2.1 的顺序。
*
* <p>⚠️ 这是<b>全集</b>(含只用于读历史数据的两类),不是"能新建的清单"。
* 界面下拉要用 {@link #selectableCodes()}。
*/
public static List<String> allCodes() {
return KINDS.stream().map(Kind::code).toList();
}
/**
* <b>一期实际可选</b>的材料码(4 类,全都有实测样本)。
*
* <p>为什么与 {@link #allCodes()} 分开:{@code XJW_BA}(学信网备案表)与
* {@code OTHER_PROOF}(其它佐证材料)属于**已取消的二期**,样本为零。
* 它们保留在 {@link #KINDS} 里只为了一件真实的事 ——
* 历史任务可能配过这两类材料,删掉目录会让那些材料项**悬空**(界面显示不出名字)。
* 所以:**读得到、选不到**。
*/
public static List<String> selectableCodes() {
return List.of("PHOTO1", "IDCARD", "SCHOOL_ROLL", "WORK_PROOF");
}
public static Kind of(String code) {
return code == null ? null : BY_CODE.get(code);
}
@@ -134,10 +152,10 @@ public final class MaterialCatalog {
public record TemplateItem(String code, String visibleWhen) {
}
/** §7.10 的三个内置模板。 */
/** 内置模板。**一期只有学校收集一种** —— 社会收集与专项职业能力已随二期一起取消。 */
private static final Map<String, List<TemplateItem>> TEMPLATES = new LinkedHashMap<>();
/** FR-402 的级联:在校生看学籍表,非在校生看工作证明 / 学信网备案表。 */
/** FR-402 的级联:在校生看学籍表,非在校生看工作证明。 */
private static final String WHEN_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":true}";
private static final String WHEN_NOT_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":false}";
@@ -150,17 +168,6 @@ public final class MaterialCatalog {
new TemplateItem("IDCARD", null),
new TemplateItem("SCHOOL_ROLL", WHEN_STUDENT),
new TemplateItem("WORK_PROOF", WHEN_NOT_STUDENT)));
// 社会收集:没有学籍表,多一张学信网备案表(非在校生才有)
TEMPLATES.put("TPL_SOCIAL", List.of(
new TemplateItem("PHOTO1", null),
new TemplateItem("IDCARD", null),
new TemplateItem("WORK_PROOF", null),
new TemplateItem("XJW_BA", WHEN_NOT_STUDENT)));
// 专项职业能力:只要三样
TEMPLATES.put("TPL_ZHUANXIANG", List.of(
new TemplateItem("PHOTO1", null),
new TemplateItem("IDCARD", null),
new TemplateItem("WORK_PROOF", null)));
}
public static List<TemplateItem> template(String templateCode) {
@@ -179,8 +186,6 @@ public final class MaterialCatalog {
m.put("templateCode", code);
m.put("label", switch (code) {
case "TPL_SCHOOL_ROLL" -> "学校收集(照片+身份证+学籍表+工作证明)";
case "TPL_SOCIAL" -> "社会收集(照片+身份证+工作证明+学信网备案表)";
case "TPL_ZHUANXIANG" -> "专项职业能力(照片+身份证+工作证明)";
default -> code;
});
m.put("materialCodes", items.stream().map(TemplateItem::code).toList());
@@ -84,7 +84,6 @@ public interface TeacherDashboardMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
GROUP BY o.id, o.org_name
@@ -114,7 +113,6 @@ public interface TeacherDashboardMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
@@ -142,7 +140,6 @@ public interface TeacherDashboardMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
@@ -177,7 +174,6 @@ public interface TeacherDashboardMapper {
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
@@ -200,7 +196,6 @@ public interface TeacherDashboardMapper {
LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0
WHERE cto.task_id = t.id AND cto.status = 1
AND (cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
)
ORDER BY t.end_time ASC
@@ -86,7 +86,7 @@ public final class NotifyDtos {
@Schema(description = "登记订阅消息额度")
public static class SubscribeReq {
/** 模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY / JOIN_APPROVE */
/** 模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY */
private String templateCode;
/** 本次授权次数(微信返回,默认 1) */
@@ -30,7 +30,6 @@ public class CollectMessage extends TenantEntity {
public static final int BIZ_APPROVE = 4;
public static final int BIZ_URGE = 5;
public static final int BIZ_EXPORT = 6;
public static final int BIZ_JOIN_AUDIT = 7;
/** 点开消息跳哪儿。 */
public static final String LINK_SUBMISSION = "SUBMISSION";
@@ -99,7 +99,6 @@ public interface UrgeMapper {
</if>
<if test="scopeUserId != null">
AND (cto.head_user_id = #{scopeUserId}
OR cto.deputy_user_ids LIKE ('%,' || #{scopeUserId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{scopeUserId}))
</if>
ORDER BY o.org_name, r.id
@@ -314,7 +313,6 @@ public interface UrgeMapper {
AND r.task_id = #{taskId}
</if>
AND (cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
</script>
""")
@@ -72,14 +72,14 @@ public class NotificationService {
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
* </ul>
* 两套里 {@code JOIN_APPROVE} 恰好**同名**,纯属巧合 ——
* 下一个改代码的人很容易当成同一个东西,所以在这里写清楚(见《08》F-106)。
* ⚠️ 删掉「加入申请」之后,两套同名的那一个({@code JOIN_APPROVE})已经不存在了:
* 上一版这里专门写过"同名纯属巧合",现在两套码**没有任何重名**,
* 但仍要记住它们是两个命名空间(见《08》F-106)。
*/
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
public static final String TPL_EXPORT_READY = "EXPORT_READY";
public static final String TPL_JOIN_APPROVE = "JOIN_APPROVE";
private final CollectMessageMapper messageMapper;
private final SubscribeQuotaMapper quotaMapper;
@@ -69,10 +69,6 @@ public final class NotifyTemplates {
"导出包 {导出包名} 已生成,请到「导出中心」下载。"
+ "(下载链接有有效期,过期请重新导出)",
"导出完成"),
new Def("JOIN_APPROVE", 7, "加入名单申请已处理",
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员"),
new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
"驳回次数达上限时发给学校管理员(契约 §11.4)")
@@ -162,17 +162,18 @@ public interface ScheduleMapper {
/**
* 某个任务下"该审批的人"(初审=班级负责人,终审=学校管理员)。
*
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` /
* `deputy_user_ids` / 组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` 优先,
* 为空回退到组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
* 发给所有人等于没人负责。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT DISTINCT u.id AS "userId", u.real_name AS "realName"
FROM collect_task_org cto
LEFT JOIN sys_org o
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
JOIN sys_user u ON u.deleted = 0 AND u.status = 1
AND (u.id = cto.head_user_id
OR cto.deputy_user_ids LIKE ('%,' || u.id || ',%'))
AND u.id = COALESCE(cto.head_user_id, o.leader_user_id)
WHERE cto.task_id = #{taskId} AND cto.status = 1 AND cto.tenant_id = #{tenantId}
""")
List<Map<String, Object>> classAuditors(@Param("tenantId") Long tenantId,
@@ -71,6 +71,7 @@ public class ScheduleJobs {
private final cn.tianbaotong.fill.module.tenant.service.TenantUsageService usageService;
private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService;
private final cn.tianbaotong.fill.module.file.mapper.FileMapper fileMapper;
private final cn.tianbaotong.fill.support.RoleProvisioner roleProvisioner;
/** 原始件一次最多清多少行(分轮清完,别一次把磁盘 IO 打满)。 */
private static final int RAW_CLEAN_BATCH = 200;
@@ -487,6 +488,50 @@ public class ScheduleJobs {
}
}
// ══════════════ ⑨ 内置角色授权同步(每日 03:20)══════════════
/**
* 把每个租户的**内置角色授权对齐到代码**(二期「角色与授权」的前置)。
*
* <p>为什么必须有这一步:内置角色的权限由代码决定({@code BuiltinRole}),
* 而库里的 {@code sys_role_permission} 只在**开通租户那一刻**写过一次。
* 于是代码里给某个内置角色加了/减了一条权限之后,**已经开通的租户永远停在旧的那套** ——
* 而权限判定本身不读这张表(它按代码算),所以这个不一致**不会报错、也不会被任何测试抓到**,
* 只会在有人查库、或将来改成"从库读"的时候炸出来。
*
* <p>幂等:{@code provisionBuiltinRoles} 只在值真的不同才写。
* 03:20 挑的是所有定时任务都不在的时间段(口径见各任务的 cron)。
*/
@Scheduled(cron = "0 20 3 * * *")
public void syncBuiltinRoleGrants() {
try {
List<Map<String, Object>> tenants = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(),
snapshotMapper::activeTenants);
assertTenantPresent(tenants, "role");
int done = 0;
for (Map<String, Object> row : tenants) {
Long tenantId = longOf(row, "tenantId");
if (tenantId == null) {
continue;
}
try {
// 写数据必须在"摆好租户上下文"这一步:否则 tenant_id 会被写成 0
// (角色发到平台租户下,那个学校的人永远看不到,而且不报错)
TenantScope.runAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE),
() -> roleProvisioner.provisionBuiltinRoles(tenantId));
done++;
} catch (RuntimeException e) {
log.warn("内置角色授权同步失败:tenant={} 原因={}", tenantId, e.toString());
}
}
if (done > 0) {
log.info("内置角色授权同步:{} 个租户", done);
}
} catch (RuntimeException e) {
log.warn("内置角色授权同步任务失败(下一轮会重试)", e);
}
}
private int remindExpire(Long tenantId, String tenantName, int daysLeft) {
var receivers = scheduleMapper.usersOfRole(tenantId,
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
@@ -527,13 +572,14 @@ public class ScheduleJobs {
* <p>为什么要这个入口:**定时任务的"等不起"是测试与运维的共同问题** ——
* 验证"截止后会怎样"总不能在测试里等到明天 09:00(F-37 已经踩过一次)。
*
* @param job 任务名({@code close} / {@code urge} / {@code timeout} / {@code raw})
* @param job 任务名({@code close} / {@code urge} / {@code timeout} / {@code raw}
* / {@code usage} / {@code retry} / {@code expire} / {@code role})
* @return 这一轮做了什么(给人看的说明)
*/
public String runNow(String job) {
if (job == null || job.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"请指定要跑的任务:close / urge / timeout / raw / usage / retry / expire");
"请指定要跑的任务:close / urge / timeout / raw / usage / retry / expire / role");
}
switch (job.trim().toLowerCase()) {
case "close" -> autoCloseExpiredTasks();
@@ -543,8 +589,9 @@ public class ScheduleJobs {
case "usage" -> snapshotUsage();
case "retry" -> retryFailedProcess();
case "expire" -> remindTenantExpiring();
case "role" -> syncBuiltinRoleGrants();
default -> throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"不认识的任务名「" + job + "」:只有 close / urge / timeout / raw / usage / retry / expire");
"不认识的任务名「" + job + "」:只有 close / urge / timeout / raw / usage / retry / expire / role");
}
return "已手动触发一轮:" + job;
}
@@ -52,15 +52,11 @@ public class TaskOrgSaveReq {
* 该班初审人(班主任)。
*
* <p>可空 —— 为空表示取班级负责人。指定时必须是本租户内
* <b>持有班主任或副班主任角色</b>的启用账号(见 {@code TaskOrgService})。
* <b>持有班主任角色</b>的启用账号(见 {@code TaskOrgService})。
*/
@Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id")
private Long headUserId;
/** 副班主任。同一人不能既是主又是副(服务端会拒)。 */
@Schema(description = "副班主任")
private List<Long> deputyUserIds = new ArrayList<>();
@Min(value = 0, message = "应填人数不能为负")
@Schema(description = "应填人数,用于与名册实际人数比对告警")
private Integer expectedCount;
@@ -4,9 +4,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 任务覆盖的班级与班主任(《03-接口设计》§7.6)。
*
@@ -34,11 +31,6 @@ public class TaskOrgVO {
private String headUserName;
/** 副班主任 / 协办人。 */
private List<Long> deputyUserIds = new ArrayList<>();
private List<String> deputyUserNames = new ArrayList<>();
/**
* 应填人数。
*
@@ -44,17 +44,6 @@ public class CollectTaskOrg implements Serializable {
/** 该班初审人(班主任);为空则回退到 {@code sys_org.leader_user_id}。 */
private Long headUserId;
/**
* 副班主任 / 协办人,逗号分隔。
*
* <p>⚠️ 存的是<b>首尾都带逗号</b>的形式,如 {@code ,3,7,},不是 {@code 3,7}。
* 原因:用 {@code LIKE '%,3,%'} 精确匹配某个 id 时,
* 裸逗号分隔的 {@code 3,17} 会被子串误命中。
* 首尾补逗号后每个 id 都被逗号夹住,不可能误匹配 ——
* 这是个很小的选择,但错了会表现为"莫名其妙的协办人能看到别人的班"。
*/
private String deputyUserIds;
/** 应填人数(冗余,由名册汇总)。 */
private Integer expectedCount;
@@ -18,9 +18,14 @@ import java.util.List;
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
*
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
* 全部业务表,2026-09-30 核对为 14 张):任务本身不在此列(由 {@code TaskService} 删);
* 全部业务表):任务本身不在此列(由 {@code TaskService} 删);
* {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。
*
* <p>⚠️ <b>删模块时必须回来删这里的一行</b>:2026-10 删掉「加入申请」模块、
* V19 把 {@code collect_join_request} 表 DROP 掉之后,这里的
* {@code deleteJoinRequests} 还在删那张表 —— 表现是**归档任务删不掉,接口返回 50000**,
* 而报错信息完全不提是哪一行 SQL 引起的。表清单少了这张之后是 13 张。
*
* <p><b>为什么子表物理删、任务行却软删</b>:F-14 的动机是"库会一直堆"(演示/测试任务
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
@@ -75,9 +80,6 @@ public interface TaskCascadeMapper {
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
int deleteSubmissions(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_join_request WHERE task_id = #{taskId}")
int deleteJoinRequests(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
int deleteRoster(@Param("taskId") Long taskId);
@@ -13,7 +13,7 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
/**
* 某人在本租户下"能看到的"任务 id。
*
* <p><b>谁需要这个</b>:班主任与副班主任。《03-接口设计》§7.1 明确
* <p><b>谁需要这个</b>:班主任。《03-接口设计》§7.1 明确
* 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」,
* 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。
*
@@ -21,8 +21,8 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
* 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人,
* 不该要求每条都显式指定一遍。
*
* <p><b>副班主任</b>走 {@code deputy_user_ids} 的逗号匹配。该列存的是
* 首尾带逗号的 {@code ,3,7,} 形式,所以 {@code '%,3,%'} 不会误命中 {@code 17}。
* <p>⚠️ 这里以前还有一条 {@code deputy_user_ids}(副班主任)分支,
* 随该字段与角色一起删掉了 —— 现在**一个班只有一个初审人**。
*
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这是一条<b>权限过滤</b>用的查询。
* 漏掉租户条件会返回别校的任务 id,而调用方是拿它去 {@code IN (...)} 的 ——
@@ -38,7 +38,6 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
AND cto.tenant_id = #{tenantId}
AND (
COALESCE(cto.head_user_id, o.leader_user_id) = #{userId}
OR cto.deputy_user_ids LIKE '%,' || #{userId} || ',%'
)
""")
List<Long> selectVisibleTaskIds(@Param("tenantId") Long tenantId,
@@ -136,9 +136,6 @@ public class TaskCloneService {
copy.setOrgId(o.getOrgId());
copy.setExpectedCount(o.getExpectedCount());
copy.setHeadUserId(o.getHeadUserId());
// ⚠️ 是 `deputy_user_ids`(逗号分隔的多个人),不是单数的 deputy_user_id ——
// 按"应该叫这个"猜字段名,编译期才会发现
copy.setDeputyUserIds(o.getDeputyUserIds());
copy.setStatus(o.getStatus());
// 刻意**不**复制二维码 URL:二维码是"每个任务的入口",来源任务的码
// 扫进去是旧任务 —— 复制过来会出现"扫新任务的码、进旧任务"这种最难查的错
@@ -27,7 +27,6 @@ import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
@@ -43,9 +42,11 @@ import java.util.Set;
* 不透露"它存在但不属于你"。这是最容易漏的一环 ——
* 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任
* 就出现在 A 校的审批流里。</li>
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER} 或 {@code DEPUTY_TEACHER})。
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER})。
* 指定初审人本质上是一次<b>授权</b>:那个人将能看到该班学生的身份证号、
* 学籍材料。放开这一条等于把授权能力变成任意授权。</li>
* 学籍材料。放开这一条等于把授权能力变成任意授权。
* ⚠️ 这里以前还接受 {@code DEPUTY_TEACHER}(副班主任);
* 该角色与「协办人」字段一起删掉了,现在是**一个班一个初审人**。</li>
* <li><b>移除是软移除</b>({@code status = 0}),不是物理删除。
* 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。</li>
* <li><b>「移除再加回」必须复用原来那一行</b>。表上有
@@ -60,8 +61,8 @@ import java.util.Set;
@RequiredArgsConstructor
public class TaskOrgService {
/** 可以被指定为初审人的角色。 */
private static final Set<String> TEACHER_ROLE_CODES = Set.of("HEAD_TEACHER", "DEPUTY_TEACHER");
/** 可以被指定为初审人的角色。删掉副班主任后只剩班主任一个。 */
private static final Set<String> TEACHER_ROLE_CODES = Set.of("HEAD_TEACHER");
/**
* 覆盖范围可改的任务状态:草稿 / 收集中 / 已暂停。
@@ -122,9 +123,9 @@ public class TaskOrgService {
"任务已" + statusText(status) + ",覆盖班级不可再改");
}
Map<Long, Set<Long>> deputiesOf = normalizeAndValidate(req);
normalizeAndValidate(req);
persist(taskId, req, deputiesOf);
persist(taskId, req);
log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId,
req.getOrgs().size());
@@ -133,9 +134,10 @@ public class TaskOrgService {
/**
* 参数归一化与全部校验。
*
* @return 每个班归一化后的副班主任 id 集合(已去重、去掉 null、并校验过与主班主任不重复)
* <p>删掉「协办人」之后,这里只剩一件事:<b>每个班最多一个初审人</b>,
* 且他必须是在本租户内、已启用、持班主任角色的账号。
*/
private Map<Long, Set<Long>> normalizeAndValidate(TaskOrgSaveReq req) {
private void normalizeAndValidate(TaskOrgSaveReq req) {
// 同一个班只能出现一次。不拦的话会因为唯一索引报错,而错误信息
// 完全指不出是哪个班的问题(PG 只说 duplicate key)
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
@@ -148,32 +150,15 @@ public class TaskOrgService {
assertOrgsAreClasses(wanted.keySet());
Map<Long, Set<Long>> deputiesOf = new LinkedHashMap<>();
Set<Long> allUserIds = new LinkedHashSet<>();
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
TaskOrgSaveReq.Item item = entry.getValue();
LinkedHashSet<Long> deputies = new LinkedHashSet<>();
if (item.getDeputyUserIds() != null) {
deputies.addAll(item.getDeputyUserIds());
}
deputies.removeIf(Objects::isNull);
if (item.getHeadUserId() != null && deputies.contains(item.getHeadUserId())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"同一个班的主班主任与副班主任不能是同一人(userId=" + item.getHeadUserId() + ")");
}
deputiesOf.put(entry.getKey(), deputies);
Set<Long> headUserIds = new LinkedHashSet<>();
for (TaskOrgSaveReq.Item item : wanted.values()) {
if (item.getHeadUserId() != null) {
allUserIds.add(item.getHeadUserId());
headUserIds.add(item.getHeadUserId());
}
allUserIds.addAll(deputies);
}
if (!allUserIds.isEmpty()) {
assertAssignableTeachers(allUserIds);
if (!headUserIds.isEmpty()) {
assertAssignableTeachers(headUserIds);
}
return deputiesOf;
}
/** 班级必须存在、在同一个租户内、且确实是"班级"。 */
@@ -237,7 +222,7 @@ public class TaskOrgService {
SysUser user = users.get(userId);
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + user.getRealName() + "」不是班主任,不能作为初审人;"
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」或「副班主任」角色");
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」角色");
}
}
}
@@ -251,7 +236,7 @@ public class TaskOrgService {
* 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现,
* 这种"保存成功但没保存"是最难查的一类 bug。
*/
private void persist(Long taskId, TaskOrgSaveReq req, Map<Long, Set<Long>> deputiesOf) {
private void persist(Long taskId, TaskOrgSaveReq req) {
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
for (TaskOrgSaveReq.Item item : req.getOrgs()) {
wanted.put(item.getOrgId(), item);
@@ -283,7 +268,6 @@ public class TaskOrgService {
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
Long orgId = entry.getKey();
TaskOrgSaveReq.Item item = entry.getValue();
String deputies = joinIds(deputiesOf.get(orgId));
CollectTaskOrg reuse = activeByOrg.get(orgId);
if (reuse == null) {
@@ -299,7 +283,6 @@ public class TaskOrgService {
insert.setTaskId(taskId);
insert.setOrgId(orgId);
insert.setHeadUserId(item.getHeadUserId());
insert.setDeputyUserIds(deputies);
// expectedCount 为 null 就是"未设置",**不能写成 0** ——
// 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让
// "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上,
@@ -311,7 +294,6 @@ public class TaskOrgService {
taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate()
.eq(CollectTaskOrg::getId, reuse.getId())
.set(CollectTaskOrg::getHeadUserId, item.getHeadUserId())
.set(CollectTaskOrg::getDeputyUserIds, deputies)
.set(CollectTaskOrg::getExpectedCount, item.getExpectedCount())
.set(CollectTaskOrg::getStatus, STATUS_ACTIVE));
}
@@ -327,15 +309,11 @@ public class TaskOrgService {
Set<Long> orgIds = new LinkedHashSet<>();
Set<Long> userIds = new LinkedHashSet<>();
Map<Long, List<Long>> deputiesOf = new LinkedHashMap<>();
for (CollectTaskOrg row : rows) {
orgIds.add(row.getOrgId());
List<Long> deputies = parseIds(row.getDeputyUserIds());
deputiesOf.put(row.getId(), deputies);
if (row.getHeadUserId() != null) {
userIds.add(row.getHeadUserId());
}
userIds.addAll(deputies);
}
Map<Long, String> orgNames = loadOrgNames(orgIds);
@@ -348,11 +326,6 @@ public class TaskOrgService {
vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)"));
vo.setHeadUserId(row.getHeadUserId());
vo.setHeadUserName(userNames.get(row.getHeadUserId()));
List<Long> deputies = deputiesOf.get(row.getId());
vo.setDeputyUserIds(deputies);
vo.setDeputyUserNames(deputies.stream()
.map(id -> userNames.getOrDefault(id, "(账号已删除)"))
.toList());
vo.setExpectedCount(row.getExpectedCount());
vo.setStatus(row.getStatus());
out.add(vo);
@@ -396,44 +369,6 @@ public class TaskOrgService {
return task;
}
/**
* 逗号分隔 id 的序列化。
*
* <p>存的是<b>首尾都带逗号</b>的形式({@code ,3,7,}),不是 {@code 3,7}。
* 原因:用 {@code LIKE '%,3,%'} 精确匹配时,裸逗号分隔的 {@code 3,17}
* 会被子串误命中,让"莫名其妙的协办人能看到别人的班"。
*/
private static String joinIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder(",");
for (Long id : ids) {
sb.append(id).append(',');
}
return sb.toString();
}
private static List<Long> parseIds(String raw) {
List<Long> out = new ArrayList<>();
if (raw == null || raw.isBlank()) {
return out;
}
for (String part : raw.split(",")) {
String trimmed = part.trim();
if (trimmed.isEmpty()) {
continue;
}
try {
out.add(Long.valueOf(trimmed));
} catch (NumberFormatException e) {
// 库里存了脏数据不该让整个列表 500;跳过并在日志里留痕
log.warn("collect_task_org.deputy_user_ids 含非法 id:{}", trimmed);
}
}
return out;
}
private static String statusText(int status) {
return switch (status) {
case CollectTask.STATUS_DRAFT -> "草稿";
@@ -216,7 +216,11 @@ public class TaskService {
}
/**
* 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。
* 是否看全租户:学校 / 机构管理员({@code user_type = 2})。平台运营不分管具体学校的任务,但也不该被拦。
*
* <p>⚠️ 这里以前判角色码 {@code hasAnyRole("SCHOOL_ADMIN", "AUDITOR")}:
* 「审核员」角色已删除,且租户自建的管理类角色也应看全租户 ——
* 所以改成 {@link LoginUser#isTenantAdmin()}(判 {@code user_type})。
*
* <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
@@ -226,7 +230,7 @@ public class TaskService {
*/
private boolean seesWholeTenant() {
LoginUser me = UserContext.require();
return me.isPlatformStaff() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
return me.isPlatformStaff() || me.isTenantAdmin();
}
// ── 详情 ──────────────────────────────────────
@@ -427,7 +431,6 @@ public class TaskService {
+ cascadeMapper.deleteExportFiles(id)
+ cascadeMapper.deleteExportTasks(id)
+ cascadeMapper.deleteSubmissions(id)
+ cascadeMapper.deleteJoinRequests(id)
+ cascadeMapper.deleteRoster(id)
+ cascadeMapper.deleteFormSchemas(id)
+ cascadeMapper.deleteMaterialDefs(id)
@@ -0,0 +1,114 @@
package cn.tianbaotong.fill.module.user.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.user.dto.RoleDtos;
import cn.tianbaotong.fill.module.user.service.RoleService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 角色与授权(二期)。
*
* <p><b>权限怎么分</b>:读列表用 {@code role:manage} **或** {@code user:view}
* (账号管理页的角色下拉也要这份数据,没有它那个下拉就是空的);
* 看权限码目录与所有写操作用 {@code role:manage}。
*
* <p>内置角色只读({@code roleType=1}):可以看它的权限矩阵,改不了 ——
* 这是"管理员把自己锁在系统外"的唯一入口,而这类错误不可逆。
*/
@Tag(name = "角色与授权")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class RoleController {
private final RoleService roleService;
@GetMapping("/roles")
@RequirePerm({PermissionDef.ROLE_MANAGE, PermissionDef.USER_VIEW, PermissionDef.USER_MANAGE})
@Operation(summary = "角色列表",
description = "内置角色 + 自定义角色。内置角色的权限与菜单取代码定义(库里那份只是可审计),"
+ "所以它永远显示「现在真正生效」的那一套")
public ApiResult<List<RoleDtos.RoleVO>> list() {
return ApiResult.ok(roleService.list());
}
@GetMapping("/permissions")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "权限码目录",
description = "按分组返回全部权限码(含中文名与适用端)。platformOnly=true 的码学校租户不可授予")
public ApiResult<List<RoleDtos.PermGroupVO>> permissions() {
return ApiResult.ok(roleService.permissionCatalog());
}
@PostMapping("/roles")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "新增自定义角色",
description = "角色码创建后不可改;学校租户不能授予平台级权限,也不能建平台运营类的角色")
public ApiResult<Long> create(@Valid @RequestBody RoleDtos.RoleCreateReq req) {
return ApiResult.ok(roleService.create(req));
}
@PutMapping("/roles/{id}")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "修改自定义角色",
description = "只改名称/用户类型/备注;角色码与权限走各自的接口。"
+ "角色还有账号在用时不改「用户类型」(会让账号与角色对不上)")
public ApiResult<Void> update(@PathVariable Long id, @Valid @RequestBody RoleDtos.RoleUpdateReq req) {
roleService.update(id, req);
return ApiResult.ok();
}
@DeleteMapping("/roles/{id}")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "删除自定义角色",
description = "还有账号在用返回 40045;不允许删掉自己进入本页所依赖的最后一个角色")
public ApiResult<Void> delete(@PathVariable Long id) {
roleService.delete(id);
return ApiResult.ok();
}
@PutMapping("/roles/{id}/permissions")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "设置角色的权限码(覆盖式)",
description = "改完立即生效(服务端会清掉该角色的授权缓存),不需要重新登录")
public ApiResult<Void> setPermissions(@PathVariable Long id,
@Valid @RequestBody RoleDtos.RolePermReq req) {
roleService.setPermissions(id, req.getPermCodes());
return ApiResult.ok();
}
@PutMapping("/roles/{id}/menus")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "设置角色的可见菜单(覆盖式)",
description = "菜单只决定「看得见哪些入口」,不代表有权限操作;改完立即生效")
public ApiResult<Void> setMenus(@PathVariable Long id,
@Valid @RequestBody RoleDtos.RoleMenuReq req) {
roleService.setMenus(id, req.getMenus());
return ApiResult.ok();
}
@PutMapping("/roles/{id}/status")
@RequirePerm(PermissionDef.ROLE_MANAGE)
@Operation(summary = "启用 / 停用角色",
description = "停用后该角色立刻不再给权限(不必等 token 过期);内置角色不可停用")
public ApiResult<Void> setStatus(@PathVariable Long id, @RequestParam Integer status) {
roleService.setStatus(id, status);
return ApiResult.ok();
}
}
@@ -0,0 +1,196 @@
package cn.tianbaotong.fill.module.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 「角色与授权」(二期)的请求与响应。
*
* <p>⚠️ <b>内置角色只读</b>:{@code roleType=1} 的角色在界面上可以看、不可以改
* (权限由代码决定,见 {@code BuiltinRole})。这不是"暂时没做",是刻意的 ——
* 一次误操作就能把管理员锁在系统外,而这类错误不可逆。
*
* <p>⚠️ <b>{@code dataScope} 本期只展示、不可配</b>:数据范围(本人/本班/本院系/全租户)
* 目前由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,散在审核/催报/代传/看板各处;
* 把它做成可配需要先把那些判据收口(单独一批做)。所以这里**不提供**修改入口 ——
* 让界面出现一个"改了不生效"的开关,比没有这个开关更糟。
*/
public final class RoleDtos {
private RoleDtos() {
}
@Getter
@Setter
@Schema(description = "角色列表项")
public static class RoleVO {
private Long id;
private String roleCode;
private String roleName;
/** {@code 1} 内置(只读)/ {@code 2} 自定义。 */
private Integer roleType;
/** {@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。 */
private Integer userType;
/** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人(本期只展示)。 */
private Integer dataScope;
/** {@code 0} 停用 / {@code 1} 正常。 */
private Integer status;
private Integer sortNo;
private String remark;
/** 有多少账号在用(删除前的依据)。 */
private Integer userCount;
/** 能否编辑 / 删除 / 停用(内置角色恒 {@code false})。 */
private Boolean editable;
/**
* 能否发给账号。
*
* <p>与 {@link #editable} 是两件事:内置的「平台运营」角色**可以看**(列表里列出来),
* 但在学校租户里**发不出去**(服务端 40301 —— 拿到它会直接越权到平台级)。
* 界面据此不显示没得选的项,而不是让人选完再收到一个错误。
*/
private Boolean assignable;
/** 权限码:内置角色取代码定义,自定义角色取库里的授权。 */
private List<String> permCodes;
/** 可见菜单键(与前端 {@code XtMenu.key} 一致)。 */
private List<String> menus;
}
@Getter
@Setter
@Schema(description = "新增自定义角色")
public static class RoleCreateReq {
/**
* 角色码。
*
* <p>格式对齐内置角色的风格(大写 + 下划线):它会进 JWT 的 {@code roles}、
* 出现在日志与排查对话里,混进小写或中文会让"这串东西是什么"变得难以检索。
*/
@NotBlank(message = "请填写角色码")
@Size(max = 64, message = "角色码过长")
@Pattern(regexp = "^[A-Z][A-Z0-9_]{1,63}$",
message = "角色码只能是大写字母、数字与下划线,且以字母开头")
@Schema(description = "角色码,租户内唯一,创建后不可改", example = "CLASS_AUDITOR",
requiredMode = Schema.RequiredMode.REQUIRED)
private String roleCode;
@NotBlank(message = "请填写角色名称")
@Size(max = 64, message = "角色名称过长")
@Schema(description = "角色名称(显示用,可改)", example = "年级审核员",
requiredMode = Schema.RequiredMode.REQUIRED)
private String roleName;
@NotNull(message = "请选择用户类型")
@Min(value = 1, message = "用户类型不合法")
@Max(value = 4, message = "用户类型不合法")
@Schema(description = "该角色对应的用户类型(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生);"
+ "账号的 userType 由它推导,所以必须选对", example = "3",
requiredMode = Schema.RequiredMode.REQUIRED)
private Integer userType;
@Size(max = 255, message = "备注过长")
@Schema(description = "备注")
private String remark;
@NotEmpty(message = "请至少勾选一项权限")
@Schema(description = "权限码清单(学校租户不能包含平台级权限,见 /permissions 的 platformOnly)",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> permCodes;
@NotEmpty(message = "请至少勾选一个菜单")
@Schema(description = "可见菜单键(决定登录后左侧能看到哪些入口,不代表有权限操作)",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> menus;
}
@Getter
@Setter
@Schema(description = "修改自定义角色(角色码不可改)")
public static class RoleUpdateReq {
@NotBlank(message = "请填写角色名称")
@Size(max = 64, message = "角色名称过长")
@Schema(description = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED)
private String roleName;
@NotNull(message = "请选择用户类型")
@Min(value = 1, message = "用户类型不合法")
@Max(value = 4, message = "用户类型不合法")
@Schema(description = "该角色对应的用户类型", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer userType;
@Size(max = 255, message = "备注过长")
@Schema(description = "备注")
private String remark;
}
@Getter
@Setter
@Schema(description = "设置角色的权限码(覆盖式)")
public static class RolePermReq {
@NotNull(message = "请传 permCodes(可以是空数组)")
@Schema(description = "权限码清单,覆盖式替换", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> permCodes;
}
@Getter
@Setter
@Schema(description = "设置角色的可见菜单(覆盖式)")
public static class RoleMenuReq {
@NotNull(message = "请传 menus(可以是空数组)")
@Schema(description = "菜单键清单,覆盖式替换", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> menus;
}
@Getter
@Setter
@Schema(description = "权限码目录(按分组,供角色授权界面渲染)")
public static class PermGroupVO {
private String group;
private List<PermVO> items;
}
@Getter
@Setter
@Schema(description = "权限码")
public static class PermVO {
private String code;
private String label;
/** {@code PC} / {@code MP} / {@code BOTH}。 */
private String platform;
/** 平台级权限:学校租户不可授予(界面要标出来并禁止勾选)。 */
private Boolean platformOnly;
}
}
@@ -24,6 +24,18 @@ public class SysRole extends TenantEntity {
/** {@code 1} 内置(不可删,权限由代码决定)/ {@code 2} 自定义。 */
private Integer roleType;
/**
* 角色对应的用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
*
* <p>存在的理由:{@code sys_user.user_type} 是 NOT NULL,而它的值一直是"从角色反推"的
* (见 {@code BuiltinRole.UserType})。自定义角色不在代码映射里,反推不出来 ——
* 没有这一列,"角色建得出来、却发不出去"。
*
* <p>⚠️ 内置角色的权威仍是代码({@code BuiltinRole.getUserType()});
* 这列对内置角色只用于"看得见、可审计"。
*/
private Integer userType;
/** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人。 */
private Integer dataScope;
@@ -0,0 +1,26 @@
package cn.tianbaotong.fill.module.user.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 角色与菜单键的关联(对应 {@code sys_role_menu},二期「角色与授权」)。
*
* <p>菜单键本身的定义在代码里({@code BuiltinRole} 的菜单集合,与前端
* {@code XtMenu.key} 逐字一致);这张表只存「这个角色能看见哪些入口」。
*
* <p>⚠️ <b>看得见 ≠ 有权限</b>:真正的访问控制仍在服务端的权限码校验上,
* 菜单只是"不显示没权限的入口"(绕过前端只会拿到 40301)。
*/
@Getter
@Setter
@TableName("sys_role_menu")
public class SysRoleMenu extends TenantEntity {
private Long roleId;
/** 菜单键,如 {@code task}。 */
private String menuKey;
}
@@ -2,6 +2,51 @@ package cn.tianbaotong.fill.module.user.mapper;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface RoleMapper extends BaseMapper<SysRole> {
/**
* 每个角色被多少账号使用(角色列表页展示"能不能删"的依据)。
*
* <p>一次查完再在内存里配对,而不是"每个角色查一次" —— 角色列表是
* "1 个页面 N 行"的形状,逐行查就是 N+1(本项目对 N+1 有明确的压测结论,见《09》§9.5)。
*
* <p>⚠️ 别名写成 {@code user_count}(蛇形)而不是 {@code userCount}:
* PostgreSQL 会把**不加引号的别名折成小写**(`userCount` → `usercount`),
* 而 MyBatis 的蛇形→驼峰映射只认 `user_count`。这个坑本项目踩过两次(《08》F-54 / F-83)。
*/
@Select("""
SELECT ur.role_id, COUNT(*) AS user_count
FROM sys_user_role ur
JOIN sys_user u ON u.id = ur.user_id AND u.deleted = 0
WHERE ur.deleted = 0 AND ur.tenant_id = #{tenantId}
GROUP BY ur.role_id
""")
List<RoleUserCount> countUsersByRole(@Param("tenantId") Long tenantId);
/** 上面那条查询的结果行。 */
class RoleUserCount {
private Long roleId;
private Integer userCount;
public Long getRoleId() {
return roleId;
}
public void setRoleId(Long roleId) {
this.roleId = roleId;
}
public Integer getUserCount() {
return userCount;
}
public void setUserCount(Integer userCount) {
this.userCount = userCount;
}
}
}
@@ -0,0 +1,13 @@
package cn.tianbaotong.fill.module.user.mapper;
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 角色菜单({@code sys_role_menu})。
*
* <p>与 {@code RolePermissionMapper} 一样,只做最基础的事 —— 菜单键的定义在代码里,
* 所以没有"菜单字典表",也没有联表查询。
*/
public interface RoleMenuMapper extends BaseMapper<SysRoleMenu> {
}
@@ -0,0 +1,236 @@
package cn.tianbaotong.fill.module.user.service;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RoleGrantSource;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 自定义角色授权(权限码 + 菜单)的库实现({@link RoleGrantSource})。
*
* <h3>为什么必须有缓存</h3>
*
* <p>权限判定发生在**每一个**受保护请求上({@code AuthInterceptor} → {@code PermissionChecker}),
* 而它此前是**零 IO** 的(权限由代码 {@code BuiltinRole} 决定)。开放自定义角色之后,
* "这个角色被授了什么"只能从库里读 —— 不加缓存就是每个请求多 1~2 条 SQL,
* 与本项目的压测基线(SQL 条数恒定、DB ≤2ms,见《09-部署与运维》§9.5)直接冲突。
*
* <p>缓存放 Redis(不是 JVM 内存):① 多实例部署时各存一份内存缓存,
* "改了权限但只有一台机器生效"是那种最难查的错;② 本项目**本来就每请求读一次 Redis**
* (登出黑名单 {@code TokenBlacklist} 用的是 {@code hasKey}),所以这是同一量级的开销。
* 权限与菜单合并成**一个**缓存项:{@code /auth/me} 两样都要,分开存就是两次往返。
*
* <h3>三个必须守住的细节</h3>
*
* <ol>
* <li><b>空结果也要缓存</b>({@link #EMPTY})。不缓存"没有"的话,
* 一个已被删除角色的 token(8 小时内仍有效)会把库打穿 —— 每个请求都回查一次。</li>
* <li><b>Redis 挂了要退化成直读库</b>,不能当成"没有权限"(那会让全校忽然登不进去),
* 也不能当成"有权限"(那是越权)。所以缓存读写失败只记一行 warn,然后照常查库。</li>
* <li><b>TTL 是兜底,不是机制</b>:正常路径靠写操作后的 {@link #evict} 立即失效;
* TTL 只保证"万一漏了 evict"也不会永久生效一个错的值。</li>
* </ol>
*
* <p>⚠️ 内置角色({@code role_type=1})**不读这两张表**:它们的权限与菜单由代码决定。
* 库里那些行是为了"看得见、可审计",不是生效依据 —— 详见 {@link RoleGrantSource}。
*/
@Slf4j
@Component
public class DbRoleGrantSource implements RoleGrantSource {
/** 缓存键:{@code xt:role:grant:{tenantId}:{roleCode}}。与 {@code xt:jwt:bl:} 等同一套命名。 */
static final String KEY_PREFIX = "xt:role:grant:";
/** 空结果标记。用 {@code -} 而不是空串:空串与"没缓存"在 Redis 里不好区分。 */
static final String EMPTY = "-";
/** 集合内部的分隔符。权限码与菜单键都不含逗号,所以拼接是安全的(省掉一次 JSON 解析)。 */
static final String SEP = ",";
/** 权限与菜单之间的分隔符。 */
static final String PART_SEP = "|";
/** 兜底 TTL:漏了 evict 也不会永久生效一个错的值。 */
private static final Duration TTL = Duration.ofMinutes(5);
private final StringRedisTemplate redis;
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final RoleMenuMapper roleMenuMapper;
public DbRoleGrantSource(StringRedisTemplate redis, RoleMapper roleMapper,
RolePermissionMapper rolePermissionMapper, RoleMenuMapper roleMenuMapper) {
this.redis = redis;
this.roleMapper = roleMapper;
this.rolePermissionMapper = rolePermissionMapper;
this.roleMenuMapper = roleMenuMapper;
}
@Override
public Set<String> permsOf(long tenantId, String roleCode) {
return grant(tenantId, roleCode).perms();
}
@Override
public Set<String> menusOf(long tenantId, String roleCode) {
return grant(tenantId, roleCode).menus();
}
/**
* 让某个角色的授权缓存立即失效(权限与菜单一起)。
*
* <p>**每一次**改授权、改菜单、改状态、删角色之后都必须调它 ——
* 否则"我明明把权限去掉了,他怎么还能点"会持续到 TTL 到期(最多 5 分钟),
* 而这类现象会被当成"系统没保存"。
*/
public void evict(long tenantId, String roleCode) {
if (roleCode == null || roleCode.isBlank()) {
return;
}
try {
redis.delete(KEY_PREFIX + tenantId + ":" + roleCode);
} catch (RuntimeException e) {
// 删不掉不是致命错误:TTL 会兜住(5 分钟后自然失效)
log.warn("清理角色授权缓存失败(TTL 到期后会自然失效):tenant={} role={} 原因={}",
tenantId, roleCode, e.toString());
}
}
// ── 内部 ──────────────────────────────────────
private Grant grant(long tenantId, String roleCode) {
if (roleCode == null || roleCode.isBlank()) {
return Grant.EMPTY;
}
String key = KEY_PREFIX + tenantId + ":" + roleCode;
String cached = readCache(key);
if (cached != null) {
return decode(cached);
}
Grant fresh = loadFromDb(tenantId, roleCode);
writeCache(key, encode(fresh));
return fresh;
}
private Grant loadFromDb(long tenantId, String roleCode) {
// 角色是租户隔离的,而这里可能跑在平台上下文(定时任务)里 —— 拦截器那时不生效,
// 所以 tenant_id 必须**显式**写进条件,不能只靠拦截器。
SysRole role = roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getRoleCode, roleCode)
.last("LIMIT 1"));
// 只有自定义角色(role_type=2)的授权存在库里;
// 内置角色即使库里有行也不作数(权限与菜单都由代码决定)。
if (role == null || role.getRoleType() == null || role.getRoleType() != 2) {
return Grant.EMPTY;
}
// 停用的角色 = 不生效。放在这里而不是只在登录时判:停用要**立刻**生效,
// 否则"把他那个角色停掉"要等到 token 过期(PC 最长 8 小时)。
if (role.getStatus() != null && role.getStatus() == 0) {
return Grant.EMPTY;
}
return new Grant(loadPerms(tenantId, role.getId()), loadMenus(tenantId, role.getId()));
}
private Set<String> loadPerms(long tenantId, Long roleId) {
List<SysRolePermission> rows = rolePermissionMapper.selectList(
Wrappers.<SysRolePermission>lambdaQuery()
.eq(SysRolePermission::getTenantId, tenantId)
.eq(SysRolePermission::getRoleId, roleId));
Set<String> codes = new LinkedHashSet<>();
for (SysRolePermission rp : rows) {
// 只认代码里还存在的权限码:{@code @RequirePerm} 只可能校验枚举里的码,
// 把库里残留的一个已删除码塞进"当前用户的权限全集"只会污染 /auth/me。
if (rp.getPermCode() != null && PermissionDef.ofCode(rp.getPermCode()) != null) {
codes.add(rp.getPermCode());
}
}
return codes;
}
private Set<String> loadMenus(long tenantId, Long roleId) {
List<SysRoleMenu> rows = roleMenuMapper.selectList(
Wrappers.<SysRoleMenu>lambdaQuery()
.eq(SysRoleMenu::getTenantId, tenantId)
.eq(SysRoleMenu::getRoleId, roleId));
Set<String> keys = new LinkedHashSet<>();
Set<String> known = BuiltinRole.allMenuKeys();
for (SysRoleMenu rm : rows) {
// 同上:只认现存的菜单键(XT_MENUS 里没有的键,前端渲染不出来)
if (rm.getMenuKey() != null && known.contains(rm.getMenuKey())) {
keys.add(rm.getMenuKey());
}
}
return keys;
}
private String readCache(String key) {
try {
return redis.opsForValue().get(key);
} catch (RuntimeException e) {
// 降级为直读库:**不能**当成"没有权限"(全校忽然登不进去),
// 也**不能**当成"有权限"(越权)。查库是唯一两个方向都安全的选择。
log.warn("读角色授权缓存失败,本次直读数据库:key={} 原因={}", key, e.toString());
return null;
}
}
private void writeCache(String key, String value) {
try {
redis.opsForValue().set(key, value, TTL);
} catch (RuntimeException e) {
log.warn("写角色授权缓存失败(不影响本次判定):key={} 原因={}", key, e.toString());
}
}
private static String encode(Grant g) {
return encodeSet(g.perms()) + PART_SEP + encodeSet(g.menus());
}
private static String encodeSet(Set<String> values) {
return values.isEmpty() ? EMPTY : String.join(SEP, values);
}
private static Grant decode(String raw) {
String[] parts = raw.split(Pattern.quote(PART_SEP), -1);
Set<String> perms = decodeSet(parts.length > 0 ? parts[0] : null);
Set<String> menus = decodeSet(parts.length > 1 ? parts[1] : null);
return new Grant(perms, menus);
}
private static Set<String> decodeSet(String part) {
if (part == null || part.isBlank() || EMPTY.equals(part)) {
return Set.of();
}
Set<String> values = new LinkedHashSet<>();
for (String v : part.split(SEP)) {
if (!v.isBlank()) {
values.add(v);
}
}
return values;
}
/** 一个自定义角色的生效内容:能做什么(perms)+ 看得见什么(menus)。 */
record Grant(Set<String> perms, Set<String> menus) {
static final Grant EMPTY = new Grant(Set.of(), Set.of());
}
}
@@ -0,0 +1,92 @@
package cn.tianbaotong.fill.module.user.service;
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 角色授权(权限码 + 菜单)的**唯一写入口**。
*
* <p>为什么抽出来:同一件事有两个调用方 ——
* {@code RoleProvisioner}(开通租户与每日同步内置角色)与 {@code RoleService}(租户自己配自定义角色)。
* 两边各写一份"缺的补上、多的删掉"必然分叉,而分叉的表现是
* 「内置角色同步得好好的、自定义角色改完却多出一条旧权限」这种一半对一半错的现象。
*
* <p><b>幂等是硬要求</b>:它每天会被定时任务调一次({@code ScheduleJobs} 的「内置角色授权同步」),
* 两边集合一样时**一行都不写**。
*/
@Component
@RequiredArgsConstructor
public class RoleGrantWriter {
private final RolePermissionMapper rolePermissionMapper;
private final RoleMenuMapper roleMenuMapper;
/** 把角色的权限码对齐到给定集合(覆盖式)。 */
public void replacePerms(long tenantId, Long roleId, Set<String> want) {
List<SysRolePermission> exist = rolePermissionMapper.selectList(
Wrappers.<SysRolePermission>lambdaQuery()
.eq(SysRolePermission::getTenantId, tenantId)
.eq(SysRolePermission::getRoleId, roleId));
Set<String> have = new LinkedHashSet<>();
for (SysRolePermission rp : exist) {
if (rp.getPermCode() != null) {
have.add(rp.getPermCode());
}
}
for (String code : want) {
if (!have.contains(code)) {
SysRolePermission rp = new SysRolePermission();
rp.setTenantId(tenantId);
rp.setRoleId(roleId);
rp.setPermCode(code);
rolePermissionMapper.insert(rp);
}
}
for (SysRolePermission rp : exist) {
if (rp.getPermCode() != null && !want.contains(rp.getPermCode())) {
// 软删(实体基类带 @TableLogic):硬删会让 uk_roleperm_role_code
// 与"这条授权什么时候没的"对不上
rolePermissionMapper.deleteById(rp.getId());
}
}
}
/** 把角色的可见菜单对齐到给定集合(覆盖式)。 */
public void replaceMenus(long tenantId, Long roleId, Set<String> want) {
List<SysRoleMenu> exist = roleMenuMapper.selectList(
Wrappers.<SysRoleMenu>lambdaQuery()
.eq(SysRoleMenu::getTenantId, tenantId)
.eq(SysRoleMenu::getRoleId, roleId));
Set<String> have = new LinkedHashSet<>();
for (SysRoleMenu rm : exist) {
if (rm.getMenuKey() != null) {
have.add(rm.getMenuKey());
}
}
for (String key : want) {
if (!have.contains(key)) {
SysRoleMenu rm = new SysRoleMenu();
rm.setTenantId(tenantId);
rm.setRoleId(roleId);
rm.setMenuKey(key);
roleMenuMapper.insert(rm);
}
}
for (SysRoleMenu rm : exist) {
if (rm.getMenuKey() != null && !want.contains(rm.getMenuKey())) {
roleMenuMapper.deleteById(rm.getId());
}
}
}
}
@@ -0,0 +1,524 @@
package cn.tianbaotong.fill.module.user.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
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.service.OperationLogService;
import cn.tianbaotong.fill.module.user.dto.RoleDtos;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.security.PermissionDef;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 角色与授权(二期)。
*
* <h3>四条口径(都不是"随便定的",每条都对应一类会出事的做法)</h3>
*
* <ol>
* <li><b>内置角色只读</b>:{@code role_type=1} 的角色可以看、不可以改。
* 它是"管理员把自己锁在系统外"的唯一入口,而这类错误不可逆。</li>
* <li><b>权限码的白名单在服务端</b>:学校租户不能授予平台级权限({@code tenant:*} 等)。
* 界面上不显示那些勾选项只是"不提示",绕过前端直接调接口才是真正的攻击面。</li>
* <li><b>防自锁</b>:不允许把"自己管理角色所依赖的最后一个来源"改掉/删掉/停用 ——
* 包括那条 {@code role:manage} 权限与 {@code role} 菜单键。</li>
* <li><b>改动立即生效</b>:每次写授权都清缓存({@code DbRoleGrantSource.evict})。
* 不清的话"我把权限去掉了,他怎么还能点"会持续到 TTL 到期,而用户会认为"没保存"。</li>
* </ol>
*
* <p>⚠️ {@code data_scope} 本期不提供修改入口:数据范围目前由"拿到了哪些权限码 +
* 任务里被指派了哪些班"共同决定,散在审核/催报/代传/看板各处,做成可配要先把那些判据收口。
* 让界面出现一个"改了不生效"的开关,比没有这个开关更糟。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RoleService {
/** 自定义角色的默认数据范围:{@code 4} 仅本人(最保守的那一档)。 */
private static final int DEFAULT_DATA_SCOPE = 4;
/** 自定义角色的排序值:排在 6 个内置角色(1~90)之后。 */
private static final int CUSTOM_SORT_NO = 500;
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final RoleMenuMapper roleMenuMapper;
private final RoleGrantWriter grantWriter;
private final DbRoleGrantSource grantSource;
private final OperationLogService operationLogService;
// ── 查询 ──────────────────────────────────────
/** 角色列表(内置 + 自定义)。 */
public List<RoleDtos.RoleVO> list() {
Long tenantId = UserContext.tenantId();
List<SysRole> roles = roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.orderByAsc(SysRole::getRoleType)
.orderByAsc(SysRole::getSortNo)
.orderByAsc(SysRole::getId));
if (roles.isEmpty()) {
return List.of();
}
// 三条批量查询,避免"每个角色查一次"的 N+1
List<Long> ids = new ArrayList<>();
roles.forEach(r -> ids.add(r.getId()));
Map<Long, List<String>> permsByRole = permsOfRoles(tenantId, ids);
Map<Long, List<String>> menusByRole = menusOfRoles(tenantId, ids);
Map<Long, Integer> counts = new HashMap<>();
for (RoleMapper.RoleUserCount row : roleMapper.countUsersByRole(tenantId)) {
if (row.getRoleId() != null) {
counts.put(row.getRoleId(), row.getUserCount() == null ? 0 : row.getUserCount());
}
}
List<RoleDtos.RoleVO> out = new ArrayList<>();
boolean platform = isPlatformTenant();
Set<String> assignableBuiltins = BuiltinRole.assignableCodes(platform);
for (SysRole role : roles) {
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
boolean builtin = br != null && role.getRoleType() != null && role.getRoleType() == 1;
boolean enabled = role.getStatus() != null && role.getStatus() == 1;
RoleDtos.RoleVO vo = new RoleDtos.RoleVO();
vo.setId(role.getId());
vo.setRoleCode(role.getRoleCode());
vo.setRoleName(role.getRoleName());
vo.setRoleType(role.getRoleType());
vo.setUserType(builtin ? br.getUserType().code() : role.getUserType());
vo.setDataScope(role.getDataScope());
vo.setStatus(role.getStatus());
vo.setSortNo(role.getSortNo());
vo.setRemark(role.getRemark());
vo.setUserCount(counts.getOrDefault(role.getId(), 0));
vo.setEditable(!builtin);
// 能不能发给账号:停用的不行;内置角色还要过"这个租户能不能发它"的白名单
// (学校租户发不了 PLATFORM_ADMIN —— 那是越权到平台级的入口)
vo.setAssignable(enabled && (!builtin || assignableBuiltins.contains(role.getRoleCode())));
// 内置角色的权限与菜单**取代码**:库里的行只是"看得见、可审计",
// 若这里读库,代码改了权限而库没同步时,界面就会显示一套过期的授权
vo.setPermCodes(builtin ? List.copyOf(br.permissionCodes())
: permsByRole.getOrDefault(role.getId(), List.of()));
vo.setMenus(builtin ? List.copyOf(br.getMenus())
: menusByRole.getOrDefault(role.getId(), List.of()));
out.add(vo);
}
return out;
}
/** 权限码目录(按分组),供角色授权界面渲染勾选项。 */
public List<RoleDtos.PermGroupVO> permissionCatalog() {
Map<String, List<RoleDtos.PermVO>> groups = new LinkedHashMap<>();
Set<String> platformOnly = PermissionDef.platformOnlyCodes();
for (PermissionDef def : PermissionDef.values()) {
RoleDtos.PermVO item = new RoleDtos.PermVO();
item.setCode(def.getCode());
item.setLabel(def.getLabel());
item.setPlatform(def.getPlatform().name());
item.setPlatformOnly(platformOnly.contains(def.getCode()));
groups.computeIfAbsent(def.getGroup(), k -> new ArrayList<>()).add(item);
}
List<RoleDtos.PermGroupVO> out = new ArrayList<>();
groups.forEach((group, items) -> {
RoleDtos.PermGroupVO vo = new RoleDtos.PermGroupVO();
vo.setGroup(group);
vo.setItems(items);
out.add(vo);
});
return out;
}
// ── 写 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public Long create(RoleDtos.RoleCreateReq req) {
Long tenantId = UserContext.tenantId();
String code = req.getRoleCode() == null ? "" : req.getRoleCode().trim();
if (BuiltinRole.ofCode(code) != null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + code + "」是内置角色码,不能占用");
}
if (findByCode(tenantId, code) != null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色码已存在:" + code);
}
assertUserTypeAllowed(req.getUserType());
Set<String> perms = validatePerms(req.getPermCodes());
Set<String> menus = validateMenus(req.getMenus());
SysRole role = new SysRole();
role.setTenantId(tenantId);
role.setRoleCode(code);
role.setRoleName(req.getRoleName().trim());
role.setRoleType(2);
role.setUserType(req.getUserType());
role.setDataScope(DEFAULT_DATA_SCOPE);
role.setSortNo(CUSTOM_SORT_NO);
role.setStatus(1);
role.setRemark(req.getRemark());
roleMapper.insert(role);
grantWriter.replacePerms(tenantId, role.getId(), perms);
grantWriter.replaceMenus(tenantId, role.getId(), menus);
grantSource.evict(tenantId, code);
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_CREATE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
null, code + " " + role.getRoleName() + " 权限 " + perms.size() + " 项");
return role.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, RoleDtos.RoleUpdateReq req) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
assertUserTypeAllowed(req.getUserType());
// 改了用户类型而角色还挂在账号上 → 那些账号的 user_type 会与角色对不上,
// 而 user_type 决定"他算学生还是老师"(学生走小程序、老师走 PC)。
// 与其静默制造不一致,不如让他先解绑(这是一次很少发生的操作)。
if (!java.util.Objects.equals(role.getUserType(), req.getUserType())) {
int used = countUsers(tenantId, role.getId());
if (used > 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"该角色还有 " + used + " 个账号在用,改「用户类型」会让这些账号与角色不一致;"
+ "请先给这些账号换角色,或另建一个角色");
}
}
String before = role.getRoleCode() + " " + role.getRoleName() + " / userType=" + role.getUserType();
role.setRoleName(req.getRoleName().trim());
role.setUserType(req.getUserType());
role.setRemark(req.getRemark());
roleMapper.updateById(role);
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_UPDATE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
before, role.getRoleCode() + " " + role.getRoleName() + " / userType=" + role.getUserType());
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
int used = countUsers(tenantId, role.getId());
if (used > 0) {
throw new BizException(ErrorCode.ROLE_IN_USE,
"该角色还有 " + used + " 个账号在用,请先改掉这些账号的角色");
}
assertNotSelfLock(tenantId, role, Set.of(), Set.of());
roleMapper.deleteById(role.getId());
// 授权与菜单一起软删:留着孤儿行会让"这个角色曾经能做什么"与"现在还有什么"混在一起
grantWriter.replacePerms(tenantId, role.getId(), Set.of());
grantWriter.replaceMenus(tenantId, role.getId(), Set.of());
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_DELETE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
role.getRoleCode() + " " + role.getRoleName(), null);
}
@Transactional(rollbackFor = Exception.class)
public void setPermissions(Long id, List<String> permCodes) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
Set<String> perms = validatePerms(permCodes);
assertNotSelfLock(tenantId, role, perms, currentMenus(tenantId, role));
Set<String> before = currentPerms(tenantId, role);
grantWriter.replacePerms(tenantId, role.getId(), perms);
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_GRANT_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
String.join(",", before), String.join(",", perms));
}
@Transactional(rollbackFor = Exception.class)
public void setMenus(Long id, List<String> menus) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
Set<String> keys = validateMenus(menus);
assertNotSelfLock(tenantId, role, currentPerms(tenantId, role), keys);
Set<String> before = currentMenus(tenantId, role);
grantWriter.replaceMenus(tenantId, role.getId(), keys);
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_UPDATE_ROLE_MENU, OperationLogService.TARGET_ROLE, role.getId(),
String.join(",", before), String.join(",", keys));
}
@Transactional(rollbackFor = Exception.class)
public void setStatus(Long id, Integer status) {
Long tenantId = UserContext.tenantId();
SysRole role = requireCustomRole(tenantId, id);
if (status == null || (status != 0 && status != 1)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(正常)");
}
if (status == 0) {
// 停用 = 这个角色立刻不再给权限(见 DbRoleGrantSource),所以要过防自锁
assertNotSelfLock(tenantId, role, Set.of(), Set.of());
}
role.setStatus(status);
roleMapper.updateById(role);
grantSource.evict(tenantId, role.getRoleCode());
operationLogService.record(OperationLogService.MODULE_ROLE,
OperationLogService.ACTION_TOGGLE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
null, role.getRoleCode() + " → " + (status == 1 ? "正常" : "停用"));
}
// ── 内部:取数与校验 ────────────────────────────
private SysRole findByCode(Long tenantId, String roleCode) {
return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getRoleCode, roleCode)
.last("LIMIT 1"));
}
/**
* 取一个**自定义**角色;内置角色直接拒绝。
*
* <p>⚠️ 这里额外比一次 {@code tenant_id}:平台运营身份下租户拦截器是关闭的
* (它是唯一跨租户的身份),只靠 {@code selectById} 就能拿到别的租户的角色行,
* 而随后写回去会把它写成"平台租户的角色"。所以平台身份走这条路时也要挡住 ——
* 平台想改哪个学校,必须先像其它页面一样选定学校({@code X-Tenant-Id})。
*/
private SysRole requireCustomRole(Long tenantId, Long id) {
SysRole role = roleMapper.selectById(id);
if (role == null || (role.getTenantId() != null && !role.getTenantId().equals(tenantId))) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在");
}
if (role.getRoleType() == null || role.getRoleType() != 2) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + role.getRoleName() + "」是内置角色,权限由代码决定,不可修改");
}
return role;
}
/** 学校租户不能创建"平台运营"那一类的角色(与 {@code UserService.resolveAssignableRoles} 同一条防线)。 */
private void assertUserTypeAllowed(Integer userType) {
if (!isPlatformTenant() && userType != null
&& userType == BuiltinRole.UserType.PLATFORM.code()) {
throw new BizException(ErrorCode.FORBIDDEN, "无权创建平台运营类的角色");
}
}
private Set<String> validatePerms(List<String> codes) {
Set<String> out = new LinkedHashSet<>();
if (codes == null) {
return out;
}
boolean platform = isPlatformTenant();
Set<String> platformOnly = PermissionDef.platformOnlyCodes();
for (String raw : codes) {
if (raw == null || raw.isBlank()) {
continue;
}
String code = raw.trim();
PermissionDef def = PermissionDef.ofCode(code);
if (def == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "权限码不存在:" + code);
}
if (!platform && platformOnly.contains(code)) {
throw new BizException(ErrorCode.FORBIDDEN,
"无权授予平台级权限:" + def.getLabel() + "(" + code + ")");
}
out.add(code);
}
return out;
}
private Set<String> validateMenus(List<String> keys) {
Set<String> known = BuiltinRole.allMenuKeys();
Set<String> platformOnly = BuiltinRole.platformOnlyMenuKeys();
boolean platform = isPlatformTenant();
Set<String> out = new LinkedHashSet<>();
if (keys == null) {
return out;
}
for (String raw : keys) {
if (raw == null || raw.isBlank()) {
continue;
}
String key = raw.trim();
if (!known.contains(key)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "菜单键不存在:" + key);
}
// 与权限码同一类防线:把平台专用的入口配给学校的角色,
// 左侧会多出一个点进去必然 403 的菜单 —— 用户只会认为"系统坏了"
if (!platform && platformOnly.contains(key)) {
throw new BizException(ErrorCode.FORBIDDEN,
"无权配置平台级菜单:" + key + "(学校租户看不到这个入口)");
}
out.add(key);
}
return out;
}
/**
* 防自锁:不允许把"自己管理角色所依赖的最后一个来源"拿掉。
*
* <p>两类自锁各对应一个具体现象:
* <ul>
* <li>去掉 {@code role:manage} → 本页所有接口立刻 40301,只能找人用 SQL 救;</li>
* <li>去掉 {@code role} 菜单键 → 菜单里不再有这一项,前端连路由都不会注册
* ({@code buildXtRoutes} 按菜单键过滤),页面在界面上彻底消失。</li>
* </ul>
*
* <p>判据是"**别的**角色里还有没有这一条":内置角色(如学校管理员)天然有,
* 所以正常情况下这个校验永远不会触发 —— 它防的是"管理员把自己的自定义角色改秃了"。
*/
private void assertNotSelfLock(Long tenantId, SysRole role, Set<String> newPerms, Set<String> newMenus) {
LoginUser me = UserContext.get();
if (me == null || me.isPlatformAdmin() || me.roles() == null) {
return;
}
if (!me.roles().contains(role.getRoleCode())) {
return;
}
if (!otherRolesGrant(tenantId, me.roles(), role.getRoleCode(),
PermissionDef.ROLE_MANAGE.getCode(), false)
&& !newPerms.contains(PermissionDef.ROLE_MANAGE.getCode())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这是你自己进入「角色与授权」所依赖的最后一个来源:去掉「角色与授权」权限后,"
+ "你将无法再打开本页。请先给另一个角色授予该权限");
}
if (!otherRolesGrant(tenantId, me.roles(), role.getRoleCode(),
ROLE_MENU_KEY, true) && !newMenus.contains(ROLE_MENU_KEY)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这是你自己进入「角色与授权」所依赖的最后一个来源:去掉「角色与授权」菜单后,"
+ "左侧菜单里将不再出现本页。请先给另一个角色配上该菜单");
}
}
/** {@code role} 菜单键 —— 与前端 {@code XtMenu.key} 逐字一致。 */
private static final String ROLE_MENU_KEY = "role";
/**
* 当前用户的**其它**角色里,有没有哪个角色带这一条(权限码或菜单键)。
*
* @param menu true = 查菜单键,false = 查权限码
*/
private boolean otherRolesGrant(Long tenantId, Collection<String> myRoles, String excludeCode,
String want, boolean menu) {
for (String rc : myRoles) {
if (rc == null || rc.equals(excludeCode)) {
continue;
}
BuiltinRole br = BuiltinRole.ofCode(rc);
if (br != null) {
boolean has = menu ? br.getMenus().contains(want) : br.permissionCodes().contains(want);
if (has) {
return true;
}
continue;
}
boolean has = menu ? grantSource.menusOf(tenantId, rc).contains(want)
: grantSource.permsOf(tenantId, rc).contains(want);
if (has) {
return true;
}
}
return false;
}
private Set<String> currentPerms(Long tenantId, SysRole role) {
Set<String> out = new LinkedHashSet<>();
for (SysRolePermission rp : rolePermissionMapper.selectList(
Wrappers.<SysRolePermission>lambdaQuery()
.eq(SysRolePermission::getTenantId, tenantId)
.eq(SysRolePermission::getRoleId, role.getId()))) {
if (rp.getPermCode() != null) {
out.add(rp.getPermCode());
}
}
return out;
}
private Set<String> currentMenus(Long tenantId, SysRole role) {
Set<String> out = new LinkedHashSet<>();
for (SysRoleMenu rm : roleMenuMapper.selectList(
Wrappers.<SysRoleMenu>lambdaQuery()
.eq(SysRoleMenu::getTenantId, tenantId)
.eq(SysRoleMenu::getRoleId, role.getId()))) {
if (rm.getMenuKey() != null) {
out.add(rm.getMenuKey());
}
}
return out;
}
private int countUsers(Long tenantId, Long roleId) {
for (RoleMapper.RoleUserCount row : roleMapper.countUsersByRole(tenantId)) {
if (roleId.equals(row.getRoleId())) {
return row.getUserCount() == null ? 0 : row.getUserCount();
}
}
return 0;
}
private Map<Long, List<String>> permsOfRoles(Long tenantId, List<Long> roleIds) {
Map<Long, List<String>> out = new HashMap<>();
if (roleIds.isEmpty()) {
return out;
}
for (SysRolePermission rp : rolePermissionMapper.selectList(
Wrappers.<SysRolePermission>lambdaQuery()
.eq(SysRolePermission::getTenantId, tenantId)
.in(SysRolePermission::getRoleId, roleIds))) {
if (rp.getPermCode() != null) {
out.computeIfAbsent(rp.getRoleId(), k -> new ArrayList<>()).add(rp.getPermCode());
}
}
return out;
}
private Map<Long, List<String>> menusOfRoles(Long tenantId, List<Long> roleIds) {
Map<Long, List<String>> out = new HashMap<>();
if (roleIds.isEmpty()) {
return out;
}
for (SysRoleMenu rm : roleMenuMapper.selectList(
Wrappers.<SysRoleMenu>lambdaQuery()
.eq(SysRoleMenu::getTenantId, tenantId)
.in(SysRoleMenu::getRoleId, roleIds))) {
if (rm.getMenuKey() != null) {
out.computeIfAbsent(rm.getRoleId(), k -> new ArrayList<>()).add(rm.getMenuKey());
}
}
return out;
}
private boolean isPlatformTenant() {
Long tenantId = UserContext.tenantId();
return tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
}
}
@@ -13,10 +13,13 @@ import cn.tianbaotong.fill.module.user.dto.ResetPasswordReq;
import cn.tianbaotong.fill.module.user.dto.UserImportResult;
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
import cn.tianbaotong.fill.module.user.dto.UserVO;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.support.RoleProvisioner;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.alibaba.excel.EasyExcel;
@@ -73,18 +76,11 @@ public class UserService {
private static final Pattern PHONE_RE = Pattern.compile("^1[3-9]\\d{9}$");
/** 学校租户可分配的角色。 */
private static final Set<String> SCHOOL_ASSIGNABLE = Set.of(
"SCHOOL_ADMIN", "HEAD_TEACHER", "DEPUTY_TEACHER", "AUDITOR", "STUDENT");
/** 平台租户可分配的角色。 */
private static final Set<String> PLATFORM_ASSIGNABLE = Set.of("PLATFORM_ADMIN");
/** 拥有 {@code user:manage} 的角色。判断"还有没有人能管账号"只需看这两个。 */
private static final Set<String> ADMIN_ROLE_CODES = Set.of("PLATFORM_ADMIN", "SCHOOL_ADMIN");
/**
* 角色 → 用户类型(《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致)。
* 用户类型 → 中文名(用于错误提示)。
*
* <p>⚠️ <b>不要照 {@code sys_user} 那行 DDL 注释写</b>。那行注释写的是
* 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的
@@ -94,16 +90,12 @@ public class UserService {
*
* <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉,
* 而 {@code 3} 恰恰是<b>班主任</b> —— 最高频的一种账号。
*
* <p>二期改动:原先这里还有一张 {@code ROLE_TO_USER_TYPE} 的"角色码 → 用户类型"表,
* 现在删了 —— 用户类型改由 {@link #effectiveUserType} 取(内置角色看代码的
* {@code BuiltinRole.UserType},自定义角色看 {@code sys_role.user_type})。
* 留两张表迟早会分叉,而分叉的表现是"新建账号报类型不符、但看不出为什么"。
*/
private static final Map<String, Integer> ROLE_TO_USER_TYPE = Map.of(
"PLATFORM_ADMIN", 1,
"SCHOOL_ADMIN", 2,
"AUDITOR", 2,
"HEAD_TEACHER", 3,
"DEPUTY_TEACHER", 3,
"STUDENT", 4);
/** 用户类型 → 中文名(用于错误提示)。与 {@link #ROLE_TO_USER_TYPE} 同一套口径。 */
private static final Map<Integer, String> USER_TYPE_NAMES = Map.of(
1, "平台运营",
2, "学校管理员",
@@ -138,7 +130,9 @@ public class UserService {
private final UserMapper userMapper;
private final UserRoleMapper userRoleMapper;
private final OrgMapper orgMapper;
private final RoleMapper roleMapper;
private final RoleProvisioner roleProvisioner;
private final DbRoleGrantSource roleGrantSource;
private final SensitiveCodec codec;
private final PasswordEncoder passwordEncoder;
private final LoginGuard loginGuard;
@@ -184,9 +178,18 @@ public class UserService {
}
}
if (roleCode != null && BuiltinRole.ofCode(roleCode) != null) {
// roleCode 已经过内置角色白名单校验,拼串是安全的。
// 用子查询而不是 JOIN,是为了让 MP 的租户过滤与逻辑删除条件继续作用于主表。
if (roleCode != null) {
// ⚠️ 角色码**不认识时必须返回空**,绝不能"忽略这个过滤条件"。
// 忽略的后果是:调用方以为筛出了"班主任",实际拿到**全校账号(含学生)**——
// 而且不报任何错。这个坑真实发生过:2026-10 删掉「副班主任」角色之后,
// 前端还在传 `roleCode=DEPUTY_TEACHER`,内置枚举里已经没有它了,
// 于是"选初审人"的下拉里出现了学生。
if (!isKnownRoleCode(roleCode)) {
log.warn("按未知角色码过滤账号,按失败关闭返回空:roleCode={}", roleCode);
return PageResult.of(List.of(), 0, num, size);
}
// 角色码已经过白名单校验(内置枚举或库里真实存在),拼串是安全的。
// 用子查询而不是 JOIN,是为了让租户过滤与逻辑删除条件继续作用于主表。
q.inSql(SysUser::getId,
"SELECT ur.user_id FROM sys_user_role ur"
+ " JOIN sys_role r ON r.id = ur.role_id"
@@ -215,7 +218,7 @@ public class UserService {
String username = req.getUsername().trim();
assertUsernameUnique(username, null);
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
assertRoleMatchesUserType(req.getUserType(), roles);
String initialPassword = MaskUtil.generateInitialPassword();
@@ -230,12 +233,12 @@ public class UserService {
codec.applyPhone(u, req.getPhone());
userMapper.insert(u);
for (BuiltinRole br : roles) {
roleProvisioner.bindRole(tenantId, u.getId(), br);
for (SysRole role : roles) {
roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
}
log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream()
.map(BuiltinRole::getCode).toList());
.map(SysRole::getRoleCode).toList());
return new CreateResult(u.getId(), initialPassword);
}
@@ -254,7 +257,7 @@ public class UserService {
SysUser exist = requireUser(id);
long tenantId = UserContext.tenantId();
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
assertRoleMatchesUserType(req.getUserType(), roles);
// 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死"
@@ -276,6 +279,12 @@ public class UserService {
assertAnotherAdminExists(exist, "取消管理员角色");
}
// 二期新增的一条自锁:**自定义角色**让"管理员"不再等于那两个内置角色码。
// 上面的判据只看内置管理员角色,所以「自己靠自定义角色管账号、再把自己换成别的角色」
// 会绕过它 —— 账号还在、还能登录,但再也进不了账号管理与角色授权。
// 这类错误不可逆(只能找人用 SQL 救),所以单独判一次。
assertKeepsAccountManage(exist, roles);
SysUser patch = new SysUser();
patch.setId(id);
patch.setRealName(req.getRealName().trim());
@@ -382,7 +391,7 @@ public class UserService {
public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) {
assertImportFile(file);
BuiltinRole fallbackRole = resolveFallbackRole(defaultRoleCode);
SysRole fallbackRole = resolveFallbackRole(defaultRoleCode);
List<Map<Integer, String>> rows;
try (InputStream in = file.getInputStream()) {
@@ -472,7 +481,7 @@ public class UserService {
}
}
BuiltinRole role = fallbackRole;
SysRole role = fallbackRole;
if (err == null && roleText != null && !roleText.isBlank()) {
role = resolveRoleByText(roleText);
if (role == null) {
@@ -481,7 +490,7 @@ public class UserService {
}
// 用户类型从角色推导(二者一对一绑定)—— 导入文件里没有这一列,
// 也不该要求用户去填:填错就是整行白导。
Integer userType = role == null ? null : ROLE_TO_USER_TYPE.get(role.getCode());
Integer userType = role == null ? null : effectiveUserType(role);
if (err == null && userType == null) {
err = new UserImportResult.RowError(excelRow, "role", roleText,
"该角色没有对应的用户类型");
@@ -506,7 +515,7 @@ public class UserService {
u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim()));
}
userMapper.insert(u);
roleProvisioner.bindRole(tenantId, u.getId(), role);
roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
seenInFile.add(username.trim());
result.setSuccess(result.getSuccess() + 1);
@@ -521,6 +530,28 @@ public class UserService {
// ── 护栏 ──────────────────────────────────────
/**
* 角色码是否真实存在:**内置枚举** 或 **本租户的自定义角色**。
*
* <p>它有两层用途:
* <ol>
* <li>它是下面那段拼串 SQL 的**白名单** —— 只有"库里确实存在的角色码"才允许被拼进去,
* 所以天然挡住注入;</li>
* <li>它把"传了一个不存在的角色码"变成**失败关闭**。上一版对这种情况是
* <b>静默忽略过滤条件</b>,于是调用方以为筛出了"班主任",
* 实际拿到全校账号(含学生)—— 而且不报任何错。
* 2026-10 删掉「副班主任」角色后就真实发生过一次(前端还在传那个码)。</li>
* </ol>
*/
private boolean isKnownRoleCode(String roleCode) {
if (BuiltinRole.ofCode(roleCode) != null) {
return true;
}
Long n = roleMapper.selectCount(
Wrappers.<SysRole>lambdaQuery().eq(SysRole::getRoleCode, roleCode));
return n != null && n > 0;
}
/**
* 校验并解析可分配的角色。
*
@@ -536,11 +567,19 @@ public class UserService {
* <p>两道防线:本方法按"当前租户能分配什么角色"白名单校验;
* 同时 {@code RoleProvisioner} 不再给学校租户发放 {@code PLATFORM_ADMIN} 角色行。
* 任一道单独都够用,但都留着 —— 这类错误一旦发生是不可逆的信息泄露。
*
* <p><b>二期(角色与授权)的改法</b>:返回类型从 {@code BuiltinRole} 换成 {@code SysRole} ——
* 因为可选角色不再只有 6 个内置角色,租户自己建的角色也要能发出去。
* 内置角色那条白名单**一个字都没改**(它防的是越权到平台级);
* 自定义角色不需要白名单:它是本租户的行(上面的查询本身就带 {@code tenant_id}),
* 而它能包含哪些权限码在 {@code RoleService.validatePerms} 里已经挡过平台级权限。
*/
private List<BuiltinRole> resolveAssignableRoles(List<String> codes) {
private List<SysRole> resolveAssignableRoles(List<String> codes) {
Long tenantId = UserContext.tenantId();
boolean platform = tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
Set<String> allowed = platform ? PLATFORM_ASSIGNABLE : SCHOOL_ASSIGNABLE;
// 白名单规则只有一处(BuiltinRole.assignableCodes):界面上的 assignable 标志也用它,
// 两处各写一份必然分叉成"界面能选、点了 40301"
Set<String> allowed = BuiltinRole.assignableCodes(platform);
LinkedHashSet<String> distinct = new LinkedHashSet<>();
if (codes != null) {
@@ -554,16 +593,25 @@ public class UserService {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请至少选择一个角色");
}
List<BuiltinRole> roles = new ArrayList<>();
// 一次读出本租户全部启用中的角色(内置 + 自定义),再按码取 —— 避免逐条查库
Map<String, SysRole> byCode = new LinkedHashMap<>();
for (SysRole r : roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getStatus, 1))) {
byCode.put(r.getRoleCode(), r);
}
List<SysRole> roles = new ArrayList<>();
for (String c : distinct) {
BuiltinRole br = BuiltinRole.ofCode(c);
if (br == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在:" + c);
SysRole role = byCode.get(c);
if (role == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在或已停用:" + c);
}
if (!allowed.contains(c)) {
BuiltinRole br = BuiltinRole.ofCode(c);
if (br != null && !allowed.contains(c)) {
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
}
roles.add(br);
roles.add(role);
}
return roles;
}
@@ -572,20 +620,20 @@ public class UserService {
* 用户类型必须与所选角色自洽。
*
* <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>:
* 角色与用户类型本来就是一对一绑定的(见 {@link #ROLE_TO_USER_TYPE}),
* 角色与用户类型本来就是一对一绑定的(见 {@link #effectiveUserType}),
* 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。
*
* <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以,
* 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。
*/
private void assertRoleMatchesUserType(Integer userType, List<BuiltinRole> roles) {
private void assertRoleMatchesUserType(Integer userType, List<SysRole> roles) {
if (userType == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型");
}
Set<Integer> expected = new LinkedHashSet<>();
for (BuiltinRole br : roles) {
Integer t = ROLE_TO_USER_TYPE.get(br.getCode());
for (SysRole role : roles) {
Integer t = effectiveUserType(role);
if (t != null) {
expected.add(t);
}
@@ -611,6 +659,18 @@ public class UserService {
}
}
/**
* 角色对应的用户类型。
*
* <p>内置角色**取代码**({@link BuiltinRole#getUserType()}),自定义角色取
* {@code sys_role.user_type}。与权限判定同一套口径:内置角色的权威在代码,
* 库里的行只是"看得见、可审计"。
*/
private Integer effectiveUserType(SysRole role) {
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
return br != null ? br.getUserType().code() : role.getUserType();
}
private void assertUsernameUnique(String username, Long excludeId) {
SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getUsername, username)
@@ -663,9 +723,9 @@ public class UserService {
* <p>只有在目标<b>原本确实是管理员</b>时才返回 true ——
* 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。
*/
private boolean losingAdminRole(SysUser target, List<BuiltinRole> newRoles) {
private boolean losingAdminRole(SysUser target, List<SysRole> newRoles) {
boolean stillAdmin = newRoles.stream()
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getCode()));
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getRoleCode()));
if (stillAdmin) {
return false;
}
@@ -673,6 +733,64 @@ public class UserService {
return now != null && now > 0;
}
/**
* 改角色后,操作者自己是否还留着「账号管理」这条能力(**只判自己**)。
*
* <p>为什么必须单独判:{@link #losingAdminRole} 只看那两个内置管理员角色码,
* 而二期之后"能管账号"也可能来自一个自定义角色(它被授了 {@code user:manage})。
* 于是「自己靠自定义角色管账号 → 把自己换成别的角色」会绕过上面那条检查:
* 账号还在、还能登录,但再也打不开账号管理与角色授权,而**这类错误不可逆**。
*
* <p>为什么只判自己:别人的权限由别人负责,拦下来只会让正常的人员交接做不了;
* 而"把自己改秃"的代价是他一个人再也回不来。
*/
private void assertKeepsAccountManage(SysUser target, List<SysRole> newRoles) {
if (!Objects.equals(target.getId(), UserContext.uid())) {
return;
}
Long tenantId = UserContext.tenantId();
if (tenantId == null || tenantId == LoginUser.PLATFORM_TENANT_ID) {
return; // 平台运营不受权限码限制,锁不住自己
}
String want = PermissionDef.USER_MANAGE.getCode();
if (rolesGrant(tenantId, newRoles, want)) {
return;
}
// 改之前本来就没有 → 他不是靠这条权限进来的,不拦
if (!rolesGrant(tenantId, currentRolesOf(tenantId, target.getId()), want)) {
return;
}
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这是你自己进入「账号管理」所依赖的最后一个来源:改完之后你将无法再管理本单位的账号。"
+ "请先给另一个账号配上带「账号管理」的角色");
}
/** 这些角色里有没有哪个带这条权限(内置看代码,自定义看库)。 */
private boolean rolesGrant(Long tenantId, List<SysRole> roles, String permCode) {
for (SysRole role : roles) {
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
if (br != null) {
if (br.permissionCodes().contains(permCode)) {
return true;
}
} else if (roleGrantSource.permsOf(tenantId, role.getRoleCode()).contains(permCode)) {
return true;
}
}
return false;
}
/** 某个人现在挂着的角色行。 */
private List<SysRole> currentRolesOf(Long tenantId, Long userId) {
List<String> codes = userRoleMapper.selectRoleCodesByUserId(userId);
if (codes.isEmpty()) {
return List.of();
}
return roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.in(SysRole::getRoleCode, codes));
}
private void assertPasswordStrength(String plain) {
cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain);
}
@@ -699,11 +817,11 @@ public class UserService {
return u;
}
private void rebindRoles(long tenantId, Long userId, List<BuiltinRole> roles) {
private void rebindRoles(long tenantId, Long userId, List<SysRole> roles) {
userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId));
for (BuiltinRole br : roles) {
roleProvisioner.bindRole(tenantId, userId, br);
for (SysRole role : roles) {
roleProvisioner.bindRoleByCode(tenantId, userId, role.getRoleCode());
}
}
@@ -724,17 +842,26 @@ public class UserService {
}
}
private BuiltinRole resolveFallbackRole(String defaultRoleCode) {
BuiltinRole br = defaultRoleCode == null || defaultRoleCode.isBlank()
? BuiltinRole.HEAD_TEACHER
: BuiltinRole.ofCode(defaultRoleCode.trim());
if (br == null) {
/**
* 导入时的默认角色。
*
* <p>二期改成查库:可选角色不再只有 6 个内置角色,租户自己建的角色也要能当默认值。
* 返回 {@code SysRole}(内置与自定义同一形状),后面的绑定与用户类型推导都用它。
*/
private SysRole resolveFallbackRole(String defaultRoleCode) {
String code = defaultRoleCode == null || defaultRoleCode.isBlank()
? BuiltinRole.HEAD_TEACHER.getCode()
: defaultRoleCode.trim();
List<SysRole> roles;
try {
roles = resolveAssignableRoles(List.of(code));
} catch (BizException e) {
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + code);
}
if (roles.isEmpty()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "默认角色不存在:" + defaultRoleCode);
}
if (resolveAssignableRoles(List.of(br.getCode())).isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
}
return br;
return roles.get(0);
}
/**
@@ -825,31 +952,46 @@ public class UserService {
return s.isEmpty() ? null : s;
}
/** 角色列既可能写中文名(「班主任」)也可能写角色码(HEAD_TEACHER),两种都认。 */
private BuiltinRole resolveRoleByText(String text) {
/**
* 角色列既可能写中文名(「班主任」)也可能写角色码({@code HEAD_TEACHER}),两种都认。
*
* <p>二期改成按库里的角色认(内置 + 自定义都在 {@code sys_role} 里),
* 所以"租户自己建的角色"也能在导入文件里按名字写。
* 返回 {@code null} 表示"认不出或不可分配",调用方据此把该行标成错误行。
*/
private SysRole resolveRoleByText(String text) {
String t = text.trim();
BuiltinRole byCode = BuiltinRole.ofCode(t.toUpperCase());
if (byCode != null) {
try {
resolveAssignableRoles(List.of(byCode.getCode()));
return byCode;
} catch (BizException e) {
return null;
for (SysRole role : tenantRoles()) {
if (role.getRoleCode() != null && role.getRoleCode().equalsIgnoreCase(t)) {
return assignableRole(role.getRoleCode());
}
}
for (BuiltinRole br : BuiltinRole.all()) {
if (br.getName().equals(t)) {
try {
resolveAssignableRoles(List.of(br.getCode()));
return br;
} catch (BizException e) {
return null;
}
for (SysRole role : tenantRoles()) {
if (role.getRoleName() != null && role.getRoleName().equals(t)) {
return assignableRole(role.getRoleCode());
}
}
return null;
}
/** 本租户启用中的角色(按内置优先、排序值排)。 */
private List<SysRole> tenantRoles() {
return roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, UserContext.tenantId())
.eq(SysRole::getStatus, 1)
.orderByAsc(SysRole::getSortNo));
}
/** 这个角色码当前能不能分配;不能就返回 {@code null}(而不是抛异常 —— 导入要逐行报错)。 */
private SysRole assignableRole(String roleCode) {
try {
List<SysRole> roles = resolveAssignableRoles(List.of(roleCode));
return roles.isEmpty() ? null : roles.get(0);
} catch (BizException e) {
return null;
}
}
/** 表头原文,用 | 连接,供界面展示确认。 */
private static String joinHeaderText(Map<Integer, String> headerCells) {
if (headerCells == null || headerCells.isEmpty()) {
@@ -937,6 +1079,17 @@ public class UserService {
}
}
// 角色码 → 角色名(一次查完)。二期自定义角色的名字只在库里,
// 而内置角色的名字库里也有(RoleProvisioner 发放时写的),所以不必再分两路。
Long tenantId = UserContext.tenantId();
Map<String, String> roleNames = new HashMap<>();
for (SysRole r : roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
.eq(tenantId != null, SysRole::getTenantId, tenantId))) {
if (r.getRoleCode() != null) {
roleNames.put(r.getRoleCode(), r.getRoleName() == null ? r.getRoleCode() : r.getRoleName());
}
}
List<UserVO> list = new ArrayList<>(users.size());
for (SysUser u : users) {
UserVO vo = new UserVO();
@@ -953,13 +1106,11 @@ public class UserService {
List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of());
vo.setRoleCodes(codes);
// 一期角色全部内置(没有自定义角色的界面),名字直接从代码取;
// 二期支持自定义角色后,这里要改成从 sys_role.role_name 读。
// 二期:角色名读库(sys_role.role_name)—— 自定义角色的名字不在代码里。
// 内置角色的行也在库里(开通租户时由 RoleProvisioner 写入),所以一条查询覆盖两种;
// 查不到(角色被删)时回落成角色码,至少不会显示一片空白。
vo.setRoleNames(codes.stream()
.map(c -> {
BuiltinRole br = BuiltinRole.ofCode(c);
return br == null ? c : br.getName();
})
.map(c -> roleNames.getOrDefault(c, c))
.toList());
list.add(vo);
}
@@ -43,15 +43,14 @@ public enum BuiltinRole {
PermissionDef.TASK_MANAGE, PermissionDef.TASK_VIEW, PermissionDef.TASK_CLONE,
PermissionDef.FORM_MANAGE, PermissionDef.MATERIAL_MANAGE, PermissionDef.PLAN_IMPORT,
PermissionDef.ROSTER_MANAGE, PermissionDef.ROSTER_VIEW,
PermissionDef.JOIN_REQUEST_AUDIT,
PermissionDef.NOTIFY_SEND,
PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL, PermissionDef.AUDIT_WITHDRAW,
PermissionDef.FILE_PROXY_UPLOAD,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED,
PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task",
"join", "audit", "export", "cleanup", "log",
"audit", "export", "log",
// ⚠️ 顶层菜单里**没有**「名单管理 / 表单设计 / 材料规则」这三条。
// 它们的功能早就做好了,但都是**按任务**的:入口在「收集任务」
// 每一行的链接里(名单管理 / 表单设计 / 材料规则)。
@@ -75,21 +74,6 @@ public enum BuiltinRole {
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "audit")),
/** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */
DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
Set.of(PermissionDef.TASK_VIEW,
PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
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", "audit")),
/** 学生:只能操作本人那一份。 */
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
Set.of(PermissionDef.SUBMISSION_OWN, PermissionDef.PROGRESS_VIEW_OWN),
@@ -161,7 +145,7 @@ public enum BuiltinRole {
return codes;
}
/** 开通租户时默认创建的 6 个内置角色。 */
/** 开通租户时默认创建的 4 个内置角色。 */
public static Set<BuiltinRole> all() {
return new LinkedHashSet<>(Arrays.asList(values()));
}
@@ -196,4 +180,64 @@ public enum BuiltinRole {
}
return merged;
}
/**
* 这些**内置**角色码在当前租户类型下可以发给账号。
*
* <p>平台运营角色只发平台租户 —— 学校管理员拿到它会直接越权到平台级
* ({@code /tenants} 只校验权限码,不校验"是否真的在平台租户下")。
*
* <p>这条规则有两个使用方:{@code UserService.resolveAssignableRoles}(写时的防线)
* 与 {@code RoleService.list}(读时给界面一个 {@code assignable} 标志)。
* 抽到这里是为了**只有一处**:两处各写一份,界面会显示一个"点了必然 403"的选项。
*/
public static Set<String> assignableCodes(boolean platformTenant) {
Set<String> codes = new LinkedHashSet<>();
for (BuiltinRole r : values()) {
if (!platformTenant && r == PLATFORM_ADMIN) {
continue;
}
if (platformTenant && r != PLATFORM_ADMIN) {
continue;
}
codes.add(r.code);
}
return codes;
}
/**
* 系统现有的全部菜单键(= 所有内置角色声明过的入口之并集)。
*
* <p>用途是**校验自定义角色配的菜单**(二期):菜单键是前后端契约
* (后端这里与前端 {@code XtMenu.key} 逐字一致,由 {@code check-wiring.py} ⑤
* 双向核对),所以"合法取值"就是这份并集 —— 不需要再造一张菜单字典表
* (多一张表就多一处会漂移的地方)。
*/
public static Set<String> allMenuKeys() {
Set<String> keys = new LinkedHashSet<>();
for (BuiltinRole r : values()) {
keys.addAll(r.getMenus());
}
return keys;
}
/**
* **只有平台运营**声明过的菜单键(学校租户不可配置)。
*
* <p>现在只有 {@code tenant}(租户管理)。{@code log}(操作日志)不算 ——
* 学校管理员也有本租户的操作日志({@code log:viewTenant}),只是看的范围不同。
*
* <p>为什么要有这条:把 {@code tenant} 菜单配给学校的自定义角色,左侧会多出一个
* 点进去必然 403 的入口 —— 而用户只会得出"系统坏了"的结论。
* 与权限码的 {@code platformOnlyCodes} 是同一类防线(那里挡的是能力,这里挡的是入口)。
*/
public static Set<String> platformOnlyMenuKeys() {
Set<String> only = new LinkedHashSet<>(PLATFORM_ADMIN.getMenus());
for (BuiltinRole r : values()) {
if (r != PLATFORM_ADMIN) {
only.removeAll(r.getMenus());
}
}
return only;
}
}
@@ -4,21 +4,35 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 权限判定。
*
* <p><b>为什么不查库</b>:内置角色的权限由代码决定({@link BuiltinRole}),
* 直接从 JWT 里的 {@code roles} 推导即可,零 IO。自定义角色的授权在 DB 里,
* 但一期不开放自定义角色,故暂不需要查询。二期开放自定义角色时,
* 在这里加一层"角色码 → DB 授权"的补充查询即可,调用方无感。
* <p><b>两个来源,各有各的权威</b>:
*
* <ul>
* <li><b>内置角色</b>({@code PLATFORM_ADMIN} / {@code SCHOOL_ADMIN} / …)的权限由代码决定
* ({@link BuiltinRole}),从 JWT 里的 {@code roles} 直接推导,**不查库**。
* 库里 {@code sys_role_permission} 的那些行是"看得见、可审计",不是生效依据 ——
* 否则代码改了权限集而库没同步,就会出现"界面上显示 A、实际生效 B"。</li>
* <li><b>自定义角色</b>(二期)的授权在库里,走 {@link RoleGrantSource} 补一层查询,
* 实现自带缓存(见 {@code DbRoleGrantSource} 的类注释:为什么必须有缓存、
* 为什么空结果也要缓存、Redis 挂了为什么退化成直读库)。</li>
* </ul>
*
* <p>调用方完全无感:{@code @RequirePerm} 与服务层的 {@code require} 一行都不用改。
*/
@Component
@RequiredArgsConstructor
public class PermissionChecker {
private final RoleGrantSource roleGrantSource;
/** 当前用户是否拥有指定权限。 */
public boolean has(PermissionDef perm) {
LoginUser user = UserContext.get();
@@ -29,7 +43,7 @@ public class PermissionChecker {
if (user.isPlatformAdmin()) {
return true;
}
return BuiltinRole.mergePermissions(user.roles()).contains(perm.getCode());
return effective(user).contains(perm.getCode());
}
public boolean hasAny(PermissionDef... perms) {
@@ -63,12 +77,49 @@ public class PermissionChecker {
if (user.isPlatformAdmin()) {
return PermissionDef.allCodes();
}
return BuiltinRole.mergePermissions(user.roles());
return effective(user);
}
/** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */
public Set<String> currentMenus() {
LoginUser user = UserContext.require();
return BuiltinRole.mergeMenus(user.roles());
Set<String> menus = new LinkedHashSet<>(BuiltinRole.mergeMenus(user.roles()));
Long tenantId = user.tenantId();
if (tenantId == null || user.roles() == null) {
return menus;
}
// 与权限同一套口径:内置角色的菜单看代码,自定义角色的菜单看库里的配置
for (String roleCode : user.roles()) {
if (BuiltinRole.ofCode(roleCode) == null) {
menus.addAll(roleGrantSource.menusOf(tenantId, roleCode));
}
}
return menus;
}
/**
* 生效的权限码全集 = 内置角色(按代码)+ 自定义角色(按库里的授权)。
*
* <p>⚠️ 只对"代码里不存在的角色码"查库。反过来写(先查库、查不到再按代码)
* 会让内置角色也被库里的历史授权影响 —— 那条路一旦走通,
* "把 SCHOOL_ADMIN 的某条权限收紧"就要同时改代码与每个租户的库。
*/
private Set<String> effective(LoginUser user) {
Set<String> codes = new LinkedHashSet<>();
if (user.roles() == null || user.roles().isEmpty()) {
return codes;
}
codes.addAll(BuiltinRole.mergePermissions(user.roles()));
Long tenantId = user.tenantId();
if (tenantId == null) {
return codes;
}
for (String roleCode : user.roles()) {
if (BuiltinRole.ofCode(roleCode) == null) {
codes.addAll(roleGrantSource.permsOf(tenantId, roleCode));
}
}
return codes;
}
}
@@ -19,55 +19,57 @@ import java.util.Set;
*
* <p>菜单与权限的关系:一个菜单可能由多个权限共同决定可见性(如「审核」菜单
* 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。
*
* <p>{@link #group} 是给「角色与授权」页的权限矩阵分组用的:
* 28 条权限平铺成一张表没人看得懂,而分组**只在展示层有意义**,不影响任何校验 ——
* 所以它是枚举上的一个字符串,而不是一张表。
*/
@Getter
public enum PermissionDef {
// ── 平台运营 ──────────────────────────────────
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC),
TENANT_VIEW("tenant:view", "租户查看", Platform.PC),
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC),
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC, "平台运营"),
TENANT_VIEW("tenant:view", "租户查看", Platform.PC, "平台运营"),
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC, "平台运营"),
// ── 组织与账号 ────────────────────────────────
ORG_MANAGE("org:manage", "组织架构管理", Platform.PC),
ORG_VIEW("org:view", "组织架构查看", Platform.PC),
USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC),
USER_VIEW("user:view", "账号查看", Platform.PC),
ROLE_MANAGE("role:manage", "角色与授权", Platform.PC),
LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC),
ORG_MANAGE("org:manage", "组织架构管理", Platform.PC, "组织与账号"),
ORG_VIEW("org:view", "组织架构查看", Platform.PC, "组织与账号"),
USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC, "组织与账号"),
USER_VIEW("user:view", "账号查看", Platform.PC, "组织与账号"),
ROLE_MANAGE("role:manage", "角色与授权", Platform.PC, "组织与账号"),
LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC, "组织与账号"),
// ── 收集任务 ──────────────────────────────────
TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC),
TASK_VIEW("task:view", "任务查看", Platform.PC),
TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC),
FORM_MANAGE("form:manage", "表单设计", Platform.PC),
MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC),
PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC),
TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC, "收集任务"),
TASK_VIEW("task:view", "任务查看", Platform.PC, "收集任务"),
TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC, "收集任务"),
FORM_MANAGE("form:manage", "表单设计", Platform.PC, "收集任务"),
MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC, "收集任务"),
PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC, "收集任务"),
// ── 名册 ──────────────────────────────────────
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC),
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC),
ROSTER_VIEW("roster:view", "名册查看", Platform.PC),
JOIN_REQUEST_AUDIT("join:audit", "名单加入申请审批", Platform.BOTH),
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC, "名册"),
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC, "名册"),
ROSTER_VIEW("roster:view", "名册查看", Platform.PC, "名册"),
// ── 通知与进度 ────────────────────────────────
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH),
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH),
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH),
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH, "通知与进度"),
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH, "通知与进度"),
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH, "通知与进度"),
// ── 审核 ──────────────────────────────────────
AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH),
AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC),
AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC),
FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH),
AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH, "审核"),
AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC, "审核"),
AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC, "审核"),
FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH, "审核"),
// ── 导出 ──────────────────────────────────────
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC),
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC),
CLEANUP_RUN("cleanup:run", "历史数据整理器", Platform.PC),
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC, "导出"),
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC, "导出"),
// ── 学生端 ────────────────────────────────────
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP);
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP, "学生端");
/** 端类型。 */
public enum Platform {
@@ -88,10 +90,14 @@ public enum PermissionDef {
/** 适用范围。 */
private final Platform platform;
PermissionDef(String code, String label, Platform platform) {
/** 展示分组(只影响「角色与授权」页的矩阵排版,不参与任何校验)。 */
private final String group;
PermissionDef(String code, String label, Platform platform, String group) {
this.code = code;
this.label = label;
this.platform = platform;
this.group = group;
}
public static PermissionDef ofCode(String code) {
@@ -106,4 +112,23 @@ public enum PermissionDef {
Arrays.stream(values()).forEach(p -> set.add(p.code));
return set;
}
/**
* 学校租户**不可授予**的权限码(平台级能力)。
*
* <p>这三条能让持有者跨租户:{@code tenant:manage} 能开通/改租户,
* {@code log:viewAll} 能读全平台日志,{@code tenant:view} 能列举所有租户。
* 自定义角色的可勾选项里必须把它们排除 —— 界面不显示只是"不提示",
* 服务端这一层才是防线(与 {@code UserService.resolveAssignableRoles} 同一条思路:
* 传了白名单之外的东西要拿到 40301,而不是"界面没这个选项所以不会发生")。
*/
public static Set<String> platformOnlyCodes() {
Set<String> set = new LinkedHashSet<>();
for (PermissionDef p : values()) {
if (p.code.startsWith("tenant:") || p == LOG_VIEW_ALL) {
set.add(p.code);
}
}
return set;
}
}
@@ -0,0 +1,38 @@
package cn.tianbaotong.fill.security;
import java.util.Set;
/**
* 自定义角色的授权来源(「角色与授权」二期)。
*
* <p><b>为什么是接口,而不是让 {@link PermissionChecker} 直接查库</b>:
* 「角色被授了哪些权限码」存在 {@code sys_role_permission}({@code module.user} 的东西),
* 而 {@code security} 这一层被 {@code module} 依赖。让 security 反过来依赖 module,
* 会形成一个方向相反的依赖,并且**迟早成环** —— 角色管理服务自己就要调
* {@code permissionChecker.require(ROLE_MANAGE)}。所以这里只声明"能问什么",
* 实现放在 {@code module.user.service.DbRoleGrantSource}。
*
* <p><b>实现必须只回答自定义角色</b>:内置角色的权限由代码决定({@link BuiltinRole}),
* 对内置角色码或不存在的角色码一律返回空集 —— 否则库里的历史授权会盖住代码,
* 而那正是"界面上显示一套、实际生效另一套"的来源。
*/
public interface RoleGrantSource {
/**
* 这个角色码被授予的权限码。
*
* @param tenantId 角色所属租户(角色是租户隔离的,必须显式带上)
* @param roleCode 角色码
* @return 权限码集合;内置角色、不存在的角色、已停用的角色一律返回空集
*/
Set<String> permsOf(long tenantId, String roleCode);
/**
* 这个角色码可见的菜单键。
*
* <p>与 {@link #permsOf} 同一套口径(只回答自定义角色、停用即空集)。
* 「看得见」与「有权限」是两件事:菜单只影响前端显示哪些入口,
* 真正的访问控制在权限码校验上(绕过前端只会拿到 40301)。
*/
Set<String> menusOf(long tenantId, String roleCode);
}
@@ -1,11 +1,10 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.entity.SysUserRole;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
import cn.tianbaotong.fill.module.user.service.RoleGrantWriter;
import cn.tianbaotong.fill.security.BuiltinRole;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
@@ -28,10 +27,20 @@ import java.util.Set;
public class RoleProvisioner {
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final UserRoleMapper userRoleMapper;
private final RoleGrantWriter grantWriter;
/** 幂等:已存在的角色跳过。 */
/**
* 幂等:角色已存在就补齐定义,并把**授权对齐到代码**。
*
* <p>⚠️ 第一版是「已存在就 {@code continue}」,看起来更省事,但结果是:
* 代码里改了某条权限之后,**已经开通的租户永远停在发放那一刻** ——
* 库里的授权与代码不一致,而且没有任何地方会报错。
* 所以现在即使角色已存在,也要走一遍 {@link #syncGrants}(值一样时一行都不写)。
*
* <p>内置角色的权限**永远以代码为准**(见 {@code PermissionChecker}):
* 这里的同步是为了"库里看到的 = 实际生效的",让人查库时不会被误导。
*/
public void provisionBuiltinRoles(long tenantId) {
// ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。
// 以前这里是「所有租户都发全部内置角色」,看起来更"整齐",
@@ -47,30 +56,57 @@ public class RoleProvisioner {
if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) {
continue;
}
if (findRole(tenantId, br.getCode()) != null) {
continue;
}
SysRole role = new SysRole();
role.setTenantId(tenantId);
role.setRoleCode(br.getCode());
role.setRoleName(br.getName());
role.setRoleType(1);
role.setDataScope(br.getDataScope().code());
role.setSortNo(br.getSortNo());
role.setStatus(1);
role.setRemark("内置角色,权限由代码决定,不可修改");
roleMapper.insert(role);
for (String permCode : br.permissionCodes()) {
SysRolePermission rp = new SysRolePermission();
rp.setTenantId(tenantId);
rp.setRoleId(role.getId());
rp.setPermCode(permCode);
rolePermissionMapper.insert(rp);
SysRole role = findRole(tenantId, br.getCode());
if (role == null) {
role = new SysRole();
role.setTenantId(tenantId);
role.setRoleCode(br.getCode());
role.setRoleName(br.getName());
role.setRoleType(1);
role.setDataScope(br.getDataScope().code());
role.setSortNo(br.getSortNo());
role.setUserType(br.getUserType().code());
role.setStatus(1);
role.setRemark("内置角色,权限由代码决定,不可修改");
roleMapper.insert(role);
} else if (alignToCode(role, br)) {
roleMapper.updateById(role);
}
grantWriter.replacePerms(tenantId, role.getId(), br.permissionCodes());
grantWriter.replaceMenus(tenantId, role.getId(), br.getMenus());
}
}
/**
* 把库里那一行按代码对齐(名字 / 数据范围 / 排序 / 用户类型),**有变化才写库**。
*
* <p>为什么不连 {@code status} 一起刷:内置角色在界面上不可停用,
* 但万一有人用 SQL 停用了它,每天定时任务把它悄悄打开会更糟 ——
* 那等于"系统自己改了别人的决定"。所以状态不在同步范围内。
*
* @return 是否需要 updateById
*/
private boolean alignToCode(SysRole role, BuiltinRole br) {
boolean dirty = false;
if (!java.util.Objects.equals(role.getRoleName(), br.getName())) {
role.setRoleName(br.getName());
dirty = true;
}
if (!java.util.Objects.equals(role.getDataScope(), br.getDataScope().code())) {
role.setDataScope(br.getDataScope().code());
dirty = true;
}
if (!java.util.Objects.equals(role.getSortNo(), br.getSortNo())) {
role.setSortNo(br.getSortNo());
dirty = true;
}
if (!java.util.Objects.equals(role.getUserType(), br.getUserType().code())) {
role.setUserType(br.getUserType().code());
dirty = true;
}
return dirty;
}
public SysRole findRole(long tenantId, String roleCode) {
return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
@@ -84,6 +120,26 @@ public class RoleProvisioner {
if (role == null) {
throw new IllegalStateException("内置角色未创建:" + br.getCode());
}
bind(tenantId, userId, role);
}
/**
* 按角色码绑定(内置与自定义都走这里)。
*
* <p>二期新增:账号管理页可选的角色不再只有 6 个内置角色,所以绑定不能再只认
* {@link BuiltinRole} —— 传角色码、按租户查行、绑行 id,对两种角色是同一条路径。
* 「这个角色能不能发给这个租户」由调用方({@code UserService.resolveAssignableRoles})判定,
* 这里只负责"查到就绑"。
*/
public void bindRoleByCode(long tenantId, long userId, String roleCode) {
SysRole role = findRole(tenantId, roleCode);
if (role == null) {
throw new IllegalStateException("角色未创建:" + roleCode);
}
bind(tenantId, userId, role);
}
private void bind(long tenantId, long userId, SysRole role) {
SysUserRole ur = new SysUserRole();
ur.setTenantId(tenantId);
ur.setUserId(userId);
@@ -1,5 +1,5 @@
# ═══════════════════════════════════════════════════════════
# 数据信息填报系统 · 主配置
# 深学在线 · 主配置
# 环境差异全部放 application-{dev,test,prod}.yml
# ⚠️ 生产密钥一律走环境变量,不写在这里
# ═══════════════════════════════════════════════════════════
@@ -0,0 +1,51 @@
-- 角色与授权(二期):① sys_role 补 user_type;② 新建 sys_role_menu。
--
-- 为什么现在才补:一期只有 6 个**内置**角色,权限由代码决定(BuiltinRole),
-- 于是有两件事一直由代码隐式表达、库里没有:
-- ① 这个角色对应哪一类用户(sys_user.user_type 是 NOT NULL,而它一直是"从角色反推"的);
-- ② 这个角色能看见哪些菜单(一直只有 BuiltinRole 里的一个字符串集合)。
-- 二期开放自定义角色之后,这两件事必须能由租户自己配 —— 所以要落到库里。
--
-- ⚠️ 内置角色在这两处的**权威仍然是代码**(见 PermissionChecker 与 RoleProvisioner 的注释):
-- 库里的行是为了「看得见、可审计、可同步」,不是生效依据。
-- 否则代码收紧一条权限、而库里没同步,就会出现"界面显示一套、实际生效另一套"。
-- ── 1) sys_role.user_type ───────────────────────────────────────────
-- 不加这一列的后果很具体:给账号发一个自定义角色时,服务端要反推 user_type
--(UserService.assertRoleMatchesUserType),而自定义角色不在代码映射里 → 直接 40001,
-- 也就是"角色建得出来、却发不出去"。
ALTER TABLE sys_role ADD COLUMN user_type smallint;
-- 回填:此刻库里只有内置角色(自定义角色的界面还没上线),按 BuiltinRole 的映射逐行写。
-- 刻意写成 role_code 条件而不是全表刷一个值:映射关系就在代码里,这里只是把它落到行上。
UPDATE sys_role SET user_type = 1 WHERE role_code = 'PLATFORM_ADMIN';
UPDATE sys_role SET user_type = 2 WHERE role_code IN ('SCHOOL_ADMIN', 'AUDITOR');
UPDATE sys_role SET user_type = 3 WHERE role_code IN ('HEAD_TEACHER', 'DEPUTY_TEACHER');
UPDATE sys_role SET user_type = 4 WHERE role_code = 'STUDENT';
-- 兜底:万一有上面没覆盖到的行(例如某个租户手工插过自定义角色),
-- 给最保守的一类(学生=只能操作本人那一份),而不是留 NULL 让 NOT NULL 失败。
UPDATE sys_role SET user_type = 4 WHERE user_type IS NULL;
ALTER TABLE sys_role ALTER COLUMN user_type SET NOT NULL;
COMMENT ON COLUMN sys_role.user_type IS '角色对应的用户类型 1平台运营 2学校管理员 3班主任 4学生(契约 §2.7)';
-- ── 2) sys_role_menu 角色可见菜单 ───────────────────────────────────
-- 菜单**不做成权限的附属**:一个菜单可能由多个权限共同决定可见性
--(「审核」菜单初审与终审权限都能看到),所以单独一张表,与 sys_role_permission 同构。
CREATE TABLE sys_role_menu (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
role_id bigint NOT NULL,
menu_key varchar(64) NOT NULL,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX uk_rolemenu_role_key ON sys_role_menu (role_id, menu_key) WHERE deleted = 0;
CREATE INDEX idx_rolemenu_tenant_role ON sys_role_menu (tenant_id, role_id);
COMMENT ON TABLE sys_role_menu IS '角色与菜单键的关联(菜单键定义在代码 BuiltinRole,与前端 XtMenu.key 逐字一致)';
COMMENT ON COLUMN sys_role_menu.menu_key IS '菜单键,如 task;只影响"看得见哪些入口",不代表有权限操作';
@@ -0,0 +1,86 @@
-- V19:需求收敛(2026-10 客户确认)
--
-- 删掉四样东西,一次做完:
-- ① 内置角色「副班主任 DEPUTY_TEACHER」「审核员 AUDITOR」——连同**只挂这两个角色的账号**
-- ② collect_task_org.deputy_user_ids("协办人"列)——审核范围由三级回退收窄为两级
-- ③ 「历史整理器」表(collect_cleanup_job)
-- ④ 「加入申请」表(collect_join_request)
--
-- ⚠️ 三条刻意的写法,别"顺手改成更干净的写法":
-- 1. **账号是软删(deleted=1 + status=0),不是物理 DELETE。**
-- 本系统的"删除"一律是软删;而历史填报单、审核记录、操作日志里都留着他们的 user_id,
-- 物理删会让那些行变成"指向一个不存在的用户"(审核时间轴显示不出人)。
-- 2. **只删"除这两个角色外没有别的角色"的账号。** 一个班主任如果同时被挂了副班主任,
-- 删掉他等于把一个还在用的人踢出系统 —— 那种错在库里看不出来,只有当事人来问。
-- 3. **顺序:先处理绑定关系,再软删角色行。** 反过来的话,后面的语句按 role_code 找不到角色
-- (本迁移里所有查询都带 deleted = 0),会静默什么都不做。
--
-- ⚠️ 若某租户的**唯一管理账号**恰好是审核员(异常情况,正常开通租户时必定有一个学校管理员),
-- 删掉后该租户会没有人能进后台。上线前用这条查一遍:
-- SELECT r.tenant_id FROM sys_role r WHERE r.role_code = 'AUDITOR' AND r.deleted = 0;
-- ── ① 找出"只挂被删角色"的账号 ──────────────────────────────
CREATE TEMP TABLE tmp_dead_user ON COMMIT DROP AS
SELECT u.id AS user_id
FROM sys_user u
WHERE u.deleted = 0
AND EXISTS (
SELECT 1
FROM sys_user_role ur
JOIN sys_role r ON r.id = ur.role_id
WHERE ur.user_id = u.id AND ur.deleted = 0 AND r.deleted = 0
AND r.role_code IN ('DEPUTY_TEACHER', 'AUDITOR'))
AND NOT EXISTS (
SELECT 1
FROM sys_user_role ur2
JOIN sys_role r2 ON r2.id = ur2.role_id
WHERE ur2.user_id = u.id AND ur2.deleted = 0 AND r2.deleted = 0
AND r2.role_code NOT IN ('DEPUTY_TEACHER', 'AUDITOR'));
-- 软删这些账号(顺手停用:软删后仍可能被历史记录引用,停用让它不可能再登录)
UPDATE sys_user
SET deleted = 1, status = 0, update_time = CURRENT_TIMESTAMP
WHERE id IN (SELECT user_id FROM tmp_dead_user);
-- ── ② 解除这两个角色的全部绑定(含"多角色用户"身上的那一条)──────
UPDATE sys_user_role
SET deleted = 1, update_time = CURRENT_TIMESTAMP
WHERE deleted = 0
AND role_id IN (SELECT id FROM sys_role
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
UPDATE sys_role_permission
SET deleted = 1, update_time = CURRENT_TIMESTAMP
WHERE deleted = 0
AND role_id IN (SELECT id FROM sys_role
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
UPDATE sys_role_menu
SET deleted = 1, update_time = CURRENT_TIMESTAMP
WHERE deleted = 0
AND role_id IN (SELECT id FROM sys_role
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
-- ── ③ 软删两个内置角色本身 ─────────────────────────────────
UPDATE sys_role
SET deleted = 1, status = 0, update_time = CURRENT_TIMESTAMP
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR');
-- ── ④ 删掉「协办人」列 ─────────────────────────────────────
-- 该列存的是首尾带逗号的 id 串(,3,7,),被 6 个模块的 SQL 用来做"这份单归不归我"的判定。
-- 删掉后审核范围只剩两级:collect_task_org.head_user_id → sys_org.leader_user_id。
ALTER TABLE collect_task_org DROP COLUMN IF EXISTS deputy_user_ids;
-- ── ⑤ 删掉两个模块的表 ─────────────────────────────────────
DROP TABLE IF EXISTS collect_cleanup_job;
DROP TABLE IF EXISTS collect_join_request;
-- ── ⑥ 两处注释对齐(值本身仍在历史行里,注释别说谎)────────────
-- collect_file.source = 4 曾经是"整理器导入";整理器下线后这个值不会再产生,
-- 但历史行里可能还有,所以保留在注释里说明它是历史值。
COMMENT ON COLUMN collect_file.source IS
'1本人上传 2班主任代传 3管理员代传 4整理器导入(历史值:整理器已下线,不再产生新值)';
-- 操作日志的对象类型:JOIN_REQUEST 已不再产生新记录(历史行保留)
COMMENT ON COLUMN sys_operation_log.target_type IS
'被操作对象的类型:ROSTER/TASK/SUBMISSION/FILE/EXPORT/USER/ROLE(历史值另有 JOIN_REQUEST)';