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.
139 lines
12 KiB
Markdown
139 lines
12 KiB
Markdown
# 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(活动报名、审核与签到)未自动进入,等待用户确认。**
|