Files
danyang-match/docs/Phase-14-V11-Recovery-Completion-Report-2026-09-17.md
WorkBuddy 755a570849 fix(activity): close Phase 14 KEEP-VERIFY/EXTEND — 11 backend + 3 frontend-contract + 2 test-fidelity defects
Phase 14 (multi-type public-welfare activities) verification closure.

Backend (D1-D11):
- D1 CRITICAL: ActivityMapper.countRemainingCapacity had no @Select and no XML
  -> activity detail / register / approve all threw Invalid bound statement.
  Removed the shell; unified on ActivityRegistrationMapper.
- D2 CRITICAL: register & approve were read-then-write -> oversold under
  concurrency. Replaced with single atomic SQL (insertIfCapacityAvailable /
  approveIfCapacityAvailable, affectedRows==0 => full).
- D3 HIGH: activityType / orgRole never validated against production
  chk_activity_type / chk_org_role -> 500 only on PostgreSQL.
- D4 HIGH: ActivityListItemDTO had no activityType -> miniapp category tabs
  always empty (Gate failure).
- D5: male/female counts were fabricated as approvedCount/2; now real
  aggregation over user_profiles.gender.
- D6: minAge/maxAge was a TODO no-op; added assertAgeInRange.
- D7: registration_policy write-only; per-type eligibility now enforced
  (identity / local eligibility / gender quota).
- D8: POST /operator/activities dropped type, multi-org and capacity groups
  and set organizer_org_id to the operator user id; now delegates to
  createActivityFromAdmin (single implementation).
- D9: three operator write endpoints were mounted on the user-side
  ActivityController (dual implementation, zero callers) -> moved.
- D10: NPE on soft-deleted activity in getMyActivities; NPE/500 on missing
  admin form fields (Instant.parse(null)); tolerant time parsing added.
- D11: DTO lacked registeredCount / status / coverFileId / description
  -> card always showed 0 registered, "view detail", no cover, no summary.

Frontend contract (F1-F3):
- F1: miniapp category code "classroom" is not in the production whitelist
  (should be relationship_education); three types had no tab at all.
- F2: ui-activity-card read 7 fields that do not exist on the backend;
  now reads only real DTO fields, plus per-type badge and eligibility hint.
- F3: admin-web had no activity management page at all; added
  ActivityManagementView.vue (type-driven dynamic form, multi-org,
  capacity groups, publish/cancel) + /activities route and nav entry.

Test fidelity (T1-T2):
- T1: deleted two assertTrue(true) placeholder tests (testNoOverbooking,
  testNoDuplicateRegistration) that covered the exact broken invariants.
- T2: test schema lacked V023 columns/constraints and three tables; the
  activity tables were only in the OPTIONAL set (zero coverage) -> schema
  completed and moved to REQUIRED_IN_TEST_SCHEMA.

Tests: ActivityPhase14Test (23 cases), ActivityConstraintGuardTest (9 cases,
parses real db/migration/V*.sql plus miniapp source). Two negative-verification
rounds proved both guard families actually turn red.

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

139 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 14:多类型公益活动 — KEEP-VERIFY/EXTEND 复验完成报告
- **日期**:2026-09-17
- **分支**:`main`
- **Tag**:`phase14-recovery-2026-09-17`
- **基线**:Phase 13A 关闭时后端 **420 tests 全绿**
- **复验结论**:发现并修复 **11 处后端缺陷 + 3 处前端契约缺陷 + 2 处测试保真度缺陷**;全量回归 **450 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**
- **计划出处**:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` Phase 14(第 337-347 行)
---
## 一、Gate 结论
| Gate | 结论 | 证据 |
|------|------|------|
| matchmaking 与课堂/反诈类活动显示和资格策略不同 | ✅ 通过 | `resolveEligibility` 按类型返回三档策略;`matchmakingAndAntiFraudPoliciesMustDiffer` 守卫 + `resolveEligibilityDiffersByType` / `matchmakingRequiresIdentityAndLocalEligibility` / `antiFraudDoesNotRequireIdentity` 三条行为用例 |
| 容量按配置正确扣减 | ✅ 通过 | 原子 SQL `insertIfCapacityAvailable` / `approveIfCapacityAvailable`;`registerRespectsTotalLimitAtomically`、`approveRespectsTotalLimitAtomically`、`genderQuotaIsEnforcedForMatchmaking`、`antiFraudIgnoresGenderQuota` |
| 主办组织关系不自动授予用户库访问权限(INV-020) | ✅ 通过 | `activityOrganizationsExposeNoUserData`(组织接口零用户字段)+ `registrationListRequiresOperatorPermission`(运营端类级 `@PreAuthorize('activity.view')`,用户端无报名列表) |
---
## 二、缺陷清单
### 2.1 后端(11 处)
| 编号 | 级别 | 缺陷 | 修复 |
|------|------|------|------|
| D1 | **Critical 生产故障** | `ActivityMapper.countRemainingCapacity` **既无 `@Select` 也无 XML**,且项目未配置 `mapper-locations` → 活动详情 / 报名 / 审核**三条路径必抛** `Invalid bound statement (not found)`。而 `ActivityService` 调用的正是这个空壳 | 删除空壳,统一走 `ActivityRegistrationMapper`(唯一有真实 SQL 的实现) |
| D2 | **Critical 并发** | 报名与审核都是「先 SELECT 余量 → 再 INSERT/UPDATE」,并发下必然超卖;且 `needReview=false` 时写入 approved 却完全不校验名额 | 改为单条原子 SQL:`INSERT ... SELECT ... WHERE (SELECT COUNT...) < total_limit [AND 性别名额]`,`affectedRows == 0` 即报「名额已满」;审核同理 `approveIfCapacityAvailable` |
| D3 | High 生产故障 | 创建活动 / 添加组织关系**未校验** `activityType`、`orgRole` 是否落在生产 `chk_activity_type` / `chk_org_role` 白名单内。非法值在 H2 测试库静默通过,上 PostgreSQL 才 500(典型的"只在 PG 上炸") | Service 侧白名单校验(`VALID_ACTIVITY_TYPES` / `VALID_ORG_ROLES`),常量由守卫**解析真实 DDL** 双向校验 |
| D4 | High 契约 | `ActivityListItemDTO` **没有 `activityType` 字段**,而小程序 `pages/activity/activity.ts` 正是读它做分类筛选 → 分类 Tabs 恒无数据,Gate ① 直接失败 | 补字段并在 `toListItemDTO` 回填 |
| D5 | Medium 数据造假 | `toListItemDTO` 用 `approvedCount / 2` 与 `approvedCount - approvedCount/2` **凭空编造**男女人数;`countApprovedMaleRegistrations` 的 SQL 忽略 gender 入参且零调用方 | 新增 `countApprovedByGender`,按 `user_profiles.gender` 真实聚合 |
| D6 | Medium | 年龄门槛 `minAge` / `maxAge` 为 TODO 空壳,**完全不生效** | 补 `assertAgeInRange` |
| D7 | Medium Gate 未落地 | `registration_policy` 字段只写不读,按类型的资格策略实际为空 | 改为按类型强制执行:实名(`TYPES_REQUIRING_IDENTITY`)、本地资格(`TYPES_REQUIRING_LOCAL_ELIGIBILITY`)、性别名额(`TYPES_WITH_GENDER_QUOTA`) |
| D8 | Medium 生产故障 | `POST /operator/activities` **丢弃** activityType / registrationPolicy / 多组织 / 容量分组,并把 `organizer_org_id` **错设为操作者 userId** → 后台动态表单无法创建带类型活动 | 改为委托 `createActivityFromAdmin`(单一实现),并回填 `organizer_org_id` |
| D9 | Medium 权限边界 | 三个运营写入端点(`POST /activities/operator`、`/operator/{id}/organizations`、`/operator/{id}/capacity-groups`)挂在**用户端** `ActivityController`,与运营端**双实现**且零调用方 | 迁至 `ActivityOperatorController`(类级 `@PreAuthorize('activity.view')` + 方法级 `activity.create`) |
| D10 | Medium NPE | ① `getMyActivities` 对已软删活动 `selectById` 返回 null 直接 NPE;② `createActivityFromAdmin` 对缺字段请求 `Instant.parse(null)` / `((Number) null).intValue()` 直接 500 | ① 跳过软删活动;② 缺省值兜底 + 容忍时间戳 / 本地无时区文本 / ISO 文本三种时间格式 |
| D11 | Medium 契约 | DTO 缺 `registeredCount` / `status` / `coverFileId` / `description` → 卡片"报名人数"恒 0、按钮恒显示"查看详情"、封面与摘要恒不显示 | 四个字段补齐并在 `toListItemDTO` 回填 |
### 2.2 前端契约(3 处)
| 编号 | 缺陷 | 修复 |
|------|------|------|
| F1 | 小程序分类 Tabs 的 code 写的是 `classroom`,而生产白名单里**根本没有该值**(婚恋课堂是 `relationship_education`)→ 点「婚恋课堂」恒为空白列表;且 `interest_social` / `premarital_guidance` / `marriage_custom_culture` 三类**完全没有入口** | 六个 code 逐字对齐生产白名单,补全全部六类 |
| F2 | `ui-activity-card` 读取 `registeredCount` / `currentMaleCount` / `currentFemaleCount` / `coverImageUrl` / `activityStartAt` / `suitableFor` / `intro` —— **后端一个都没有** | 改为只读 DTO 真实字段;封面按 `coverUrl ?? uploadBase + '/files/' + coverFileId` 拼接;时间本地格式化;新增类型标签与差异化资格提示(Gate ① 的前端落点) |
| F3 | admin-web **完全没有活动管理页**(仅有互选页),后台动态表单 100% 缺失 | 新增 `ActivityManagementView.vue`:类型驱动的动态表单(性别名额/互选开关按类型显隐)、多组织增删、容量分组增删、发布/取消;注册 `/activities` 路由与导航 |
### 2.3 测试保真度(2 处)
| 编号 | 缺陷 | 修复 |
|------|------|------|
| T1 | **假绿占位测试**:`ActivityServiceTest#testNoOverbooking` 与 `testNoDuplicateRegistration` 是 `assertTrue(true); // 占位测试`。名字覆盖两条核心不变量,实际什么都不验证 —— 而真实实现恰恰是坏的 | 删除(占位测试不是"待补",是持续误导的假信号),迁至 `ActivityPhase14Test` 走真实落库路径 |
| T2 | **测试库漂移**:`activities` 缺 V023 五列与 `chk_activity_type`;`organizations` / `activity_organizations` / `activity_capacity_groups` **三表完全缺失**;且这五张表在 `EntityMigrationParityTest` 中只属"可选白名单"(= 零覆盖) | `schema-h2.sql` 补齐列、CHECK 约束与三张表;五张表从 `TEST_SCHEMA_OPTIONAL_TABLES` 移入 `REQUIRED_IN_TEST_SCHEMA` |
---
## 三、新增测试
| 测试类 | 用例数 | 性质 |
|--------|--------|------|
| `ActivityPhase14Test` | 23 | Spring 上下文行为测试:原子容量扣减、按类型资格策略、白名单校验、年龄门槛、列表数据真实性、软删不 NPE、INV-020、多组织/容量分组落库、后台表单容错 |
| `ActivityConstraintGuardTest` | 9 | 纯反射 + 文件解析(无 Spring):解析**真实** `db/migration/V*.sql` 校验 `chk_activity_type` / `chk_org_role`;解析小程序 `activity.ts` 分类 code 与卡片 `activity.*` 字段名 |
**守卫自检**:`parserMustResolveActivityConstraints` 断言迁移文件数 ≥ 30 且两个约束白名单非空 —— 解析器失效时守卫自己先红,避免恒真假绿。
---
## 四、负向验证(两轮,证明守卫非恒真)
### 第一轮:后端
| 注入的缺陷 | 变红的用例 |
|-----------|-----------|
| 从 `V023` 的 `chk_activity_type` 白名单删除 `anti_fraud` | `ActivityConstraintGuardTest.activityTypeConstantsWithinProductionWhitelist`(精准命中,说明解析的是**真实 DDL** 而非副本) |
| 报名退化为「不检查名额直接 INSERT」 | `ActivityPhase14Test.registerRespectsTotalLimitAtomically` + `genderQuotaIsEnforcedForMatchmaking` |
结果:`Tests run: 26, Failures: 3` / `BUILD FAILURE`。已还原。
### 第二轮:前端契约
| 注入的缺陷 | 变红的用例 |
|-----------|-----------|
| 分类 code 改回 `classroom` 并删除 `anti_fraud` 入口 | `miniappCategoryCodesWithinProductionWhitelist` + `everyProductionTypeHasMiniappTab` |
| 卡片加回 `activity.suitableFor` | `cardFieldsExistInListItemDto` |
结果:`Tests run: 9, Failures: 3` / `BUILD FAILURE`。已还原。
> 两轮负向验证中均出现过**守卫自身**的解析缺陷(`categories:` 先命中接口声明、正则扫到注释里的 `activity.capacity`),已一并修正 —— 守卫必须**先剥注释再扫描**,且匹配要精确到 `categories: [`。
---
## 五、回归证据
| 项目 | 结果 |
|------|------|
| 后端全量 `mvn -o test` | **Tests run: 450, Failures: 0, Errors: 0, Skipped: 3** / `BUILD SUCCESS`(4 分 52 秒) |
| 基线对比 | Phase 13A 420 → **450**(净 +30 = 新增 23 + 9,删除 2 条占位测试) |
| admin-web `vue-tsc --noEmit` | 通过,0 error |
| admin-web `vite build` | `✓ built in 9.75s`,新增 `ActivityManagementView`(13.28 kB) |
| 后端 `mvn test-compile` | `BUILD SUCCESS`(282 main + 75 test 源文件) |
---
## 六、变更文件
**后端**
- `activity/mapper/ActivityMapper.java` — 删除无 SQL 空壳;新增 `findOpenActivitiesByType`、`countApprovedByGender`
- `activity/mapper/ActivityRegistrationMapper.java` — 新增原子 `insertIfCapacityAvailable` / `approveIfCapacityAvailable`
- `activity/service/ActivityService.java` — 报名/审核改原子;按类型资格策略;白名单校验;年龄门槛;DTO 回填;后台表单容错
- `activity/dto/ActivityListItemDTO.java` — 新增 `activityType` / `registeredCount` / `coverFileId` / `description` / `status`
- `activity/controller/ActivityOperatorController.java` — 建活动改走 V1.1;新增组织/容量分组 4 个端点;列表补 `activityType`
- `activity/controller/ActivityController.java` — 删除 3 个错挂在用户端的运营端点
**前端**
- `miniapp/pages/activity/activity.ts` — 分类 code 对齐生产白名单(6 类)
- `miniapp/components/activity-card/{index.js,index.wxml,index.wxss}` — 只读 DTO 真实字段;类型标签 + 差异化资格提示 + 封面/时间/报名人数
- `admin-web/src/modules/activity/ActivityManagementView.vue` — **新增**,类型驱动动态表单
- `admin-web/src/router/index.ts`、`admin-web/src/layouts/MainLayout.vue` — 注册 `/activities` 路由与导航
**测试**
- 新增 `ActivityPhase14Test`(23)、`ActivityConstraintGuardTest`(9)
- `ActivityServiceTest` — 删除 2 条占位假绿测试
- `EntityMigrationParityTest` — activity 五表移入必选集合
- `backend/src/test/resources/schema-h2.sql` — 补 V023 列/约束与三张缺失表
---
## 七、遗留与建议(不在本阶段范围)
1. **容量分组尚未参与名额计算**:`activity_capacity_groups` 目前只做配置落库与回读,报名时的名额扣减仍走 `total_limit` + 男女名额。Phase 15(报名/审核/签到)应把分组规则真正接入扣减链路。
2. **`registration_policy` 目前仍只写不读**:本阶段按 `activityType` 推导资格策略(可落地、可验证),`registration_policy` 作为更细粒度的覆盖项尚未接线。若产品需要"同类型不同策略",应在 Phase 15 明确其优先级。
3. **首页活动卡片未复用 `ui-activity-card`**:`miniapp/pages/index/index.wxml` 仍内联渲染,且把 `coverFileId` 直接当图片 src(应为 `uploadBase + '/files/' + id`)。建议后续统一走组件。
---
## 八、结论
Phase 14(多类型公益活动)**复验关闭**。三项计划任务均已完成,三项 Gate 全部达成并通过行为用例与守卫双重覆盖。
**Phase 15(活动报名、审核与签到)未自动进入,等待用户确认。**