Files
danyang-match/docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md
WorkBuddy 29112eed6e fix(activity): close Phase 15 KEEP-VERIFY/EXTEND — 2 critical + 14 other defects, 3 test-fidelity fixes
Phase 15 (activity registration / review / checkin) verification closure.

Two CRITICAL defects, both "production always had it, test schema never did":
- D1: generateCheckinToken wrote the token to Redis with value "used", while
  scanCheckin rejects any token marked "used" -> every freshly issued check-in
  code was dead on arrival, staff scanning failed 100% of the time.
  Issuing now writes "issued"; "used" is set only after a successful scan.
- D9: UNIQUE(activity_id, user_id) exists in production, but re-registering
  after cancel/reject went through INSERT -> users who cancelled once could
  never register for that activity again (duplicate key 500). The H2 test
  schema lacked the UNIQUE, so the path had zero coverage. Now the original
  row is reused via an atomic conditional UPDATE.

Checkin (D2-D8):
- D2: duplicate check-in was read-then-write; now atomic checkinIfNotCheckedIn
  and repeat scans are idempotent (alreadyCheckedIn) without duplicate audit.
- D3: no data scope at all -> added activity staff assignment (V040) and
  assertCheckinScope; INV-020 (org relation grants nothing).
- D4: manualCheckin did not validate activity existence/cancellation.
- D5: getCheckinStats NPE on missing activity; remainingCount was semantically
  "free seats" -> replaced with notCheckedInCount.
- D6: malformed timestamp caused 500 -> 400. D7: constant-time signature compare.

Registration / review / cancel (D10-D13):
- D10: backend registration list only queried status='approved' -> the review
  screen never saw pending rows (review unusable).
- D11: registration_start_at / registration_end_at were never enforced.
- D12: cancel had no audit log (the only write without one).
- D13: reject/cancel were read-then-full-update -> rejectIfPending/cancelIfActive.

Capacity groups (D14, plan item 2): groups were stored and read back but never
participated in capacity -> incrementIfAvailable / decrementIfPositive now make
approved_count real; activities without groups are unaffected.

Frontend contract (F1-F2):
- F1: admin-web had no check-in surface at all -> ActivityCheckinView.vue
  (paste/scan token, manual check-in, stats) + /activities/checkin route & nav.
- F2: miniapp had no cancel-registration wrapper although the endpoint existed.

Test fidelity (T1-T3):
- T1: test schema gained UNIQUE(activity_id,user_id) + chk_reg_status +
  chk_checkin_status; two new guards parse the real V010 DDL and compare.
- T2: 3 assertTrue(true) placeholders in CheckinServiceTest (covering duplicate
  check-in, expiry, no-sensitive-plaintext) replaced with 5 real guards that
  parse the real source and real production DDL.
- T3: activity_registrations + activity_staff_assignments moved into
  REQUIRED_IN_TEST_SCHEMA; MigrationValidationTest baseline 39 -> 40.

Note: ActivityStaffAssignment must NOT extend BaseEntity - the table is a
composite PK (activity_id, operator_id); BaseEntity would add id/updated_at/
deleted_at to every INSERT and break on PostgreSQL (caught by
EntityMigrationParityTest immediately).

Tests: ActivityPhase15Test (21), CheckinServiceTest (5), 2 new constraint
guards. Negative verification: 3 injected defects -> 8 assertions turned red,
all restored.

Regression: 474 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 450). admin-web vue-tsc 0 error, vite build OK.
2026-09-17 15:07:52 +08:00

9.5 KiB
Raw Permalink Blame History

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