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

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

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

验证:scripts/e2e-m2-join.py 50/50。
This commit is contained in:
2026-09-30 09:02:52 +08:00
parent ad86e2d521
commit bb91515590
12 changed files with 2221 additions and 1 deletions
+125
View File
@@ -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: '该证件号已在名册中 —— 学生只是姓名打错了,让他回去重新核验即可。'
}