# Phase 15(活动报名、审核与签到)V1.1 增量复验完成报告 - **日期**:2026-09-17 - **模式**:KEEP-VERIFY / EXTEND - **范围**:activity registration、checkin 服务与 U29~U32 页面 - **基线**:Phase 14 收尾 `755a570`(全量回归 450 tests) - **结论**:**关闭**。三项计划事项全部落地,Gate 两项均达成 --- ## 一、计划项达成情况 | 计划项 | 结论 | 证据 | |---|---|---| | 回归报名、审核、取消、签到码、重复签到和名额并发 | ✅ | `ActivityPhase15Test` 21 例,覆盖取消/拒绝后重报名、重复报名、审核并发、重复扫码幂等、过期码、跨活动复用 | | 增加非 matchmaking 活动和 capacity group 场景测试 | ✅ | 容量分组**真正参与扣减**(此前只落库回读);婚恋课堂类不设实名/本地资格门槛 | | 服务站操作员只能操作指定活动或站点任务 | ✅ | 新增 V040 `activity_staff_assignments` + 签到/审核数据范围校验(INV-020) | **Gate** | Gate | 结论 | |---|---| | 重复请求和并发报名不超额 | ✅ 报名/审核/取消/签到全部改为单条条件 SQL,靠数据库保证原子性 | | 签到不可跨活动复用 | ✅ 签到码作用域由 token 内 `activityId` 决定;且工作人员必须被显式指派到该活动 | --- ## 二、修复缺陷清单(16 处) ### A. 签到(8 处,含 1 处必崩) - **D1 CRITICAL — 每一个签到码一生成即作废**:`generateCheckinToken` 把 token 写入 Redis 值 `"used"`,而 `scanCheckin` 见到 `"used"` 立即拒绝 → **扫码签到 100% 失败**。改为签发时写 `issued`,消费标记只在扫码成功后写入。 - **D2 CRITICAL — 重复签到竞态**:原为「读 → 判断 → `updateById` 全量覆盖」,并发扫码会重复写 `checkin_at` 与审计。改为 `checkinIfNotCheckedIn` 条件更新;重复扫码幂等返回 `alreadyCheckedIn=true`,不重复记审计。 - **D3 HIGH — 签到无数据范围**(计划项③):任意持 `activity.checkin` 权限者可签到任意活动任意用户。新增 `assertCheckinScope`:被指派过活动的工作人员只能操作被指派的活动;无任何指派记录者为平台级人员。 - **D4 HIGH — 手工签到无校验**:`manualCheckin` 不校验活动是否存在/已取消(原实现只有 `generate` 校验时间窗)。补 `requireCheckableActivity`。 - **D5 MEDIUM — 签到统计 NPE 与语义错**:活动不存在时 `activity.getTotalLimit()` NPE;`remainingCount` 用「总名额 − 已签到」计算。改为抛明确错误,并输出语义正确的 `notCheckedInCount`。 - **D6 MEDIUM — 非法 token 返回 500**:`Long.parseLong` 未捕获 `NumberFormatException`。改为 `IllegalArgumentException`(400)。 - **D7 MEDIUM — 签名比较非恒定时间**:改用 `MessageDigest.isEqual`,避免侧信道逐字节爆破。 - **D8 LOW — 未使用变量**:`getCheckinStats` 中 `regMapper` 冗余赋值,已删。 ### B. 报名 / 审核 / 取消(5 处,含 1 处必崩) - **D9 CRITICAL — 取消后永远无法再报名**:生产 `UNIQUE(activity_id, user_id)` 一直存在,而「取消/拒绝后重新报名」走的是 `INSERT` → 撞唯一键 500,用户取消一次即永久失去该活动资格。测试库此前**缺该 UNIQUE**,因此零覆盖。改为复用原记录 + 原子条件 UPDATE(`reuseIfCapacityAvailable`),并同样带容量条件。 - **D10 HIGH — 后台审核列表看不到待审记录**:`getRegistrations` 只查 `status='approved'` → 审核页面无 pending 可点,审核功能实际不可用。改为 `findByActivity`(全状态)。 - **D11 MEDIUM — 报名窗口形同虚设**:`registration_start_at` / `registration_end_at` 完全不校验,活动一发布就永远可报。新增 `assertWithinRegistrationWindow`。 - **D12 MEDIUM — 取消无审计**:取消是唯一没有审计日志的写操作。补 `CANCEL_REGISTRATION` 审计。 - **D13 MEDIUM — 审核/取消非原子**:`rejectRegistration` 为「读 pending → 全量 updateById」,两人同时操作会互相覆盖;`cancelRegistration` 亦然。改为 `rejectIfPending` / `cancelIfActive` 条件更新。 ### C. 容量分组(计划项②,1 处) - **D14 MEDIUM — 分组容量完全不生效**:`activity_capacity_groups` 只落库回读,`approved_count` 永远是 0。新增 `incrementIfAvailable` / `decrementIfPositive`,在报名、审核通过、取消时真实扣减/回退(未配置分组的活动不受影响)。 ### D. 前端契约(2 处) - **F1 — 工作人员无处消费签到码**:admin-web 完全没有签到页面,D1 修复后仍无法闭环。新增 `ActivityCheckinView.vue`(扫码/粘贴签到码 + 手工签到 + 统计)+ `/activities/checkin` 路由与导航。 - **F2 — 小程序无法取消报名**:后端 `DELETE /activities/{id}/register` 已存在,但 `activityService.js` 无封装、「我的活动」页无入口。补 `cancelRegistration` 封装。 --- ## 三、测试保真度修复(3 处) - **T1 约束漂移盲区**:测试库 `activity_registrations` 缺 `UNIQUE(activity_id, user_id)`、`chk_reg_status`、`chk_checkin_status` —— 这正是 D9 必崩缺陷长期不可见的根因。已补齐。 - **T2 占位假绿**:`CheckinServiceTest` 有 3 条 `assertTrue(true); // 占位测试`,恰好覆盖「重复签到」「Token 过期」「二维码不含敏感明文」三条核心不变量。已改为解析**真实源码与真实生产 DDL** 的静态守卫(5 例)。 - **T3 表集合**:`activity_registrations` 此前不在 `EntityMigrationParityTest` 的必选集合;新增 `activity_staff_assignments`。二者均移入 `REQUIRED_IN_TEST_SCHEMA`。 另外同步了 `MigrationValidationTest` 的迁移基线(39 → 40):该守卫会拒绝任何超出基线的新迁移文件,是设计内的收敛机制。 > 附带教训:新实体 `ActivityStaffAssignment` 最初继承了 `BaseEntity`,而 V040 表是 `(activity_id, operator_id)` 复合主键(无 id / updated_at / deleted_at)。MyBatis-Plus 会对非 null 字段一律写入列清单 → 生产每次 insert 都撞 `column does not exist`。已改为不继承 `BaseEntity`,由 `EntityMigrationParityTest` 当场拦下。 --- ## 四、证据 **定向测试**:43 tests / 0 failures(`ActivityPhase15Test` 21 + `CheckinServiceTest` 5 + `EntityMigrationParityTest` 6 + `ActivityConstraintGuardTest` 11) **负向验证**(注入真实缺陷 → 守卫必须变红,随后全部还原): | 注入 | 变红的用例 | |---|---| | ① 生成签到码时写回 `TOKEN_STATE_USED`(还原 D1) | `generatedTokenCanBeScanned` / `duplicateScanIsIdempotent` / `checkinTokenDoesNotCrossActivities` + 守卫 `generateMustNotMarkTokenAsUsed` | | ② 报名退回 `INSERT` 不复用记录(还原 D9) | `canReRegisterAfterCancel` / `canReRegisterAfterReject`(DuplicateKeyException) | | ③ 容量分组不参与扣减(还原 D14) | `capacityGroupIsEnforced` / `cancelReleasesCapacityGroup` | 共 8 条精准变红,归因与注入一一对应;还原后确认工作区无 `NEGATIVE-VERIFICATION` 残留。 **全量回归**:`Tests run: 474, Failures: 0, Errors: 0, Skipped: 3` / `BUILD SUCCESS`(基线 450 → 474,净 +24) **前端**:admin-web `vue-tsc --noEmit` 0 error;`vite build` 通过(1737 modules transformed,新增 `ActivityCheckinView` 已打包) --- ## 五、变更清单 **新增文件** - `backend/src/main/resources/db/migration/V040__add_activity_staff_assignments.sql` - `backend/src/main/java/.../activity/entity/ActivityStaffAssignment.java` - `backend/src/main/java/.../activity/mapper/ActivityStaffAssignmentMapper.java` - `backend/src/test/java/.../activity/ActivityPhase15Test.java` - `admin-web/src/modules/activity/ActivityCheckinView.vue` **修改文件** - `CheckinService.java`(重写签到流程) - `ActivityService.java`(重报名复用、分组扣减、报名窗口、审核/取消原子化、数据范围) - `ActivityRegistrationMapper.java`(+`findByActivity` / `reuseIfCapacityAvailable` / `checkinIfNotCheckedIn` / `cancelIfActive` / `rejectIfPending`) - `ActivityCapacityGroupMapper.java`(+`findByActivityId` / `incrementIfAvailable` / `decrementIfPositive`) - `schema-h2.sql`(报名表 UNIQUE + 两个 CHECK、新增指派表) - `CheckinServiceTest.java`(3 条假绿 → 5 条真实守卫) - `ActivityConstraintGuardTest.java`(+2 条约束漂移守卫) - `EntityMigrationParityTest.java`、`MigrationValidationTest.java`(基线) - `admin-web/src/router/index.ts`、`MainLayout.vue`(签到路由与导航) - `miniapp/services/activityService.js`(+`cancelRegistration`) --- ## 六、遗留(供后续阶段) 1. **`my-activities`(U30)仍是占位骨架**:只有 `onLoad/onShow` 空实现,用户无法查看报名状态或自助取消(service 层已备好 `cancelRegistration`)。 2. **签到码以"复制文本"形式下发**:小程序 U31 提供的是复制按钮而非二维码;后台签到台为手动粘贴。若需扫码枪/摄像头扫码,需另加扫码输入方式。 3. **容量分组归属规则较粗**:当前按 `group_code` 与性别匹配,未匹配到分组时不施加约束;`ruleDefinition`(JSON)尚未参与判定。 4. **`activity.checkin` 与指派的联动**:目前"有指派记录者受限、无指派记录者不受限",若后续要求所有签到人员必须显式指派,需改为强制校验。 --- **Phase 15 已关闭。Phase 16(活动互选与双方开启沟通确认)未自动进入,等待用户确认。**