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.
12 KiB
12 KiB
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 客户需求基线增量开发与修复计划.mdPhase 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、countApprovedByGenderactivity/mapper/ActivityRegistrationMapper.java— 新增原子insertIfCapacityAvailable/approveIfCapacityAvailableactivity/service/ActivityService.java— 报名/审核改原子;按类型资格策略;白名单校验;年龄门槛;DTO 回填;后台表单容错activity/dto/ActivityListItemDTO.java— 新增activityType/registeredCount/coverFileId/description/statusactivity/controller/ActivityOperatorController.java— 建活动改走 V1.1;新增组织/容量分组 4 个端点;列表补activityTypeactivity/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 列/约束与三张缺失表
七、遗留与建议(不在本阶段范围)
- 容量分组尚未参与名额计算:
activity_capacity_groups目前只做配置落库与回读,报名时的名额扣减仍走total_limit+ 男女名额。Phase 15(报名/审核/签到)应把分组规则真正接入扣减链路。 registration_policy目前仍只写不读:本阶段按activityType推导资格策略(可落地、可验证),registration_policy作为更细粒度的覆盖项尚未接线。若产品需要"同类型不同策略",应在 Phase 15 明确其优先级。- 首页活动卡片未复用
ui-activity-card:miniapp/pages/index/index.wxml仍内联渲染,且把coverFileId直接当图片 src(应为uploadBase + '/files/' + id)。建议后续统一走组件。
八、结论
Phase 14(多类型公益活动)复验关闭。三项计划任务均已完成,三项 Gate 全部达成并通过行为用例与守卫双重覆盖。
Phase 15(活动报名、审核与签到)未自动进入,等待用户确认。