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> <artifactId>fill-parent</artifactId>
<version>1.0.0-SNAPSHOT</version> <version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<name>数据信息填报系统</name> <name>深学在线</name>
<description>职业技能等级认定 · 报名材料收集 SaaS</description> <description>职业技能等级认定 · 报名材料收集 SaaS</description>
<modules> <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> * <b>改这里之前先改文档,不要只改代码。</b>
* *
* <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如 * <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如
@@ -101,22 +101,14 @@ public enum ErrorCode {
INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"), INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"),
/** 字典项有下级,不可停用 */ /** 字典项有下级,不可停用 */
DICT_HAS_CHILDREN(40036, "字典项有下级,不可停用"), 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, "名册中该证件号已被占用"), ROSTER_ID_CARD_TAKEN(40041, "名册中该证件号已被占用"),
/** 申请已通过,不可再驳回 */
JOIN_ALREADY_APPROVED(40042, "申请已通过,不可再驳回"),
/** 文件归属存疑,阻断提交且无「仍然继续」 */ /** 文件归属存疑,阻断提交且无「仍然继续」 */
FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"), FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"),
/** 一期不支持该收集模式 / 认定类型 */ /** 一期不支持该收集模式 / 认定类型 */
SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"), SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"),
/** 角色还有账号在用,不可删除 */
ROLE_IN_USE(40045, "该角色还有账号在用,请先改掉这些账号的角色"),
// ── 认证与权限 ──────────────────────────────── // ── 认证与权限 ────────────────────────────────
/** 未登录 / token 失效(真 HTTP 401) */ /** 未登录 / token 失效(真 HTTP 401) */
@@ -146,7 +138,7 @@ public enum ErrorCode {
STORAGE_FAILED(50007, "存储写入失败,请重传"), STORAGE_FAILED(50007, "存储写入失败,请重传"),
// ── 兜底 ────────────────────────────────────── // ── 兜底 ──────────────────────────────────────
/** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */ /** 系统内部错误(不在文档的 59 个码内,仅用于未预期异常兜底) */
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试"); INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */ /** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
@@ -68,9 +68,24 @@ public record LoginUser(
return false; 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。 */ /** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */
public boolean coversOrg(Long orgId) { public boolean coversOrg(Long orgId) {
if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) { if (isPlatformAdmin() || isTenantAdmin()) {
return true; return true;
} }
return orgId != null && orgIds != null && orgIds.contains(orgId); 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; import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 数据信息填报系统 · 主应用。 * 深学在线 · 主应用。
* *
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。 * <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。 * 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
@@ -26,7 +26,7 @@ public class OpenApiConfig {
public OpenAPI fillOpenApi() { public OpenAPI fillOpenApi() {
return new OpenAPI() return new OpenAPI()
.info(new Info() .info(new Info()
.title("数据信息填报系统 API") .title("深学在线 API")
.version("1.0.0") .version("1.0.0")
.description(""" .description("""
职业技能等级认定 · 报名材料收集 SaaS 职业技能等级认定 · 报名材料收集 SaaS
@@ -66,7 +66,7 @@ public class StorageConfig {
*/ */
private void selfCheck(QiniuStorageService svc) { private void selfCheck(QiniuStorageService svc) {
String path = "_selfcheck/" + System.currentTimeMillis() + ".txt"; String path = "_selfcheck/" + System.currentTimeMillis() + ".txt";
byte[] payload = "填报通存储自检 ok".getBytes(StandardCharsets.UTF_8); byte[] payload = "深学在线存储自检 ok".getBytes(StandardCharsets.UTF_8);
try { try {
svc.put(path, payload, "text/plain; charset=utf-8"); svc.put(path, payload, "text/plain; charset=utf-8");
if (!svc.exists(path)) { if (!svc.exists(path)) {
@@ -10,18 +10,17 @@ import java.util.List;
* 审核范围:<b>"这个人能审哪些班"</b>。 * 审核范围:<b>"这个人能审哪些班"</b>。
* *
* <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}), * <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
* 这里把它翻译成一次查询。三级回退的顺序是<b>刻意的</b>: * 这里把它翻译成一次查询。两级回退的顺序是<b>刻意的</b>:
* *
* <ol> * <ol>
* <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li> * <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li>
* <li>{@code deputy_user_ids} —— 副班主任 / 协办人</li>
* <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li> * <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li>
* </ol> * </ol>
* *
* <p>⚠️ <b>{@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的</b>: * <p>⚠️ 这里以前还有一级 {@code deputy_user_ids}(副班主任 / 协办人)。
* 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17} * 该字段与副班主任角色已一并删除,所以**一个班只有一个初审人**;
* 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。 * 需要多人协审时,正确做法是给其中一人指定为该班初审人,
* 写入端在 {@code TaskOrgService.joinIds},两处必须一致。 * 而不是再引入一个"协办人"字段。
* *
* <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b> * <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b>
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求: * (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
@@ -48,7 +47,6 @@ public interface AuditScopeMapper {
</if> </if>
AND ( AND (
cto.head_user_id = #{userId} 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}) OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
) )
ORDER BY cto.org_id ORDER BY cto.org_id
@@ -72,7 +70,6 @@ public interface AuditScopeMapper {
AND cto.status = 1 AND cto.status = 1
AND ( AND (
cto.head_user_id = #{userId} 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}) 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.status = 1
AND ( AND (
cto.head_user_id = #{auditorId} 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}) 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.status = 1
AND ( AND (
cto.head_user_id = #{auditorId} 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}) OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
) )
) )
@@ -158,6 +158,13 @@ public class AuthService {
Set.copyOf(roleCodes), Set.copyOf(roleCodes),
user.getUserType()); user.getUserType());
// ⚠️ 把上下文**升级成完整的 principal**(上面那个是"只有租户、没有角色"的临时的)。
// 必须在这里做:下面的 buildProfile 会用 PermissionChecker 装配菜单与权限,
// 而它是**从上下文里的 roles 算的** —— 上下文还是空角色的话,
// 管理员登录响应里的 permissions 会是空数组,前端于是把所有按钮都藏起来
// (现象是"刚登录什么都没有,刷新一下又好了")。
UserContext.set(principal);
String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC); String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC);
SysUser touch = new SysUser(); SysUser touch = new SysUser();
@@ -183,9 +190,9 @@ public class AuthService {
throw new BizException(ErrorCode.UNAUTHORIZED); throw new BizException(ErrorCode.UNAUTHORIZED);
} }
List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId()); List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
LoginVO vo = buildProfile(user, roleCodes); // 权限与菜单由 buildProfile 统一装配(含自定义角色的授权),这里不再单独覆盖一次 ——
vo.setPermissions(permissionChecker.currentPermissions()); // 两处各写一份的话,登录响应与 /auth/me 迟早会不一致,而那种不一致只在"刷新一下就好了"里表现
return vo; return buildProfile(user, roleCodes);
} }
// ── 修改自己的密码 ───────────────────────────── // ── 修改自己的密码 ─────────────────────────────
@@ -358,8 +365,13 @@ public class AuthService {
} }
vo.setRoleCodes(roleCodes); vo.setRoleCodes(roleCodes);
vo.setMenus(BuiltinRole.mergeMenus(roleCodes)); // ⚠️ 菜单与权限都走 PermissionChecker,**不要**直接取 BuiltinRole:
vo.setPermissions(BuiltinRole.mergePermissions(roleCodes)); // 二期的自定义角色,它的授权在库里(sys_role_permission / sys_role_menu),
// 直接取 BuiltinRole 会让"自定义角色的人登录后左侧菜单是空的"——
// 而登录响应里的这两项前端会直接落库用(省一次 /auth/me 往返),
// 所以漏在这里就等于"要刷新一次才看得见菜单"。
vo.setMenus(permissionChecker.currentMenus());
vo.setPermissions(permissionChecker.currentPermissions());
return vo; 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_JOIN = "JOIN";
public static final String MODULE_USER = "USER"; public static final String MODULE_USER = "USER";
public static final String MODULE_FILE = "FILE"; 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_IDCARD = "UPDATE_IDCARD";
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME"; 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_PROXY_UPLOAD = "PROXY_UPLOAD";
public static final String ACTION_VIEW_IDCARD_FULL = "VIEW_IDCARD_FULL"; 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_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_ROSTER = "ROSTER";
public static final String TARGET_TASK = "TASK"; public static final String TARGET_TASK = "TASK";
public static final String TARGET_SUBMISSION = "SUBMISSION"; public static final String TARGET_SUBMISSION = "SUBMISSION";
public static final String TARGET_FILE = "FILE"; public static final String TARGET_FILE = "FILE";
public static final String TARGET_EXPORT = "EXPORT"; public static final String TARGET_EXPORT = "EXPORT";
public static final String TARGET_JOIN_REQUEST = "JOIN_REQUEST";
public static final String TARGET_USER = "USER"; 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<>(); Map<String, Object> out = new LinkedHashMap<>();
List<Map<String, Object>> codes = new java.util.ArrayList<>(); 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); MaterialCatalog.Kind kind = MaterialCatalog.of(code);
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
m.put("code", code); 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() { public static List<String> allCodes() {
return KINDS.stream().map(Kind::code).toList(); 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) { public static Kind of(String code) {
return code == null ? null : BY_CODE.get(code); return code == null ? null : BY_CODE.get(code);
} }
@@ -134,10 +152,10 @@ public final class MaterialCatalog {
public record TemplateItem(String code, String visibleWhen) { public record TemplateItem(String code, String visibleWhen) {
} }
/** §7.10 的三个内置模板。 */ /** 内置模板。**一期只有学校收集一种** —— 社会收集与专项职业能力已随二期一起取消。 */
private static final Map<String, List<TemplateItem>> TEMPLATES = new LinkedHashMap<>(); 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_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":true}";
private static final String WHEN_NOT_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":false}"; 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("IDCARD", null),
new TemplateItem("SCHOOL_ROLL", WHEN_STUDENT), new TemplateItem("SCHOOL_ROLL", WHEN_STUDENT),
new TemplateItem("WORK_PROOF", WHEN_NOT_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) { public static List<TemplateItem> template(String templateCode) {
@@ -179,8 +186,6 @@ public final class MaterialCatalog {
m.put("templateCode", code); m.put("templateCode", code);
m.put("label", switch (code) { m.put("label", switch (code) {
case "TPL_SCHOOL_ROLL" -> "学校收集(照片+身份证+学籍表+工作证明)"; case "TPL_SCHOOL_ROLL" -> "学校收集(照片+身份证+学籍表+工作证明)";
case "TPL_SOCIAL" -> "社会收集(照片+身份证+工作证明+学信网备案表)";
case "TPL_ZHUANXIANG" -> "专项职业能力(照片+身份证+工作证明)";
default -> code; default -> code;
}); });
m.put("materialCodes", items.stream().map(TemplateItem::code).toList()); m.put("materialCodes", items.stream().map(TemplateItem::code).toList());
@@ -84,7 +84,6 @@ public interface TeacherDashboardMapper {
</if> </if>
AND ( AND (
cto.head_user_id = #{userId} 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}) OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
) )
GROUP BY o.id, o.org_name GROUP BY o.id, o.org_name
@@ -114,7 +113,6 @@ public interface TeacherDashboardMapper {
</if> </if>
AND ( AND (
cto.head_user_id = #{userId} 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}) OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
) )
</script> </script>
@@ -142,7 +140,6 @@ public interface TeacherDashboardMapper {
</if> </if>
AND ( AND (
cto.head_user_id = #{userId} 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}) OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
) )
</script> </script>
@@ -177,7 +174,6 @@ public interface TeacherDashboardMapper {
</if> </if>
AND ( AND (
cto.head_user_id = #{userId} 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}) OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
) )
</script> </script>
@@ -200,7 +196,6 @@ public interface TeacherDashboardMapper {
LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0 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 WHERE cto.task_id = t.id AND cto.status = 1
AND (cto.head_user_id = #{userId} 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})) OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
) )
ORDER BY t.end_time ASC ORDER BY t.end_time ASC
@@ -86,7 +86,7 @@ public final class NotifyDtos {
@Schema(description = "登记订阅消息额度") @Schema(description = "登记订阅消息额度")
public static class SubscribeReq { 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; private String templateCode;
/** 本次授权次数(微信返回,默认 1) */ /** 本次授权次数(微信返回,默认 1) */
@@ -30,7 +30,6 @@ public class CollectMessage extends TenantEntity {
public static final int BIZ_APPROVE = 4; public static final int BIZ_APPROVE = 4;
public static final int BIZ_URGE = 5; public static final int BIZ_URGE = 5;
public static final int BIZ_EXPORT = 6; public static final int BIZ_EXPORT = 6;
public static final int BIZ_JOIN_AUDIT = 7;
/** 点开消息跳哪儿。 */ /** 点开消息跳哪儿。 */
public static final String LINK_SUBMISSION = "SUBMISSION"; public static final String LINK_SUBMISSION = "SUBMISSION";
@@ -99,7 +99,6 @@ public interface UrgeMapper {
</if> </if>
<if test="scopeUserId != null"> <if test="scopeUserId != null">
AND (cto.head_user_id = #{scopeUserId} 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})) OR (cto.head_user_id IS NULL AND o.leader_user_id = #{scopeUserId}))
</if> </if>
ORDER BY o.org_name, r.id ORDER BY o.org_name, r.id
@@ -314,7 +313,6 @@ public interface UrgeMapper {
AND r.task_id = #{taskId} AND r.task_id = #{taskId}
</if> </if>
AND (cto.head_user_id = #{userId} 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})) OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
</script> </script>
""") """)
@@ -72,14 +72,14 @@ public class NotificationService {
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …}) * <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li> * 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
* </ul> * </ul>
* 两套里 {@code JOIN_APPROVE} 恰好**同名**,纯属巧合 —— * ⚠️ 删掉「加入申请」之后,两套同名的那一个({@code JOIN_APPROVE})已经不存在了:
* 下一个改代码的人很容易当成同一个东西,所以在这里写清楚(见《08》F-106)。 * 上一版这里专门写过"同名纯属巧合",现在两套码**没有任何重名**,
* 但仍要记住它们是两个命名空间(见《08》F-106)。
*/ */
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS"; public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT"; public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND"; public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
public static final String TPL_EXPORT_READY = "EXPORT_READY"; public static final String TPL_EXPORT_READY = "EXPORT_READY";
public static final String TPL_JOIN_APPROVE = "JOIN_APPROVE";
private final CollectMessageMapper messageMapper; private final CollectMessageMapper messageMapper;
private final SubscribeQuotaMapper quotaMapper; 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, "有一条填报单需要你介入", new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。", "{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
"驳回次数达上限时发给学校管理员(契约 §11.4)") "驳回次数达上限时发给学校管理员(契约 §11.4)")
@@ -162,17 +162,18 @@ public interface ScheduleMapper {
/** /**
* 某个任务下"该审批的人"(初审=班级负责人,终审=学校管理员)。 * 某个任务下"该审批的人"(初审=班级负责人,终审=学校管理员)。
* *
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` / * <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` 优先,
* `deputy_user_ids` / 组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**, * 为空回退到组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
* 发给所有人等于没人负责。 * 发给所有人等于没人负责。
*/ */
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")
@Select(""" @Select("""
SELECT DISTINCT u.id AS "userId", u.real_name AS "realName" SELECT DISTINCT u.id AS "userId", u.real_name AS "realName"
FROM collect_task_org cto 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 JOIN sys_user u ON u.deleted = 0 AND u.status = 1
AND (u.id = cto.head_user_id AND u.id = COALESCE(cto.head_user_id, o.leader_user_id)
OR cto.deputy_user_ids LIKE ('%,' || u.id || ',%'))
WHERE cto.task_id = #{taskId} AND cto.status = 1 AND cto.tenant_id = #{tenantId} WHERE cto.task_id = #{taskId} AND cto.status = 1 AND cto.tenant_id = #{tenantId}
""") """)
List<Map<String, Object>> classAuditors(@Param("tenantId") Long 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.tenant.service.TenantUsageService usageService;
private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService; 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.module.file.mapper.FileMapper fileMapper;
private final cn.tianbaotong.fill.support.RoleProvisioner roleProvisioner;
/** 原始件一次最多清多少行(分轮清完,别一次把磁盘 IO 打满)。 */ /** 原始件一次最多清多少行(分轮清完,别一次把磁盘 IO 打满)。 */
private static final int RAW_CLEAN_BATCH = 200; 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) { private int remindExpire(Long tenantId, String tenantName, int daysLeft) {
var receivers = scheduleMapper.usersOfRole(tenantId, var receivers = scheduleMapper.usersOfRole(tenantId,
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode()); cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
@@ -527,13 +572,14 @@ public class ScheduleJobs {
* <p>为什么要这个入口:**定时任务的"等不起"是测试与运维的共同问题** —— * <p>为什么要这个入口:**定时任务的"等不起"是测试与运维的共同问题** ——
* 验证"截止后会怎样"总不能在测试里等到明天 09:00(F-37 已经踩过一次)。 * 验证"截止后会怎样"总不能在测试里等到明天 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 这一轮做了什么(给人看的说明) * @return 这一轮做了什么(给人看的说明)
*/ */
public String runNow(String job) { public String runNow(String job) {
if (job == null || job.isBlank()) { if (job == null || job.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, 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()) { switch (job.trim().toLowerCase()) {
case "close" -> autoCloseExpiredTasks(); case "close" -> autoCloseExpiredTasks();
@@ -543,8 +589,9 @@ public class ScheduleJobs {
case "usage" -> snapshotUsage(); case "usage" -> snapshotUsage();
case "retry" -> retryFailedProcess(); case "retry" -> retryFailedProcess();
case "expire" -> remindTenantExpiring(); case "expire" -> remindTenantExpiring();
case "role" -> syncBuiltinRoleGrants();
default -> throw new BizException(ErrorCode.VALIDATION_BLOCKED, 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; return "已手动触发一轮:" + job;
} }
@@ -52,15 +52,11 @@ public class TaskOrgSaveReq {
* 该班初审人(班主任)。 * 该班初审人(班主任)。
* *
* <p>可空 —— 为空表示取班级负责人。指定时必须是本租户内 * <p>可空 —— 为空表示取班级负责人。指定时必须是本租户内
* <b>持有班主任或副班主任角色</b>的启用账号(见 {@code TaskOrgService})。 * <b>持有班主任角色</b>的启用账号(见 {@code TaskOrgService})。
*/ */
@Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id") @Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id")
private Long headUserId; private Long headUserId;
/** 副班主任。同一人不能既是主又是副(服务端会拒)。 */
@Schema(description = "副班主任")
private List<Long> deputyUserIds = new ArrayList<>();
@Min(value = 0, message = "应填人数不能为负") @Min(value = 0, message = "应填人数不能为负")
@Schema(description = "应填人数,用于与名册实际人数比对告警") @Schema(description = "应填人数,用于与名册实际人数比对告警")
private Integer expectedCount; private Integer expectedCount;
@@ -4,9 +4,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/** /**
* 任务覆盖的班级与班主任(《03-接口设计》§7.6)。 * 任务覆盖的班级与班主任(《03-接口设计》§7.6)。
* *
@@ -34,11 +31,6 @@ public class TaskOrgVO {
private String headUserName; 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}。 */ /** 该班初审人(班主任);为空则回退到 {@code sys_org.leader_user_id}。 */
private Long headUserId; 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; private Integer expectedCount;
@@ -18,9 +18,14 @@ import java.util.List;
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。 * review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
* *
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的 * <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
* 全部业务表,2026-09-30 核对为 14 张):任务本身不在此列(由 {@code TaskService} 删); * 全部业务表):任务本身不在此列(由 {@code TaskService} 删);
* {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。 * {@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 的动机是"库会一直堆"(演示/测试任务 * <p><b>为什么子表物理删、任务行却软删</b>:F-14 的动机是"库会一直堆"(演示/测试任务
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉; * 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code} * 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
@@ -75,9 +80,6 @@ public interface TaskCascadeMapper {
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}") @Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
int deleteSubmissions(@Param("taskId") Long 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}") @Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
int deleteRoster(@Param("taskId") Long taskId); int deleteRoster(@Param("taskId") Long taskId);
@@ -13,7 +13,7 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
/** /**
* 某人在本租户下"能看到的"任务 id。 * 某人在本租户下"能看到的"任务 id。
* *
* <p><b>谁需要这个</b>:班主任与副班主任。《03-接口设计》§7.1 明确 * <p><b>谁需要这个</b>:班主任。《03-接口设计》§7.1 明确
* 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」, * 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」,
* 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。 * 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。
* *
@@ -21,8 +21,8 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
* 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人, * 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人,
* 不该要求每条都显式指定一遍。 * 不该要求每条都显式指定一遍。
* *
* <p><b>副班主任</b>走 {@code deputy_user_ids} 的逗号匹配。该列存的是 * <p>⚠️ 这里以前还有一条 {@code deputy_user_ids}(副班主任)分支,
* 首尾带逗号的 {@code ,3,7,} 形式,所以 {@code '%,3,%'} 不会误命中 {@code 17}。 * 随该字段与角色一起删掉了 —— 现在**一个班只有一个初审人**。
* *
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这是一条<b>权限过滤</b>用的查询。 * <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这是一条<b>权限过滤</b>用的查询。
* 漏掉租户条件会返回别校的任务 id,而调用方是拿它去 {@code IN (...)} 的 —— * 漏掉租户条件会返回别校的任务 id,而调用方是拿它去 {@code IN (...)} 的 ——
@@ -38,7 +38,6 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
AND cto.tenant_id = #{tenantId} AND cto.tenant_id = #{tenantId}
AND ( AND (
COALESCE(cto.head_user_id, o.leader_user_id) = #{userId} COALESCE(cto.head_user_id, o.leader_user_id) = #{userId}
OR cto.deputy_user_ids LIKE '%,' || #{userId} || ',%'
) )
""") """)
List<Long> selectVisibleTaskIds(@Param("tenantId") Long tenantId, List<Long> selectVisibleTaskIds(@Param("tenantId") Long tenantId,
@@ -136,9 +136,6 @@ public class TaskCloneService {
copy.setOrgId(o.getOrgId()); copy.setOrgId(o.getOrgId());
copy.setExpectedCount(o.getExpectedCount()); copy.setExpectedCount(o.getExpectedCount());
copy.setHeadUserId(o.getHeadUserId()); copy.setHeadUserId(o.getHeadUserId());
// ⚠️ 是 `deputy_user_ids`(逗号分隔的多个人),不是单数的 deputy_user_id ——
// 按"应该叫这个"猜字段名,编译期才会发现
copy.setDeputyUserIds(o.getDeputyUserIds());
copy.setStatus(o.getStatus()); copy.setStatus(o.getStatus());
// 刻意**不**复制二维码 URL:二维码是"每个任务的入口",来源任务的码 // 刻意**不**复制二维码 URL:二维码是"每个任务的入口",来源任务的码
// 扫进去是旧任务 —— 复制过来会出现"扫新任务的码、进旧任务"这种最难查的错 // 扫进去是旧任务 —— 复制过来会出现"扫新任务的码、进旧任务"这种最难查的错
@@ -27,7 +27,6 @@ import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
@@ -43,9 +42,11 @@ import java.util.Set;
* 不透露"它存在但不属于你"。这是最容易漏的一环 —— * 不透露"它存在但不属于你"。这是最容易漏的一环 ——
* 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任 * 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任
* 就出现在 A 校的审批流里。</li> * 就出现在 A 校的审批流里。</li>
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER} 或 {@code DEPUTY_TEACHER})。 * <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER})。
* 指定初审人本质上是一次<b>授权</b>:那个人将能看到该班学生的身份证号、 * 指定初审人本质上是一次<b>授权</b>:那个人将能看到该班学生的身份证号、
* 学籍材料。放开这一条等于把授权能力变成任意授权。</li> * 学籍材料。放开这一条等于把授权能力变成任意授权。
* ⚠️ 这里以前还接受 {@code DEPUTY_TEACHER}(副班主任);
* 该角色与「协办人」字段一起删掉了,现在是**一个班一个初审人**。</li>
* <li><b>移除是软移除</b>({@code status = 0}),不是物理删除。 * <li><b>移除是软移除</b>({@code status = 0}),不是物理删除。
* 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。</li> * 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。</li>
* <li><b>「移除再加回」必须复用原来那一行</b>。表上有 * <li><b>「移除再加回」必须复用原来那一行</b>。表上有
@@ -60,8 +61,8 @@ import java.util.Set;
@RequiredArgsConstructor @RequiredArgsConstructor
public class TaskOrgService { 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) + ",覆盖班级不可再改"); "任务已" + statusText(status) + ",覆盖班级不可再改");
} }
Map<Long, Set<Long>> deputiesOf = normalizeAndValidate(req); normalizeAndValidate(req);
persist(taskId, req, deputiesOf); persist(taskId, req);
log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId, log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId,
req.getOrgs().size()); 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) // 完全指不出是哪个班的问题(PG 只说 duplicate key)
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>(); Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
@@ -148,32 +150,15 @@ public class TaskOrgService {
assertOrgsAreClasses(wanted.keySet()); assertOrgsAreClasses(wanted.keySet());
Map<Long, Set<Long>> deputiesOf = new LinkedHashMap<>(); Set<Long> headUserIds = new LinkedHashSet<>();
Set<Long> allUserIds = new LinkedHashSet<>(); for (TaskOrgSaveReq.Item item : wanted.values()) {
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);
if (item.getHeadUserId() != null) { if (item.getHeadUserId() != null) {
allUserIds.add(item.getHeadUserId()); headUserIds.add(item.getHeadUserId());
} }
allUserIds.addAll(deputies);
} }
if (!headUserIds.isEmpty()) {
if (!allUserIds.isEmpty()) { assertAssignableTeachers(headUserIds);
assertAssignableTeachers(allUserIds);
} }
return deputiesOf;
} }
/** 班级必须存在、在同一个租户内、且确实是"班级"。 */ /** 班级必须存在、在同一个租户内、且确实是"班级"。 */
@@ -237,7 +222,7 @@ public class TaskOrgService {
SysUser user = users.get(userId); SysUser user = users.get(userId);
throw new BizException(ErrorCode.VALIDATION_BLOCKED, throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + user.getRealName() + "」不是班主任,不能作为初审人;" "「" + user.getRealName() + "」不是班主任,不能作为初审人;"
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」或「副班主任」角色"); + "请先在「组织与账号 → 账号管理」里给他分配「班主任」角色");
} }
} }
} }
@@ -251,7 +236,7 @@ public class TaskOrgService {
* 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现, * 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现,
* 这种"保存成功但没保存"是最难查的一类 bug。 * 这种"保存成功但没保存"是最难查的一类 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<>(); Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
for (TaskOrgSaveReq.Item item : req.getOrgs()) { for (TaskOrgSaveReq.Item item : req.getOrgs()) {
wanted.put(item.getOrgId(), item); wanted.put(item.getOrgId(), item);
@@ -283,7 +268,6 @@ public class TaskOrgService {
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) { for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
Long orgId = entry.getKey(); Long orgId = entry.getKey();
TaskOrgSaveReq.Item item = entry.getValue(); TaskOrgSaveReq.Item item = entry.getValue();
String deputies = joinIds(deputiesOf.get(orgId));
CollectTaskOrg reuse = activeByOrg.get(orgId); CollectTaskOrg reuse = activeByOrg.get(orgId);
if (reuse == null) { if (reuse == null) {
@@ -299,7 +283,6 @@ public class TaskOrgService {
insert.setTaskId(taskId); insert.setTaskId(taskId);
insert.setOrgId(orgId); insert.setOrgId(orgId);
insert.setHeadUserId(item.getHeadUserId()); insert.setHeadUserId(item.getHeadUserId());
insert.setDeputyUserIds(deputies);
// expectedCount 为 null 就是"未设置",**不能写成 0** —— // expectedCount 为 null 就是"未设置",**不能写成 0** ——
// 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让 // 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让
// "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上, // "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上,
@@ -311,7 +294,6 @@ public class TaskOrgService {
taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate() taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate()
.eq(CollectTaskOrg::getId, reuse.getId()) .eq(CollectTaskOrg::getId, reuse.getId())
.set(CollectTaskOrg::getHeadUserId, item.getHeadUserId()) .set(CollectTaskOrg::getHeadUserId, item.getHeadUserId())
.set(CollectTaskOrg::getDeputyUserIds, deputies)
.set(CollectTaskOrg::getExpectedCount, item.getExpectedCount()) .set(CollectTaskOrg::getExpectedCount, item.getExpectedCount())
.set(CollectTaskOrg::getStatus, STATUS_ACTIVE)); .set(CollectTaskOrg::getStatus, STATUS_ACTIVE));
} }
@@ -327,15 +309,11 @@ public class TaskOrgService {
Set<Long> orgIds = new LinkedHashSet<>(); Set<Long> orgIds = new LinkedHashSet<>();
Set<Long> userIds = new LinkedHashSet<>(); Set<Long> userIds = new LinkedHashSet<>();
Map<Long, List<Long>> deputiesOf = new LinkedHashMap<>();
for (CollectTaskOrg row : rows) { for (CollectTaskOrg row : rows) {
orgIds.add(row.getOrgId()); orgIds.add(row.getOrgId());
List<Long> deputies = parseIds(row.getDeputyUserIds());
deputiesOf.put(row.getId(), deputies);
if (row.getHeadUserId() != null) { if (row.getHeadUserId() != null) {
userIds.add(row.getHeadUserId()); userIds.add(row.getHeadUserId());
} }
userIds.addAll(deputies);
} }
Map<Long, String> orgNames = loadOrgNames(orgIds); Map<Long, String> orgNames = loadOrgNames(orgIds);
@@ -348,11 +326,6 @@ public class TaskOrgService {
vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)")); vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)"));
vo.setHeadUserId(row.getHeadUserId()); vo.setHeadUserId(row.getHeadUserId());
vo.setHeadUserName(userNames.get(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.setExpectedCount(row.getExpectedCount());
vo.setStatus(row.getStatus()); vo.setStatus(row.getStatus());
out.add(vo); out.add(vo);
@@ -396,44 +369,6 @@ public class TaskOrgService {
return task; 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) { private static String statusText(int status) {
return switch (status) { return switch (status) {
case CollectTask.STATUS_DRAFT -> "草稿"; 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()} 而不是 * <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id}) * {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
@@ -226,7 +230,7 @@ public class TaskService {
*/ */
private boolean seesWholeTenant() { private boolean seesWholeTenant() {
LoginUser me = UserContext.require(); 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.deleteExportFiles(id)
+ cascadeMapper.deleteExportTasks(id) + cascadeMapper.deleteExportTasks(id)
+ cascadeMapper.deleteSubmissions(id) + cascadeMapper.deleteSubmissions(id)
+ cascadeMapper.deleteJoinRequests(id)
+ cascadeMapper.deleteRoster(id) + cascadeMapper.deleteRoster(id)
+ cascadeMapper.deleteFormSchemas(id) + cascadeMapper.deleteFormSchemas(id)
+ cascadeMapper.deleteMaterialDefs(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} 自定义。 */ /** {@code 1} 内置(不可删,权限由代码决定)/ {@code 2} 自定义。 */
private Integer roleType; 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} 仅本人。 */ /** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人。 */
private Integer dataScope; 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 cn.tianbaotong.fill.module.user.entity.SysRole;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; 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> { 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.UserImportResult;
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq; import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
import cn.tianbaotong.fill.module.user.dto.UserVO; 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.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.UserMapper;
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper; import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
import cn.tianbaotong.fill.security.BuiltinRole; import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.support.RoleProvisioner; import cn.tianbaotong.fill.support.RoleProvisioner;
import cn.tianbaotong.fill.support.SensitiveCodec; import cn.tianbaotong.fill.support.SensitiveCodec;
import com.alibaba.excel.EasyExcel; 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 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} 的角色。判断"还有没有人能管账号"只需看这两个。 */ /** 拥有 {@code user:manage} 的角色。判断"还有没有人能管账号"只需看这两个。 */
private static final Set<String> ADMIN_ROLE_CODES = Set.of("PLATFORM_ADMIN", "SCHOOL_ADMIN"); 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>。那行注释写的是 * <p>⚠️ <b>不要照 {@code sys_user} 那行 DDL 注释写</b>。那行注释写的是
* 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的 * 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的
@@ -94,16 +90,12 @@ public class UserService {
* *
* <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉, * <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉,
* 而 {@code 3} 恰恰是<b>班主任</b> —— 最高频的一种账号。 * 而 {@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( private static final Map<Integer, String> USER_TYPE_NAMES = Map.of(
1, "平台运营", 1, "平台运营",
2, "学校管理员", 2, "学校管理员",
@@ -138,7 +130,9 @@ public class UserService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final UserRoleMapper userRoleMapper; private final UserRoleMapper userRoleMapper;
private final OrgMapper orgMapper; private final OrgMapper orgMapper;
private final RoleMapper roleMapper;
private final RoleProvisioner roleProvisioner; private final RoleProvisioner roleProvisioner;
private final DbRoleGrantSource roleGrantSource;
private final SensitiveCodec codec; private final SensitiveCodec codec;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final LoginGuard loginGuard; private final LoginGuard loginGuard;
@@ -184,9 +178,18 @@ public class UserService {
} }
} }
if (roleCode != null && BuiltinRole.ofCode(roleCode) != null) { if (roleCode != null) {
// roleCode 已经过内置角色白名单校验,拼串是安全的。 // ⚠️ 角色码**不认识时必须返回空**,绝不能"忽略这个过滤条件"。
// 用子查询而不是 JOIN,是为了让 MP 的租户过滤与逻辑删除条件继续作用于主表。 // 忽略的后果是:调用方以为筛出了"班主任",实际拿到**全校账号(含学生)**——
// 而且不报任何错。这个坑真实发生过: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, q.inSql(SysUser::getId,
"SELECT ur.user_id FROM sys_user_role ur" "SELECT ur.user_id FROM sys_user_role ur"
+ " JOIN sys_role r ON r.id = ur.role_id" + " JOIN sys_role r ON r.id = ur.role_id"
@@ -215,7 +218,7 @@ public class UserService {
String username = req.getUsername().trim(); String username = req.getUsername().trim();
assertUsernameUnique(username, null); assertUsernameUnique(username, null);
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes()); List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
assertRoleMatchesUserType(req.getUserType(), roles); assertRoleMatchesUserType(req.getUserType(), roles);
String initialPassword = MaskUtil.generateInitialPassword(); String initialPassword = MaskUtil.generateInitialPassword();
@@ -230,12 +233,12 @@ public class UserService {
codec.applyPhone(u, req.getPhone()); codec.applyPhone(u, req.getPhone());
userMapper.insert(u); userMapper.insert(u);
for (BuiltinRole br : roles) { for (SysRole role : roles) {
roleProvisioner.bindRole(tenantId, u.getId(), br); roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
} }
log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream() log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream()
.map(BuiltinRole::getCode).toList()); .map(SysRole::getRoleCode).toList());
return new CreateResult(u.getId(), initialPassword); return new CreateResult(u.getId(), initialPassword);
} }
@@ -254,7 +257,7 @@ public class UserService {
SysUser exist = requireUser(id); SysUser exist = requireUser(id);
long tenantId = UserContext.tenantId(); long tenantId = UserContext.tenantId();
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes()); List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
assertRoleMatchesUserType(req.getUserType(), roles); assertRoleMatchesUserType(req.getUserType(), roles);
// 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死" // 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死"
@@ -276,6 +279,12 @@ public class UserService {
assertAnotherAdminExists(exist, "取消管理员角色"); assertAnotherAdminExists(exist, "取消管理员角色");
} }
// 二期新增的一条自锁:**自定义角色**让"管理员"不再等于那两个内置角色码。
// 上面的判据只看内置管理员角色,所以「自己靠自定义角色管账号、再把自己换成别的角色」
// 会绕过它 —— 账号还在、还能登录,但再也进不了账号管理与角色授权。
// 这类错误不可逆(只能找人用 SQL 救),所以单独判一次。
assertKeepsAccountManage(exist, roles);
SysUser patch = new SysUser(); SysUser patch = new SysUser();
patch.setId(id); patch.setId(id);
patch.setRealName(req.getRealName().trim()); patch.setRealName(req.getRealName().trim());
@@ -382,7 +391,7 @@ public class UserService {
public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) { public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) {
assertImportFile(file); assertImportFile(file);
BuiltinRole fallbackRole = resolveFallbackRole(defaultRoleCode); SysRole fallbackRole = resolveFallbackRole(defaultRoleCode);
List<Map<Integer, String>> rows; List<Map<Integer, String>> rows;
try (InputStream in = file.getInputStream()) { 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()) { if (err == null && roleText != null && !roleText.isBlank()) {
role = resolveRoleByText(roleText); role = resolveRoleByText(roleText);
if (role == null) { 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) { if (err == null && userType == null) {
err = new UserImportResult.RowError(excelRow, "role", roleText, err = new UserImportResult.RowError(excelRow, "role", roleText,
"该角色没有对应的用户类型"); "该角色没有对应的用户类型");
@@ -506,7 +515,7 @@ public class UserService {
u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim())); u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim()));
} }
userMapper.insert(u); userMapper.insert(u);
roleProvisioner.bindRole(tenantId, u.getId(), role); roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
seenInFile.add(username.trim()); seenInFile.add(username.trim());
result.setSuccess(result.getSuccess() + 1); 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>两道防线:本方法按"当前租户能分配什么角色"白名单校验; * <p>两道防线:本方法按"当前租户能分配什么角色"白名单校验;
* 同时 {@code RoleProvisioner} 不再给学校租户发放 {@code PLATFORM_ADMIN} 角色行。 * 同时 {@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(); Long tenantId = UserContext.tenantId();
boolean platform = tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID; 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<>(); LinkedHashSet<String> distinct = new LinkedHashSet<>();
if (codes != null) { if (codes != null) {
@@ -554,16 +593,25 @@ public class UserService {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请至少选择一个角色"); 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) { for (String c : distinct) {
BuiltinRole br = BuiltinRole.ofCode(c); SysRole role = byCode.get(c);
if (br == null) { if (role == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在:" + c); 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()); throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
} }
roles.add(br); roles.add(role);
} }
return roles; return roles;
} }
@@ -572,20 +620,20 @@ public class UserService {
* 用户类型必须与所选角色自洽。 * 用户类型必须与所选角色自洽。
* *
* <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>: * <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>:
* 角色与用户类型本来就是一对一绑定的(见 {@link #ROLE_TO_USER_TYPE}), * 角色与用户类型本来就是一对一绑定的(见 {@link #effectiveUserType}),
* 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。 * 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。
* *
* <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以, * <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以,
* 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。 * 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。
*/ */
private void assertRoleMatchesUserType(Integer userType, List<BuiltinRole> roles) { private void assertRoleMatchesUserType(Integer userType, List<SysRole> roles) {
if (userType == null) { if (userType == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型");
} }
Set<Integer> expected = new LinkedHashSet<>(); Set<Integer> expected = new LinkedHashSet<>();
for (BuiltinRole br : roles) { for (SysRole role : roles) {
Integer t = ROLE_TO_USER_TYPE.get(br.getCode()); Integer t = effectiveUserType(role);
if (t != null) { if (t != null) {
expected.add(t); 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) { private void assertUsernameUnique(String username, Long excludeId) {
SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery() SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getUsername, username) .eq(SysUser::getUsername, username)
@@ -663,9 +723,9 @@ public class UserService {
* <p>只有在目标<b>原本确实是管理员</b>时才返回 true —— * <p>只有在目标<b>原本确实是管理员</b>时才返回 true ——
* 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。 * 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。
*/ */
private boolean losingAdminRole(SysUser target, List<BuiltinRole> newRoles) { private boolean losingAdminRole(SysUser target, List<SysRole> newRoles) {
boolean stillAdmin = newRoles.stream() boolean stillAdmin = newRoles.stream()
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getCode())); .anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getRoleCode()));
if (stillAdmin) { if (stillAdmin) {
return false; return false;
} }
@@ -673,6 +733,64 @@ public class UserService {
return now != null && now > 0; 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) { private void assertPasswordStrength(String plain) {
cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain); cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain);
} }
@@ -699,11 +817,11 @@ public class UserService {
return u; 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() userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId)); .eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId));
for (BuiltinRole br : roles) { for (SysRole role : roles) {
roleProvisioner.bindRole(tenantId, userId, br); 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()); * <p>二期改成查库:可选角色不再只有 6 个内置角色,租户自己建的角色也要能当默认值。
if (br == null) { * 返回 {@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); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "默认角色不存在:" + defaultRoleCode);
} }
if (resolveAssignableRoles(List.of(br.getCode())).isEmpty()) { return roles.get(0);
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
}
return br;
} }
/** /**
@@ -825,31 +952,46 @@ public class UserService {
return s.isEmpty() ? null : s; 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(); String t = text.trim();
BuiltinRole byCode = BuiltinRole.ofCode(t.toUpperCase()); for (SysRole role : tenantRoles()) {
if (byCode != null) { if (role.getRoleCode() != null && role.getRoleCode().equalsIgnoreCase(t)) {
try { return assignableRole(role.getRoleCode());
resolveAssignableRoles(List.of(byCode.getCode()));
return byCode;
} catch (BizException e) {
return null;
} }
} }
for (BuiltinRole br : BuiltinRole.all()) { for (SysRole role : tenantRoles()) {
if (br.getName().equals(t)) { if (role.getRoleName() != null && role.getRoleName().equals(t)) {
try { return assignableRole(role.getRoleCode());
resolveAssignableRoles(List.of(br.getCode()));
return br;
} catch (BizException e) {
return null;
}
} }
} }
return null; 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) { private static String joinHeaderText(Map<Integer, String> headerCells) {
if (headerCells == null || headerCells.isEmpty()) { 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()); List<UserVO> list = new ArrayList<>(users.size());
for (SysUser u : users) { for (SysUser u : users) {
UserVO vo = new UserVO(); UserVO vo = new UserVO();
@@ -953,13 +1106,11 @@ public class UserService {
List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of()); List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of());
vo.setRoleCodes(codes); vo.setRoleCodes(codes);
// 一期角色全部内置(没有自定义角色的界面),名字直接从代码取; // 二期:角色名读库(sys_role.role_name)—— 自定义角色的名字不在代码里。
// 二期支持自定义角色后,这里要改成从 sys_role.role_name 读。 // 内置角色的行也在库里(开通租户时由 RoleProvisioner 写入),所以一条查询覆盖两种;
// 查不到(角色被删)时回落成角色码,至少不会显示一片空白。
vo.setRoleNames(codes.stream() vo.setRoleNames(codes.stream()
.map(c -> { .map(c -> roleNames.getOrDefault(c, c))
BuiltinRole br = BuiltinRole.ofCode(c);
return br == null ? c : br.getName();
})
.toList()); .toList());
list.add(vo); list.add(vo);
} }
@@ -43,15 +43,14 @@ public enum BuiltinRole {
PermissionDef.TASK_MANAGE, PermissionDef.TASK_VIEW, PermissionDef.TASK_CLONE, PermissionDef.TASK_MANAGE, PermissionDef.TASK_VIEW, PermissionDef.TASK_CLONE,
PermissionDef.FORM_MANAGE, PermissionDef.MATERIAL_MANAGE, PermissionDef.PLAN_IMPORT, PermissionDef.FORM_MANAGE, PermissionDef.MATERIAL_MANAGE, PermissionDef.PLAN_IMPORT,
PermissionDef.ROSTER_MANAGE, PermissionDef.ROSTER_VIEW, PermissionDef.ROSTER_MANAGE, PermissionDef.ROSTER_VIEW,
PermissionDef.JOIN_REQUEST_AUDIT,
PermissionDef.NOTIFY_SEND, PermissionDef.NOTIFY_SEND,
PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_OWN, PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL, PermissionDef.AUDIT_WITHDRAW, PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL, PermissionDef.AUDIT_WITHDRAW,
PermissionDef.FILE_PROXY_UPLOAD, PermissionDef.FILE_PROXY_UPLOAD,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN, PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED,
PermissionDef.LOG_VIEW_TENANT), PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task", 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), PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "audit")), 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, STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
Set.of(PermissionDef.SUBMISSION_OWN, PermissionDef.PROGRESS_VIEW_OWN), Set.of(PermissionDef.SUBMISSION_OWN, PermissionDef.PROGRESS_VIEW_OWN),
@@ -161,7 +145,7 @@ public enum BuiltinRole {
return codes; return codes;
} }
/** 开通租户时默认创建的 6 个内置角色。 */ /** 开通租户时默认创建的 4 个内置角色。 */
public static Set<BuiltinRole> all() { public static Set<BuiltinRole> all() {
return new LinkedHashSet<>(Arrays.asList(values())); return new LinkedHashSet<>(Arrays.asList(values()));
} }
@@ -196,4 +180,64 @@ public enum BuiltinRole {
} }
return merged; 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.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext; import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException; import cn.tianbaotong.fill.common.exception.BizException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.LinkedHashSet;
import java.util.Set; import java.util.Set;
/** /**
* 权限判定。 * 权限判定。
* *
* <p><b>为什么不查库</b>:内置角色的权限由代码决定({@link BuiltinRole}), * <p><b>两个来源,各有各的权威</b>:
* 直接从 JWT 里的 {@code roles} 推导即可,零 IO。自定义角色的授权在 DB 里, *
* 但一期不开放自定义角色,故暂不需要查询。二期开放自定义角色时, * <ul>
* 在这里加一层"角色码 → DB 授权"的补充查询即可,调用方无感。 * <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 @Component
@RequiredArgsConstructor
public class PermissionChecker { public class PermissionChecker {
private final RoleGrantSource roleGrantSource;
/** 当前用户是否拥有指定权限。 */ /** 当前用户是否拥有指定权限。 */
public boolean has(PermissionDef perm) { public boolean has(PermissionDef perm) {
LoginUser user = UserContext.get(); LoginUser user = UserContext.get();
@@ -29,7 +43,7 @@ public class PermissionChecker {
if (user.isPlatformAdmin()) { if (user.isPlatformAdmin()) {
return true; return true;
} }
return BuiltinRole.mergePermissions(user.roles()).contains(perm.getCode()); return effective(user).contains(perm.getCode());
} }
public boolean hasAny(PermissionDef... perms) { public boolean hasAny(PermissionDef... perms) {
@@ -63,12 +77,49 @@ public class PermissionChecker {
if (user.isPlatformAdmin()) { if (user.isPlatformAdmin()) {
return PermissionDef.allCodes(); return PermissionDef.allCodes();
} }
return BuiltinRole.mergePermissions(user.roles()); return effective(user);
} }
/** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */ /** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */
public Set<String> currentMenus() { public Set<String> currentMenus() {
LoginUser user = UserContext.require(); 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>菜单与权限的关系:一个菜单可能由多个权限共同决定可见性(如「审核」菜单
* 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。 * 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。
*
* <p>{@link #group} 是给「角色与授权」页的权限矩阵分组用的:
* 28 条权限平铺成一张表没人看得懂,而分组**只在展示层有意义**,不影响任何校验 ——
* 所以它是枚举上的一个字符串,而不是一张表。
*/ */
@Getter @Getter
public enum PermissionDef { public enum PermissionDef {
// ── 平台运营 ────────────────────────────────── // ── 平台运营 ──────────────────────────────────
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC), TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC, "平台运营"),
TENANT_VIEW("tenant:view", "租户查看", Platform.PC), TENANT_VIEW("tenant:view", "租户查看", Platform.PC, "平台运营"),
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC), LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC, "平台运营"),
// ── 组织与账号 ──────────────────────────────── // ── 组织与账号 ────────────────────────────────
ORG_MANAGE("org:manage", "组织架构管理", Platform.PC), ORG_MANAGE("org:manage", "组织架构管理", Platform.PC, "组织与账号"),
ORG_VIEW("org:view", "组织架构查看", Platform.PC), ORG_VIEW("org:view", "组织架构查看", Platform.PC, "组织与账号"),
USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC), USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC, "组织与账号"),
USER_VIEW("user:view", "账号查看", Platform.PC), USER_VIEW("user:view", "账号查看", Platform.PC, "组织与账号"),
ROLE_MANAGE("role:manage", "角色与授权", Platform.PC), ROLE_MANAGE("role:manage", "角色与授权", Platform.PC, "组织与账号"),
LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC), LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC, "组织与账号"),
// ── 收集任务 ────────────────────────────────── // ── 收集任务 ──────────────────────────────────
TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC), TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC, "收集任务"),
TASK_VIEW("task:view", "任务查看", Platform.PC), TASK_VIEW("task:view", "任务查看", Platform.PC, "收集任务"),
TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC), TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC, "收集任务"),
FORM_MANAGE("form:manage", "表单设计", Platform.PC), FORM_MANAGE("form:manage", "表单设计", Platform.PC, "收集任务"),
MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC), MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC, "收集任务"),
PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC), PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC, "收集任务"),
// ── 名册 ────────────────────────────────────── // ── 名册 ──────────────────────────────────────
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC), ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC, "名册"),
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC), ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC, "名册"),
ROSTER_VIEW("roster:view", "名册查看", Platform.PC), ROSTER_VIEW("roster:view", "名册查看", Platform.PC, "名册"),
JOIN_REQUEST_AUDIT("join:audit", "名单加入申请审批", Platform.BOTH),
// ── 通知与进度 ──────────────────────────────── // ── 通知与进度 ────────────────────────────────
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH), NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH, "通知与进度"),
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH), PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH, "通知与进度"),
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH), PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH, "通知与进度"),
// ── 审核 ────────────────────────────────────── // ── 审核 ──────────────────────────────────────
AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH), AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH, "审核"),
AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC), AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC, "审核"),
AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC), AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC, "审核"),
FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH), FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH, "审核"),
// ── 导出 ────────────────────────────────────── // ── 导出 ──────────────────────────────────────
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC), EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC, "导出"),
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC), EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC, "导出"),
CLEANUP_RUN("cleanup:run", "历史数据整理器", Platform.PC),
// ── 学生端 ──────────────────────────────────── // ── 学生端 ────────────────────────────────────
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP); SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP, "学生端");
/** 端类型。 */ /** 端类型。 */
public enum Platform { public enum Platform {
@@ -88,10 +90,14 @@ public enum PermissionDef {
/** 适用范围。 */ /** 适用范围。 */
private final Platform platform; 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.code = code;
this.label = label; this.label = label;
this.platform = platform; this.platform = platform;
this.group = group;
} }
public static PermissionDef ofCode(String code) { public static PermissionDef ofCode(String code) {
@@ -106,4 +112,23 @@ public enum PermissionDef {
Arrays.stream(values()).forEach(p -> set.add(p.code)); Arrays.stream(values()).forEach(p -> set.add(p.code));
return set; 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; package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.module.user.entity.SysRole; 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.entity.SysUserRole;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper; 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.mapper.UserRoleMapper;
import cn.tianbaotong.fill.module.user.service.RoleGrantWriter;
import cn.tianbaotong.fill.security.BuiltinRole; import cn.tianbaotong.fill.security.BuiltinRole;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -28,10 +27,20 @@ import java.util.Set;
public class RoleProvisioner { public class RoleProvisioner {
private final RoleMapper roleMapper; private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final UserRoleMapper userRoleMapper; private final UserRoleMapper userRoleMapper;
private final RoleGrantWriter grantWriter;
/** 幂等:已存在的角色跳过。 */ /**
* 幂等:角色已存在就补齐定义,并把**授权对齐到代码**。
*
* <p>⚠️ 第一版是「已存在就 {@code continue}」,看起来更省事,但结果是:
* 代码里改了某条权限之后,**已经开通的租户永远停在发放那一刻** ——
* 库里的授权与代码不一致,而且没有任何地方会报错。
* 所以现在即使角色已存在,也要走一遍 {@link #syncGrants}(值一样时一行都不写)。
*
* <p>内置角色的权限**永远以代码为准**(见 {@code PermissionChecker}):
* 这里的同步是为了"库里看到的 = 实际生效的",让人查库时不会被误导。
*/
public void provisionBuiltinRoles(long tenantId) { public void provisionBuiltinRoles(long tenantId) {
// ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。 // ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。
// 以前这里是「所有租户都发全部内置角色」,看起来更"整齐", // 以前这里是「所有租户都发全部内置角色」,看起来更"整齐",
@@ -47,30 +56,57 @@ public class RoleProvisioner {
if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) { if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) {
continue; continue;
} }
if (findRole(tenantId, br.getCode()) != null) { SysRole role = findRole(tenantId, br.getCode());
continue; if (role == null) {
} role = new SysRole();
SysRole role = new SysRole(); role.setTenantId(tenantId);
role.setTenantId(tenantId); role.setRoleCode(br.getCode());
role.setRoleCode(br.getCode()); role.setRoleName(br.getName());
role.setRoleName(br.getName()); role.setRoleType(1);
role.setRoleType(1); role.setDataScope(br.getDataScope().code());
role.setDataScope(br.getDataScope().code()); role.setSortNo(br.getSortNo());
role.setSortNo(br.getSortNo()); role.setUserType(br.getUserType().code());
role.setStatus(1); role.setStatus(1);
role.setRemark("内置角色,权限由代码决定,不可修改"); role.setRemark("内置角色,权限由代码决定,不可修改");
roleMapper.insert(role); roleMapper.insert(role);
} else if (alignToCode(role, br)) {
for (String permCode : br.permissionCodes()) { roleMapper.updateById(role);
SysRolePermission rp = new SysRolePermission();
rp.setTenantId(tenantId);
rp.setRoleId(role.getId());
rp.setPermCode(permCode);
rolePermissionMapper.insert(rp);
} }
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) { public SysRole findRole(long tenantId, String roleCode) {
return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery() return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId) .eq(SysRole::getTenantId, tenantId)
@@ -84,6 +120,26 @@ public class RoleProvisioner {
if (role == null) { if (role == null) {
throw new IllegalStateException("内置角色未创建:" + br.getCode()); 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(); SysUserRole ur = new SysUserRole();
ur.setTenantId(tenantId); ur.setTenantId(tenantId);
ur.setUserId(userId); ur.setUserId(userId);
@@ -1,5 +1,5 @@
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# 数据信息填报系统 · 主配置 # 深学在线 · 主配置
# 环境差异全部放 application-{dev,test,prod}.yml # 环境差异全部放 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)';
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name" : "填报通", "name" : "深学在线",
"appid" : "", "appid" : "",
"description" : "", "description" : "",
"versionName" : "1.0.0", "versionName" : "1.0.0",
+1 -9
View File
@@ -25,14 +25,6 @@
"navigationBarTextStyle": "white" "navigationBarTextStyle": "white"
} }
}, },
{
"path": "pages/join/join",
"style": {
"navigationBarTitleText": "申请加入名单",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{ {
"path": "pages/tasks/tasks", "path": "pages/tasks/tasks",
"style": { "style": {
@@ -87,7 +79,7 @@
], ],
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "white", "navigationBarTextStyle": "white",
"navigationBarTitleText": "填报通", "navigationBarTitleText": "深学在线",
"navigationBarBackgroundColor": "#0052D9", "navigationBarBackgroundColor": "#0052D9",
"backgroundColor": "#F3F3F3" "backgroundColor": "#F3F3F3"
} }
@@ -78,7 +78,7 @@
<!-- 学生改动 --> <!-- 学生改动 -->
<view v-if="(d.changedDetail || []).length" class="det__card"> <view v-if="(d.changedDetail || []).length" class="det__card">
<view class="det__title">学生改动({{ d.changedDetail.length }} 项)</view> <view class="det__title">学生改动({{ (d.changedDetail || []).length }} 项)</view>
<view v-for="c in d.changedDetail" :key="c.field" class="det__change"> <view v-for="c in d.changedDetail" :key="c.field" class="det__change">
<text class="det__change-label">{{ c.label || c.field }}</text> <text class="det__change-label">{{ c.label || c.field }}</text>
<text class="det__muted">名册:{{ c.rosterValue || '—' }} → 学生:{{ c.studentValue || '—' }}</text> <text class="det__muted">名册:{{ c.rosterValue || '—' }} → 学生:{{ c.studentValue || '—' }}</text>
@@ -94,29 +94,16 @@
</view> </view>
<!-- <!--
代传入口(§10.6):只在这个学生**还没提交**时才有意义 —— ⚠️ 2026-10:这里原本有一段「帮他传材料」的模板(代传,§10.6),
已提交的单子不能塞材料(那会让审到一半的材料清单变了)。 但它引用的 `canProxy / proxyMaterial / proxyReason / proxyBusy / doProxy`
放在最下面而不是和"通过/驳回"并排:它是补救动作,不是主流程。 **五个变量在脚本里从未定义** —— 也就是这一段**永远不会渲染**,
而 vue-tsc 一直在报 TS2339,看起来像"页面写坏了"。
现在直接删掉:需求收敛后主流程里没有代传(学生填报 → 班主任/管理员审核),
后端 `file:proxyUpload` 接口还在(`e2e-m5-proxy.py` 33 条断言锁着),
但**两端都没有代传入口**。将来要做,请重新实现并补 UI 断言,
不要照着这段删掉的模板"恢复"。
--> -->
<view v-if="canProxy" class="det__proxy">
<view class="det__proxy-title">帮他传材料</view>
<view class="det__proxy-hint">
学生不会弄(照片在家长手机里 / 不会压缩)时用这里。
<text class="det__strong">这份材料会标注「由你代传」</text>,学生也看得到。
</view>
<view class="det__proxy-mats">
<view v-for="g in (d.materials || []).filter((m) => m.required)" :key="g.materialCode"
class="det__proxy-mat" :class="{ 'det__proxy-mat--on': proxyMaterial === g.materialCode }"
@click="proxyMaterial = g.materialCode">
{{ g.materialName }}{{ g.done ? '(已交)' : '' }}
</view>
</view>
<input v-model="proxyReason" class="det__proxy-reason"
placeholder="代传原因(选填,但学生看得到)" :maxlength="100" />
<button class="det__btn det__btn--ghost" :disabled="proxyBusy" @click="doProxy">
{{ proxyBusy ? '正在上传…' : '选择照片并代传' }}
</button>
</view>
</template> </template>
<!-- 驳回弹层:原因快选 + 必须二次确认 --> <!-- 驳回弹层:原因快选 + 必须二次确认 -->
+1 -1
View File
@@ -48,7 +48,7 @@
红 {{ cur.redCount }} · 黄 {{ cur.yellowCount }} 红 {{ cur.redCount }} · 黄 {{ cur.yellowCount }}
</view> </view>
<view v-if="(cur.changedFields || []).length" class="audit__changed"> <view v-if="(cur.changedFields || []).length" class="audit__changed">
学生改了 {{ cur.changedFields.length }} 项预填内容 学生改了 {{ (cur.changedFields || []).length }} 项预填内容
</view> </view>
<view class="audit__thumbs"> <view class="audit__thumbs">
@@ -19,7 +19,7 @@
<!-- 待办:这一屏最该被点的是"待我初审" --> <!-- 待办:这一屏最该被点的是"待我初审" -->
<view class="dash__todos"> <view class="dash__todos">
<view class="dash__todo" @click="goAudit"> <view class="dash__todo" @click="goAudit()">
<text class="dash__todo-num">{{ t.auditCount }}</text> <text class="dash__todo-num">{{ t.auditCount }}</text>
<text class="dash__todo-label">待我初审</text> <text class="dash__todo-label">待我初审</text>
</view> </view>
+1 -1
View File
@@ -519,7 +519,7 @@ function uploadFor(m: Material) {
let sendPath = path let sendPath = path
// 选中的原始文件名。**H5 用 blob 地址上传时 multipart 里带不过去**(是一串 UUID、 // 选中的原始文件名。**H5 用 blob 地址上传时 multipart 里带不过去**(是一串 UUID、
// 没有后缀),所以下面要显式告诉服务端叫什么 —— 否则它按后缀判格式会直接拒掉。 // 没有后缀),所以下面要显式告诉服务端叫什么 —— 否则它按后缀判格式会直接拒掉。
const pickedName = ((res.tempFiles || [])[0] as { name?: string } | undefined)?.name || '' const pickedName = ((res.tempFiles as Array<{ name?: string }> | undefined)?.[0])?.name || ''
try { try {
const plan = planFor(m.compressProfile, m.maxSizeKb, m.minQuality) const plan = planFor(m.compressProfile, m.maxSizeKb, m.minQuality)
if (plan) { if (plan) {
+1 -1
View File
@@ -2,7 +2,7 @@
<view class="land"> <view class="land">
<!-- 顶部:这是哪个学校、要填什么 --> <!-- 顶部:这是哪个学校、要填什么 -->
<view class="land__hero"> <view class="land__hero">
<text class="land__tenant">{{ ctx.tenantName || '填报通' }}</text> <text class="land__tenant">{{ ctx.tenantName || '深学在线' }}</text>
<text class="land__task">{{ ctx.taskName || title }}</text> <text class="land__task">{{ ctx.taskName || title }}</text>
<view v-if="ctx.taskStatusText" class="land__badge" :class="badgeClass"> <view v-if="ctx.taskStatusText" class="land__badge" :class="badgeClass">
{{ ctx.taskStatusText }} {{ ctx.taskStatusText }}
-466
View File
@@ -1,466 +0,0 @@
<template>
<view class="join">
<view class="join__wrap">
<view class="join__hero">
<text class="join__tenant">{{ ctx.tenantName || '材料填报' }}</text>
<text class="join__task">{{ ctx.taskName }}</text>
</view>
<!--
① 已经申请过:把状态摆在最上面。
这条通道最容易让人误解的就是"我提交了,是不是就能填了?" ——
所以三个状态各有一句话直说结果,尤其待审时要说清**还不能填**。
-->
<view v-if="mine" class="join__card">
<view class="join__status" :class="`join__status--${mine.status}`">
{{ mine.statusText }}
</view>
<view class="join__row">
<text class="join__row-label">申请编号</text>
<text class="join__row-value">#{{ mine.requestId }}</text>
</view>
<view class="join__row">
<text class="join__row-label">姓名</text>
<text class="join__row-value">{{ mine.realName }}</text>
</view>
<view class="join__row">
<text class="join__row-label">身份证号</text>
<text class="join__row-value">{{ mine.idCardMask }}</text>
</view>
<view class="join__row">
<text class="join__row-label">提交时间</text>
<text class="join__row-value">{{ fmt(mine.createTime) }}</text>
</view>
<!-- 待审:说清"还不能填",并给出他能做的事 -->
<view v-if="mine.status === 0" class="join__note">
老师审核通过后,你<text class="join__strong">返回重新核验</text>就能开始填材料。
审核前暂时还不能填 —— 名单是准入依据,这一步不能跳过。
</view>
<!-- 通过:直接给"去核验"的入口 -->
<view v-else-if="mine.status === 1" class="join__note join__note--ok">
已经加入名单了,点下面的按钮重新核验一次即可开始填报。
</view>
<!-- 驳回:原因原样显示(这是学生唯一能知道"哪里不对"的来源) -->
<view v-else class="join__note join__note--bad">
<text class="join__strong">未通过原因:</text>{{ mine.rejectReason || '(老师未填写)' }}
</view>
<button v-if="mine.status === 1" class="join__btn" @click="reverify">
重新核验,开始填报
</button>
<button v-if="mine.status === 2" class="join__btn join__btn--ghost"
:disabled="submitting" @click="showForm = true">
重新填写申请(还剩 {{ remainHint }})
</button>
</view>
<!-- ② 填申请 -->
<view v-if="!mine || mine.status === 2" class="join__card">
<template v-if="showForm">
<view class="join__title">申请加入名单</view>
<view class="join__hint">
核对不上,通常是因为<text class="join__strong">老师名单里还没有你</text>,
或者姓名写得和报名时不一样。填一次申请,老师核对后会把你加进名单。
</view>
<view class="join__field">
<text class="join__label">姓名</text>
<input v-model="form.realName" class="join__input" placeholder="与报名时一致的姓名"
:disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">身份证号</text>
<input v-model="form.idCardNo" class="join__input" placeholder="18 位身份证号"
maxlength="18" :disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">手机号</text>
<input v-model="form.phone" class="join__input" placeholder="老师要能联系到你"
maxlength="11" type="number" :disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">班级(选填)</text>
<input v-model="form.orgHint" class="join__input" placeholder="如 24中技数控2班"
:disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">说明(选填)</text>
<input v-model="form.reason" class="join__input" placeholder="如:我是转班过来的"
:disabled="submitting" />
</view>
<button class="join__btn" :disabled="submitting || !canSubmit" @click="submit">
{{ submitting ? '提交中…' : '提交申请' }}
</button>
<view v-if="error" class="join__error">{{ error }}</view>
<view class="join__foot">
身份证号只用于与名单核对,系统<text class="join__strong">不会保存明文</text>。
</view>
</template>
<template v-else>
<view class="join__title">还没有加入名单?</view>
<view class="join__hint">
如果你确认姓名和身份证号没填错,但仍然核对不上,可以在这里提交申请,
由老师核对后加入名单。
</view>
<button class="join__btn" @click="showForm = true">申请加入名单</button>
</template>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 申请加入名单(FR-509 / 客户确认后新增,原型 STEP 03B)。
*
* ## 三条必须说清的事(文案不是装饰,是这段流程的一部分)
*
* 1. **申请期间仍然不能填报。** 这条通道只解决"进名单",不绕过门禁 ——
* 学生在待审状态最容易以为"我交了就能填",所以状态卡上直说"暂时还不能填"。
* 2. **通过后要重新核验一次。** 通过时服务端已经把他绑到名册行上了,
* 所以重进就能过;说清这一步,他就不会以为"通过了还要再填一遍资料"。
* 3. **身份证号不存明文。** 未核验的人最该被告知这一点 ——
* 否则"填身份证号"这一步会让人犹豫。
*/
defineOptions({ name: 'JoinRequest' })
interface SceneContext {
tenantName?: string
taskName?: string
taskId?: number
}
interface MineRequest {
requestId: number
taskId: number
taskName?: string
status: number
statusText: string
realName: string
idCardMask: string
orgHint?: string
createTime?: string
auditTime?: string
rejectReason?: string
rosterId?: number
canFillNow?: boolean
}
const ctx = ref<SceneContext>({})
const mine = ref<MineRequest | null>(null)
const showForm = ref(false)
const submitting = ref(false)
const error = ref('')
let taskId = 0
let scene = ''
const form = ref({
realName: '',
idCardNo: '',
phone: '',
orgHint: '',
reason: ''
})
const canSubmit = computed(() => {
const f = form.value
return f.realName.trim().length > 0
&& f.idCardNo.trim().length === 18
&& /^1[3-9]\d{9}$/.test(f.phone.trim())
})
const remainHint = computed(() => '最多 2 次机会')
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
async function loadContext(s: string) {
if (!s) return
try {
const data = await request<SceneContext>({
url: `/api/v1/mp/scene-context?scene=${encodeURIComponent(s)}`
})
ctx.value = data
taskId = data.taskId || 0
} catch (e) {
error.value = e instanceof Error ? e.message : '读取任务信息失败'
}
}
/** 拉"我的申请":决定显示状态卡还是表单 */
async function loadMine() {
try {
const data = await request<{ list: MineRequest[], total: number }>({
url: `/api/v1/mp/roster/join-request${taskId ? `?taskId=${taskId}` : ''}`,
token: token()
})
// 只看最近一条:一个人在同一任务下的申请按时间倒序,最新那条决定他现在的处境
mine.value = (data.list || [])[0] || null
} catch (e) {
// 拉不到不算致命:最多是少显示状态,仍可提交
error.value = e instanceof Error ? e.message : ''
}
}
async function submit() {
error.value = ''
const f = form.value
if (!canSubmit.value) {
error.value = '请把姓名、18 位身份证号、手机号填完整'
return
}
submitting.value = true
try {
await request({
url: '/api/v1/mp/roster/join-request',
method: 'POST',
token: token(),
data: {
taskId,
realName: f.realName.trim(),
idCardNo: f.idCardNo.trim(),
phone: f.phone.trim(),
orgHint: f.orgHint.trim() || undefined,
reason: f.reason.trim() || undefined
}
})
uni.showToast({ title: '已提交,等待老师审核', icon: 'none', duration: 2500 })
showForm.value = false
await loadMine()
} catch (e) {
// 后端的话原样显示("你已在名单中,请直接返回重新核验"这类提示必须让学生看到)
error.value = e instanceof Error ? e.message : '提交失败'
} finally {
submitting.value = false
}
}
/** 通过之后回核验页 —— 服务端已绑定,重进就能过 */
function reverify() {
uni.redirectTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(scene)}` })
}
onLoad(async (options) => {
scene = options?.scene || ''
taskId = Number(options?.taskId || 0)
// 从核验页带过来的姓名/证件号:他刚打过一遍,不必再打
form.value.realName = options?.name || ''
form.value.idCardNo = options?.idCardNo || ''
await loadContext(scene)
await loadMine()
// 带过来的是"门禁没匹配上"的那份输入 —— 直接展开表单,少一次点击
if (!mine.value && (form.value.realName || form.value.idCardNo)) {
showForm.value = true
}
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.join {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__hero {
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
border-radius: 16rpx;
}
&__tenant {
display: block;
font-size: 24rpx;
color: rgb(255 255 255 / 80%);
}
&__task {
display: block;
margin-top: 8rpx;
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
&__card {
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
background: #fff;
border-radius: 16rpx;
}
&__title {
font-size: 34rpx;
font-weight: 600;
color: #1b2430;
}
&__status {
display: inline-block;
padding: 6rpx 18rpx;
margin-bottom: 20rpx;
font-size: 26rpx;
border-radius: 8rpx;
&--0 {
color: $warn;
background: #fdf2e9;
}
&--1 {
color: $success;
background: #e8f6f0;
}
&--2 {
color: $danger;
background: #fdecee;
}
}
&__hint {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #4a5768;
}
&__strong {
font-weight: 600;
color: #1b2430;
}
&__row {
display: flex;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
&__row-label {
font-size: 26rpx;
color: #8b95a3;
}
&__row-value {
font-size: 26rpx;
color: #1b2430;
}
&__note {
padding: 20rpx;
margin-top: 20rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
background: #f7f8fa;
border-radius: 12rpx;
&--ok {
color: #2f6b4f;
background: #e8f6f0;
}
&--bad {
color: #a03a44;
background: #fdecee;
}
}
&__field {
margin-top: 24rpx;
}
&__label {
display: block;
margin-bottom: 10rpx;
font-size: 26rpx;
color: #4a5768;
}
&__input {
width: 100%;
height: 84rpx;
padding: 0 20rpx;
font-size: 30rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
&__btn {
height: 88rpx;
margin-top: 36rpx;
font-size: 32rpx;
color: #fff;
background: $primary;
border-radius: 12rpx;
&[disabled] {
color: #fff;
background: #b9c6dd;
}
&--ghost {
color: $primary;
background: #eaf1fd;
&[disabled] {
color: #8b95a3;
background: #f2f3f5;
}
}
}
&__error {
padding: 18rpx 20rpx;
margin-top: 24rpx;
font-size: 26rpx;
line-height: 1.6;
color: $danger;
background: #fdecee;
border-radius: 12rpx;
}
&__foot {
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.7;
color: #8b95a3;
}
}
</style>
+1 -1
View File
@@ -45,7 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request' import { request } from '@/utils/request'
+6 -25
View File
@@ -33,18 +33,13 @@
<view v-if="error" class="verify__error">{{ error }}</view> <view v-if="error" class="verify__error">{{ error }}</view>
<!-- <!--
核验失败时给一条**受控通道**(FR-509 / 客户确认后新增)。 核验失败时**只给"重新填写"这一条路**(2026-10 需求收敛:删掉「申请加入名单」)。
注意两件事: 刻意不提任何联系方式:名单外的人怎么进名单是老师那边的事,
1) 这条通道**不绕过门禁** —— 申请期间仍然不能填,去申请页会说清这一点; 系统内不展示班主任/管理员的电话(客户定过的规矩)。
2) 它只在真失败过之后才出现(`error` 非空),不是一进来就摆个"申请"按钮。
否则会有学生绕过核验直接去申请,把管理员的待办当成人工客服。
--> -->
<button v-if="error" class="verify__btn verify__btn--ghost" @click="gotoJoin"> <view v-if="error" class="verify__foot">
申请加入名单 核对不上?请先确认姓名和身份证号没有打错。
</button> 如果确实已经报名,请直接联系老师,让老师把你加进名单后再来核对。
<view class="verify__foot">
核对不上?先确认姓名和证件号没有打错。仍然不行可点上面的「申请加入名单」。
</view> </view>
</view> </view>
@@ -225,20 +220,6 @@ function next() {
}) })
} }
/** 去申请加入名单(FR-509 的入口)—— 带上已填的姓名与证件号,省他再输一遍。 */
function gotoJoin() {
const s = sceneFromUrl()
const params = new URLSearchParams({
scene: s,
taskId: String(ctx.value.taskId || 0),
// 只把**已经打过的字**带过去。这不是"预填答案":门禁刚判定它不匹配,
// 带过去是为了让他少打一遍,而不是暗示它是对的。
name: realName.value.trim(),
idCardNo: idCardNo.value.trim()
})
uni.navigateTo({ url: `/pages/join/join?${params.toString()}` })
}
onLoad((options) => { onLoad((options) => {
loadContext(options?.scene || '') loadContext(options?.scene || '')
}) })
+6 -4
View File
@@ -4,7 +4,7 @@
用途:回答"121 个契约接口里,哪些已经实现、哪些还没做"。 用途:回答"121 个契约接口里,哪些已经实现、哪些还没做"。
—— 这个数在《08-完成度盘点与问题清单.md》里被引用,必须可复现、不能靠记忆。 —— 这个数在《08-完成度盘点与问题清单.md》里被引用,必须可复现、不能靠记忆。
契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 121 个,全文权威清单)。 契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 119 个,全文权威清单)。
代码来源:backend 所有 controller 的类级 @RequestMapping + 方法级 @*Mapping。 代码来源:backend 所有 controller 的类级 @RequestMapping + 方法级 @*Mapping。
★ 两个必须容忍的写法(踩过): ★ 两个必须容忍的写法(踩过):
@@ -160,9 +160,11 @@ def main() -> int:
print() print()
print("说明:--missing 只列缺口;不带参数时会先打全部已注册接口。") print("说明:--missing 只列缺口;不带参数时会先打全部已注册接口。")
# 契约必须恰好 121(§2 的口径),不等就说明解析或文档出了问题 # 契约必须恰好 119(需求收敛 2026-10 后的口径:删掉「历史整理器」5 个与
if claimed != 121: # 「加入申请」5 个,原 129 → 119;缺口只保留 /mp/ocr/idcard 一个),
print(" [FAIL] 契约接口数应为 121,实际解析出 %d —— 检查 §4 表格格式" % claimed) # 不等就说明解析或文档出了问题
if claimed != 119:
print(" [FAIL] 契约接口数应为 119,实际解析出 %d —— 检查 §4 表格格式" % claimed)
return 1 return 1
return 0 return 0
+4 -2
View File
@@ -51,12 +51,14 @@ for c in used:
print(" ✗ %s **ErrorCode 里没有这个码**" % c) print(" ✗ %s **ErrorCode 里没有这个码**" % c)
# ② 关键文案 # ② 关键文案
# ⚠️ 2026-10 需求收敛:原列表里的「你已在名单中,请返回重新核验」与
# 「你已提交过申请,请耐心等待」两句属于**已删除的加入申请模块**
# (`module/join`,连同错误码 40037/40038/40039/40040/40042),
# 代码里已不存在 —— 因此从本清单移除,不要再加回来。
key_phrases = [ key_phrases = [
"没有找到你的信息,请核对后重试", "没有找到你的信息,请核对后重试",
"操作过于频繁,请稍后再试", "操作过于频繁,请稍后再试",
"该信息已被登记", "该信息已被登记",
"你已在名单中,请返回重新核验",
"你已提交过申请,请耐心等待",
"这份材料可能不是你的,请重新上传", "这份材料可能不是你的,请重新上传",
"已进入审核,需先撤回才能删材料", "已进入审核,需先撤回才能删材料",
"已通过终审,不可撤回", "已通过终审,不可撤回",
+124 -8
View File
@@ -21,7 +21,7 @@
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表, 逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
而不是靠下次再想一遍)。 而不是靠下次再想一遍)。
## 查七件事 ## 查九件事
| # | 查什么 | 没接上的后果 | | # | 查什么 | 没接上的后果 |
|:--:|---|---| |:--:|---|---|
@@ -32,6 +32,8 @@
| 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 | | 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 |
| 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 | | 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) | | 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
### 第 7 项是 2026-10-09 补的(自己踩了一次) ### 第 7 项是 2026-10-09 补的(自己踩了一次)
@@ -42,6 +44,20 @@
这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**; 这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**;
而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。 而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。
### 第 8 / 9 项是 2026-10 补的(重搭 PC 端之后)
第 8 项:仓库**没有 auto-import**,漏 import 的 `<n-button>` 被当成未知自定义元素,
只丢样式不报错(实测 4 个文件 6 处)。
第 9 项:SVG 是 XML,**注释里出现连续两个减号**(比如顺手写下 CSS 自定义属性的
写法)就会让整个文件 not well-formed。浏览器对 `<img src="x.svg">` 的失败完全静默 ——
控制台不打、`vue-tsc` 不打、`eslint` 不打,页面上只是那个图标裂开。
换品牌标时连踩两次:`logo.svg` 的注释里写了变量名,登录页与侧栏的标直接裂图,
而 typecheck / lint / 本脚本全绿。
后两项严格说不是"声明没接线",而是**同一族:坏了但没有任何工具会红**。
放这里是因为它想要的就是"一条命令把这一族扫一遍"。
## 它**不查**什么(免得被当成"全绿就没事") ## 它**不查**什么(免得被当成"全绿就没事")
@@ -61,6 +77,7 @@ import glob
import io import io
import os import os
import re import re
import xml.dom.minidom
import sys import sys
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
@@ -78,8 +95,6 @@ XT_ROUTES = 'frontend-pc/src/config/xt-routes.ts'
# ── 已知例外:**有意不接线的**,写在这里(连同理由),不要靠下次再想一遍 ── # ── 已知例外:**有意不接线的**,写在这里(连同理由),不要靠下次再想一遍 ──
# 权限码:没有任何地方校验它(既没有 @RequirePerm,也没有服务层的 permissionChecker) # 权限码:没有任何地方校验它(既没有 @RequirePerm,也没有服务层的 permissionChecker)
PERM_NO_CHECK = { PERM_NO_CHECK = {
'ROLE_MANAGE': '「角色与授权」页是二期功能(权限码先定下来,见 08 文档 6n):'
'一期不开放自定义角色,所以没有任何接口需要它',
'ROSTER_MANAGE_OWN': '「本班名册」不是独立接口,而是 roster 接口里的**数据范围**分支' 'ROSTER_MANAGE_OWN': '「本班名册」不是独立接口,而是 roster 接口里的**数据范围**分支'
'(服务层按角色判 orgIds),接口级权限用 ROSTER_MANAGE / ROSTER_VIEW', '(服务层按角色判 orgIds),接口级权限用 ROSTER_MANAGE / ROSTER_VIEW',
'PROGRESS_VIEW_OWN': '同上:进度看板的"本班/本人"是同一接口的范围分支(见 ProgressService)', 'PROGRESS_VIEW_OWN': '同上:进度看板的"本班/本人"是同一接口的范围分支(见 ProgressService)',
@@ -291,9 +306,9 @@ def strip_js_comments(src):
def template_send_points(sources, code, tpl_file): def template_send_points(sources, code, tpl_file):
"""站内信模板的"发送点" = 代码里有人把这个码当成模板码用。 """站内信模板的"发送点" = 代码里有人把这个码当成模板码用。
⚠️ 判据不能用"标识符出现次数":`JOIN_APPROVE` 同时是 ⚠️ 判据不能用"标识符出现次数":模板码常常**同时是别的常量的值** ——
`OperationLogService.ACTION_JOIN_APPROVE`(操作日志动作码)与 例如 `URGE` 既是站内信的模板码,又是催报业务类型(`SUBJECT_URGE`)的名字;
`NotificationService.TPL_JOIN_APPROVE`(微信订阅消息模板码)的值 —— 早先的 `JOIN_APPROVE` 更是同时挂在操作日志动作码与微信订阅消息模板码上。
按标识符数会得出"3 处引用",而它当时**一个发送点都没有**。 按标识符数会得出"3 处引用",而它当时**一个发送点都没有**。
所以认两件事: 所以认两件事:
@@ -488,8 +503,13 @@ def main():
# ── ⑦ PC 端引用的权限码 ↔ PermissionDef ───────────────────── # ── ⑦ PC 端引用的权限码 ↔ PermissionDef ─────────────────────
# #
# 两个写法都认:`userStore.permissions.includes('org:view')` 与 # 三种写法都认:
# `v-hasPerm="['audit:final']"`(后者是模板里写的指令参数)。 # · `userStore.permissions.includes('org:view')`(重搭前的写法,保留兼容)
# · `v-hasPerm="['audit:final']"`(重搭前的指令参数,保留兼容)
# · `user.has('task:manage')` / `user.hasAny('a','b')`
# —— **2026-10 重搭为纯 Vue 3 + Naive 后的唯一写法**(已无 v-hasPerm 指令)。
# ⚠️ 少了这一条,本项会永远报"PC 端引用的权限码 0 个":
# 看起来是"通过",实际是**检查器失效**(安全网静默消失,正是本脚本要防的那类错)。
# #
# ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。 # ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。
# 2026-10-09 加它的时候正是拿一个写错的码试过的(那时是真的写错了)。 # 2026-10-09 加它的时候正是拿一个写错的码试过的(那时是真的写错了)。
@@ -507,7 +527,15 @@ def main():
for i, line in enumerate(src.split('\n'), 1): for i, line in enumerate(src.split('\n'), 1):
for m in re.finditer(r"permissions\.includes\(\s*'([^']+)'", line): for m in re.finditer(r"permissions\.includes\(\s*'([^']+)'", line):
fe_codes.setdefault(m.group(1), []).append('%s:%d' % (p, i)) fe_codes.setdefault(m.group(1), []).append('%s:%d' % (p, i))
# `user.has('码')` / `user.hasAny('码', ...)`。
# ⚠️ 这里加了 `域:动作` 的形状约束:`.has(` 在别处也用于 Set/Map
# (`changed.has(key)`、`set.has(r.orgId)`),不约束形状会收进一堆假阳性。
# 本项目的权限码一律是 `域:动作` 形状(见 PermissionDef)。
for m in re.finditer(r"\.has(?:Any)?\(([^)]*)\)", line):
for c in re.findall(r"'([a-zA-Z]+:[a-zA-Z]+)'", m.group(1)):
fe_codes.setdefault(c, []).append('%s:%d' % (p, i))
# `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找 # `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找
# (指令本身已随 2026-10 重搭移除,这里保留是为了旧分支/文档示例仍能被查出来)
if is_vue: if is_vue:
for m in re.finditer(r"v-hasPerm\s*=\s*\"\[([^\]]*)\]\"", line): for m in re.finditer(r"v-hasPerm\s*=\s*\"\[([^\]]*)\]\"", line):
for c in re.findall(r"'([^']+)'", m.group(1)): for c in re.findall(r"'([^']+)'", m.group(1)):
@@ -526,6 +554,94 @@ def main():
'PermissionDef 里没有这个码(判断会恒为 false:%s)' % where)) 'PermissionDef 里没有这个码(判断会恒为 false:%s)' % where))
say(' ✗ %-22s PermissionDef 里没有它 → 这个判断永远是 false %s' % (code, where)) say(' ✗ %-22s PermissionDef 里没有它 → 这个判断永远是 false %s' % (code, where))
# ── ⑧ PC 端 `<n-*>` 组件 ↔ import ────────────────────────────
#
# ⚠️ 为什么它属于「声明了但没接上」:本仓库**没有 auto-import**(见 frontend-pc/AGENTS.md
# 第 1 条),漏 import 的后果是 Vue 把它当成**未知自定义元素**渲染 ——
# 不报错、不崩、lint 与 vue-tsc 也全绿,只是样式整块丢掉。
# 2026-10 重搭成纯 Vue 3 + Naive 后实测踩到 4 个文件 6 处:
# `<n-button>` 变成无边框透明块、`<n-avatar>` 变成 18×29 的裸元素、
# `<n-form>` 的表单排版全丢 —— 表现就是"这个页面样式坏了",但控制台只给一句 warn。
#
# 判定方式:在 `<template>` 段里出现的 `<n-xxx>`,其 PascalCase 名字必须在本文件
# 的某个 `import { ... } from '...'` 里出现过。
naive_missing = []
for p in sorted(glob.glob('frontend-pc/src/**/*.vue', recursive=True)):
try:
raw = read(p)
except Exception:
continue
m = re.search(r'<template>(.*)</template>', raw, re.S)
if not m:
continue
used = set(re.findall(r'<(n-[a-z0-9-]+)[\s/>]', m.group(1)))
if not used:
continue
imported = set()
for im in re.finditer(r'import\s+([^;]+?)\s+from\s+[\'"][^\'"]+[\'"]', raw, re.S):
clause = im.group(1)
brace = re.search(r'\{([^}]*)\}', clause, re.S)
if brace:
for item in brace.group(1).split(','):
item = item.split(' as ')[-1].strip()
if item:
imported.add(item)
head = clause.split('{')[0].strip().rstrip(',')
if head and not head.startswith('*'):
imported.add(head)
for tag in sorted(used):
comp = ''.join(part.capitalize() for part in tag.split('-'))
if comp not in imported:
naive_missing.append((p, tag, comp))
say('\n【⑧】PC 端 `<n-*>` 组件未 import 的 %d 处' % len(naive_missing))
seen_files = set()
for p, tag, comp in naive_missing:
problems.append(('未 import 的组件', '<%s>' % tag,
'%s 里没 import %s(会被当成未知元素渲染,样式整块丢失)'
% (p, comp)))
if p not in seen_files:
seen_files.add(p)
say(' %s' % p)
say(' ✗ <%-16s 需要 import { %s } from \'naive-ui\'' % (tag + '>', comp))
if not naive_missing:
say(' ✓ 模板里用到的 <n-*> 组件都有对应 import')
# ── ⑨ SVG 资产是不是合法 XML ──────────────────────────────
#
# ⚠️ 为什么它属于这一族:SVG 是 XML,**注释里出现连续两个减号**
# (比如顺手写下 CSS 自定义属性的写法)就会让整个文件 not well-formed。
# 浏览器对 `<img src="x.svg">` 的失败**完全静默** ——
# 控制台不打、vue-tsc 不打、eslint 不打,页面上只是那个图标裂开。
# 2026-10 换品牌标时连踩两次:logo.svg 注释里写了变量名,
# 登录页(34px)与侧栏(24px)的标一起变成裂图,而三样检查全绿。
#
# 判定方式:拿标准库把每个 .svg 当 XML 解析一遍 —— 不需要浏览器,
# 也不依赖某个具体 SVG 特性。
svg_paths = []
for pattern in ('frontend-pc/src/**/*.svg', 'frontend-pc/public/**/*.svg',
'frontend-mp/src/**/*.svg', 'backend/**/*.svg'):
svg_paths.extend(glob.glob(pattern, recursive=True))
svg_paths = sorted(p for p in set(svg_paths)
if '/node_modules/' not in p and '\\node_modules\\' not in p
and '/dist/' not in p and '\\dist\\' not in p
and '/target/' not in p and '\\target\\' not in p)
svg_bad = []
for p in svg_paths:
try:
xml.dom.minidom.parse(p)
except Exception as e:
svg_bad.append((p, str(e)))
say('\n【⑨】SVG 资产 %d 个 / XML 非法的 %d 个' % (len(svg_paths), len(svg_bad)))
for p, why in svg_bad:
problems.append(('SVG 资产', p,
'%s(浏览器静默不渲染:图标裂开,任何检查都不报)' % why))
say(' ✗ %s\n %s' % (p, why))
if not svg_bad:
say(' ✓ 全部能被解析为合法 XML')
# ── 汇总 ──────────────────────────────────────────────── # ── 汇总 ────────────────────────────────────────────────
print('\n' + '=' * 88) print('\n' + '=' * 88)
if notes: if notes:
+4 -5
View File
@@ -218,16 +218,15 @@ def main() -> int:
# ── 演示班主任:**必须显式指定初审人**,否则这一屏验不了 ── # ── 演示班主任:**必须显式指定初审人**,否则这一屏验不了 ──
# #
# 初审的授权口径是"这个班的初审人 / 副班主任 / 班级负责人"(`AuditScopeMapper` # 初审的授权口径是"这个班的初审人 / 班级负责人"(`AuditScopeMapper`
# 的三级回退),而不是"有 audit:first 权限就行"。所以管理员自己**看不到初审待办** —— # 的两级回退),而不是"有 audit:first 权限就行"。所以管理员自己**看不到初审待办** ——
# 这是设计(权限 ≠ 范围),但演示时必须有一个真实的班主任才能把初审那一屏走通。 # 这是设计(权限 ≠ 范围),但演示时必须有一个真实的班主任才能把初审那一屏走通。
teacher_id, teacher_name = ensure_teacher(token, org["id"]) teacher_id, teacher_name = ensure_teacher(token, org["id"])
st, body = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", token, { st, body = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{ "orgs": [{
"orgId": org["id"], "orgId": org["id"],
"expectedCount": len(STUDENTS), "expectedCount": len(STUDENTS),
"headUserId": teacher_id, "headUserId": teacher_id
"deputyUserIds": []
}] }]
}) })
if body.get("code") != 0: if body.get("code") != 0:
@@ -351,7 +350,7 @@ def ensure_teacher(token: str, org_id: int) -> tuple[int, str]:
准备一个演示班主任(已存在就复用),返回 (id, 姓名)。 准备一个演示班主任(已存在就复用),返回 (id, 姓名)。
为什么必须有一个**真实的班主任账号**: 为什么必须有一个**真实的班主任账号**:
初审的范围校验是"这个班归不归我"(指定初审人 → 副班主任 → 班级负责人), 初审的范围校验是"这个班归不归我"(指定初审人 → 班级负责人),
管理员自己**看不到初审待办** —— 这是设计,不是缺陷。 管理员自己**看不到初审待办** —— 这是设计,不是缺陷。
所以演示/验收初审那一屏时,必须有一个真账号登进去看。 所以演示/验收初审那一屏时,必须有一个真账号登进去看。
+28 -4
View File
@@ -351,6 +351,17 @@ def main() -> int:
# ── 6. 查询 ──────────────────────────────────────── # ── 6. 查询 ────────────────────────────────────────
print("\n[6] 查询与分页") print("\n[6] 查询与分页")
# ⚠️ 这条是 2026-10 需求收敛时补的防线回归:
# 删掉「副班主任」角色后,前端一度还在传 roleCode=DEPUTY_TEACHER,
# 而旧实现对**不认识的角色码是静默忽略过滤条件** → 返回全校账号(含学生),
# 且不报任何错。现在必须**失败关闭**(空列表)。
status, body = call("GET", f"{base}/api/v1/users?roleCode=DEPUTY_TEACHER", token)
ghost = (body.get("data") or {}).get("list")
check("⭐ 按已删除的角色码过滤 → 返回空(不是「忽略过滤条件」返回全校)",
body.get("code") == 0 and ghost == [],
f"code={body.get('code')} 条数={len(ghost) if isinstance(ghost, list) else ghost}")
status, body = call("GET", f"{base}/api/v1/users?pageNum=1&pageSize=5", token) status, body = call("GET", f"{base}/api/v1/users?pageNum=1&pageSize=5", token)
data = body.get("data") or {} data = body.get("data") or {}
check("列表返回 {list,total,pageNum,pageSize}", check("列表返回 {list,total,pageNum,pageSize}",
@@ -398,10 +409,13 @@ def main() -> int:
# ── 7. 修改 ──────────────────────────────────────── # ── 7. 修改 ────────────────────────────────────────
print("\n[7] 修改账号") print("\n[7] 修改账号")
# ⚠️ 2026-10 需求收敛:删掉「副班主任」后,user_type=3 的内置角色只剩班主任一个,
# 所以这里改成单角色改名,并**另加一条**断言把"多角色必须同属一个用户类型"这条
# 规则本身验出来(以前拿「班主任 + 副班主任」验的是"两个同类角色都生效")。
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}", token, { status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}", token, {
"realName": teacher_name + "改", "realName": teacher_name + "改",
"phone": teacher_phone, "phone": teacher_phone,
"roleCodes": ["HEAD_TEACHER", "DEPUTY_TEACHER"], "roleCodes": ["HEAD_TEACHER"],
"userType": 3, "userType": 3,
}) })
check("修改姓名与角色成功", body.get("code") == 0, check("修改姓名与角色成功", body.get("code") == 0,
@@ -411,9 +425,19 @@ def main() -> int:
status, body = call("GET", f"{base}/api/v1/users?keyword={teacher_user}", token) status, body = call("GET", f"{base}/api/v1/users?keyword={teacher_user}", token)
row = next((h for h in (body.get("data") or {}).get("list", []) if h["id"] == teacher_uid), None) row = next((h for h in (body.get("data") or {}).get("list", []) if h["id"] == teacher_uid), None)
check("两个角色都生效", row is not None and sorted(row.get("roleCodes") or []) check("改后的角色生效", row is not None and sorted(row.get("roleCodes") or [])
== ["DEPUTY_TEACHER", "HEAD_TEACHER"], == ["HEAD_TEACHER"],
f"roleCodes={row.get('roleCodes') if row else 'N/A'}") f"roleCodes={row.get('roleCodes') if row else 'N/A'}")
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}", token, {
"realName": teacher_name + "改",
"phone": teacher_phone,
"roleCodes": ["HEAD_TEACHER", "SCHOOL_ADMIN"],
"userType": 3,
})
check("⭐ 多角色必须同属一个用户类型(班主任 + 学校管理员 → 40001)",
body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
check("登录名没有被改掉(username 不可改)", check("登录名没有被改掉(username 不可改)",
row is not None and row.get("username") == teacher_user, row is not None and row.get("username") == teacher_user,
f"username={row.get('username') if row else 'N/A'}") f"username={row.get('username') if row else 'N/A'}")
@@ -643,7 +667,7 @@ def build_import_xlsx(suffix: str, base: str, token: str) -> str | None:
# 3 条合法 # 3 条合法
ws.append([f"导入甲{suffix}", f"imp1{suffix}", "13900139001", org_name, "班主任"]) ws.append([f"导入甲{suffix}", f"imp1{suffix}", "13900139001", org_name, "班主任"])
ws.append([f"导入乙{suffix}", f"imp2{suffix}", "13900139002", org_name, None]) ws.append([f"导入乙{suffix}", f"imp2{suffix}", "13900139002", org_name, None])
ws.append([f"导入丙{suffix}", f"imp3{suffix}", "13900139003", None, "副班主任"]) ws.append([f"导入丙{suffix}", f"imp3{suffix}", "13900139003", None, "学校管理员"])
# 4 条非法 # 4 条非法
ws.append(["手机号错", f"imp4{suffix}", "1390013", None, None]) ws.append(["手机号错", f"imp4{suffix}", "1390013", None, None])
ws.append(["账号重复", f"imp1{suffix}", "13900139005", None, None]) ws.append(["账号重复", f"imp1{suffix}", "13900139005", None, None])
+9 -2
View File
@@ -254,11 +254,18 @@ def main() -> int:
print("\n[2] 材料目录接口") print("\n[2] 材料目录接口")
status, body = call("GET", f"{base}/api/v1/material-catalog", token) status, body = call("GET", f"{base}/api/v1/material-catalog", token)
cat = body.get("data") or {} cat = body.get("data") or {}
check("目录返回 6 种材料码", len(cat.get("codes") or []) == 6, # ⚠️ 2026-10 需求收敛:可选材料码 6 → 4(`XJW_BA`/`OTHER_PROOF` 属已取消的二期,
# 它们**读得到、选不到** —— 仍留在目录里只为让历史任务的材料项显示得出名字)。
check("目录只返回 4 种**可选**材料码(学信网备案表/其它佐证材料不再可选)",
len(cat.get("codes") or []) == 4,
f"{[c.get('code') for c in (cat.get('codes') or [])]}") f"{[c.get('code') for c in (cat.get('codes') or [])]}")
check("目录返回 6 个压缩档位", len(cat.get("profiles") or []) == 6, f"{cat.get('profiles')}") check("目录返回 6 个压缩档位", len(cat.get("profiles") or []) == 6, f"{cat.get('profiles')}")
check("目录返回 5 种允许格式", len(cat.get("fileTypes") or []) == 5, f"{cat.get('fileTypes')}") check("目录返回 5 种允许格式", len(cat.get("fileTypes") or []) == 5, f"{cat.get('fileTypes')}")
check("目录返回 3 个内置模板", len(cat.get("templates") or []) == 3, f"{cat.get('templates')}") # 内置模板 3 → 1:社会收集与专项职业能力属已取消的二期,只剩学校收集一种。
check("目录只返回 1 个内置模板(学校收集)",
len(cat.get("templates") or []) == 1
and (cat.get("templates") or [{}])[0].get("templateCode") == "TPL_SCHOOL_ROLL",
f"{cat.get('templates')}")
check("目录返回体积可配范围 307~5120", check("目录返回体积可配范围 307~5120",
cat.get("maxSizeRange") == [307, 5120], f"{cat.get('maxSizeRange')}") cat.get("maxSizeRange") == [307, 5120], f"{cat.get('maxSizeRange')}")
+13 -15
View File
@@ -331,8 +331,8 @@ def main() -> int:
c1, c2 = classes[0]["id"], classes[1]["id"] c1, c2 = classes[0]["id"], classes[1]["id"]
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, { status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [ "orgs": [
{"orgId": c1, "headUserId": head_id, "deputyUserIds": [], "expectedCount": 28}, {"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2, "headUserId": None, "deputyUserIds": [head_id], "expectedCount": 31}, {"orgId": c2, "headUserId": None, "expectedCount": 31},
], ],
}) })
check("保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") check("保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
@@ -350,15 +350,14 @@ def main() -> int:
and f"({ht_user})" in (r1.get("headUserName") or ""), and f"({ht_user})" in (r1.get("headUserName") or ""),
f"headUserName={r1.get('headUserName')}") f"headUserName={r1.get('headUserName')}")
check("第一个班:应填人数保存正确", r1.get("expectedCount") == 28, f"{r1.get('expectedCount')}") check("第一个班:应填人数保存正确", r1.get("expectedCount") == 28, f"{r1.get('expectedCount')}")
check("第一个班:副班主任为空数组(不是 null)", check("第一个班:响应里不再有「协办人」字段(V19 已删列)",
r1.get("deputyUserIds") == [] and r1.get("deputyUserNames") == [], "deputyUserIds" not in r1 and "deputyUserNames" not in r1,
f"{r1.get('deputyUserIds')} / {r1.get('deputyUserNames')}") f"keys={sorted(r1.keys())}")
check("第二个班:初审人为空(表示取班级负责人,不是「没人审」)", check("第二个班:初审人为空(表示取班级负责人,不是「没人审」)",
r2.get("headUserId") is None and r2.get("headUserName") is None, r2.get("headUserId") is None and r2.get("headUserName") is None,
f"headUserId={r2.get('headUserId')} headUserName={r2.get('headUserName')}") f"headUserId={r2.get('headUserId')} headUserName={r2.get('headUserName')}")
check("第二个班:副班主任姓名已装配", check("第二个班:应填人数保存正确", r2.get("expectedCount") == 31,
r2.get("deputyUserNames") and r2["deputyUserNames"][0].startswith(f"E2E班主任{suffix}"), f"{r2.get('expectedCount')}")
f"deputyUserNames={r2.get('deputyUserNames')}")
check("status 恒为 1(只返回生效行)", all(r.get("status") == 1 for r in rows), check("status 恒为 1(只返回生效行)", all(r.get("status") == 1 for r in rows),
f"{[r.get('status') for r in rows]}") f"{[r.get('status') for r in rows]}")
@@ -396,11 +395,10 @@ def main() -> int:
"不是班主任" in (body.get("msg") or "") and "账号管理" in (body.get("msg") or ""), "不是班主任" in (body.get("msg") or "") and "账号管理" in (body.get("msg") or ""),
f"msg={body.get('msg')}") f"msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, { okk, out = sql("SELECT count(*) FROM information_schema.columns "
"orgs": [{"orgId": c1, "headUserId": head_id, "deputyUserIds": [head_id]}], "WHERE table_name = 'collect_task_org' AND column_name = 'deputy_user_ids'")
}) check("⚠️ collect_task_org.deputy_user_ids 列已被 V19 删除(协办人机制下线)",
check("主班主任与副班主任同一人被拒 40001", body.get("code") == 40001, okk and out.strip() == "0", f"查询结果={out}")
f"code={body.get('code')} msg={body.get('msg')}")
if other_org_id: if other_org_id:
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, { status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
@@ -437,7 +435,7 @@ def main() -> int:
okk, before = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}") okk, before = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, { status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28}, "orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}], {"orgId": c2, "expectedCount": 31}],
}) })
check("把移除的班加回来成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}") check("把移除的班加回来成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
okk, after = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}") okk, after = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
@@ -459,7 +457,7 @@ def main() -> int:
# 恢复 2 个班,然后造发布前置数据走到"收集中" # 恢复 2 个班,然后造发布前置数据走到"收集中"
call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, { call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28}, "orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}], {"orgId": c2, "expectedCount": 31}],
}) })
sql(f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) " sql(f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) "
f"VALUES ({tenant_id}, {task_id}, {c1}, 'E2E学生A', 'seed', 'seed')") f"VALUES ({tenant_id}, {task_id}, {c1}, 'E2E学生A', 'seed', 'seed')")
+458
View File
@@ -0,0 +1,458 @@
# -*- coding: utf-8 -*-
"""角色与授权 E2E(二期批次 1~4)。
这个脚本验的不是"接口返回 200",而是**"改完之后,系统真的按新授权做事"** ——
权限系统最容易出的错是"看起来配好了、其实没生效",而这个脚本专门盯这一类:
| # | 要证明的事 | 怎么证明 |
|---|---|---|
| 1 | 自定义角色的**权限码真的在挡** | 给自定义角色只授 `task:view` → 该账号 GET /tasks 通、POST /tasks 得 40301 |
| 2 | 改授权**立即生效**(不必重新登录) | 用**同一个 token** 改完授权马上再请求,权限随之变化 |
| 3 | 停用角色**立即生效** | 停用后同一个 token 立刻失去该角色的权限 |
| 4 | 菜单跟着角色走 | `/auth/me` 的 menus 随角色配置增减(登录响应里也必须有,否则"刷新一次才看得见") |
| 5 | 内置角色**只读** | 改/删/授权/停用内置角色一律 40001 |
| 6 | 越权防线在**服务端** | 学校租户授平台级权限码 → 40301;建"平台运营"类角色 → 40301;发 PLATFORM_ADMIN → 40301 |
| 7 | 内置角色的权限**以代码为准** | 人为删掉库里的一行授权 → 接口返回的仍是代码那一套;跑一次同步任务 → 库里那行回来 |
| 8 | 防自锁 | 管理员把自己唯一一条 `role:manage` / `role` 菜单去掉 → 40001(而不是真的锁死) |
| 9 | 删除守卫 | 还有账号在用的角色删不掉(40045);解绑后能删,且账号立刻失去权限 |
跑法:python scripts/e2e-m10-role.py
前置:后端 8080 在跑、xt-pg / xt-redis 在跑(与其它 e2e 相同)。
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = "http://localhost:8080"
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_USERS: list[int] = []
CREATED_ROLES: list[int] = []
def _load(module_name: str, filename: str):
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> str:
_, out = R.sql(stmt)
return out
def body_of(method: str, path: str, token: str | None = None,
payload: dict | None = None) -> dict:
_, b = R.call(method, f"{BASE}{path}", token, payload)
return b
def clear_login_fail(*accounts: str) -> None:
"""清掉登录失败计数:脚本反复跑会把账号锁掉,而锁定看起来像"密码错了"。"""
for a in accounts:
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:login:fail:%s > NUL 2>&1' % a)
def login(username: str, password: str, tenant: str | None = "demo"):
_, b = R.login(BASE, username, password, tenant)
if b.get("code") != 0:
return None, b
return b["data"]["token"], b["data"]
def role_by_code(token: str, code: str) -> dict:
for r in (body_of("GET", "/api/v1/roles", token).get("data") or []):
if r.get("roleCode") == code:
return r
return {}
def create_role(token: str, code: str, name: str, user_type: int,
perms: list[str], menus: list[str]) -> dict:
b = body_of("POST", "/api/v1/roles", token, {
"roleCode": code, "roleName": name, "userType": user_type,
"permCodes": perms, "menus": menus,
})
if b.get("code") == 0 and b.get("data"):
CREATED_ROLES.append(b["data"])
return b
def create_user(token: str, username: str, real_name: str, phone: str,
role_codes: list[str], user_type: int) -> dict:
b = body_of("POST", "/api/v1/users", token, {
"username": username, "realName": real_name, "phone": phone,
"roleCodes": role_codes, "userType": user_type,
})
if b.get("code") == 0 and (b.get("data") or {}).get("id"):
CREATED_USERS.append(b["data"]["id"])
return b
def cleanup() -> None:
"""只删本脚本造的东西(演示租户里别人的数据一律不碰)。"""
for uid in CREATED_USERS:
R.sql(f"delete from sys_user_role where user_id={uid};")
R.sql(f"delete from sys_user where id={uid};")
for rid in CREATED_ROLES:
R.sql(f"delete from sys_role_permission where role_id={rid};")
R.sql(f"delete from sys_role_menu where role_id={rid};")
R.sql(f"delete from sys_role where id={rid};")
def main() -> int:
ap = argparse.ArgumentParser(description="角色与授权 E2E")
ap.parse_args()
print("=" * 82)
print(" 角色与授权 E2E(自定义角色真的生效 / 内置角色只读 / 越权与自锁防线)")
print("=" * 82)
suffix = os.urandom(3).hex()
role_a_code = f"E2E_ROLE_A_{suffix.upper()}"
role_b_code = f"E2E_ROLE_B_{suffix.upper()}"
user_a = f"e2e_role_a_{suffix}"
user_b = f"e2e_role_b_{suffix}"
clear_login_fail("admin", "platform", user_a, user_b)
at, adm = login("admin", "Admin@123")
if not at:
print(f" x 管理员登录失败:{adm}")
return 2
admin_uid = adm["user"]["id"]
# ══════════════ 1. 权限码目录 ══════════════
print("\n[1] 权限码目录(角色授权界面的勾选项来源)")
b = body_of("GET", "/api/v1/permissions", at)
groups = b.get("data") or []
check("权限码目录可读且按分组返回", b.get("code") == 0 and len(groups) >= 5,
json.dumps(b, ensure_ascii=False)[:200])
items = [i for g in groups for i in (g.get("items") or [])]
check("目录里 28 个权限码一个不少(删掉 join:audit / cleanup:run 之后)",
len(items) == 28, f"实际 {len(items)}")
by_code = {i.get("code"): i for i in items}
check("每条权限码都带中文名与适用端",
all(i.get("label") and i.get("platform") for i in items))
check("⭐ 平台级权限被标出来(tenant:manage → platformOnly=true)",
by_code.get("tenant:manage", {}).get("platformOnly") is True)
check("role:manage 不是平台级(学校管理员可以授予)",
by_code.get("role:manage", {}).get("platformOnly") is False)
# ══════════════ 2. 内置角色:只读 + 权限来自代码 ══════════════
print("\n[2] 内置角色:看得见、改不了")
roles = body_of("GET", "/api/v1/roles", at).get("data") or []
check("角色列表可读(内置 4 个 + 可能的自定义角色)", len(roles) >= 4, f"实际 {len(roles)} 个")
check("⭐ 内置角色一律 editable=false(权限由代码决定)",
all(r.get("editable") is False for r in roles if r.get("roleType") == 1),
json.dumps([(r.get("roleCode"), r.get("editable")) for r in roles],
ensure_ascii=False)[:220])
sa = role_by_code(at, "SCHOOL_ADMIN")
check("内置角色的权限码取代码定义(含 role:manage / task:manage)",
"role:manage" in (sa.get("permCodes") or []) and "task:manage" in (sa.get("permCodes") or []),
json.dumps(sa, ensure_ascii=False)[:220])
check("内置角色的菜单取代码定义(含 role 菜单)",
"role" in (sa.get("menus") or []))
check("内置角色带 userCount(删除守卫与界面都要用)",
isinstance(sa.get("userCount"), int) and sa.get("userCount") >= 1,
f"userCount={sa.get('userCount')}")
check("⭐ 可分配的内置角色 assignable=true(学校管理员能发给账号)",
sa.get("assignable") is True, f"assignable={sa.get('assignable')}")
# ⚠️ demo 租户里有一行历史遗留的 PLATFORM_ADMIN(早于"平台角色只发平台租户"那次修正)。
# 它正好是这条断言的样本:**看得见、发不出去** —— 界面据此不显示它。
pa = role_by_code(at, "PLATFORM_ADMIN")
if pa:
check("⭐ 学校租户里的平台运营角色 assignable=false(看得见但发不出去)",
pa.get("assignable") is False, f"assignable={pa.get('assignable')}")
else:
check("学校租户里没有平台运营角色(也没问题)", True)
# ══════════════ 3. 建自定义角色:服务端校验 ══════════════
print("\n[3] 建自定义角色(含服务端白名单校验)")
b = create_role(at, role_a_code, "E2E 年级审核员", 3,
["task:view", "material:manage", "progress:viewOwn"],
["dashboard", "task"])
check("自定义角色创建成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
role_a = role_by_code(at, role_a_code)
check("列表里能看到它,且 editable=true", role_a.get("editable") is True)
check("权限码原样保存", sorted(role_a.get("permCodes") or [])
== sorted(["task:view", "material:manage", "progress:viewOwn"]))
check("菜单原样保存", sorted(role_a.get("menus") or []) == sorted(["dashboard", "task"]))
check("userType 落库(账号的用户类型由它推导)", role_a.get("userType") == 3)
check("新建角色默认 dataScope=4 仅本人(最保守)", role_a.get("dataScope") == 4)
check("新建角色 userCount=0", role_a.get("userCount") == 0)
check("角色码重复 → 40001",
create_role(at, role_a_code, "重名", 3, ["task:view"], ["task"]).get("code") == 40001)
check("⭐ 占用内置角色码 → 40001(不能造一个假 SCHOOL_ADMIN)",
create_role(at, "SCHOOL_ADMIN", "假管理员", 2,
["task:view"], ["task"]).get("code") == 40001)
check("角色码格式不合法(小写)→ 40001",
create_role(at, f"e2e_bad_{suffix}", "小写码", 3,
["task:view"], ["task"]).get("code") == 40001)
check("⭐ 学校租户授平台级权限 → 40301",
create_role(at, f"E2E_ESC_{suffix.upper()}", "越权角色", 2,
["tenant:manage"], ["tenant"]).get("code") == 40301,
json.dumps(create_role(at, f"E2E_ESC2_{suffix.upper()}", "越权角色2", 2,
["tenant:manage"], ["tenant"]), ensure_ascii=False)[:200])
check("不存在的权限码 → 40001",
create_role(at, f"E2E_P_{suffix.upper()}", "坏权限", 3,
["no:such:perm"], ["task"]).get("code") == 40001)
check("不存在的菜单键 → 40001",
create_role(at, f"E2E_M_{suffix.upper()}", "坏菜单", 3,
["task:view"], ["no-such-menu"]).get("code") == 40001)
check("⭐ 学校租户配平台专用菜单(租户管理)→ 40301",
create_role(at, f"E2E_MT_{suffix.upper()}", "平台菜单", 3,
["task:view"], ["tenant"]).get("code") == 40301)
check("⭐ 学校租户建「平台运营」类角色 → 40301",
create_role(at, f"E2E_U1_{suffix.upper()}", "平台类角色", 1,
["task:view"], ["task"]).get("code") == 40301)
# ══════════════ 4. 发出去 + 权限真的在挡 ══════════════
print("\n[4] 把自定义角色发给账号,验证权限码真的在挡")
phone_a = "139" + "".join(c for c in suffix if c.isdigit()).ljust(8, "0")[:8]
b = create_user(at, user_a, "E2E角色甲", phone_a, [role_a_code], 3)
check("给账号发自定义角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
uid_a = (b.get("data") or {}).get("id")
pwd_a = (b.get("data") or {}).get("initialPassword")
check("新增账号返回初始密码", bool(pwd_a))
check("⭐ 用户类型与角色不符 → 40001(角色说 3,传 4)",
create_user(at, f"{user_a}_x", "E2E角色甲x", "13900000001",
[role_a_code], 4).get("code") == 40001)
check("⭐ 学校租户发 PLATFORM_ADMIN → 40301(原有防线未回归)",
create_user(at, f"{user_a}_p", "E2E越权", "13900000002",
["PLATFORM_ADMIN"], 1).get("code") == 40301)
ta, prof_a = login(user_a, pwd_a)
check("自定义角色账号能登录", bool(ta), json.dumps(prof_a, ensure_ascii=False)[:200])
check("⭐ 登录响应里就带着自定义角色的权限(否则要刷新一次才看得见按钮)",
"task:view" in (prof_a.get("permissions") or []),
json.dumps(prof_a.get("permissions"), ensure_ascii=False)[:220])
check("登录响应里带着自定义角色的菜单",
"task" in (prof_a.get("menus") or []) and "role" not in (prof_a.get("menus") or []),
json.dumps(prof_a.get("menus"), ensure_ascii=False)[:220])
b = body_of("GET", "/api/v1/auth/me", ta)
check("/auth/me 的 permissions 与登录响应一致",
"task:view" in (b.get("data", {}).get("permissions") or []),
json.dumps(b, ensure_ascii=False)[:220])
b = body_of("GET", "/api/v1/tasks", ta)
check("有 task:view → 任务列表可读", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
b = body_of("POST", "/api/v1/tasks", ta, {
"taskName": "不该建出来", "batchNo": "202610", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"})
check("⭐ 没有 task:manage → 建任务被 40301 拦住",
b.get("code") == 40301, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("GET", "/api/v1/roles", ta)
check("⭐ 没有 role:manage / user:view → 读角色列表 40301",
b.get("code") == 40301, f"code={b.get('code')}")
# ══════════════ 5. 改授权:立即生效(同一个 token)══════════════
print("\n[5] 改授权立即生效(不需要重新登录)")
b = body_of("PUT", f"/api/v1/roles/{role_a['id']}/permissions", at,
{"permCodes": ["roster:view"]})
check("管理员改角色权限成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
b = body_of("GET", "/api/v1/tasks", ta)
check("⭐ 用**同一个 token** 立刻失去 task:view(40301,不必重新登录)",
b.get("code") == 40301, f"code={b.get('code')}")
b = body_of("GET", "/api/v1/auth/me", ta)
check("同一 token 的 /auth/me 权限同步变化",
"task:view" not in (b.get("data", {}).get("permissions") or []),
json.dumps(b.get("data", {}).get("permissions"), ensure_ascii=False)[:200])
body_of("PUT", f"/api/v1/roles/{role_a['id']}/permissions", at,
{"permCodes": ["task:view", "material:manage"]})
b = body_of("GET", "/api/v1/tasks", ta)
check("权限加回来 → 立刻又能读(缓存双向都生效)", b.get("code") == 0, f"code={b.get('code')}")
# ══════════════ 6. 菜单随角色走 ══════════════
print("\n[6] 菜单跟着角色配置走")
body_of("PUT", f"/api/v1/roles/{role_a['id']}/menus", at, {"menus": ["dashboard", "export"]})
menus = (body_of("GET", "/api/v1/auth/me", ta).get("data", {}) or {}).get("menus") or []
check("⭐ 菜单改成 export → /auth/me 里出现 export、task 消失",
"export" in menus and "task" not in menus, json.dumps(menus, ensure_ascii=False)[:200])
body_of("PUT", f"/api/v1/roles/{role_a['id']}/menus", at, {"menus": ["dashboard", "task"]})
menus = (body_of("GET", "/api/v1/auth/me", ta).get("data", {}) or {}).get("menus") or []
check("菜单改回来 → task 回来", "task" in menus)
# ══════════════ 7. 停用角色立即生效 ══════════════
print("\n[7] 停用角色:立刻不再给权限")
b = body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=0", at)
check("停用成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:160])
b = body_of("GET", "/api/v1/tasks", ta)
check("⭐ 同一个 token 立刻失去权限(40301)", b.get("code") == 40301, f"code={b.get('code')}")
body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=1", at)
check("重新启用 → 权限回来",
body_of("GET", "/api/v1/tasks", ta).get("code") == 0)
check("非法状态值 → 40001",
body_of("PUT", f"/api/v1/roles/{role_a['id']}/status?status=9", at).get("code") == 40001)
# ══════════════ 8. 内置角色不可改 ══════════════
print("\n[8] 内置角色:改 / 删 / 授权 / 停用 一律拒绝")
sid = sa["id"]
check("改内置角色 → 40001",
body_of("PUT", f"/api/v1/roles/{sid}", at,
{"roleName": "改个名", "userType": 2}).get("code") == 40001)
check("改内置角色授权 → 40001",
body_of("PUT", f"/api/v1/roles/{sid}/permissions", at,
{"permCodes": ["task:view"]}).get("code") == 40001)
check("改内置角色菜单 → 40001",
body_of("PUT", f"/api/v1/roles/{sid}/menus", at,
{"menus": ["task"]}).get("code") == 40001)
check("停用内置角色 → 40001",
body_of("PUT", f"/api/v1/roles/{sid}/status?status=0", at).get("code") == 40001)
check("删内置角色 → 40001",
body_of("DELETE", f"/api/v1/roles/{sid}", at).get("code") == 40001)
check("改一个不存在的角色 → 40001",
body_of("PUT", "/api/v1/roles/99999999", at,
{"roleName": "x", "userType": 3}).get("code") == 40001)
# ══════════════ 9. 账号列表里的角色名(读库)══════════════
print("\n[9] 账号列表的角色名读库(自定义角色的名字不在代码里)")
b = body_of("GET", f"/api/v1/users?keyword={user_a}", at)
row = next((u for u in (b.get("data", {}).get("list") or []) if u.get("id") == uid_a), {})
check("⭐ 自定义角色显示的是库里的角色名",
row.get("roleNames") == ["E2E 年级审核员"],
json.dumps(row.get("roleNames"), ensure_ascii=False)[:200])
# ══════════════ 10. 删除守卫 ══════════════
print("\n[10] 删除守卫:还有账号在用就删不掉")
b = body_of("DELETE", f"/api/v1/roles/{role_a['id']}", at)
check("⭐ 还有账号在用 → 40045", b.get("code") == 40045,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/users/{uid_a}", at, {
"realName": "E2E角色甲", "phone": phone_a,
"roleCodes": ["HEAD_TEACHER"], "userType": 3})
check("把该账号换成内置角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
b = body_of("DELETE", f"/api/v1/roles/{role_a['id']}", at)
check("解绑后可以删除", b.get("code") == 0, f"code={b.get('code')} msg={b.get('msg')}")
check("删除后列表里不再有它", not role_by_code(at, role_a_code))
ta2, _ = login(user_a, pwd_a)
perms = (body_of("GET", "/api/v1/auth/me", ta2).get("data", {}) or {}).get("permissions") or []
# ⚠️ 判据要选**只有自定义角色才有**的那条(material:manage)。
# 第一版断言的是 roster:view / audit:first —— 而 HEAD_TEACHER 本来就有这两条,
# 于是"换角色成功"被读成了"权限没清掉"(断言写错,不是功能错)。
check("⭐ 换角色后重新登录:自定义角色的权限没了、新角色的权限在",
"material:manage" not in perms and "task:view" in perms,
json.dumps(perms, ensure_ascii=False)[:220])
# ══════════════ 11. 防自锁 ══════════════
print("\n[11] 防自锁:不能把自己管理角色的最后一条路去掉")
b = create_role(at, role_b_code, "E2E 角色管理员", 2,
["role:manage", "user:manage", "user:view"],
["dashboard", "role", "user"])
check("建「角色管理员」角色成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
role_b = role_by_code(at, role_b_code)
b = create_user(at, user_b, "E2E角色乙", "13900000003", [role_b_code], 2)
check("发给账号成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
uid_b = (b.get("data") or {}).get("id")
pwd_b = (b.get("data") or {}).get("initialPassword")
tb, prof_b = login(user_b, pwd_b)
check("该账号能登录,且带着 role:manage",
"role:manage" in (prof_b.get("permissions") or []),
json.dumps(prof_b.get("permissions"), ensure_ascii=False)[:220])
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/permissions", tb,
{"permCodes": ["user:manage", "user:view"]})
check("⭐ 去掉自己唯一的 role:manage → 40001(防自锁)", b.get("code") == 40001,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/menus", tb,
{"menus": ["dashboard", "user"]})
check("⭐ 去掉自己唯一的 role 菜单 → 40001(否则菜单里再也点不到本页)",
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/status?status=0", tb)
check("⭐ 停用自己唯一的角色 → 40001", b.get("code") == 40001,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/users/{uid_b}", tb, {
"realName": "E2E角色乙", "phone": "13900000003",
"roleCodes": ["HEAD_TEACHER"], "userType": 3})
check("⭐ 把自己换成普通角色(会丢掉账号管理)→ 40001",
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}/permissions", tb,
{"permCodes": ["role:manage", "user:manage", "user:view", "task:view"]})
check("加权限(不减少)不受防自锁影响", b.get("code") == 0,
json.dumps(b, ensure_ascii=False)[:200])
b = body_of("PUT", f"/api/v1/users/{uid_b}", tb, {
"realName": "E2E角色乙", "phone": "13900000003",
"roleCodes": [role_b_code], "userType": 2})
check("改自己的其它字段(角色不变)不受影响", b.get("code") == 0,
json.dumps(b, ensure_ascii=False)[:200])
# ══════════════ 12. 内置角色授权同步(跨租户定时任务)══════════════
print("\n[12] 内置角色的权限以代码为准 + 每日同步回填")
role_id_sa = sa["id"]
R.sql(f"delete from sys_role_permission where role_id={role_id_sa} and perm_code='task:clone';")
check("夹具:库里那一行已删掉",
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
f"and perm_code='task:clone' and deleted=0;").strip() == "0")
sa2 = role_by_code(at, "SCHOOL_ADMIN")
check("⭐ 接口返回的仍是代码那一套(库里缺了也不影响显示与判定)",
"task:clone" in (sa2.get("permCodes") or []))
_, ops = R.login(BASE, "platform", "Platform@123", None)
ops_token = (ops.get("data") or {}).get("token")
check("平台运营账号可登录(同步任务是运维动作)", bool(ops_token))
b = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "role"})
check("手工触发「内置角色授权同步」", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
check("⭐ 同步后库里那行回来了",
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
f"and perm_code='task:clone' and deleted=0;").strip() == "1")
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "role"})
check("⭐ 再跑一次不产生重复行(幂等)",
sql(f"select count(*) from sys_role_permission where role_id={role_id_sa} "
f"and perm_code='task:clone' and deleted=0;").strip() == "1")
# ══════════════ 13. 越权:跨租户拿不到别人的角色 ══════════════
print("\n[13] 跨租户防线")
b = body_of("PUT", f"/api/v1/roles/{role_b['id']}", ops_token,
{"roleName": "平台改名", "userType": 2})
check("⭐ 平台运营不选学校就改学校的角色 → 40001(必须先选定学校)",
b.get("code") == 40001, f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("GET", "/api/v1/roles", None)
check("未登录读角色列表 → 40101", b.get("code") == 40101, f"code={b.get('code')}")
cleanup()
print("\n" + "=" * 82)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 82)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
sys.stdout.flush()
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())
-406
View File
@@ -1,406 +0,0 @@
# -*- coding: utf-8 -*-
"""B1 名单加入申请 E2E(FR-509,《03-接口设计》§8.7)。
它验的是这条通道的**四条边界**(都是刻意的设计,不是"随手加的校验"):
1. **不绕过门禁**:申请期间学生**仍然不能填报** —— 这条最容易被实现成
"提交申请就能填",那样门禁就变成形式主义,整套"凭证件号认人"的根基没了。
2. **已在名册 → 40039 让他回去重新核验**,而不是受理申请(他只是姓名输错了)。
3. **每人每任务最多 2 次**(40037):没有上限的话,一个人能刷出几十条待办。
4. **通过即自动绑定**:学生返回重新核验就能直接进,不用再输一遍姓名与证件号。
跑法(前提:后端 8080 在跑、xt-pg 在跑;脚本自己摆数据、自己清理):
python scripts/e2e-m2-join.py
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
def _load(module_name: str, filename: str):
"""复用既有 E2E 的工具与夹具,不复制第二份(两份必然分叉)。"""
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
BASE = "http://localhost:8080"
CREATED_TASK_IDS: list[int] = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-join-roster.xlsx")
# 名册里的那个人(老师已导入)
IN_ROSTER_NAME = "曾伊豪"
IN_ROSTER_ID = R.ID_A
# 不在名册里的人(要申请加入)——身份证号用校验位合法的
OUT_NAME = "新来同学"
OUT_ID = R.with_checksum("36102920090101005")
OUT_PHONE = "13900139009"
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> str:
okk, out = R.sql(stmt)
if not okk:
fail("SQL 执行失败", f"{stmt[:80]} → {out}")
return out
def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict:
_, b = R.call(method, f"{BASE}{path}", token, payload)
return b
def ensure_student(uid: int) -> str:
"""
准备一个"有 openid 的学生",返回他的 token。
为什么要自己建账号:这条通道按 **openid** 限次与去重(契约 §8.7.1),
而"从微信进来的人才有 openid"是产品的真实前提。用 SQL 直接造出带 open_id 的账号,
是把"微信授权"这一步(需要真公众号,E2E 走不了)替换成等价的前置数据 ——
**其余全走真实接口**。`ON CONFLICT` 让脚本可以重复跑。
"""
openid = f"e2e-join-{uid}"
R.sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status)
VALUES ({uid}, 1, 'join{uid}', '加入申请学生{uid}', '{openid}', 4, 1)
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, status = 1
""")
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def main() -> int:
ap = argparse.ArgumentParser(description="B1 名单加入申请 E2E")
ap.parse_args()
print("=" * 74)
print(" B1 名单加入申请 E2E(FR-509)")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(f" ✗ 管理员登录失败:{adm}")
return 2
at = adm["data"]["token"]
cfg = R.read_dev_config()
# ── 0. 摆数据:一个收集中的任务 + 名册里一个人 ──
print("\n[0] 摆夹具:任务 + 名册(一个人)+ 一个不在名册的学生")
suffix = os.urandom(3).hex()
_, b = R.call("POST", f"{BASE}/api/v1/tasks", at, {
"taskName": f"E2E加入申请 {suffix}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
if b.get("code") != 0:
print(f" ✗ 建任务失败:{b}")
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
_, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
if not classes:
print(" ✗ demo 租户里没有班级")
return 2
org = classes[0]
_, b = R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {
"orgs": [{"orgId": org["id"], "expectedCount": 2}]
})
if b.get("code") != 0:
print(f" ✗ 配班级失败:{b}")
return 2
_, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
if b.get("code") != 0:
print(f" ✗ 应用材料模板失败:{b}")
return 2
R.build_fixture(TMP_ROSTER)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200])
_, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
check("任务发布成功(收集中)", b.get("code") == 0,
json.dumps(b, ensure_ascii=False)[:200])
out_tok = ensure_student(993301)
# ── 1. 门禁失败:不在名册里的人进不去 ──
print("\n[1] 不在名册的人:门禁必须拦住(这是产品根基)")
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", out_tok,
{"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID})
check("门禁不通过(提示信息统一、不透露名册里有什么)", b.get("code") != 0,
json.dumps(b, ensure_ascii=False)[:200])
check("失败提示里**没有**列出名册中的任何姓名",
IN_ROSTER_NAME not in json.dumps(b, ensure_ascii=False),
json.dumps(b, ensure_ascii=False)[:200])
# ── 2. 提交申请 ──
print("\n[2] 提交加入申请")
b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, {
"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID,
"phone": OUT_PHONE, "orgHint": org["orgName"], "reason": "我是转班过来的"
})
if b.get("code") != 0:
# 学生账号可能没有 open_id(token 直连不会自动建号)→ 明确告知原因并跳过
check("提交申请(需要该学生账号有 open_id)", False,
f"code={b.get('code')} msg={b.get('msg')}")
cleanup()
return 1
req_id = b["data"]["requestId"]
check("返回 requestId", isinstance(req_id, int), f"{req_id}")
check("状态为待审(0)", b["data"]["status"] == 0, f"{b['data']}")
check("提示语说清是「等待学校审核」", "审核" in (b["data"].get("statusText") or ""),
f"{b['data'].get('statusText')}")
check("告知还剩几次机会(remainAttempts=1)", b["data"].get("remainAttempts") == 1,
f"{b['data'].get('remainAttempts')}")
check("库里证件号是 HMAC(不是明文)",
sql(f"select length(id_card_hash) from collect_join_request where id={req_id};").strip()
== "64")
check("库里**没有**证件号明文列(只有 hash + mask)",
sql(f"select id_card_mask from collect_join_request where id={req_id};").strip()
.replace("*", "").isdigit()
or "*" in sql(f"select id_card_mask from collect_join_request where id={req_id};"))
check("手机号也只存 hash + 脱敏值",
sql(f"select phone_mask from collect_join_request where id={req_id};").strip()
== "139****9009",
sql(f"select phone_mask from collect_join_request where id={req_id};"))
check("记录了申请人 uid 与 openid(通过时要用来绑定)",
sql(f"select applicant_uid from collect_join_request where id={req_id};").strip()
== "993301")
# ── 3. 申请期间:**仍然不能填报**(这条最容易被做错)──
print("\n[3] 申请期间不能填报(不绕过门禁)")
b = body_of("POST", "/api/v1/mp/submissions", out_tok, {"taskId": task_id})
check("提交申请**不等于**能建填报单", b.get("code") != 0,
f"code={b.get('code')} msg={b.get('msg')}")
# ── 4. 重复申请与已在名册 ──
print("\n[4] 重复申请 / 已在名册")
b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, {
"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID, "phone": OUT_PHONE
})
check("同证件号已有待审申请 → 40038", b.get("code") == 40038,
f"code={b.get('code')} msg={b.get('msg')}")
in_tok = ensure_student(993302)
b = body_of("POST", "/api/v1/mp/roster/join-request", in_tok, {
"taskId": task_id, "realName": IN_ROSTER_NAME, "idCardNo": IN_ROSTER_ID,
"phone": "13900139008"
})
check("证件号已在名册 → 40039(引导他回去重新核验)", b.get("code") == 40039,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("POST", "/api/v1/mp/roster/join-request", out_tok, {
"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID, "phone": "139"
})
check("手机号不合法 → 40001(老师要能联系到他)", b.get("code") == 40001,
f"code={b.get('code')} msg={b.get('msg')}")
# ── 5. 管理端待办 ──
print("\n[5] 管理端待办列表")
b = body_of("GET", f"/api/v1/roster/join-requests?taskId={task_id}&status=0", at)
check("待办列表可用", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
d = b.get("data") or {}
row = next((x for x in (d.get("list") or []) if x.get("requestId") == req_id), None)
check("列表里有这条申请", row is not None, json.dumps(d, ensure_ascii=False)[:300])
if row:
check("列表里证件号是脱敏的(不含 18 位明文)",
"*" in (row.get("idCardMask") or "") and len(row.get("idCardMask") or "") <= 18,
f"{row.get('idCardMask')}")
check("列表里手机号是脱敏的", "*" in (row.get("phoneMask") or ""),
f"{row.get('phoneMask')}")
check("列表带等待时长", row.get("waitHours") is not None, f"{row.get('waitHours')}")
check("列表带自述班级与申请说明",
row.get("orgHint") == org["orgName"] and row.get("reason") == "我是转班过来的",
json.dumps(row, ensure_ascii=False)[:200])
check("counts 里有三个计数", (d.get("counts") or {}).get("pending", 0) >= 1,
json.dumps(d.get("counts"), ensure_ascii=False))
# 权限:学生不能看管理端列表
b = body_of("GET", f"/api/v1/roster/join-requests?taskId={task_id}", out_tok)
check("学生调管理端接口 → 40301 或 403",
b.get("code") in (40301, 403) or b.get("_http") == 403,
f"code={b.get('code')} http={b.get('_http')}")
# ── 6. 审批通过 ──
print("\n[6] 审批通过(建名册 + 自动绑定)")
b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at,
{"orgId": org["id"]})
check("通过成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:300])
av = b.get("data") or {}
roster_id = av.get("rosterId")
check("返回新建的名册项 id", isinstance(roster_id, int), f"{roster_id}")
check("rosterCreated=true", av.get("rosterCreated") is True, f"{av}")
check("canFillNow=true(已绑定,学生重进就能填)", av.get("canFillNow") is True, f"{av}")
check("notifySent=false(通知渠道未实现,不谎报)", av.get("notifySent") is False, f"{av}")
check("名册里真的多了一行,且已绑定到申请人",
sql(f"select bind_user_id from collect_roster where id={roster_id};").strip() == "993301",
sql(f"select bind_user_id, bind_type from collect_roster where id={roster_id};"))
check("bind_type=3(来自申请)",
sql(f"select bind_type from collect_roster where id={roster_id};").strip() == "3")
check("名册行的班级是管理员指定的那个",
sql(f"select org_id from collect_roster where id={roster_id};").strip() == str(org["id"]))
check("名册行留了来源痕迹(ext_json.source=JOIN_REQUEST)",
"JOIN_REQUEST" in sql(f"select ext_json from collect_roster where id={roster_id};"))
check("申请单回写了 roster_id 与审批人",
sql(f"select roster_id from collect_join_request where id={req_id};").strip()
== str(roster_id))
# ⭐ 通过之后:门禁必须放行(自动绑定的效果)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", out_tok,
{"taskId": task_id, "realName": OUT_NAME, "idCardNo": OUT_ID})
check("⭐ 通过后门禁放行(自动绑定生效)", b.get("code") == 0,
json.dumps(b, ensure_ascii=False)[:200])
b = body_of("POST", "/api/v1/mp/submissions", out_tok, {"taskId": task_id})
check("⭐ 通过后能建填报单了(申请期间不行)", b.get("code") == 0,
json.dumps(b, ensure_ascii=False)[:200])
# ── 7. 幂等与状态边界 ──
print("\n[7] 幂等与状态边界")
b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at, {"orgId": org["id"]})
check("重复通过 → 40040(不是再建一条名册)", b.get("code") == 40040,
f"code={b.get('code')} msg={b.get('msg')}")
# ⚠️ 不能写死总行数:夹具本身导入了 3 个人(曾伊豪/赵六/钱七),
# 加上审批新建的 1 个 = 4。写死"==2"会让断言因为**夹具**而红。
# 这里验的是真正要守的两件事:申请人只有一行、重复通过不再新建行。
check("申请人在名册里**只有一行**(重复通过没再建一条)",
sql(f"select count(*) from collect_roster where task_id={task_id} "
f"and bind_user_id=993301 and deleted=0;").strip() == "1",
sql(f"select count(*) from collect_roster where task_id={task_id} "
f"and bind_user_id=993301 and deleted=0;"))
check("名册总行数 = 夹具 3 人 + 审批新建 1 人",
sql(f"select count(*) from collect_roster where task_id={task_id} and deleted=0;").strip()
== "4",
sql(f"select real_name from collect_roster where task_id={task_id} and deleted=0;")
.replace("\n", " | "))
b = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/reject", at, {"reason": "信息不符"})
check("已通过再驳回 → 40042(要撤销请删名册项)", b.get("code") == 40042,
f"code={b.get('code')} msg={b.get('msg')}")
# ── 8. 驳回路径 ──
print("\n[8] 驳回路径(原因必填)")
other_tok = ensure_student(993303)
other_name, other_id = "再来同学", R.with_checksum("36102920090202006")
b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, {
"taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007"
})
check("第二个学生也能申请", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
req2 = (b.get("data") or {}).get("requestId")
b = body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at, {})
check("驳回不填原因 → 40001(原因要给学生看)", b.get("code") == 40001,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at,
{"reason": "你不是本校学生", "reasonCode": "NOT_THIS_SCHOOL"})
check("驳回成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
check("驳回后状态=2", sql(f"select status from collect_join_request where id={req2};").strip()
== "2")
check("驳回原因落库(学生要能看到)",
"NOT_THIS_SCHOOL" in sql(f"select reject_reason from collect_join_request where id={req2};"))
b = body_of("GET", f"/api/v1/mp/roster/join-request?taskId={task_id}", other_tok)
mine = (b.get("data") or {}).get("list") or []
check("学生能看到自己的申请与驳回原因",
any(x.get("requestId") == req2 and x.get("rejectReason") for x in mine),
json.dumps(mine, ensure_ascii=False)[:300])
# ── 9. 次数上限 ──
print("\n[9] 申请次数上限(每人每任务 ≤2)")
b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, {
"taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007"
})
check("被驳回后可以再申请一次(remainAttempts=0)",
b.get("code") == 0 and (b.get("data") or {}).get("remainAttempts") == 0,
json.dumps(b, ensure_ascii=False)[:200])
b = body_of("POST", "/api/v1/mp/roster/join-request", other_tok, {
"taskId": task_id, "realName": other_name, "idCardNo": other_id, "phone": "13900139007"
})
check("第 3 次 → 40037(上限就是上限)", b.get("code") == 40037,
f"code={b.get('code')} msg={b.get('msg')}")
# ── 10. 任务状态边界 ──
print("\n[10] 任务不在收集期时不受理")
_, b = R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/close", at, {})
check("任务已截止", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150])
tok4 = ensure_student(993304)
b = body_of("POST", "/api/v1/mp/roster/join-request", tok4, {
"taskId": task_id, "realName": "截止后同学",
"idCardNo": R.with_checksum("36102920090303007"), "phone": "13900139006"
})
check("已截止 → 不受理(40008)", b.get("code") == 40008,
f"code={b.get('code')} msg={b.get('msg')}")
cleanup()
print("\n" + "=" * 74)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 74)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
return 1 if FAILED else 0
def cleanup() -> None:
for tid in CREATED_TASK_IDS:
R.sql(f"delete from collect_join_request where task_id={tid};")
R.sql(f"delete from collect_audit_record where submission_id in "
f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_file where submission_id in "
f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_submission where task_id={tid};")
R.sql(f"delete from collect_roster where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};")
R.sql("delete from collect_join_request where applicant_uid in (993301, 993302, 993303, 993304);")
R.sql("delete from collect_roster where bind_user_id in (993301, 993302, 993303, 993304);")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:加入申请 / 名册 / 任务 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())
-1
View File
@@ -320,7 +320,6 @@ def cleanup() -> None:
f"(select id from collect_submission where task_id={tid});") f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_submission where task_id={tid};") R.sql(f"delete from collect_submission where task_id={tid};")
R.sql(f"delete from collect_roster where task_id={tid};") R.sql(f"delete from collect_roster where task_id={tid};")
R.sql(f"delete from collect_join_request where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};") R.sql(f"delete from collect_task where id={tid};")
if os.path.exists(TMP_ROSTER): if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER) os.remove(TMP_ROSTER)
+68 -79
View File
@@ -8,7 +8,7 @@
|---|---|---| |---|---|---|
| F-101 | 「通知模板」页能改文案,但**只有手工发送走模板**;驳回/通过/催报这些自动通知把文案硬编码在 Java 里 | 改掉「驳回通知」→ 触发一次真驳回 → 断言学生收到的是**改过的那句** | | F-101 | 「通知模板」页能改文案,但**只有手工发送走模板**;驳回/通过/催报这些自动通知把文案硬编码在 Java 里 | 改掉「驳回通知」→ 触发一次真驳回 → 断言学生收到的是**改过的那句** |
| F-101b | 模板停用开关对自动通知没有任何作用 | 停用模板 → 触发 → 断言**一条都没发**,且接口如实回 `notifySent=false` | | F-101b | 模板停用开关对自动通知没有任何作用 | 停用模板 → 触发 → 断言**一条都没发**,且接口如实回 `notifySent=false` |
| F-102 | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现") | 学生申请 → 断言班主任收到;审批通过/驳回 → 断言学生收到 | | F-102 | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现") | **通道已删除**(2026-10 需求收敛):断言两张表与两个角色都不在了 |
| F-103 | 导出完成后不通知(异步打包,用户只能守着进度条) | 建导出 → 等完成 → 断言发起人收到,且**正文里没有签名 URL** | | F-103 | 导出完成后不通知(异步打包,用户只能守着进度条) | 建导出 → 等完成 → 断言发起人收到,且**正文里没有签名 URL** |
| F-104 | 自动催报只有 7/3/1 天,**缺"截止当日"**(FR-1003 原文有) | 截止日=今天的任务 → 手工触发催报 → 断言未提交的人收到、已提交的人没收到 | | F-104 | 自动催报只有 7/3/1 天,**缺"截止当日"**(FR-1003 原文有) | 截止日=今天的任务 → 手工触发催报 → 断言未提交的人收到、已提交的人没收到 |
| F-105 | 驳回达上限只写一行日志,而契约说"同时通知管理员" | 连驳 3 次 → 断言管理员收到升级通知 | | F-105 | 驳回达上限只写一行日志,而契约说"同时通知管理员" | 连驳 3 次 → 断言管理员收到升级通知 |
@@ -38,7 +38,6 @@ TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-wiring-roster.xlsx")
T1 = 993801 # 班主任(初审人) T1 = 993801 # 班主任(初审人)
S1, S2, S3 = 993811, 993812, 993813 # 三个学生(各自不同用途) S1, S2, S3 = 993811, 993812, 993813 # 三个学生(各自不同用途)
JOINER = 993814 # 走「申请加入名单」的学生(不在名册里)
def _load(module_name: str, filename: str): def _load(module_name: str, filename: str):
@@ -89,17 +88,34 @@ def mp(uid: int) -> str:
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4) return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def latest_msg(uid: int, biz: int) -> tuple[str, str]: def latest_msg(uid: int, biz: int, task_id: int | None = None) -> tuple[str, str]:
"""某人某类的最新一条站内信(标题, 正文)。没有就回空串。""" """某人某类的最新一条站内信(标题, 正文)。没有就回空串。
⚠️ 传 `task_id` 时按**这条消息指向的任务**收窄(`link_type='TASK'` + `link_id=taskId`,
见 `UrgeService`)。不传就是"这个人的全部该类消息"。
"""
where = f"user_id={uid} and biz_type={biz} and deleted=0"
if task_id is not None:
where += f" and link_type='TASK' and link_id={task_id}"
out = sql(f"select title || '\t' || content from collect_message " out = sql(f"select title || '\t' || content from collect_message "
f"where user_id={uid} and biz_type={biz} and deleted=0 order by id desc limit 1;") f"where {where} order by id desc limit 1;")
parts = out.strip().split("\t") parts = out.strip().split("\t")
return (parts[0], parts[1]) if len(parts) == 2 else ("", "") return (parts[0], parts[1]) if len(parts) == 2 else ("", "")
def msg_count(uid: int, biz: int) -> int: def msg_count(uid: int, biz: int, task_id: int | None = None) -> int:
v = sql(f"select count(*) from collect_message where user_id={uid} and biz_type={biz} " """某人某类站内信条数。
f"and deleted=0;").strip()
⚠️ **催报这类断言必须传 `task_id`**(踩过一次):催报任务是按"截止日在今天+N 天"扫
**全库所有任务**的,而每次跑 E2E 都会新建一个"今天截止"的任务 ——
只要上一次跑留下过一个没清干净的同截止日任务(脚本提前 return 时会跳过清理),
下一次的催报就会同时命中两个任务,此人收到 2 条,断言"== 1"随机变红。
按 `link_id`(催报消息指向的就是 taskId)收窄之后,这个断言与库里有多少残留任务无关。
"""
where = f"user_id={uid} and biz_type={biz} and deleted=0"
if task_id is not None:
where += f" and link_type='TASK' and link_id={task_id}"
v = sql(f"select count(*) from collect_message where {where};").strip()
return int(v or 0) return int(v or 0)
@@ -235,21 +251,6 @@ def main() -> int:
return 2 return 2
check("三个学生都绑定了身份", True) check("三个学生都绑定了身份", True)
# ⚠️ 加入名单申请要求申请人有 **openid**(契约:同 openid 每任务 ≤2 次,
# 没有 openid 就没法按人限次 → 服务端明确拒绝,见 JoinService.submit)。
# 门禁绑定建出来的账号没有 openid,所以这里显式补一个 —— 这是夹具,不是产品行为。
# ⚠️ 这两位**不在名册里**,也就不会被门禁绑定建号 → 必须先自己建出来再补 openid。
for uid, uname, rname in ((JOINER, "wireJoin1", "接线申请者"),
(993815, "wireJoin2", "接线被拒者")):
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id,
user_type, status, open_id)
VALUES ({uid}, 1, '{uname}', '{rname}',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org["id"]}, 4, 1,
'e2e_wire_{uid}')
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, deleted = 0, status = 1
""")
# ══════════════ F-101:改模板 → 自动驳回真的用新文案 ══════════════ # ══════════════ F-101:改模板 → 自动驳回真的用新文案 ══════════════
print("\n[1] F-101 模板可配置:自动通知是否真的走模板") print("\n[1] F-101 模板可配置:自动通知是否真的走模板")
tpl = customize_template(at, "AUDIT_REJECT") tpl = customize_template(at, "AUDIT_REJECT")
@@ -312,48 +313,41 @@ def main() -> int:
back = find_template(at, "AUDIT_REJECT") back = find_template(at, "AUDIT_REJECT")
check("恢复默认后读回内置文案", back.get("customized") is False, f"{back.get('customized')}") check("恢复默认后读回内置文案", back.get("customized") is False, f"{back.get('customized')}")
# ══════════════ F-102:加入名单申请的通知 ══════════════ # ══════════════ 需求收敛:删掉的东西必须真的不在(2026-10)══════════════
print("\n[3] F-102 加入名单申请:提交要通知老师、审批要通知学生") print("\n[3] 需求收敛:两张表、两个内置角色、两个权限码都已下线")
joiner = mp(JOINER) for tbl in ("collect_join_request", "collect_cleanup_job"):
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner, { out = sql(f"SELECT count(*) FROM information_schema.tables WHERE table_name = '{tbl}'")
"taskId": task_id, "realName": "接线申请者", "idCardNo": R.with_checksum("36102920080404044"), check(f"⚠️ 表 {tbl} 已被 V19 删除", out.strip() == "0", f"查询结果={out!r}")
"phone": "13800137888", "orgHint": org.get("orgName"), "reason": "E2E 申请"
})
check("学生提交加入申请成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
req_id = (b.get("data") or {}).get("requestId")
t_title, t_content = latest_msg(T1, 7) out = sql("SELECT count(*) FROM information_schema.columns "
check("⭐ 班主任收到了申请通知(以前一个都不发)", bool(t_title), f"title={t_title!r}") "WHERE table_name = 'collect_task_org' AND column_name = 'deputy_user_ids'")
check("通知里说清是谁、哪个班、哪个任务", check("⚠️ collect_task_org.deputy_user_ids 列已删除(协办人机制下线)",
"接线申请者" in t_content and (org.get("orgName") or "") in t_content out.strip() == "0", f"查询结果={out!r}")
and "E2E通知接线" in t_content, f"实际={t_content!r}")
a_title, _ = latest_msg(admin_uid, 7) # 学校管理员(SCHOOL_ADMIN,兜底收件人)
check("学校管理员也收到了(任务没配班主任时的兜底)", bool(a_title), f"title={a_title!r}")
before_join = msg_count(JOINER, 7) # ⚠️ 不要用 `string_agg` 拼起来比字符串:它**没有 ORDER BY**,
ap_body = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at, # 行的物理顺序会随 vacuum/重建而变,断言就会"偶尔红一次"(跑全量时才复现)。
{"orgId": org["id"]}) # 这里改成"数个数 + 点名单",与顺序无关。
check("审批通过成功", ap_body.get("code") == 0, json.dumps(ap_body, ensure_ascii=False)[:200]) out = sql("SELECT count(*) FROM sys_role WHERE deleted = 0 AND role_type = 1 AND tenant_id = 1")
check("⭐ 学生收到了审批结果(站内信 bizType=7)", check("⚠️ 内置角色只剩 4 个", out.strip() == "4", f"实际={out.strip()!r}")
msg_count(JOINER, 7) > before_join,
f"审批前={before_join} 审批后={msg_count(JOINER, 7)}")
j_title, j_content = latest_msg(JOINER, 7)
check("结果通知里带任务名,并告诉他可以开始填报了",
"E2E通知接线" in j_content and "填报" in j_content, f"实际={j_content!r}")
# 驳回那条路(换个学生,避免"每任务 2 次"的上限) out = sql("SELECT count(*) FROM sys_role WHERE deleted = 0 AND role_type = 1 AND tenant_id = 1 "
JO = 993815 "AND role_code IN ('DEPUTY_TEACHER','AUDITOR')")
joiner2 = mp(JO) check("⚠️ 内置角色里没有副班主任 / 审核员", out.strip() == "0", f"查询结果={out!r}")
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner2, {
"taskId": task_id, "realName": "接线被拒者", "idCardNo": R.with_checksum("36102920080505045"), out = sql("SELECT count(*) FROM sys_user_role ur JOIN sys_role r ON r.id = ur.role_id "
"phone": "13800137999", "orgHint": org.get("orgName"), "reason": "E2E 申请2" "WHERE ur.deleted = 0 AND r.role_code IN ('DEPUTY_TEACHER','AUDITOR')")
}) check("⚠️ 没有任何账号还挂着这两个角色", out.strip() == "0", f"查询结果={out!r}")
req2 = (b.get("data") or {}).get("requestId")
body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at, # 权限码目录:join:audit 与 cleanup:run 必须已经消失(否则「角色与授权」页还能勾到它们)
{"reasonCode": "NOT_STUDENT", "reason": "E2E 不是本校学生"}) perms = body_of("GET", "/api/v1/permissions", at)
_, rej_content = latest_msg(JO, 7) codes = set()
check("⭐ 驳回也通知学生,且把原因说清(否则他会反复重提)", for grp in (perms.get("data") or []):
"E2E 不是本校学生" in rej_content, f"实际={rej_content!r}") for it in (grp.get("items") or []):
codes.add(it.get("code"))
check("⭐ 权限码目录里不再有 join:audit / cleanup:run",
"join:audit" not in codes and "cleanup:run" not in codes,
f"实际命中={sorted(c for c in codes if c in ('join:audit', 'cleanup:run'))}")
check("⭐ 权限码总数收敛为 28", len(codes) == 28, f"实际 {len(codes)} 个")
# ══════════════ F-103:导出完成的通知 ══════════════ # ══════════════ F-103:导出完成的通知 ══════════════
print("\n[4] F-103 导出完成:通知发起人(且正文里不放签名 URL)") print("\n[4] F-103 导出完成:通知发起人(且正文里不放签名 URL)")
@@ -406,17 +400,19 @@ def main() -> int:
return 2 return 2
ops_token = ops["data"]["token"] ops_token = ops["data"]["token"]
# 丙还没提交 → 该被催;甲、乙已提交 → 不该被催 # 丙还没提交 → 该被催;甲、乙已提交 → 不该被催
# ⚠️ 计数用 `task_id` 收窄:催报任务扫的是"截止日=今天+N 天"的**全库**任务,
# 残留的同截止日任务会让计数变成 2(详见 msg_count 的注释)。
sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}) and biz_type=5;") sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}) and biz_type=5;")
run = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"}) run = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"})
check("手工触发自动催报成功", run.get("code") == 0, check("手工触发自动催报成功", run.get("code") == 0,
json.dumps(run, ensure_ascii=False)[:200]) json.dumps(run, ensure_ascii=False)[:200])
c_title, c_content = latest_msg(S3, 5) c_title, c_content = latest_msg(S3, 5, task_id)
check("⭐ 截止当日(0 天)也会催 —— 这一档以前是缺的", check("⭐ 截止当日(0 天)也会催 —— 这一档以前是缺的",
msg_count(S3, 5) == 1, f"丙的催报条数={msg_count(S3, 5)}") msg_count(S3, 5, task_id) == 1, f"丙本任务催报条数={msg_count(S3, 5, task_id)}")
check("文案说的是「今天是截止日」而不是「最后一天」(两种急迫程度不同)", check("文案说的是「今天是截止日」而不是「最后一天」(两种急迫程度不同)",
"今天是截止日" in c_content, f"实际={c_content!r}") "今天是截止日" in c_content, f"实际={c_content!r}")
check("⭐ 已提交的甲没有被催(催了会让他以为没交成功)", check("⭐ 已提交的甲没有被催(催了会让他以为没交成功)",
msg_count(S1, 5) == 0, f"甲的催报条数={msg_count(S1, 5)}") msg_count(S1, 5, task_id) == 0, f"甲本任务催报条数={msg_count(S1, 5, task_id)}")
check("催报正文里带任务名(学生一眼知道是哪件事)", check("催报正文里带任务名(学生一眼知道是哪件事)",
"E2E通知接线" in c_content, f"实际={c_content!r}") "E2E通知接线" in c_content, f"实际={c_content!r}")
check("模板变量 {提交进度} 真的被填了(白名单里有、以前永远空)", check("模板变量 {提交进度} 真的被填了(白名单里有、以前永远空)",
@@ -460,7 +456,7 @@ def main() -> int:
encoding="utf-8").read() for k in ("'dict'", "'notify'"))) encoding="utf-8").read() for k in ("'dict'", "'notify'")))
# ══════════════ F-107:权限码真的挂在接口上 ══════════════ # ══════════════ F-107:权限码真的挂在接口上 ══════════════
print("\n[8] F-107 权限码挂到接口:克隆与整理器") print("\n[8] F-107 权限码挂到接口:克隆任务走 task:clone")
cl = body_of("POST", f"/api/v1/tasks/{task_id}/clone", t1, {"taskName": "不该建出来"}) cl = body_of("POST", f"/api/v1/tasks/{task_id}/clone", t1, {"taskName": "不该建出来"})
check("⭐ 班主任(无 task:clone)克隆任务被 40301 拦住", check("⭐ 班主任(无 task:clone)克隆任务被 40301 拦住",
cl.get("code") == 40301, f"code={cl.get('code')} msg={cl.get('msg')}") cl.get("code") == 40301, f"code={cl.get('code')} msg={cl.get('msg')}")
@@ -469,9 +465,6 @@ def main() -> int:
json.dumps(cl2, ensure_ascii=False)[:200]) json.dumps(cl2, ensure_ascii=False)[:200])
if cl2.get("code") == 0: if cl2.get("code") == 0:
CREATED_TASK_IDS.append((cl2.get("data") or {}).get("id")) CREATED_TASK_IDS.append((cl2.get("data") or {}).get("id"))
cu = body_of("POST", "/api/v1/cleanup/jobs/json", t1, {"jobName": "不该建出来"})
check("⭐ 班主任(无 cleanup:run)调整理器被 40301 拦住",
cu.get("code") == 40301, f"code={cu.get('code')} msg={cu.get('msg')}")
cleanup() cleanup()
print("\n" + "=" * 78) print("\n" + "=" * 78)
@@ -499,7 +492,6 @@ def cleanup() -> None:
R.sql(f"delete from collect_file where submission_id in " R.sql(f"delete from collect_file where submission_id in "
f"(select id from collect_submission where task_id={tid});") f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_submission where task_id={tid};") R.sql(f"delete from collect_submission where task_id={tid};")
R.sql(f"delete from collect_join_request where task_id={tid};")
R.sql(f"delete from collect_roster where task_id={tid};") R.sql(f"delete from collect_roster where task_id={tid};")
R.sql(f"delete from collect_task_org where task_id={tid};") R.sql(f"delete from collect_task_org where task_id={tid};")
R.sql(f"delete from collect_material_def where task_id={tid};") R.sql(f"delete from collect_material_def where task_id={tid};")
@@ -510,17 +502,14 @@ def cleanup() -> None:
# 那会把演示态里别人改过的模板一起删掉(而"演示态不能被脚本弄坏"是这个仓库的硬约束)。 # 那会把演示态里别人改过的模板一起删掉(而"演示态不能被脚本弄坏"是这个仓库的硬约束)。
for tid in CREATED_TPL_IDS: for tid in CREATED_TPL_IDS:
R.sql(f"delete from collect_notify_template where id={tid};") R.sql(f"delete from collect_notify_template where id={tid};")
# 本脚本造的学生/申请人/班主任 # 本脚本造的学生/班主任
R.sql(f"delete from collect_message where user_id in " R.sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}, {T1});")
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});") R.sql(f"delete from collect_notify_record where receiver_id in ({S1}, {S2}, {S3}, {T1});")
R.sql(f"delete from collect_notify_record where receiver_id in " R.sql(f"delete from sys_user_role where user_id in ({S1}, {S2}, {S3}, {T1});")
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});") R.sql(f"delete from sys_user where id in ({S1}, {S2}, {S3}, {T1});")
R.sql(f"delete from sys_user_role where user_id in "
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
R.sql(f"delete from sys_user where id in ({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
if os.path.exists(TMP_ROSTER): if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER) os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 导出 / 申请 / 消息 / 通知记录 / 模板覆盖行 / 测试账号") print(" 已清理:任务 / 名册 / 填报单 / 导出 / 消息 / 通知记录 / 模板覆盖行 / 测试账号")
if __name__ == "__main__": if __name__ == "__main__":
-1
View File
@@ -301,7 +301,6 @@ def cleanup() -> None:
R.sql(f"delete from collect_roster where task_id={tid};") R.sql(f"delete from collect_roster where task_id={tid};")
R.sql(f"delete from collect_task_org where task_id={tid};") R.sql(f"delete from collect_task_org where task_id={tid};")
R.sql(f"delete from collect_material_def where task_id={tid};") R.sql(f"delete from collect_material_def where task_id={tid};")
R.sql(f"delete from collect_join_request where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};") R.sql(f"delete from collect_task where id={tid};")
for uid in CREATED_USER_IDS: for uid in CREATED_USER_IDS:
R.sql(f"delete from sys_user_role where user_id={uid};") R.sql(f"delete from sys_user_role where user_id={uid};")
-434
View File
@@ -1,434 +0,0 @@
# -*- coding: utf-8 -*-
"""历史数据整理器 E2E(《03-接口设计》§13.2)。"""
import argparse
import base64
import importlib.util
import io as _io
import json
import os
import re
import subprocess
import sys
import time
import zipfile
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED = []
FAILED = []
CREATED_TASK_IDS = []
CREATED_JOB_IDS = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-cleanup-roster.xlsx")
def _load(module_name, filename):
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
BASE = "http://localhost:8080"
S_A = ("整理甲", R.with_checksum("36102920080101071"))
S_B = ("整理乙", R.with_checksum("36102920080202072"))
S_C = ("整理丙", R.with_checksum("36102920080303073"))
WRONG_ID = R.with_checksum("44052419900101099")
def ok(name):
PASSED.append(name)
print(" [OK] " + name)
def fail(name, detail):
FAILED.append((name, detail))
print(" [FAIL] " + name + "\n " + detail)
def check(name, cond, detail=""):
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return bool(cond)
def sql(stmt):
_, out = R.sql(stmt)
return out
def body_of(method, path, token=None, payload=None):
_, b = R.call(method, BASE + path, token, payload)
return b
def wait_job(job_id, token, tries=60):
d = {}
for _ in range(tries):
d = body_of("GET", "/api/v1/cleanup/jobs/%d" % job_id, token).get("data") or {}
if d.get("status") in (3, 4):
return d
time.sleep(1)
return d
def make_zip(files):
bos = _io.BytesIO()
with zipfile.ZipFile(bos, "w", zipfile.ZIP_DEFLATED) as z:
for name, data in files.items():
z.writestr(name, data)
return bos.getvalue()
def tiny(tag):
return tag.encode("utf-8") * 40
def main():
ap = argparse.ArgumentParser(description="历史整理器 E2E")
ap.parse_args()
print("=" * 74)
print(" 历史数据整理器 E2E(§13.2)")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(" x 管理员登录失败:" + str(adm))
return 2
at = adm["data"]["token"]
print("\n[0] 摆夹具(正式任务 + 名册)")
suffix = os.urandom(3).hex()
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E整理名册 " + suffix, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
if b.get("code") != 0:
print(" x 建任务失败:" + str(b))
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
_, b = R.call("GET", BASE + "/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
org = classes[0]
R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % task_id, at,
{"orgs": [{"orgId": org["id"], "expectedCount": 3}]})
R.call("POST", BASE + "/api/v1/tasks/%d/materials/apply-template" % task_id, at,
{"templateCode": "TPL_SCHOOL_ROLL"})
_build_roster([S_A, S_B, S_C])
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("正式任务的名册就绪(3 人)", body.get("code") == 0,
json.dumps(body, ensure_ascii=False)[:200])
print("\n[1] 造一个形态真实的历史包")
cls = "24中技数控1班"
files = {
cls + "/整理甲/身份证.jpg.jpg": tiny("a1"),
cls + "/整理甲-" + S_A[1] + "/一寸照片.png": tiny("a2"),
cls + "/整理甲/学籍表(1).JPG": tiny("a3"),
cls + "/整理乙/身份证-" + WRONG_ID + ".jpg": tiny("b1"),
cls + "/整理乙/工作证明-" + WRONG_ID + ".png": tiny("b2"),
cls + "/新建文件夹/IMG_2034.jpg": tiny("c1"),
"__MACOSX/" + cls + "/._身份证.jpg": tiny("d1"),
cls + "/.DS_Store": tiny("d2"),
}
zip_bytes = make_zip(files)
check("历史包已生成", len(zip_bytes) > 100, "%d 字节" % len(zip_bytes))
print("\n[2] 解析预览(调规则时看每一步)")
zp = os.path.join(ROOT, "scripts", "_e2e-cleanup.zip")
with open(zp, "wb") as f:
f.write(zip_bytes)
r = subprocess.run(["curl", "-s", "-X", "POST",
BASE + "/api/v1/cleanup/jobs/parse-preview",
"-H", "Authorization: Bearer " + at,
"-F", "file=@" + zp + ";filename=history.zip;type=application/zip"],
capture_output=True, text=True, encoding="utf-8", timeout=120)
try:
pv = json.loads(r.stdout)
except Exception:
pv = {"code": -1, "msg": (r.stdout or "")[:200]}
check("解析预览可用", pv.get("code") == 0, json.dumps(pv, ensure_ascii=False)[:250])
plist = pv.get("data") or []
check("macOS 元数据被跳过(不然报告会虚高)", len(plist) == 6,
"%d 条:%s" % (len(plist), [x.get("path") for x in plist]))
by_path = {}
for x in plist:
by_path[x.get("path")] = x
k1 = cls + "/整理甲/身份证.jpg.jpg"
check("从目录名解析出姓名", by_path.get(k1, {}).get("studentName") == "整理甲",
json.dumps(by_path.get(k1), ensure_ascii=False))
check("解析出材料码 IDCARD", by_path.get(k1, {}).get("materialCode") == "IDCARD",
json.dumps(by_path.get(k1), ensure_ascii=False))
k2 = cls + "/整理甲-" + S_A[1] + "/一寸照片.png"
check("从「姓名-证件号」目录名里解析出号码(且校验位合法)",
by_path.get(k2, {}).get("idCard") is not None
and by_path.get(k2, {}).get("idCardValid") is True,
json.dumps(by_path.get(k2), ensure_ascii=False))
check("解析结果里的证件号是脱敏值(不能把明文回给前端)",
"*" in (by_path.get(k2, {}).get("idCard") or ""),
str(by_path.get(k2, {}).get("idCard")))
k3 = cls + "/新建文件夹/IMG_2034.jpg"
check("认不出的文件不硬塞分类(姓名与材料码都为空)",
by_path.get(k3, {}).get("studentName") is None
and by_path.get(k3, {}).get("materialCode") is None,
json.dumps(by_path.get(k3), ensure_ascii=False))
print("\n[3] 创建整理任务(带 rosterTaskId)")
c = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
"jobName": "E2E历史整理 " + suffix,
"sourceType": 1,
"rosterTaskId": task_id,
"zipBase64": base64.b64encode(zip_bytes).decode()
})
check("创建成功", c.get("code") == 0, json.dumps(c, ensure_ascii=False)[:250])
job_id = (c.get("data") or {}).get("jobId")
CREATED_JOB_IDS.append(job_id)
check("返回 jobId 与估时",
isinstance(job_id, int) and bool((c.get("data") or {}).get("estimatedSeconds")),
json.dumps(c.get("data"), ensure_ascii=False))
d = wait_job(job_id, at)
check("整理完成(status=3)", d.get("status") == 3,
"status=%s err=%s" % (d.get("status"), d.get("errorMsg")))
check("进度 100", d.get("progress") == 100, str(d.get("progress")))
check("总文件数 6(元数据已排除)", d.get("totalFiles") == 6, str(d.get("totalFiles")))
check("解析出 2 个学员", d.get("parsedStudents") == 2, str(d.get("parsedStudents")))
check("解析出材料种类 >= 3", (d.get("parsedMaterials") or 0) >= 3,
str(d.get("parsedMaterials")))
# ⚠️ 断言不能只是 `"%" in compressRatio` —— 那对 `-568.3%` 也成立,
# 而负数压缩率是**没有意义**的(包里的文件本来就压过、或总量太小,
# 重新打 zip 的包头开销比省下来的还多)。服务端对这种情况回「—」。
# 所以这里断言"要么是个非负百分比,要么是 —"。
_ratio = str(d.get("compressRatio"))
check("压缩率要么形如 12.3%(非负),要么是「—」" + "(不可是负百分比)",
_ratio == "—" or (re.fullmatch(r"\d+(\.\d+)?%", _ratio) is not None),
"compressRatio=" + _ratio)
check("结果包路径非空", bool(d.get("resultPath")), str(d.get("resultPath")))
print("\n[4] 体检报告(契约里最重要的一句话)")
rep = body_of("GET", "/api/v1/cleanup/jobs/%d/report" % job_id, at).get("data") or {}
s = rep.get("summary") or {}
check("报告可用", s.get("totalFiles") == 6, json.dumps(s, ensure_ascii=False))
check("算出 autoHandled / needManual(服务端算,不让前端减)",
rep.get("autoHandled") is not None and rep.get("needManual") is not None,
json.dumps({"autoHandled": rep.get("autoHandled"),
"needManual": rep.get("needManual")}))
check("autoHandled + needManual = 总文件数(不多不少)",
(rep.get("autoHandled") or 0) + (rep.get("needManual") or 0) == 6,
"%s + %s != 6" % (rep.get("autoHandled"), rep.get("needManual")))
check("检测出疑似串档 1 例(乙的两项材料号都不是名册里那个,且彼此一致)",
s.get("crossFileSuspected") == 1, json.dumps(s, ensure_ascii=False))
check("串档进了 highlights 且级别是 3(必须人工)",
any(h.get("type") == "CROSS_FILE" and h.get("level") == 3
for h in rep.get("highlights") or []),
json.dumps(rep.get("highlights"), ensure_ascii=False)[:300])
check("无法归位的记进 orphanImages(不硬塞分类)",
(s.get("orphanImages") or 0) >= 1, json.dumps(s, ensure_ascii=False))
check("改名样例给了(客户要看到系统到底做了什么)",
len(rep.get("nameFixedSamples") or []) >= 1,
json.dumps(rep.get("nameFixedSamples"), ensure_ascii=False)[:250])
check("改名样例里的规范名带姓名",
any("整理甲" in (x.get("fixed") or "") for x in rep.get("nameFixedSamples") or []),
json.dumps(rep.get("nameFixedSamples"), ensure_ascii=False)[:300])
conf = rep.get("idcardConflicts") or []
check("证件号冲突有明细", len(conf) >= 1, json.dumps(conf, ensure_ascii=False)[:250])
check("没有 OCR 时判定一律是 NEED_CONFIRM(不假装能自动修)",
all(x.get("verdict") == "NEED_CONFIRM" for x in conf),
json.dumps([x.get("verdict") for x in conf], ensure_ascii=False))
check("冲突明细里的证件号都是脱敏值(报告不外泄明文)",
all("*" in (x.get("fileNameValue") or "") or "*" in (x.get("rosterValue") or "")
for x in conf),
json.dumps(conf, ensure_ascii=False)[:300])
print("\n[5] 整理结果包(客户拿到能直接用)")
rp = os.path.join(ROOT, "scripts", "_e2e-cleanup-result.zip")
code = os.system('curl -sf -o "%s" -H "Authorization: Bearer %s" "%s/api/v1/cleanup/jobs/%d/result"'
% (rp, at, BASE, job_id))
size = os.path.getsize(rp) if os.path.exists(rp) else 0
check("结果包能下载", size > 100, "exit=%s size=%s" % (code, size))
if size > 100:
with zipfile.ZipFile(rp) as z:
names = z.namelist()
check("结果包是合法 ZIP", z.testzip() is None)
check("目录结构是班级/姓名/材料/规范文件名",
any(n.startswith(cls + "/整理甲/") and n.count("/") >= 3 for n in names),
str(names[:4]))
check("规范文件名已修好(不再是 .jpg.jpg 双扩展名)",
not any(n.endswith(".jpg.jpg") for n in names),
str([n for n in names if n.endswith(".jpg.jpg")][:2]))
check("结果包里带了「待归位」目录(认不出的那些)",
any("/待归位/" in n for n in names),
str([n for n in names if "待归位" in n][:2]))
os.remove(rp)
print("\n[6] 导入为正式任务(幂等)")
imp = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, {
"taskName": "E2E整理导入 " + suffix, "collectMode": 1, "batchNo": "202609"
})
check("导入成功", imp.get("code") == 0, json.dumps(imp, ensure_ascii=False)[:250])
new_task = (imp.get("data") or {}).get("taskId")
if isinstance(new_task, int):
CREATED_TASK_IDS.append(new_task)
check("返回新任务 id", isinstance(new_task, int), str(new_task))
check("名册建了 2 人(甲、乙各 1 行;丙在名册里但材料里没出现)",
(imp.get("data") or {}).get("rosterCount") == 2,
json.dumps(imp.get("data"), ensure_ascii=False))
check("新任务的名册行数与返回一致",
sql("select count(*) from collect_roster where task_id=%d and deleted=0;"
% new_task).strip() == "2",
sql("select real_name, id_card_mask from collect_roster where task_id=%d;"
% new_task))
check("名册里的证件号三列都写了(密文 + hash + 脱敏)",
sql("select count(*) from collect_roster where task_id=%d "
"and id_card_no is not null and id_card_hash is not null "
"and id_card_mask is not null;" % new_task).strip() == "2",
sql("select id_card_no is not null, id_card_hash is not null, id_card_mask "
"from collect_roster where task_id=%d;" % new_task))
check("新任务是草稿状态(让用户先核对再发布)",
sql("select status from collect_task where id=%d;" % new_task).strip() == "0",
sql("select status from collect_task where id=%d;" % new_task))
imp2 = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, {
"taskName": "E2E整理导入-第二次 " + suffix, "collectMode": 1, "batchNo": "202609"
})
check("重复导入不新建任务(客户手滑点两次是常事)",
(imp2.get("data") or {}).get("taskId") == new_task,
json.dumps(imp2, ensure_ascii=False)[:250])
check("库里没有多出第二个任务",
sql("select count(*) from collect_task where task_name like 'E2E整理导入%' "
"and deleted=0;").strip() == "1",
sql("select id, task_name from collect_task where task_name like 'E2E整理导入%';"))
print("\n[7] 做不到的要如实拒绝(不安静忽略)")
imp3 = body_of("POST", "/api/v1/cleanup/jobs/%d/import" % job_id, at, {
"taskName": "E2E不该建出来", "collectMode": 1, "batchNo": "202609", "importFiles": True
})
check("importFiles=true 明确拒绝(一期还不能自动挂文件)",
imp3.get("code") == 40044, "code=%s msg=%s" % (imp3.get("code"), imp3.get("msg")))
check("拒绝时没有偷偷建任务",
sql("select count(*) from collect_task where task_name='E2E不该建出来';").strip() == "0")
cloud = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
"jobName": "云盘链接", "sourceType": 3, "rosterTaskId": task_id,
"zipBase64": base64.b64encode(zip_bytes).decode()
})
check("sourceType=3(云盘)返回 40044,且提示改成 zip",
cloud.get("code") == 40044 and "zip" in (cloud.get("msg") or ""),
"code=%s msg=%s" % (cloud.get("code"), cloud.get("msg")))
folder = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
"jobName": "文件夹", "sourceType": 2, "rosterTaskId": task_id,
"zipBase64": base64.b64encode(zip_bytes).decode()
})
check("sourceType=2(文件夹)返回 40044", folder.get("code") == 40044,
"code=%s msg=%s" % (folder.get("code"), folder.get("msg")))
no_roster = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
"jobName": "无名单", "sourceType": 1,
"zipBase64": base64.b64encode(zip_bytes).decode()
})
check("不传 rosterTaskId 也能跑(只做文件名规范化)", no_roster.get("code") == 0,
json.dumps(no_roster, ensure_ascii=False)[:200])
if no_roster.get("code") == 0:
jid2 = no_roster["data"]["jobId"]
CREATED_JOB_IDS.append(jid2)
d2 = wait_job(jid2, at)
check("无名单时也能完成", d2.get("status") == 3, "status=%s" % d2.get("status"))
rep2 = body_of("GET", "/api/v1/cleanup/jobs/%d/report" % jid2, at).get("data") or {}
check("无名单时串档检测恒为 0(没有基准就无从比对,不是没检测出来)",
(rep2.get("summary") or {}).get("crossFileSuspected") == 0,
json.dumps(rep2.get("summary"), ensure_ascii=False))
bad = body_of("POST", "/api/v1/cleanup/jobs/json", at, {
"jobName": "坏包", "sourceType": 1, "zipBase64": base64.b64encode(b"not a zip").decode()
})
if bad.get("code") == 0:
jid3 = bad["data"]["jobId"]
CREATED_JOB_IDS.append(jid3)
d3 = wait_job(jid3, at)
check("坏包最终是「失败」且给了可读原因(不是挂在那里不动)",
d3.get("status") == 4 and bool(d3.get("errorMsg")),
"status=%s err=%s" % (d3.get("status"), d3.get("errorMsg")))
else:
check("坏包在创建时就被拒(形态在入口就拦)", bad.get("code") != 0,
json.dumps(bad, ensure_ascii=False)[:200])
print("\n[8] 列表与权限")
lst = body_of("GET", "/api/v1/cleanup/jobs?pageSize=5", at).get("data") or {}
check("列表可用", len(lst.get("list") or []) >= 1, json.dumps(lst, ensure_ascii=False)[:200])
check("列表项带状态文案", all(x.get("statusText") for x in lst.get("list") or []),
json.dumps((lst.get("list") or [])[:1], ensure_ascii=False)[:200])
cfg = R.read_dev_config()
stok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], 993801, 1, ["STUDENT"], 4)
b = body_of("GET", "/api/v1/cleanup/jobs", stok)
check("学生看整理任务返回 403", b.get("code") in (40101, 40301, 403),
"code=%s" % b.get("code"))
b = body_of("GET", "/api/v1/cleanup/jobs")
check("未登录返回 40101", b.get("code") == 40101, "code=%s" % b.get("code"))
cleanup()
print("\n" + "=" * 74)
print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED)))
print("=" * 74)
for n, det in FAILED:
print(" x " + n + "\n " + det)
return 1 if FAILED else 0
def _build_roster(rows):
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "整理班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, idc) in enumerate(rows):
vals = [i + 1, name, idc, "中专", "138001333%d" % i, "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=i + 2, column=c, value=v)
wb.save(TMP_ROSTER)
def cleanup():
for jid in CREATED_JOB_IDS:
R.sql("delete from collect_cleanup_job where id=%d;" % jid)
for tid in CREATED_TASK_IDS:
R.sql("delete from collect_roster where task_id=%d;" % tid)
R.sql("delete from collect_task_org where task_id=%d;" % tid)
R.sql("delete from collect_material_def where task_id=%d;" % tid)
R.sql("delete from collect_task where id=%d;" % tid)
R.sql("delete from collect_cleanup_job where job_name like 'E2E历史整理%' "
"or job_name in ('无名单','云盘链接','文件夹','坏包');")
R.sql("delete from collect_task where task_name like 'E2E整理导入%' "
"or task_name='E2E不该建出来';")
for p in (TMP_ROSTER, os.path.join(ROOT, "scripts", "_e2e-cleanup.zip"),
os.path.join(ROOT, "scripts", "_e2e-cleanup-result.zip")):
if os.path.exists(p):
os.remove(p)
print(" 已清理:整理任务 / 测试任务与名册 / 临时 zip")
if __name__ == "__main__":
raise SystemExit(main())
+3 -3
View File
@@ -431,7 +431,8 @@ def main():
check("待办里有本任务的单子(夹具生效)", len(todos) >= 1, check("待办里有本任务的单子(夹具生效)", len(todos) >= 1,
"共 %d 条" % len(todos)) "共 %d 条" % len(todos))
# ⚠️ 断言性质而不是数量:待办里每一条都必须是"我确实能审"的。 # ⚠️ 断言性质而不是数量:待办里每一条都必须是"我确实能审"的。
# 判据与审批**完全一致**(collect_task_org 的 head / deputy / 班级负责人) # 判据与审批**完全一致**(collect_task_org 的两级回退:head_user_id → 班级负责人)。
# 2026-10 需求收敛:删掉「副班主任/协办人」后,这里不再有 deputy_user_ids 这一支。
bad = sql(""" bad = sql("""
select count(*) from collect_submission s select count(*) from collect_submission s
where s.id in (%s) where s.id in (%s)
@@ -441,11 +442,10 @@ def main():
where cto.tenant_id = s.tenant_id and cto.task_id = s.task_id where cto.tenant_id = s.tenant_id and cto.task_id = s.task_id
and cto.org_id = s.org_id and cto.status = 1 and cto.org_id = s.org_id and cto.status = 1
and (cto.head_user_id = %d and (cto.head_user_id = %d
or cto.deputy_user_ids like '%%,%d,%%'
or (cto.head_user_id is null and so.leader_user_id = %d)) or (cto.head_user_id is null and so.leader_user_id = %d))
); );
""" % (",".join(str(x.get("submissionId") or x.get("id")) for x in todos) or "-1", """ % (",".join(str(x.get("submissionId") or x.get("id")) for x in todos) or "-1",
T1, T1, T1)).strip() T1, T1)).strip()
check("⭐ 待办里**没有**「我批不了」的单子(待办与审批同一套范围判据)", check("⭐ 待办里**没有**「我批不了」的单子(待办与审批同一套范围判据)",
bad == "0", "有 %s 条不在我的初审范围里:%s" % ( bad == "0", "有 %s 条不在我的初审范围里:%s" % (
bad, json.dumps([(x.get("submissionId"), x.get("taskName")) for x in todos], bad, json.dumps([(x.get("submissionId"), x.get("taskName")) for x in todos],
+6 -3
View File
@@ -12,7 +12,7 @@
| 文档自身一致 | `verify-docs.py` | 无 | | 文档自身一致 | `verify-docs.py` | 无 |
| 手册 ↔ 代码文案 | `check-manual.py` | 无 | | 手册 ↔ 代码文案 | `check-manual.py` | 无 |
| 接线(定义 ↔ 使用) | `check-wiring.py` | 无 | | 接线(定义 ↔ 使用) | `check-wiring.py` | 无 |
| 接口行为(1577+ 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** | | 接口行为(断言总数见下方"用法"里的实测值) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** | | 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** |
| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis | | CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis |
@@ -24,10 +24,13 @@
```bash ```bash
python scripts/verify-all.py # 全部(跳过需要外部前置的,并说明) python scripts/verify-all.py # 全部(跳过需要外部前置的,并说明)
python scripts/verify-all.py --with-e2e # 连 1548 条接口断言一起跑(需要后端在跑) python scripts/verify-all.py --with-e2e # 连 37 个脚本的接口断言一起跑(需要后端在跑)
python scripts/verify-all.py --quick # 只跑不需要任何前置的五项 python scripts/verify-all.py --quick # 只跑不需要任何前置的五项
``` ```
> 脚本数与断言总数会随需求变化(2026-10 需求收敛后是 **37 个脚本**,断言总数以本命令
> 实际输出为准 —— 别再往文档里抄一个会过期的固定数字)。
退出码:0 = 全过;1 = 有失败(**跳过不算失败**,但会明确列出来)。 退出码:0 = 全过;1 = 有失败(**跳过不算失败**,但会明确列出来)。
""" """
import argparse import argparse
@@ -127,7 +130,7 @@ def main():
results.append(("接口断言(E2E)", None, msg)) results.append(("接口断言(E2E)", None, msg))
print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg)) print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg))
elif not args.with_e2e: elif not args.with_e2e:
msg = "**跳过**:要跑就加 `--with-e2e`(36 个脚本 / 1500+ 条断言,约 10 分钟)" msg = "**跳过**:要跑就加 `--with-e2e`(37 个脚本、约 10 分钟)"
results.append(("接口断言(E2E)", None, msg)) results.append(("接口断言(E2E)", None, msg))
print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg)) print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg))
else: else: