From bb915155900e8dab6c98c9cc0223d081a8d59b2b Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Wed, 30 Sep 2026 09:02:52 +0800
Subject: [PATCH] =?UTF-8?q?feat(join):=20B1=20=E5=90=8D=E5=8D=95=E5=8A=A0?=
=?UTF-8?q?=E5=85=A5=E7=94=B3=E8=AF=B7=20=E2=80=94=E2=80=94=20=E5=AD=A6?=
=?UTF-8?q?=E7=94=9F=E6=8F=90=E4=BA=A4=20+=20=E7=AE=A1=E7=90=86=E7=AB=AF?=
=?UTF-8?q?=E5=AE=A1=E6=89=B9=EF=BC=88V7=20=E5=AD=98=20AES=20=E5=AF=86?=
=?UTF-8?q?=E6=96=87=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
按契约「服务端转 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。
---
.../controller/JoinRequestController.java | 72 +++
.../controller/MpJoinRequestController.java | 48 ++
.../fill/module/join/dto/JoinDtos.java | 252 ++++++++++
.../join/entity/CollectJoinRequest.java | 100 ++++
.../join/mapper/CollectJoinRequestMapper.java | 106 ++++
.../fill/module/join/service/JoinService.java | 460 +++++++++++++++++
.../V7__join_request_idcard_cipher.sql | 33 ++
frontend-mp/src/pages/join/join.vue | 466 ++++++++++++++++++
frontend-mp/src/pages/verify/verify.vue | 35 +-
frontend-pc/src/apis/xt/join-request.ts | 125 +++++
frontend-pc/src/views/join/JoinAuditModal.vue | 253 ++++++++++
frontend-pc/src/views/join/index.vue | 272 ++++++++++
12 files changed, 2221 insertions(+), 1 deletion(-)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/controller/JoinRequestController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/controller/MpJoinRequestController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/dto/JoinDtos.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/entity/CollectJoinRequest.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java
create mode 100644 backend/xt-server/src/main/resources/db/migration/V7__join_request_idcard_cipher.sql
create mode 100644 frontend-mp/src/pages/join/join.vue
create mode 100644 frontend-pc/src/apis/xt/join-request.ts
create mode 100644 frontend-pc/src/views/join/JoinAuditModal.vue
create mode 100644 frontend-pc/src/views/join/index.vue
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