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:
+72
@@ -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));
|
||||
}
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
+100
@@ -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;
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
+460
@@ -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 同算法同密钥);审批通过时原样写入名册,避免管理员重输';
|
||||
Reference in New Issue
Block a user