diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/controller/JoinRequestController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/controller/JoinRequestController.java new file mode 100644 index 0000000..403fa85 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/controller/JoinRequestController.java @@ -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)。 + * + *

权限码是现成的 {@code join:audit}({@code PermissionDef.JOIN_REQUEST_AUDIT})—— + * 不需要新加:它就是为这条通道定义的。 + * + *

⚠️ 没有"批量通过"接口,这是刻意的(契约 §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 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 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 reject(@PathVariable Long id, + @RequestBody JoinDtos.RejectReq req) { + return ApiResult.ok(joinService.reject(id, req)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/controller/MpJoinRequestController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/controller/MpJoinRequestController.java new file mode 100644 index 0000000..6e97873 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/controller/MpJoinRequestController.java @@ -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)。 + * + *

权限:登录即可(学生)。**不额外挂权限码** —— 这条通道就是给 + * "门禁没匹配上、什么权限都没有"的人用的,要求权限码等于把门关上。 + * 限次(同 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 submit(@RequestBody JoinDtos.SubmitReq req) { + return ApiResult.ok(joinService.submit(req)); + } + + @GetMapping + @Operation(summary = "我的申请(含状态与驳回原因)", + description = "通过之后 canFillNow=true:说明已经自动绑定,学生返回重新核验就能直接进") + public ApiResult> mine(@RequestParam(required = false) Long taskId) { + return ApiResult.ok(joinService.myRequests(taskId)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/dto/JoinDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/dto/JoinDtos.java new file mode 100644 index 0000000..c1e6a4c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/dto/JoinDtos.java @@ -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)。 + * + *

学生侧与管理侧各有一份形状:学生只看得到自己的申请与状态, + * 管理侧要看到"谁、什么证件号、等多久、有没有重复线索"。 + */ +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 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/entity/CollectJoinRequest.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/entity/CollectJoinRequest.java new file mode 100644 index 0000000..9ec4858 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/entity/CollectJoinRequest.java @@ -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 · 客户确认后新增)。 + * + *

这条通道解决什么、不解决什么

+ * + *

门禁不通过的人原先只能"联系老师",老师再手工加名单 —— 几百人的批次下老师负担极重。 + * 这里给一条受控通道:学生自助申请 → 管理员审批 → 自动入册。 + * + *

⚠️ 关键边界:它只解决"进名单",不绕过门禁。 + * 申请期间学生仍然不能填报 —— 否则门禁就不再是准入门槛, + * 而是"填个表就能过"的形式主义,那整个产品的根基(凭证件号认人)就没了。 + * + *

证件号与手机号都只有 HMAC 与脱敏值

+ * + *

与 {@code collect_roster} 同一口径:{@code id_card_hash} 用于等值查重与审批后写名册, + * {@code id_card_mask} 用于展示。明文不落库 —— 申请来自未核验的人, + * 更不该把明文留在库里。 + * + *

唯一索引 {@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; + + /** + * 证件号 AES-GCM 密文(V7 迁移新增)。 + * + *

为什么申请表也要存密文:审批通过要往 {@code collect_roster} 插一行, + * 而那一行的 {@code id_card_no} 是 NOT NULL 的密文列。 + * 只存 hash 的话,审批那一步会直接撞 + * {@code null value in column "id_card_no" violates not-null constraint}(500)。 + * + *

三条替代路都被排除:让学生先过门禁(先有鸡还是先有蛋)、 + * 让管理员重输(等于把学生写错一位的风险复制给管理员)、 + * 不写这一列(DDL 不允许,且审核看完整号时正是解这一列)。 + * + *

⚠️ 是密文,不是明文 —— 明文只在提交请求的内存里存在过。 + */ + private String idCardCipher; + + private String idCardMask; + + private String phoneHash; + + private String phoneMask; + + /** 自述班级(老师核对时用)。 */ + private String orgHint; + + private String reason; + + private Integer status; + + /** 驳回原因:会原样推送给学生,所以要写得能看懂。 */ + 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java new file mode 100644 index 0000000..4c886c6 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java @@ -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; + +/** + * 加入申请。 + * + *

带 SQL 的地方一律**显式写 {@code tenant_id}**:这些查询会在 + * "还没登录"或"跨任务核对"的路径上用到,而多租户拦截器只能保证 + * "有登录上下文时"注入条件 —— 显式写一遍是给自己留的证据。 + * (对照 {@code MpSceneMapper}:匿名接口那一族也是这个口径。) + */ +public interface CollectJoinRequestMapper extends BaseMapper { + + /** 同一个人(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); + + /** + * 这个证件号在**其他任务**的名册里出现过吗(帮管理员判断是不是本校学生)。 + * + *

返回 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 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; + } + } + + /** + * 审批通过后回写申请单。 + * + *

条件里带 {@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); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java new file mode 100644 index 0000000..a29889a --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java @@ -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)。 + * + *

这条通道的四条边界(都是刻意的,不要"顺手放宽")

+ * + *
    + *
  1. 只解决"进名单",不绕过门禁。申请期间学生仍然不能填报 —— + * 否则门禁就变成"填个表就能过"的形式主义,整套"凭证件号认人"的根基就没了。
  2. + *
  3. 每人每任务最多 2 次({@code 40037})。不设上限的话, + * 一个输错号码的学生能刷出几十条申请,管理员的待办里全是同一个人。
  4. + *
  5. 证件号已在名册 → {@code 40039} 让他回去重新核验,而不是受理申请。 + * 这种情况说明他只是姓名输错了,受理申请等于让管理员去纠正一个 + * 本来自己就能改对的问题。这一步不违反"统一失败提示": + * 学生已经主动提供了正确证件号,此时说"你已在名单中"**不泄露任何新信息**。
  6. + *
  7. 审批通过时自动绑定({@code bind_user_id} = 申请人)—— + * 不然学生还得再输一遍姓名与证件号,而这两项刚刚才由他提供过。
  8. + *
+ * + *

明文只在内存里存在

+ * + *

证件号与手机号在这里立刻转成 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 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 myRequests(Long taskId) { + Long uid = UserContext.uid(); + var q = Wrappers.lambdaQuery() + .eq(CollectJoinRequest::getApplicantUid, uid) + .eq(taskId != null, CollectJoinRequest::getTaskId, taskId) + .orderByDesc(CollectJoinRequest::getId); + List rows = joinMapper.selectList(q); + + Map taskNames = new HashMap<>(); + List taskIds = rows.stream().map(CollectJoinRequest::getTaskId).distinct().toList(); + if (!taskIds.isEmpty()) { + for (CollectTask t : taskMapper.selectByIds(taskIds)) { + taskNames.put(t.getId(), t.getTaskName()); + } + } + + List 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 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.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 taskNames = new HashMap<>(); + List 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 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 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.lambdaQuery() + .eq(taskId != null, CollectJoinRequest::getTaskId, taskId) + .eq(CollectJoinRequest::getStatus, status)); + } + + /** + * 审批通过(§8.7.4):建名册项 + 自动绑定 + 回写申请。 + * + *

事务:名册建了而申请没回写(或反过来),学生会看到"申请还在待审" + * 但名册里已经有他了 —— 他接着再申请一次就撞 {@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 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; + } +} diff --git a/backend/xt-server/src/main/resources/db/migration/V7__join_request_idcard_cipher.sql b/backend/xt-server/src/main/resources/db/migration/V7__join_request_idcard_cipher.sql new file mode 100644 index 0000000..46fa9c7 --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V7__join_request_idcard_cipher.sql @@ -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 同算法同密钥);审批通过时原样写入名册,避免管理员重输'; diff --git a/frontend-mp/src/pages/join/join.vue b/frontend-mp/src/pages/join/join.vue new file mode 100644 index 0000000..9aa8c1d --- /dev/null +++ b/frontend-mp/src/pages/join/join.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/frontend-mp/src/pages/verify/verify.vue b/frontend-mp/src/pages/verify/verify.vue index 8eec884..6852d70 100644 --- a/frontend-mp/src/pages/verify/verify.vue +++ b/frontend-mp/src/pages/verify/verify.vue @@ -32,8 +32,19 @@ {{ error }} + + + - 核对不上?先确认姓名和证件号没有打错。仍然不行请联系班主任。 + 核对不上?先确认姓名和证件号没有打错。仍然不行可点上面的「申请加入名单」。 @@ -214,6 +225,20 @@ 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) => { loadContext(options?.scene || '') }) @@ -318,6 +343,14 @@ page { color: #0000004d; background: #e8e8e8; } + + /* 次要动作("申请加入名单"):主按钮才是"核对并继续", + 两个实心按钮并排会让人分不清哪个是推荐路径 */ + &--ghost { + margin-top: 20rpx; + color: $primary; + background: #eaf1fd; + } } &__error { diff --git a/frontend-pc/src/apis/xt/join-request.ts b/frontend-pc/src/apis/xt/join-request.ts new file mode 100644 index 0000000..2d9a623 --- /dev/null +++ b/frontend-pc/src/apis/xt/join-request.ts @@ -0,0 +1,125 @@ +/** + * 名单加入申请接口(《03-接口设计》§8.7.3 ~ §8.7.5,FR-509)。 + * + * ## 这条通道的边界(界面文案要照着说) + * + * 它**只解决"进名单",不绕过门禁** —— 申请期间学生仍然不能填报。 + * 所以审批通过之后,界面要告诉管理员"学生返回重新核验就能进", + * 而不是"他已经可以填了"。 + * + * ## 两个刻意的设计,前端要照做 + * + * 1. **没有批量通过/一键全通过接口**(契约 §8.7.5 的取舍):身份认定环节的 + * 误操作代价极高 —— 放错一个人进名单,等于让一个不是这批的人拿到认定资格。 + * 所以这里只有逐条通过、逐条驳回(驳回还必须填原因)。 + * 2. **`notifySent` 恒为 false**:通知渠道(M5-12)还没做, + * 服务端如实返回 false,界面必须显示"请口头/群里告知学生",不能假装通知过了。 + */ +import http from '@/utils/http' + +export interface JoinDupHint { + type: string + taskName: string + realName: string +} + +export interface JoinAdminItem { + requestId: number + taskId: number + taskName: string | null + realName: string + idCardMask: string + phoneMask: string | null + orgHint: string | null + reason: string | null + /** `0` 待审 / `1` 已通过 / `2` 已驳回 */ + status: number + statusText: string + createTime: string | null + waitHours: number | null + /** 该证件号在**其他任务**的名册里出现过(帮判断是不是本校学生) */ + duplicateHint: JoinDupHint | null + rosterId: number | null +} + +export interface JoinAdminList { + list: JoinAdminItem[] + total: number + counts: { pending: number, approved: number, rejected: number } + alert: { level: number, text: string } | null +} + +export interface JoinListQuery { + taskId?: number + /** 默认 0(待审) */ + status?: number + keyword?: string + pageNum?: number + pageSize?: number +} + +export function getJoinRequests(params: JoinListQuery = {}) { + return http.get('/roster/join-requests', params) +} + +export interface JoinApproveReq { + /** 指定班级;不传则用学生的自述班级匹配组织名(匹配不上会要求手选) */ + orgId: number + /** 修正姓名(管理员核对后可改) */ + realName?: string + /** 修正证件号 */ + idCardNo?: string +} + +export interface JoinApproveResult { + requestId: number + status: number + rosterId: number + rosterCreated: boolean + /** ⚠️ 恒为 false:通知没发出去,需要人工告知 */ + notifySent: boolean + /** true = 已绑定,学生返回重新核验就能直接进 */ + canFillNow: boolean +} + +export function approveJoinRequest(id: number, data: JoinApproveReq) { + return http.post(`/roster/join-requests/${id}/approve`, data) +} + +export interface JoinRejectReq { + /** **必填** —— 会原样给学生看 */ + reason: string + reasonCode?: string +} + +export function rejectJoinRequest(id: number, data: JoinRejectReq) { + return http.post<{ requestId: number, status: number, notifySent: boolean }>( + `/roster/join-requests/${id}/reject`, + data + ) +} + +/** 申请状态。 */ +export const JOIN_STATUS: Record = { + 0: { text: '待审核', color: 'orange' }, + 1: { text: '已通过', color: 'green' }, + 2: { text: '已驳回', color: 'red' } +} + +/** 驳回快捷原因码(契约 §8.7.5)。 */ +export const REJECT_REASON_CODES = [ + { code: 'NOT_THIS_SCHOOL', label: '不是本校学生' }, + { code: 'INFO_MISMATCH', label: '信息与报名资料不符' }, + { code: 'NOT_IN_SCOPE', label: '不在本次认定范围' }, + { code: 'OTHER', label: '其他原因(请在说明里写清)' } +] + +/** 关键错误码 → 一句"接下来怎么办"。 */ +export const JOIN_ERROR_HINT: Record = { + 40040: '这条申请已经被别人处理过了(刷新列表即可看到最新状态)。', + 40041: '名册里已经有这个证件号了 —— 可能刚被老师手工加进去,去「名单管理」核对一下。', + 40042: '已通过的申请不能驳回。要撤销请到「名单管理」里删掉那条名册项。', + 40037: '该学生申请次数已达上限(每任务 2 次)。', + 40038: '该学生已有一条待审申请。', + 40039: '该证件号已在名册中 —— 学生只是姓名打错了,让他回去重新核验即可。' +} diff --git a/frontend-pc/src/views/join/JoinAuditModal.vue b/frontend-pc/src/views/join/JoinAuditModal.vue new file mode 100644 index 0000000..7a731c3 --- /dev/null +++ b/frontend-pc/src/views/join/JoinAuditModal.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/frontend-pc/src/views/join/index.vue b/frontend-pc/src/views/join/index.vue new file mode 100644 index 0000000..42b2eb3 --- /dev/null +++ b/frontend-pc/src/views/join/index.vue @@ -0,0 +1,272 @@ + + + + +