125 lines
9.5 KiB
Markdown
125 lines
9.5 KiB
Markdown
# 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(活动互选与双方开启沟通确认)未自动进入,等待用户确认。**
|