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 同算法同密钥);审批通过时原样写入名册,避免管理员重输';
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<template>
|
||||||
|
<view class="join">
|
||||||
|
<view class="join__wrap">
|
||||||
|
<view class="join__hero">
|
||||||
|
<text class="join__tenant">{{ ctx.tenantName || '材料填报' }}</text>
|
||||||
|
<text class="join__task">{{ ctx.taskName }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
① 已经申请过:把状态摆在最上面。
|
||||||
|
这条通道最容易让人误解的就是"我提交了,是不是就能填了?" ——
|
||||||
|
所以三个状态各有一句话直说结果,尤其待审时要说清**还不能填**。
|
||||||
|
-->
|
||||||
|
<view v-if="mine" class="join__card">
|
||||||
|
<view class="join__status" :class="`join__status--${mine.status}`">
|
||||||
|
{{ mine.statusText }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="join__row">
|
||||||
|
<text class="join__row-label">申请编号</text>
|
||||||
|
<text class="join__row-value">#{{ mine.requestId }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="join__row">
|
||||||
|
<text class="join__row-label">姓名</text>
|
||||||
|
<text class="join__row-value">{{ mine.realName }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="join__row">
|
||||||
|
<text class="join__row-label">身份证号</text>
|
||||||
|
<text class="join__row-value">{{ mine.idCardMask }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="join__row">
|
||||||
|
<text class="join__row-label">提交时间</text>
|
||||||
|
<text class="join__row-value">{{ fmt(mine.createTime) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 待审:说清"还不能填",并给出他能做的事 -->
|
||||||
|
<view v-if="mine.status === 0" class="join__note">
|
||||||
|
老师审核通过后,你<text class="join__strong">返回重新核验</text>就能开始填材料。
|
||||||
|
审核前暂时还不能填 —— 名单是准入依据,这一步不能跳过。
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 通过:直接给"去核验"的入口 -->
|
||||||
|
<view v-else-if="mine.status === 1" class="join__note join__note--ok">
|
||||||
|
已经加入名单了,点下面的按钮重新核验一次即可开始填报。
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 驳回:原因原样显示(这是学生唯一能知道"哪里不对"的来源) -->
|
||||||
|
<view v-else class="join__note join__note--bad">
|
||||||
|
<text class="join__strong">未通过原因:</text>{{ mine.rejectReason || '(老师未填写)' }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button v-if="mine.status === 1" class="join__btn" @click="reverify">
|
||||||
|
重新核验,开始填报
|
||||||
|
</button>
|
||||||
|
<button v-if="mine.status === 2" class="join__btn join__btn--ghost"
|
||||||
|
:disabled="submitting" @click="showForm = true">
|
||||||
|
重新填写申请(还剩 {{ remainHint }})
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ② 填申请 -->
|
||||||
|
<view v-if="!mine || mine.status === 2" class="join__card">
|
||||||
|
<template v-if="showForm">
|
||||||
|
<view class="join__title">申请加入名单</view>
|
||||||
|
<view class="join__hint">
|
||||||
|
核对不上,通常是因为<text class="join__strong">老师名单里还没有你</text>,
|
||||||
|
或者姓名写得和报名时不一样。填一次申请,老师核对后会把你加进名单。
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="join__field">
|
||||||
|
<text class="join__label">姓名</text>
|
||||||
|
<input v-model="form.realName" class="join__input" placeholder="与报名时一致的姓名"
|
||||||
|
:disabled="submitting" />
|
||||||
|
</view>
|
||||||
|
<view class="join__field">
|
||||||
|
<text class="join__label">身份证号</text>
|
||||||
|
<input v-model="form.idCardNo" class="join__input" placeholder="18 位身份证号"
|
||||||
|
maxlength="18" :disabled="submitting" />
|
||||||
|
</view>
|
||||||
|
<view class="join__field">
|
||||||
|
<text class="join__label">手机号</text>
|
||||||
|
<input v-model="form.phone" class="join__input" placeholder="老师要能联系到你"
|
||||||
|
maxlength="11" type="number" :disabled="submitting" />
|
||||||
|
</view>
|
||||||
|
<view class="join__field">
|
||||||
|
<text class="join__label">班级(选填)</text>
|
||||||
|
<input v-model="form.orgHint" class="join__input" placeholder="如 24中技数控2班"
|
||||||
|
:disabled="submitting" />
|
||||||
|
</view>
|
||||||
|
<view class="join__field">
|
||||||
|
<text class="join__label">说明(选填)</text>
|
||||||
|
<input v-model="form.reason" class="join__input" placeholder="如:我是转班过来的"
|
||||||
|
:disabled="submitting" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="join__btn" :disabled="submitting || !canSubmit" @click="submit">
|
||||||
|
{{ submitting ? '提交中…' : '提交申请' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<view v-if="error" class="join__error">{{ error }}</view>
|
||||||
|
|
||||||
|
<view class="join__foot">
|
||||||
|
身份证号只用于与名单核对,系统<text class="join__strong">不会保存明文</text>。
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="join__title">还没有加入名单?</view>
|
||||||
|
<view class="join__hint">
|
||||||
|
如果你确认姓名和身份证号没填错,但仍然核对不上,可以在这里提交申请,
|
||||||
|
由老师核对后加入名单。
|
||||||
|
</view>
|
||||||
|
<button class="join__btn" @click="showForm = true">申请加入名单</button>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请加入名单(FR-509 / 客户确认后新增,原型 STEP 03B)。
|
||||||
|
*
|
||||||
|
* ## 三条必须说清的事(文案不是装饰,是这段流程的一部分)
|
||||||
|
*
|
||||||
|
* 1. **申请期间仍然不能填报。** 这条通道只解决"进名单",不绕过门禁 ——
|
||||||
|
* 学生在待审状态最容易以为"我交了就能填",所以状态卡上直说"暂时还不能填"。
|
||||||
|
* 2. **通过后要重新核验一次。** 通过时服务端已经把他绑到名册行上了,
|
||||||
|
* 所以重进就能过;说清这一步,他就不会以为"通过了还要再填一遍资料"。
|
||||||
|
* 3. **身份证号不存明文。** 未核验的人最该被告知这一点 ——
|
||||||
|
* 否则"填身份证号"这一步会让人犹豫。
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'JoinRequest' })
|
||||||
|
|
||||||
|
interface SceneContext {
|
||||||
|
tenantName?: string
|
||||||
|
taskName?: string
|
||||||
|
taskId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MineRequest {
|
||||||
|
requestId: number
|
||||||
|
taskId: number
|
||||||
|
taskName?: string
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
realName: string
|
||||||
|
idCardMask: string
|
||||||
|
orgHint?: string
|
||||||
|
createTime?: string
|
||||||
|
auditTime?: string
|
||||||
|
rejectReason?: string
|
||||||
|
rosterId?: number
|
||||||
|
canFillNow?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = ref<SceneContext>({})
|
||||||
|
const mine = ref<MineRequest | null>(null)
|
||||||
|
const showForm = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
let taskId = 0
|
||||||
|
let scene = ''
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
realName: '',
|
||||||
|
idCardNo: '',
|
||||||
|
phone: '',
|
||||||
|
orgHint: '',
|
||||||
|
reason: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
const f = form.value
|
||||||
|
return f.realName.trim().length > 0
|
||||||
|
&& f.idCardNo.trim().length === 18
|
||||||
|
&& /^1[3-9]\d{9}$/.test(f.phone.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
const remainHint = computed(() => '最多 2 次机会')
|
||||||
|
|
||||||
|
function token(): string {
|
||||||
|
try {
|
||||||
|
return uni.getStorageSync('token') || ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v?: string) {
|
||||||
|
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContext(s: string) {
|
||||||
|
if (!s) return
|
||||||
|
try {
|
||||||
|
const data = await request<SceneContext>({
|
||||||
|
url: `/api/v1/mp/scene-context?scene=${encodeURIComponent(s)}`
|
||||||
|
})
|
||||||
|
ctx.value = data
|
||||||
|
taskId = data.taskId || 0
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '读取任务信息失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉"我的申请":决定显示状态卡还是表单 */
|
||||||
|
async function loadMine() {
|
||||||
|
try {
|
||||||
|
const data = await request<{ list: MineRequest[], total: number }>({
|
||||||
|
url: `/api/v1/mp/roster/join-request${taskId ? `?taskId=${taskId}` : ''}`,
|
||||||
|
token: token()
|
||||||
|
})
|
||||||
|
// 只看最近一条:一个人在同一任务下的申请按时间倒序,最新那条决定他现在的处境
|
||||||
|
mine.value = (data.list || [])[0] || null
|
||||||
|
} catch (e) {
|
||||||
|
// 拉不到不算致命:最多是少显示状态,仍可提交
|
||||||
|
error.value = e instanceof Error ? e.message : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
const f = form.value
|
||||||
|
if (!canSubmit.value) {
|
||||||
|
error.value = '请把姓名、18 位身份证号、手机号填完整'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
url: '/api/v1/mp/roster/join-request',
|
||||||
|
method: 'POST',
|
||||||
|
token: token(),
|
||||||
|
data: {
|
||||||
|
taskId,
|
||||||
|
realName: f.realName.trim(),
|
||||||
|
idCardNo: f.idCardNo.trim(),
|
||||||
|
phone: f.phone.trim(),
|
||||||
|
orgHint: f.orgHint.trim() || undefined,
|
||||||
|
reason: f.reason.trim() || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
uni.showToast({ title: '已提交,等待老师审核', icon: 'none', duration: 2500 })
|
||||||
|
showForm.value = false
|
||||||
|
await loadMine()
|
||||||
|
} catch (e) {
|
||||||
|
// 后端的话原样显示("你已在名单中,请直接返回重新核验"这类提示必须让学生看到)
|
||||||
|
error.value = e instanceof Error ? e.message : '提交失败'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过之后回核验页 —— 服务端已绑定,重进就能过 */
|
||||||
|
function reverify() {
|
||||||
|
uni.redirectTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(scene)}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(async (options) => {
|
||||||
|
scene = options?.scene || ''
|
||||||
|
taskId = Number(options?.taskId || 0)
|
||||||
|
// 从核验页带过来的姓名/证件号:他刚打过一遍,不必再打
|
||||||
|
form.value.realName = options?.name || ''
|
||||||
|
form.value.idCardNo = options?.idCardNo || ''
|
||||||
|
await loadContext(scene)
|
||||||
|
await loadMine()
|
||||||
|
// 带过来的是"门禁没匹配上"的那份输入 —— 直接展开表单,少一次点击
|
||||||
|
if (!mine.value && (form.value.realName || form.value.idCardNo)) {
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
$primary: #0052d9;
|
||||||
|
$success: #00a870;
|
||||||
|
$danger: #e34d59;
|
||||||
|
$warn: #ed7b2f;
|
||||||
|
$page-bg: #f3f3f3;
|
||||||
|
|
||||||
|
page {
|
||||||
|
background-color: $page-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
&__wrap {
|
||||||
|
max-width: 720rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hero {
|
||||||
|
padding: 32rpx 28rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tenant {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgb(255 255 255 / 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__task {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
padding: 32rpx 28rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1b2430;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
|
||||||
|
&--0 {
|
||||||
|
color: $warn;
|
||||||
|
background: #fdf2e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--1 {
|
||||||
|
color: $success;
|
||||||
|
background: #e8f6f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--2 {
|
||||||
|
color: $danger;
|
||||||
|
background: #fdecee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #4a5768;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1b2430;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #8b95a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #1b2430;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
padding: 20rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #4a5768;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
color: #2f6b4f;
|
||||||
|
background: #e8f6f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
color: #a03a44;
|
||||||
|
background: #fdecee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4a5768;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 84rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
height: 88rpx;
|
||||||
|
margin-top: 36rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: $primary;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
color: #fff;
|
||||||
|
background: #b9c6dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
color: $primary;
|
||||||
|
background: #eaf1fd;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
color: #8b95a3;
|
||||||
|
background: #f2f3f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error {
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $danger;
|
||||||
|
background: #fdecee;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__foot {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #8b95a3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -32,8 +32,19 @@
|
|||||||
|
|
||||||
<view v-if="error" class="verify__error">{{ error }}</view>
|
<view v-if="error" class="verify__error">{{ error }}</view>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
核验失败时给一条**受控通道**(FR-509 / 客户确认后新增)。
|
||||||
|
注意两件事:
|
||||||
|
1) 这条通道**不绕过门禁** —— 申请期间仍然不能填,去申请页会说清这一点;
|
||||||
|
2) 它只在真失败过之后才出现(`error` 非空),不是一进来就摆个"申请"按钮。
|
||||||
|
否则会有学生绕过核验直接去申请,把管理员的待办当成人工客服。
|
||||||
|
-->
|
||||||
|
<button v-if="error" class="verify__btn verify__btn--ghost" @click="gotoJoin">
|
||||||
|
申请加入名单
|
||||||
|
</button>
|
||||||
|
|
||||||
<view class="verify__foot">
|
<view class="verify__foot">
|
||||||
核对不上?先确认姓名和证件号没有打错。仍然不行请联系班主任。
|
核对不上?先确认姓名和证件号没有打错。仍然不行可点上面的「申请加入名单」。
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -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) => {
|
onLoad((options) => {
|
||||||
loadContext(options?.scene || '')
|
loadContext(options?.scene || '')
|
||||||
})
|
})
|
||||||
@@ -318,6 +343,14 @@ page {
|
|||||||
color: #0000004d;
|
color: #0000004d;
|
||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 次要动作("申请加入名单"):主按钮才是"核对并继续",
|
||||||
|
两个实心按钮并排会让人分不清哪个是推荐路径 */
|
||||||
|
&--ghost {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
color: $primary;
|
||||||
|
background: #eaf1fd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__error {
|
&__error {
|
||||||
|
|||||||
@@ -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<JoinAdminList>('/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<JoinApproveResult>(`/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<number, { text: string, color: string }> = {
|
||||||
|
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<number, string> = {
|
||||||
|
40040: '这条申请已经被别人处理过了(刷新列表即可看到最新状态)。',
|
||||||
|
40041: '名册里已经有这个证件号了 —— 可能刚被老师手工加进去,去「名单管理」核对一下。',
|
||||||
|
40042: '已通过的申请不能驳回。要撤销请到「名单管理」里删掉那条名册项。',
|
||||||
|
40037: '该学生申请次数已达上限(每任务 2 次)。',
|
||||||
|
40038: '该学生已有一条待审申请。',
|
||||||
|
40039: '该证件号已在名册中 —— 学生只是姓名打错了,让他回去重新核验即可。'
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal v-model:visible="visible" :title="mode === 'approve' ? '通过加入申请' : '驳回加入申请'"
|
||||||
|
:width="560" :mask-closable="false" :ok-loading="saving"
|
||||||
|
:ok-text="mode === 'approve' ? '确认通过' : '确认驳回'" @before-ok="onOk" @close="close">
|
||||||
|
<a-alert v-if="row" type="normal" :show-icon="false" class="jd__info">
|
||||||
|
<template #title>
|
||||||
|
<strong>{{ row.realName }}</strong> · {{ row.idCardMask }}
|
||||||
|
<template v-if="row.orgHint"> · 自述班级「{{ row.orgHint }}」</template>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<div v-if="row.reason" class="jd__muted">申请说明:{{ row.reason }}</div>
|
||||||
|
<div v-if="row.duplicateHint" class="jd__dup">
|
||||||
|
提示:该证件号在「{{ row.duplicateHint.taskName }}」的名册里出现过
|
||||||
|
(登记姓名 {{ row.duplicateHint.realName }})—— 建议核对是不是同一个人。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<!-- ── 通过 ── -->
|
||||||
|
<template v-if="mode === 'approve'">
|
||||||
|
<a-form layout="vertical" size="small" :model="approveForm">
|
||||||
|
<a-form-item label="加入哪个班级" required>
|
||||||
|
<a-select v-model="approveForm.orgId" placeholder="选择班级">
|
||||||
|
<a-option v-for="c in classOptions" :key="c.id" :value="c.id">
|
||||||
|
{{ c.label }}
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
<template #extra>
|
||||||
|
<span class="jd__muted">
|
||||||
|
学生的自述班级只作参考 —— 班级写错会让他的材料挂到别的班,
|
||||||
|
将来导出时目录也会分错
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="姓名(如与证件不符可修正)">
|
||||||
|
<a-input v-model="approveForm.realName" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<a-alert type="info" :show-icon="false" class="jd__tip">
|
||||||
|
<template #title>
|
||||||
|
通过后:系统会把他<strong>直接加入名单并绑定</strong>,
|
||||||
|
他返回重新核验即可开始填报,不用再输一遍姓名与证件号。
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<!-- 通知没做:如实说,不假装 -->
|
||||||
|
<span class="jd__muted">
|
||||||
|
⚠️ 通知渠道还没做,<strong>通过之后要人工告知学生</strong>(群里说一声)。
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── 驳回 ── -->
|
||||||
|
<template v-else>
|
||||||
|
<a-form layout="vertical" size="small" :model="rejectForm">
|
||||||
|
<a-form-item label="快捷原因">
|
||||||
|
<a-select v-model="rejectForm.reasonCode" placeholder="选择或直接在下面写" allow-clear>
|
||||||
|
<a-option v-for="r in REJECT_REASON_CODES" :key="r.code" :value="r.code">
|
||||||
|
{{ r.label }}
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="textLabel" :validate-status="textError ? 'error' : undefined"
|
||||||
|
:help="textError">
|
||||||
|
<a-textarea v-model="rejectForm.reason" :rows="3" :max-length="200" show-word-limit
|
||||||
|
placeholder="如:名单里没有你的报名记录,请联系班主任核对报名信息" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<a-alert type="warning" :show-icon="false" class="jd__tip">
|
||||||
|
<template #title>
|
||||||
|
这段说明会<strong>原样显示给学生</strong>,所以要说清"哪里不对、该怎么办",
|
||||||
|
不要只写"审核不通过"。
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { JoinAdminItem } from '@/apis/xt/join-request'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import {
|
||||||
|
approveJoinRequest,
|
||||||
|
JOIN_ERROR_HINT,
|
||||||
|
REJECT_REASON_CODES,
|
||||||
|
rejectJoinRequest
|
||||||
|
} from '@/apis/xt/join-request'
|
||||||
|
import { getOrgTree } from '@/apis/xt/org'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入申请审批弹窗(通过 / 驳回两种模式)。
|
||||||
|
*
|
||||||
|
* ## 为什么通过时要**手选班级**
|
||||||
|
*
|
||||||
|
* 学生的"自述班级"只是他自己打的字(可能写成"数控2班"、"24数控二班"…),
|
||||||
|
* 而班级决定了:他的材料归谁审、导出时落在哪个目录。**让它自动猜**,
|
||||||
|
* 猜错的表现是"材料挂到了别的班",而导出包打开时才会发现。
|
||||||
|
* 服务端在"自述班级匹配到唯一组织"时会自动选上,匹配不上或撞名时必须手选。
|
||||||
|
*
|
||||||
|
* ## 驳回必须填原因
|
||||||
|
*
|
||||||
|
* 原因会原样给学生看 —— 这也是学生唯一能知道"哪里不对"的来源。
|
||||||
|
* 只写"审核不通过"等于让他反复提交。
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'JoinAuditModal' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'done'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const mode = ref<'approve' | 'reject'>('approve')
|
||||||
|
const row = ref<JoinAdminItem | null>(null)
|
||||||
|
const classOptions = ref<Array<{ id: number, label: string }>>([])
|
||||||
|
const textError = ref('')
|
||||||
|
|
||||||
|
const approveForm = reactive({ orgId: undefined as number | undefined, realName: '' })
|
||||||
|
const rejectForm = reactive({ reasonCode: '', reason: '' })
|
||||||
|
|
||||||
|
const textLabel = computed(() =>
|
||||||
|
rejectForm.reasonCode && rejectForm.reasonCode !== 'OTHER'
|
||||||
|
? '补充说明(可选)'
|
||||||
|
: '驳回原因(必填)')
|
||||||
|
|
||||||
|
async function loadClasses() {
|
||||||
|
if (classOptions.value.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await getOrgTree()
|
||||||
|
const out: Array<{ id: number, label: string }> = []
|
||||||
|
const walk = (nodes: Array<Record<string, unknown>>) => {
|
||||||
|
for (const n of nodes || []) {
|
||||||
|
if (n.orgType === 3) {
|
||||||
|
out.push({ id: Number(n.id), label: String(n.orgName) })
|
||||||
|
}
|
||||||
|
walk((n.children as Array<Record<string, unknown>>) || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk((res.data as unknown as Array<Record<string, unknown>>) || [])
|
||||||
|
classOptions.value = out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openApprove(r: JoinAdminItem) {
|
||||||
|
mode.value = 'approve'
|
||||||
|
row.value = r
|
||||||
|
approveForm.orgId = undefined
|
||||||
|
approveForm.realName = r.realName
|
||||||
|
textError.value = ''
|
||||||
|
visible.value = true
|
||||||
|
try {
|
||||||
|
await loadClasses()
|
||||||
|
// 自述班级能唯一匹配上就预选(服务端也会做同样的匹配,这里只是少一次点击)
|
||||||
|
const hit = classOptions.value.filter((c) => r.orgHint && c.label === r.orgHint)
|
||||||
|
if (hit.length === 1) {
|
||||||
|
approveForm.orgId = hit[0].id
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 组织树取不到不阻断:他可以不选,服务端会按自述班级再试一次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReject(r: JoinAdminItem) {
|
||||||
|
mode.value = 'reject'
|
||||||
|
row.value = r
|
||||||
|
rejectForm.reasonCode = ''
|
||||||
|
rejectForm.reason = ''
|
||||||
|
textError.value = ''
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOk() {
|
||||||
|
if (!row.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (mode.value === 'approve') {
|
||||||
|
if (!approveForm.orgId) {
|
||||||
|
Message.warning('请选择要加入的班级')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const res = await approveJoinRequest(row.value.requestId, {
|
||||||
|
orgId: approveForm.orgId,
|
||||||
|
realName: approveForm.realName.trim() || undefined
|
||||||
|
})
|
||||||
|
const d = res.data
|
||||||
|
// 通知没发出去是**事实**,要说出来(不能让人以为学生已经收到通知了)
|
||||||
|
Message.success({
|
||||||
|
content: `已通过并加入名单。${d?.canFillNow ? '该学生重新核验即可填报。' : ''}${
|
||||||
|
d?.notifySent === false ? '(通知未发出,请人工告知学生)' : ''}`,
|
||||||
|
duration: 5000
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const needsText = !rejectForm.reasonCode || rejectForm.reasonCode === 'OTHER'
|
||||||
|
if (needsText && !rejectForm.reason.trim()) {
|
||||||
|
textError.value = '请写清驳回原因 —— 学生只能从这句话知道哪里不对'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await rejectJoinRequest(row.value.requestId, {
|
||||||
|
reason: rejectForm.reason.trim() || '信息与报名资料不符',
|
||||||
|
reasonCode: rejectForm.reasonCode || undefined
|
||||||
|
})
|
||||||
|
Message.success({
|
||||||
|
content: '已驳回。(通知未发出,请人工告知学生原因)',
|
||||||
|
duration: 5000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
emit('done')
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { bizCode?: number }
|
||||||
|
const hint = err.bizCode ? JOIN_ERROR_HINT[err.bizCode] : ''
|
||||||
|
Message.error(hint ? `${err.message}(${hint})` : err.message)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ openApprove, openReject })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.jd {
|
||||||
|
&__info {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dup {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--orange-6));
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tip {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<GiPageLayout>
|
||||||
|
<a-card :bordered="false">
|
||||||
|
<template #title>加入申请</template>
|
||||||
|
<template #extra>
|
||||||
|
<span class="join__hint">
|
||||||
|
门禁没匹配上的人在这里申请加入;通过后他重新核验即可填报
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-alert v-if="alert" type="warning" class="join__alert">
|
||||||
|
<template #title>{{ alert.text }}</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<div class="g-row-tool">
|
||||||
|
<a-space wrap>
|
||||||
|
<a-button v-for="t in statusTabs" :key="String(t.value)"
|
||||||
|
:type="statusFilter === t.value ? 'primary' : 'outline'" size="small"
|
||||||
|
@click="setStatus(t.value)">
|
||||||
|
{{ t.label }}
|
||||||
|
<span v-if="t.count !== undefined" class="join__count">{{ t.count }}</span>
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
|
||||||
|
<a-space wrap>
|
||||||
|
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
|
||||||
|
class="join__task" :loading="taskLoading" @change="search">
|
||||||
|
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
|
||||||
|
</a-select>
|
||||||
|
<a-input-search v-model="query.keyword" placeholder="搜索姓名" allow-clear
|
||||||
|
class="join__search" @search="search" @clear="search" />
|
||||||
|
<a-button size="small" @click="load">
|
||||||
|
<template #icon><IconRefresh /></template>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
⚠️ 这一屏**没有批量通过、没有全选**(契约 §8.7.5 的取舍):
|
||||||
|
放错一个人进名单 = 让一个不属于这批的人拿到认定资格,代价极高。
|
||||||
|
所以只有逐条的通过/驳回,且驳回必须填原因。
|
||||||
|
-->
|
||||||
|
<a-table :data="list" :loading="loading" row-key="requestId" :pagination="pagination"
|
||||||
|
:scroll="{ x: 1000 }" @page-change="onPageChange">
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="申请人" :width="170">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="join__name">{{ record.realName }}</div>
|
||||||
|
<div class="join__muted">{{ record.idCardMask }}</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="手机号" :width="130">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="join__muted">{{ record.phoneMask || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="自述班级 / 申请说明" :width="220">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div>{{ record.orgHint || '—' }}</div>
|
||||||
|
<div class="join__muted">{{ record.reason || '(未填写说明)' }}</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="任务" :width="170" ellipsis tooltip>
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div>{{ record.taskName || '—' }}</div>
|
||||||
|
<div v-if="record.duplicateHint" class="join__dup">
|
||||||
|
该证件号在「{{ record.duplicateHint.taskName }}」出现过
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="状态 / 等待" :width="120" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-tag :color="JOIN_STATUS[record.status]?.color ?? 'gray'" size="small">
|
||||||
|
{{ record.statusText || JOIN_STATUS[record.status]?.text }}
|
||||||
|
</a-tag>
|
||||||
|
<div class="join__muted">{{ fmtWait(record.waitHours) }}</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="提交时间" :width="140">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="join__muted">{{ fmt(record.createTime) }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="操作" :width="150" align="center" fixed="right">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-space v-if="record.status === 0" size="mini">
|
||||||
|
<a-link @click="onApprove(record)">通过</a-link>
|
||||||
|
<a-link status="danger" @click="onReject(record)">驳回</a-link>
|
||||||
|
</a-space>
|
||||||
|
<span v-else class="join__muted">
|
||||||
|
{{ record.rosterId ? `已入册 #${record.rosterId}` : '—' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<JoinAuditModal ref="modalRef" @done="load" />
|
||||||
|
</GiPageLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { JoinAdminItem } from '@/apis/xt/join-request'
|
||||||
|
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||||
|
import { getJoinRequests, JOIN_STATUS } from '@/apis/xt/join-request'
|
||||||
|
import { getTaskList } from '@/apis/xt/task'
|
||||||
|
import JoinAuditModal from './JoinAuditModal.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入申请审批(FR-509,原型 STEP 11)。
|
||||||
|
*
|
||||||
|
* ## 为什么没有批量通过
|
||||||
|
*
|
||||||
|
* 契约 §8.7.5 明确取舍:批量**通过**要求管理员先逐条看过,
|
||||||
|
* 而**驳回必须逐条填原因**;且批量通过**不提供"全选"快捷键**。
|
||||||
|
* 理由与"批量通过只带绿色项"同源 —— **身份认定环节的误操作代价极高**:
|
||||||
|
* 放错一个人进名单,等于让一个不属于这批的人拿到认定资格,而撤销只能删名册项。
|
||||||
|
* 所以这一屏干脆不做批量,只留逐条动作。
|
||||||
|
*
|
||||||
|
* ## 两个信息是用来"帮他判断"的
|
||||||
|
*
|
||||||
|
* - `waitHours`:等最久的排最前(列表本来就按提交时间正序)
|
||||||
|
* - `duplicateHint`:这个证件号在**别的任务**的名册里出现过 ——
|
||||||
|
* 这是判断"是不是本校学生"的唯一线索,比对着姓名猜可靠
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'Join' })
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const taskLoading = ref(false)
|
||||||
|
const list = ref<JoinAdminItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const counts = reactive({ pending: 0, approved: 0, rejected: 0 })
|
||||||
|
const alert = ref<{ level: number, text: string } | null>(null)
|
||||||
|
const statusFilter = ref<number>(0)
|
||||||
|
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
taskId: undefined as number | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const modalRef = useTemplateRef('modalRef')
|
||||||
|
|
||||||
|
const statusTabs = computed(() => [
|
||||||
|
{ label: '待审核', value: 0, count: counts.pending },
|
||||||
|
{ label: '已通过', value: 1, count: counts.approved },
|
||||||
|
{ label: '已驳回', value: 2, count: counts.rejected }
|
||||||
|
])
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
current: query.pageNum,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
total: total.value,
|
||||||
|
showTotal: true,
|
||||||
|
showPageSize: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
function fmt(v: string | null) {
|
||||||
|
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtWait(h: number | null) {
|
||||||
|
if (h === null || h === undefined) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return h < 24 ? `已等 ${h} 小时` : `已等 ${Math.floor(h / 24)} 天`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getJoinRequests({
|
||||||
|
taskId: query.taskId,
|
||||||
|
status: statusFilter.value,
|
||||||
|
keyword: query.keyword.trim() || undefined,
|
||||||
|
pageNum: query.pageNum,
|
||||||
|
pageSize: query.pageSize
|
||||||
|
})
|
||||||
|
const d = res.data
|
||||||
|
list.value = d?.list ?? []
|
||||||
|
total.value = d?.total ?? 0
|
||||||
|
Object.assign(counts, d?.counts ?? { pending: 0, approved: 0, rejected: 0 })
|
||||||
|
alert.value = d?.alert ?? null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
taskLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||||
|
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||||
|
} finally {
|
||||||
|
taskLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
query.pageNum = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(v: number) {
|
||||||
|
statusFilter.value = v
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page: number) {
|
||||||
|
query.pageNum = page
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onApprove(row: JoinAdminItem) {
|
||||||
|
modalRef.value?.openApprove(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReject(row: JoinAdminItem) {
|
||||||
|
modalRef.value?.openReject(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
loadTasks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.join {
|
||||||
|
&__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__task {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dup {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--orange-6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user