feat(join): B1 名单加入申请 —— 学生提交 + 管理端审批(V7 存 AES 密文)

按契约「服务端转 HMAC、不落明文」设计会写不进名册:collect_roster.id_card_no 是
NOT NULL 的密文列,而申请表只有 hash 与 mask(三条替代路都被排除)。
V7 给 collect_join_request 补 id_card_cipher(与名册同一把密钥/算法),审批时原样搬进名册,
明文全程只在提交请求的内存里存在过。V7 之前的老申请明确提示重新提交,不静默建一条
永远过不了校验的名册行。

另修 F-20:自述班级不能借用姓名规范化(姓名要「张 三」→「张三」,班级名里的空格有意义)。

验证:scripts/e2e-m2-join.py 50/50。
This commit is contained in:
2026-09-30 09:02:52 +08:00
parent ad86e2d521
commit bb91515590
12 changed files with 2221 additions and 1 deletions
@@ -0,0 +1,72 @@
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));
}
}
@@ -0,0 +1,48 @@
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));
}
}
@@ -0,0 +1,252 @@
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;
}
}
@@ -0,0 +1,100 @@
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;
}
@@ -0,0 +1,106 @@
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;
}
}
/**
* 审批通过后回写申请单。
*
* <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);
}
@@ -0,0 +1,460 @@
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.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;
// ── 学生侧 ─────────────────────────────────────
/** 提交申请(§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);
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);
// ⚠️ 通知(M5-12)还没做:只能靠老师口头/群里说一声。
// 这里如实回 false,**不为了让契约好看而写 true**(同驳回接口的口径)。
log.warn("加入申请已通过但**没有发出通知**(通知渠道未实现):request={} 需人工告知学生", id);
JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
vo.setRosterId(row.getId());
vo.setRosterCreated(true);
vo.setNotifySent(false);
vo.setCanFillNow(r.getApplicantUid() != null);
return vo;
}
/** 自述班级 → 组织 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);
log.warn("加入申请已驳回但**没有发出通知**(通知渠道未实现):request={}", id);
JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
vo.setNotifySent(false);
return vo;
}
}
@@ -0,0 +1,33 @@
-- ============================================================================
-- V7:加入申请补一列「证件号密文」
--
-- 为什么需要它(F-19,本轮发现)
-- ---------------------------------------------------------------------------
-- 审批通过时要往 `collect_roster` 插一行,而那一行的 `id_card_no` 是
-- **NOT NULL**(AES-GCM 密文,见列注释"不可用于等值查询")。
--
-- 原设计(契约 §8.7.4)只说"服务端转 HMAC,不落明文",于是申请表里
-- **只有 hash 与 mask,没有密文** → 审批到写名册这一步必然
-- `null value in column "id_card_no" violates not-null constraint`(500)。
--
-- 三条路都不行:
-- · 让申请人先过门禁再建名册 —— 那就变成"先有鸡还是先有蛋"(门禁靠名册判人);
-- · 让管理员在审批时重新输入证件号 —— 他手里只有学生自述的号码,
-- 重新输入等于把"学生写错一位"的风险复制到管理员身上;
-- · 干脆不写这一列 —— DDL 有 NOT NULL,而且审核要看完整号时正是解的这一列。
--
-- 所以补一列 **AES-GCM 密文**(不是明文):
-- `id_card_cipher` —— 与 `collect_roster.id_card_no` **同一把密钥、同一个算法**,
-- 审批时原样搬过去即可,明文全程只在提交请求的内存里存在过。
-- 它与 hash 的分工不变:hash 用于等值查重,cipher 用于"需要时解密"。
--
-- ⚠️ 可空是刻意的:本迁移之前提交的申请没有这一列的值,
-- 审批时会明确提示"这条申请缺少证件号密文,请让管理员核对后重新提交"
-- (**不能**静默建一条没有证件号的名册行 —— 那条行以后永远过不了名册校验)。
-- ============================================================================
ALTER TABLE collect_join_request
ADD COLUMN IF NOT EXISTS id_card_cipher VARCHAR(256);
COMMENT ON COLUMN collect_join_request.id_card_cipher IS
'证件号 AES-GCM 密文(与 collect_roster.id_card_no 同算法同密钥);审批通过时原样写入名册,避免管理员重输';