From 755a570849b316fc57c54988b595b5b3e9efeca1 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Thu, 17 Sep 2026 14:02:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(activity):=20close=20Phase=2014=20KEEP-VERI?= =?UTF-8?q?FY/EXTEND=20=E2=80=94=2011=20backend=20+=203=20frontend-contrac?= =?UTF-8?q?t=20+=202=20test-fidelity=20defects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- admin-web/src/layouts/MainLayout.vue | 2 +- .../activity/ActivityManagementView.vue | 453 ++++++++++++++++ admin-web/src/router/index.ts | 2 + .../controller/ActivityController.java | 40 +- .../ActivityOperatorController.java | 80 ++- .../activity/dto/ActivityListItemDTO.java | 38 ++ .../activity/mapper/ActivityMapper.java | 21 +- .../mapper/ActivityRegistrationMapper.java | 69 ++- .../activity/service/ActivityService.java | 317 +++++++++-- .../activity/ActivityConstraintGuardTest.java | 331 ++++++++++++ .../activity/ActivityPhase14Test.java | 497 ++++++++++++++++++ .../activity/ActivityServiceTest.java | 32 +- .../deployment/EntityMigrationParityTest.java | 10 +- backend/src/test/resources/schema-h2.sql | 49 +- docs/JIRA_BACKLOG.md | 17 + ...1-Recovery-Completion-Report-2026-09-17.md | 138 +++++ docs/STATUS.md | 22 +- ...阳 V1.1 客户需求基线增量开发与修复计划.md | 45 +- miniapp/components/activity-card/index.js | 90 +++- miniapp/components/activity-card/index.wxml | 28 +- miniapp/components/activity-card/index.wxss | 1 + miniapp/pages/activity/activity.ts | 18 +- 22 files changed, 2164 insertions(+), 136 deletions(-) create mode 100644 admin-web/src/modules/activity/ActivityManagementView.vue create mode 100644 backend/src/test/java/com/danyang/match/platform/activity/ActivityConstraintGuardTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase14Test.java create mode 100644 docs/Phase-14-V11-Recovery-Completion-Report-2026-09-17.md diff --git a/admin-web/src/layouts/MainLayout.vue b/admin-web/src/layouts/MainLayout.vue index 34328cc..91ad008 100644 --- a/admin-web/src/layouts/MainLayout.vue +++ b/admin-web/src/layouts/MainLayout.vue @@ -170,7 +170,7 @@ const navigation: NavigationItem[] = [ { label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled }, { label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection }, { label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound }, - { label: '活动管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: Calendar }, + { label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar }, { label: '内容管理', path: '/content', permissions: ['content.view'], icon: Reading }, { label: '善缘服务站', path: '/service-station', permissions: ['service_location.view'], icon: Location }, { label: '举报风控', path: '/risk/reports', permissions: ['risk.report.view'], icon: Warning }, diff --git a/admin-web/src/modules/activity/ActivityManagementView.vue b/admin-web/src/modules/activity/ActivityManagementView.vue new file mode 100644 index 0000000..f9aa0f0 --- /dev/null +++ b/admin-web/src/modules/activity/ActivityManagementView.vue @@ -0,0 +1,453 @@ + + + + + diff --git a/admin-web/src/router/index.ts b/admin-web/src/router/index.ts index dc3f801..e3784ab 100644 --- a/admin-web/src/router/index.ts +++ b/admin-web/src/router/index.ts @@ -18,6 +18,8 @@ const routes: RouteRecordRaw[] = [ children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { title: '工作台' } }, { path: 'review', name: 'Review', component: () => import('@/modules/review/ReviewView.vue'), meta: { title: '资料审核', permissions: ['review.view'] } }, + // V1.1 Phase 14:活动管理(类型化活动 + 动态表单 + 多组织 + 容量分组) + { path: 'activities', name: 'ActivityManagement', component: () => import('@/modules/activity/ActivityManagementView.vue'), meta: { title: '活动管理', permissions: ['activity.view'] } }, { path: 'activities/interests', name: 'ActivityInterests', component: () => import('@/modules/activity/ActivityInterestView.vue'), meta: { title: '活动互选管理', permissions: ['activity.review_registration'] } }, { path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } }, { path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } }, diff --git a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityController.java b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityController.java index 54efcc1..d93b432 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityController.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityController.java @@ -3,9 +3,7 @@ package com.danyang.match.platform.activity.controller; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.activity.service.ActivityService; import com.danyang.match.platform.shared.dto.ApiResponse; -import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -99,33 +97,13 @@ public class ActivityController { return ApiResponse.ok(activityService.getActivityCapacityGroups(id)); } - @PostMapping("/operator") - @PreAuthorize("hasAuthority('activity.create')") - public ApiResponse createActivityOperator( - @AuthenticationPrincipal OperatorPrincipal principal, - @RequestBody Map body) { - String operatorId = principal.id(); - return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId)); - } - - @PostMapping("/operator/{id}/organizations") - @PreAuthorize("hasAuthority('activity.create')") - public ApiResponse addActivityOrganization( - @AuthenticationPrincipal OperatorPrincipal principal, - @PathVariable String id, - @RequestBody Map body) { - String operatorId = principal.id(); - return ApiResponse.ok(activityService.addActivityOrganization(id, body.get("organizationId"), body.get("role"), operatorId)); - } - - @PostMapping("/operator/{id}/capacity-groups") - @PreAuthorize("hasAuthority('activity.create')") - public ApiResponse addCapacityGroup( - @AuthenticationPrincipal OperatorPrincipal principal, - @PathVariable String id, - @RequestBody Map body) { - String operatorId = principal.id(); - return ApiResponse.ok(activityService.addCapacityGroup(id, body, operatorId)); - } - + // ------------------------------------------------------------------ + // Phase 14 复验:以下三个运营端写入端点原先挂在**用户端** /activities 下 + // POST /activities/operator + // POST /activities/operator/{id}/organizations + // POST /activities/operator/{id}/capacity-groups + // 属双实现 + 权限边界错配(运营写操作混在用户端控制器里), + // 且后台实际调用的是 /operator/activities,这三个端点零调用方。 + // 已统一迁至 ActivityOperatorController(类级 @PreAuthorize('activity.view'))。 + // ------------------------------------------------------------------ } diff --git a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityOperatorController.java b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityOperatorController.java index a2adbe6..226aa9c 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityOperatorController.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityOperatorController.java @@ -14,8 +14,6 @@ import java.util.List; import java.util.stream.Collectors; import com.danyang.match.platform.activity.entity.Activity; import java.util.Map; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.danyang.match.platform.shared.constant.Constants; /** * 后台活动管理控制器 @@ -30,35 +28,69 @@ public class ActivityOperatorController { private final ActivityMapper activityMapper; /** - * 创建活动 + * 创建活动(V1.1 类型化) * POST /api/v1/operator/activities + * + *

Phase 14 修复:原实现把请求体的 activityType / registrationPolicy / + * 多组织 / 容量分组**全部丢弃**,且把 {@code organizer_org_id} 错设为操作者 userId。 + * 后台动态表单因此无法创建带类型的活动 —— Gate「分类 Tabs 有数据」根本不成立。 + * 现统一委托 {@link ActivityService#createActivityFromAdmin},只保留一条实现。 */ @PostMapping @PreAuthorize("hasAuthority('activity.create')") @Transactional - public ApiResponse createActivity( + public ApiResponse createActivity( @AuthenticationPrincipal OperatorPrincipal principal, @RequestBody Map body) { String operatorId = principal.id(); - Activity activity = new Activity(); - activity.setTitle((String) body.get("title")); - activity.setDescription((String) body.get("description")); - activity.setLocationName((String) body.get("locationName")); - activity.setLocationAddress((String) body.get("locationAddress")); - activity.setOrganizerOrgId(operatorId); - activity.setTotalLimit((Integer) body.getOrDefault("totalLimit", 50)); - activity.setMaleLimit((Integer) body.getOrDefault("maleLimit", 25)); - activity.setFemaleLimit((Integer) body.getOrDefault("femaleLimit", 25)); - activity.setMinAge(((Number) body.getOrDefault("minAge", 18)).shortValue()); - activity.setMaxAge(((Number) body.getOrDefault("maxAge", 60)).shortValue()); - activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT); - activity.setCreatedBy(operatorId); - // 默认时间:明天开始,持续3小时 - java.time.LocalDateTime now = java.time.LocalDateTime.now(); - activity.setStartAt(now.plusDays(1).withHour(14).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8))); - activity.setEndAt(now.plusDays(1).withHour(17).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8))); - activityMapper.insert(activity); - return ApiResponse.ok(activity); + return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId)); + } + + /** + * 活动主办/协作组织列表 + * GET /api/v1/operator/activities/{id}/organizations + */ + @GetMapping("/{id}/organizations") + public ApiResponse>> getActivityOrganizations(@PathVariable String id) { + return ApiResponse.ok(activityService.getActivityOrganizations(id)); + } + + /** + * 添加活动组织关系 + * POST /api/v1/operator/activities/{id}/organizations + * Body: { "organizationId": "...", "role": "host|organizer|co_organizer|partner|venue_partner" } + */ + @PostMapping("/{id}/organizations") + @PreAuthorize("hasAuthority('activity.create')") + public ApiResponse addActivityOrganization( + @AuthenticationPrincipal OperatorPrincipal principal, + @PathVariable String id, + @RequestBody Map body) { + return ApiResponse.ok(activityService.addActivityOrganization( + id, body.get("organizationId"), body.get("role"), principal.id())); + } + + /** + * 活动容量分组列表 + * GET /api/v1/operator/activities/{id}/capacity-groups + */ + @GetMapping("/{id}/capacity-groups") + public ApiResponse>> getActivityCapacityGroups(@PathVariable String id) { + return ApiResponse.ok(activityService.getActivityCapacityGroups(id)); + } + + /** + * 新增容量分组 + * POST /api/v1/operator/activities/{id}/capacity-groups + * Body: { "groupCode": "...", "capacity": 20, "ruleDefinition": "{...}" } + */ + @PostMapping("/{id}/capacity-groups") + @PreAuthorize("hasAuthority('activity.create')") + public ApiResponse addCapacityGroup( + @AuthenticationPrincipal OperatorPrincipal principal, + @PathVariable String id, + @RequestBody Map body) { + return ApiResponse.ok(activityService.addCapacityGroup(id, body, principal.id())); } /** @@ -104,6 +136,8 @@ public class ActivityOperatorController { m.put("id", a.getId()); m.put("title", a.getTitle()); m.put("status", a.getStatus()); + // Phase 14:后台列表必须能看到活动类型,否则运营无法核对分类是否建对 + m.put("activityType", a.getActivityType()); m.put("startAt", a.getStartAt()); m.put("endAt", a.getEndAt()); m.put("locationName", a.getLocationName()); diff --git a/backend/src/main/java/com/danyang/match/platform/activity/dto/ActivityListItemDTO.java b/backend/src/main/java/com/danyang/match/platform/activity/dto/ActivityListItemDTO.java index 61d9b11..9f160b9 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/dto/ActivityListItemDTO.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/dto/ActivityListItemDTO.java @@ -12,6 +12,26 @@ public class ActivityListItemDTO { private String activityId; private String title; private String coverUrl; + + /** + * V1.1 Phase 14:封面文件 ID。 + * + *

修复:{@code coverUrl} 此前**从未被赋值**(全库零写入点), + * 活动卡片封面恒回退到默认图。小程序侧按 {@code uploadBase + '/files/' + coverFileId} + * 拼接真实地址(见 {@code components/activity-card/index.js})。 + */ + private String coverFileId; + + /** V1.1 Phase 14:活动简介(卡片摘要)。此前 DTO 无此字段,卡片摘要恒不渲染。 */ + private String description; + + /** + * V1.1 Phase 14:活动状态。 + * + *

修复:卡片按钮按 {@code activity.status === 'registration_open'} 显示 + * "立即报名",而 DTO 根本没有 status —— 按钮永远显示"查看详情"。 + */ + private String status; private Instant startAt; private Instant endAt; private String locationName; @@ -22,4 +42,22 @@ public class ActivityListItemDTO { private int remainingSeats; private boolean registered; private String registrationStatus; + + /** + * V1.1 Phase 14:活动类型。 + * + *

修复:小程序 {@code pages/activity/activity.ts} 的分类 Tabs 读取 + * {@code item.activityType},但后端列表 DTO 此前**根本没有该字段** —— + * 分类筛选恒无数据,Gate「matchmaking 与课堂/反诈类活动显示不同」直接失败。 + */ + private String activityType; + + /** + * V1.1 Phase 14:已报名人数。 + * + *

修复:小程序 {@code ui-activity-card} 读 {@code activity.registeredCount} + * (回退 {@code currentMaleCount + currentFemaleCount}),而后端从未提供这三个字段 —— + * 卡片上的"报名人数 x/y"恒为 {@code 0/y}。此处由真实性别统计汇总。 + */ + private int registeredCount; } diff --git a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityMapper.java b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityMapper.java index d05889f..717354d 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityMapper.java @@ -10,17 +10,32 @@ import java.util.List; @Mapper public interface ActivityMapper extends BaseMapper { - int countRemainingCapacity(String activityId); @Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL ORDER BY start_at ASC") List findOpenActivities(); + /** V1.1 Phase 14: 按类型下推查询(原为全表捞取后内存 filter) */ + @Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL " + + "AND activity_type = #{activityType} ORDER BY start_at ASC") + List findOpenActivitiesByType(@Param("activityType") String activityType); + @Select("SELECT * FROM activities WHERE start_at > NOW() AND deleted_at IS NULL ORDER BY start_at ASC LIMIT 10") List findUpcomingActivities(); @Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL") int countApprovedRegistrations(@Param("activityId") String activityId); - @Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL") - int countApprovedMaleRegistrations(@Param("activityId") String activityId, @Param("gender") String gender); + /** + * 统计某活动已通过报名的**真实性别构成**。 + * + *

Phase 14 修复:原 {@code countApprovedMaleRegistrations} 的 SQL + * 完全忽略了 {@code gender} 入参(永远返回全部通过人数),且零调用方—— + * 导致 {@code ActivityService#toListItemDTO} 只能用 {@code approvedCount / 2} + * **凭空猜测**男女人数。此处改为按 {@code user_profiles.gender} 真实聚合。 + */ + @Select("SELECT COUNT(*) FROM activity_registrations r " + + "JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL " + + "WHERE r.activity_id = #{activityId} AND r.status = 'approved' " + + "AND r.deleted_at IS NULL AND p.gender = #{gender}") + int countApprovedByGender(@Param("activityId") String activityId, @Param("gender") String gender); } diff --git a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityRegistrationMapper.java b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityRegistrationMapper.java index 39f3a45..780dac3 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityRegistrationMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityRegistrationMapper.java @@ -2,9 +2,11 @@ package com.danyang.match.platform.activity.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.danyang.match.platform.activity.entity.ActivityRegistration; +import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; import java.util.Optional; @@ -23,9 +25,74 @@ public interface ActivityRegistrationMapper extends BaseMapper findByUserId(String userId); /** - * 原子检查名额:返回剩余名额数 + * 原子检查名额:返回剩余名额数。 + * + *

Phase 14 说明:本方法是全模块**唯一**有 SQL 实现的容量查询。 + * 此前 {@code ActivityMapper} 上有一个同名方法却**既无 {@code @Select} 也无 XML**, + * 而 {@code ActivityService} 调用的正是那个空壳 —— + * 活动详情 / 报名 / 审核三条路径在生产必然抛 + * {@code Invalid bound statement (not found)}。已删除空壳并统一走本方法。 */ @Select("SELECT COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 9999) - " + "(SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL) AS remaining") int countRemainingCapacity(@Param("activityId") String activityId); + + /** + * **原子**占位插入:仅当「总名额未满」**且**「该性别名额未满」时才写入 approved 报名。 + * + *

把「查余量」与「写记录」合并为一条 SQL,由数据库保证原子性, + * 避免 {@code SELECT COUNT → INSERT} 的先读后写在并发下超卖。 + * + *

性别名额按**配置**生效:{@code male_limit}/{@code female_limit} 为 NULL 时 + * (如婚恋课堂 / 反诈类活动)该条件下退化为 999999,即只受总名额约束 —— + * 这正是 Gate「matchmaking 与课堂/反诈类容量策略不同」的实现方式。 + * + * @return 影响行数;0 表示名额已满(或活动不存在),调用方必须据此报错 + */ + @Insert("INSERT INTO activity_registrations (id, activity_id, user_id, status, registered_at, " + + "checkin_status, emergency_contact_name, emergency_contact_phone_enc, created_at, updated_at) " + + "SELECT #{id}, #{activityId}, #{userId}, #{status}, NOW(), 'not_checked_in', " + + "#{contactName}, #{contactPhone}, NOW(), NOW() " + + "WHERE (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} " + + " AND status = 'approved' AND deleted_at IS NULL) " + + " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) " + + " AND (SELECT COUNT(*) FROM activity_registrations r " + + " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL " + + " WHERE r.activity_id = #{activityId} AND r.status = 'approved' " + + " AND r.deleted_at IS NULL AND p.gender = #{gender}) " + + " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit " + + " WHEN 'female' THEN a.female_limit " + + " ELSE a.total_limit END " + + " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)") + int insertIfCapacityAvailable(@Param("id") String id, + @Param("activityId") String activityId, + @Param("userId") String userId, + @Param("status") String status, + @Param("contactName") String contactName, + @Param("contactPhone") String contactPhone, + @Param("gender") String gender); + + /** + * **原子**审核通过:仅当记录仍为 pending **且**总名额与性别名额均未满时才置为 approved。 + * + * @return 影响行数;0 表示名额已满或状态已被他人变更 + */ + @Update("UPDATE activity_registrations SET status = 'approved', reviewed_by = #{operatorId}, " + + "reviewed_at = NOW(), updated_at = NOW() " + + "WHERE id = #{id} AND status = 'pending' AND deleted_at IS NULL " + + " AND (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} " + + " AND status = 'approved' AND deleted_at IS NULL) " + + " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) " + + " AND (SELECT COUNT(*) FROM activity_registrations r " + + " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL " + + " WHERE r.activity_id = #{activityId} AND r.status = 'approved' " + + " AND r.deleted_at IS NULL AND p.gender = #{gender}) " + + " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit " + + " WHEN 'female' THEN a.female_limit " + + " ELSE a.total_limit END " + + " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)") + int approveIfCapacityAvailable(@Param("id") String id, + @Param("activityId") String activityId, + @Param("operatorId") String operatorId, + @Param("gender") String gender); } diff --git a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java index 010bd36..e764fe7 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java @@ -18,6 +18,8 @@ import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper; import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper; import com.danyang.match.platform.organization.entity.Organization; import com.danyang.match.platform.organization.mapper.OrganizationMapper; +import com.danyang.match.platform.verification.service.IdentityVerificationService; +import com.danyang.match.platform.local_eligibility.service.LocalEligibilityService; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,6 +36,46 @@ public class ActivityService { private static final Logger log = LoggerFactory.getLogger(ActivityService.class); + // ==================== Phase 14:多类型活动常量(必须与生产 V023 CHECK 白名单一致)==================== + // 守卫 ActivityConstraintGuardTest 会直接解析 db/migration/V*.sql 校验这些集合, + // 任何与生产白名单的偏离都会让测试变红 —— 不要在此处"顺手"加值。 + + /** 合法活动类型 ⊆ 生产 chk_activity_type */ + public static final Set VALID_ACTIVITY_TYPES = Set.of( + Constants.ACTIVITY_TYPE_MATCHMAKING, + Constants.ACTIVITY_TYPE_INTEREST_SOCIAL, + Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION, + Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE, + Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE, + Constants.ACTIVITY_TYPE_ANTI_FRAUD); + + /** 合法活动组织角色 ⊆ 生产 chk_org_role */ + public static final Set VALID_ORG_ROLES = Set.of( + Constants.ORG_ROLE_HOST, + Constants.ORG_ROLE_ORGANIZER, + Constants.ORG_ROLE_CO_ORGANIZER, + Constants.ORG_ROLE_PARTNER, + Constants.ORG_ROLE_VENUE_PARTNER); + + /** + * 需要**实名认证**才能报名的类型。 + * + *

Gate 要求「matchmaking 与课堂/反诈类活动资格策略不同」: + * 相亲类接触陌生人、必须实名;婚恋课堂 / 反诈宣传是公益普适科普,不设实名门槛。 + */ + public static final Set TYPES_REQUIRING_IDENTITY = Set.of( + Constants.ACTIVITY_TYPE_MATCHMAKING, + Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE); + + /** 需要**本地资格**才能报名的类型(丹阳户籍/工作/居住) */ + public static final Set TYPES_REQUIRING_LOCAL_ELIGIBILITY = Set.of( + Constants.ACTIVITY_TYPE_MATCHMAKING); + + /** 需要按**性别名额**分别扣减的类型(课堂/反诈类只按总名额) */ + public static final Set TYPES_WITH_GENDER_QUOTA = Set.of( + Constants.ACTIVITY_TYPE_MATCHMAKING, + Constants.ACTIVITY_TYPE_INTEREST_SOCIAL); + private final ActivityMapper activityMapper; private final ActivityRegistrationMapper registrationMapper; private final UserProfileMapper userProfileMapper; @@ -42,6 +84,8 @@ public class ActivityService { private final ActivityOrganizationMapper activityOrganizationMapper; private final ActivityCapacityGroupMapper capacityGroupMapper; private final OrganizationMapper organizationMapper; + private final IdentityVerificationService identityVerificationService; + private final LocalEligibilityService localEligibilityService; private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService; /** @@ -144,13 +188,17 @@ public class ActivityService { detail.put("organizerOrgId", activity.getOrganizerOrgId()); detail.put("coorganizerText", activity.getCoorganizerText()); - // 报名统计 + // 报名统计(Phase 14: 改走 ActivityRegistrationMapper —— ActivityMapper 上的同名方法无 SQL 实现) int approvedCount = activityMapper.countApprovedRegistrations(activityId); - int remaining = activityMapper.countRemainingCapacity(activityId); + int remaining = registrationMapper.countRemainingCapacity(activityId); detail.put("approvedCount", approvedCount); detail.put("remainingSeats", Math.max(0, remaining)); detail.put("isFull", remaining <= 0); + // V1.1 Phase 14: 类型与资格策略(小程序据此渲染差异化的资格提示) + detail.put("activityType", activity.getActivityType()); + detail.put("eligibility", resolveEligibility(activity)); + // 用户报名状态 registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(reg -> { detail.put("registered", true); @@ -182,27 +230,40 @@ public class ActivityService { } }); - // 检查名额(事务内原子检查) - int remaining = activityMapper.countRemainingCapacity(activityId); - if (remaining <= 0) { - throw new RuntimeException("活动名额已满"); - } + // V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同) + assertEligible(userId, activity); - // 检查年龄范围 - if (activity.getMinAge() != null) { - // TODO: 获取用户年龄并校验 - } + // V1.1 Phase 14: 年龄范围校验(原实现为 TODO 空壳,minAge/maxAge 完全不生效) + assertAgeInRange(userId, activity); + + // 创建报名记录(needReview=false 时直接 approved —— 必须原子占位,否则并发超卖) + boolean autoApprove = !Boolean.TRUE.equals(activity.getNeedReview()); + String status = autoApprove + ? Constants.REGISTRATION_STATUS_APPROVED + : Constants.REGISTRATION_STATUS_PENDING; - // 创建报名记录 ActivityRegistration registration = new ActivityRegistration(); registration.setActivityId(activityId); registration.setUserId(userId); - registration.setStatus(activity.getNeedReview() ? - Constants.REGISTRATION_STATUS_PENDING : Constants.REGISTRATION_STATUS_APPROVED); + registration.setStatus(status); registration.setRegisteredAt(Instant.now()); registration.setEmergencyContactName(emergencyContactName); registration.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc); - registrationMapper.insert(registration); + + if (autoApprove) { + // 原子:仅当仍有名额才写入 approved 报名;affectedRows=0 即名额已满 + String newId = UUID.randomUUID().toString(); + int affected = registrationMapper.insertIfCapacityAvailable( + newId, activityId, userId, status, + emergencyContactName, emergencyContactPhoneEnc, genderOf(userId)); + if (affected == 0) { + throw new RuntimeException("活动名额已满"); + } + registration.setId(newId); + } else { + // pending 不占用名额,插入即可(UNIQUE(activity_id,user_id) 防重复) + registrationMapper.insert(registration); + } // P17-T03: 线下活动风险检测 try { @@ -235,16 +296,15 @@ public class ActivityService { throw new RuntimeException("当前状态不允许审核通过"); } - // 再次检查名额(防止并发超卖) - int remaining = activityMapper.countRemainingCapacity(registration.getActivityId()); - if (remaining <= 0) { - throw new RuntimeException("活动名额已满,无法通过审核"); + // 原子审核:名额已满或状态已被他人变更时 affectedRows=0 + int affected = registrationMapper.approveIfCapacityAvailable( + registrationId, registration.getActivityId(), operatorId, + genderOf(registration.getUserId())); + if (affected == 0) { + throw new RuntimeException("活动名额已满或状态已变更,无法通过审核"); } - - registration.setStatus(Constants.REGISTRATION_STATUS_APPROVED); - registration.setReviewedBy(operatorId); - registration.setReviewedAt(Instant.now()); - registrationMapper.updateById(registration); + // 回读事务内最新状态(条件更新不回填实体) + registration = registrationMapper.selectById(registrationId); // 通知用户 notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY, @@ -307,8 +367,11 @@ public class ActivityService { return registrationMapper.findByUserId(userId).stream() .map(reg -> { Activity activity = activityMapper.selectById(reg.getActivityId()); + // Phase 14 修复:活动被软删后 selectById 返回 null,原实现直接 NPE + if (activity == null) return null; Map map = new HashMap<>(); map.put("activityId", activity.getId()); + map.put("activityType", activity.getActivityType()); map.put("title", activity.getTitle()); map.put("startAt", activity.getStartAt()); map.put("locationName", activity.getLocationName()); @@ -318,6 +381,7 @@ public class ActivityService { map.put("reviewedAt", reg.getReviewedAt()); return map; }) + .filter(java.util.Objects::nonNull) .collect(Collectors.toList()); } @@ -349,19 +413,37 @@ public class ActivityService { ActivityListItemDTO dto = new ActivityListItemDTO(); dto.setActivityId(activity.getId()); dto.setTitle(activity.getTitle()); + dto.setCoverFileId(activity.getCoverFileId()); + dto.setDescription(activity.getDescription()); + dto.setStatus(activity.getStatus()); dto.setStartAt(activity.getStartAt()); dto.setEndAt(activity.getEndAt()); dto.setLocationName(activity.getLocationName()); dto.setTotalLimit(activity.getTotalLimit()); - long approvedCount = registrationMapper.selectCount( - new LambdaQueryWrapper() - .eq(ActivityRegistration::getActivityId, activity.getId()) - .eq(ActivityRegistration::getStatus, "approved")); - // 注:性别在 user_profiles 中,此处简化处理(男女各半) - dto.setMaleCount((int)(approvedCount / 2)); - dto.setFemaleCount((int)(approvedCount - approvedCount / 2)); - dto.setRemainingSeats(Math.max(0, activity.getTotalLimit() != null ? - activity.getTotalLimit() - dto.getMaleCount() - dto.getFemaleCount() : 0)); + dto.setActivityType(activity.getActivityType() == null + ? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType()); + + // Phase 14 修复:男女人数改为按 user_profiles.gender 真实统计。 + // 原实现为 approvedCount/2 与 approvedCount-approvedCount/2 —— 凭空编造的性别构成。 + int approvedCount = activityMapper.countApprovedRegistrations(activity.getId()); + int male = activityMapper.countApprovedByGender(activity.getId(), "male"); + int female = activityMapper.countApprovedByGender(activity.getId(), "female"); + dto.setMaleCount(male); + dto.setFemaleCount(female); + dto.setRegisteredCount(male + female); + + // 剩余名额:总名额优先;性别名额启用时取"两个性别各自剩余"与总剩余的较小值 + int remainingByTotal = activity.getTotalLimit() == null ? approvedCount : activity.getTotalLimit() - approvedCount; + int remaining = remainingByTotal; + if (TYPES_WITH_GENDER_QUOTA.contains(dto.getActivityType())) { + if (activity.getMaleLimit() != null) { + remaining = Math.min(remaining, activity.getMaleLimit() - male); + } + if (activity.getFemaleLimit() != null) { + remaining = Math.min(remaining, activity.getFemaleLimit() - female); + } + } + dto.setRemainingSeats(Math.max(0, remaining)); dto.setRegistered(false); return dto; } @@ -369,9 +451,14 @@ public class ActivityService { /** V1.1 Phase 14: 按活动类型过滤 */ @SuppressWarnings("unchecked") public List> getActivitiesByType(String userId, String activityType) { - List activities = activityMapper.findOpenActivities(); + // Phase 14 修复:类型必须落在生产 chk_activity_type 白名单内, + // 否则下推到 SQL 后只会静默返回空列表,掩盖前端传错值的问题。 + if (activityType == null || !VALID_ACTIVITY_TYPES.contains(activityType)) { + throw new IllegalArgumentException("非法的活动类型: " + activityType); + } + // 原实现为「全表捞取 + 内存 filter」;改为下推到 SQL。 + List activities = activityMapper.findOpenActivitiesByType(activityType); return activities.stream() - .filter(a -> activityType != null && activityType.equals(a.getActivityType())) .map(a -> (Map) convertToMap(toListItemDTO(userId, a))) .toList(); } @@ -385,8 +472,10 @@ public class ActivityService { m.put("totalLimit", dto.getTotalLimit()); m.put("maleCount", dto.getMaleCount()); m.put("femaleCount", dto.getFemaleCount()); + m.put("registeredCount", dto.getRegisteredCount()); m.put("remainingSeats", dto.getRemainingSeats()); m.put("registered", dto.isRegistered()); + m.put("activityType", dto.getActivityType()); return m; } @@ -426,33 +515,42 @@ public class ActivityService { activity.setTitle((String) body.get("title")); activity.setDescription((String) body.get("description")); activity.setCoverFileId((String) body.get("coverFileId")); - activity.setStartAt(Instant.parse((String) body.get("startAt"))); - activity.setEndAt(Instant.parse((String) body.get("endAt"))); + // Phase 14 修复:运营端表单允许缺省时间/名额/年龄,原实现直接 Instant.parse(null) + // 与 ((Number) null).intValue() —— 后台一漏填就 NPE 500。此处按旧运营端端点兜底。 + activity.setStartAt(parseInstant(body.get("startAt"), Instant.now().plusSeconds(3600))); + activity.setEndAt(parseInstant(body.get("endAt"), Instant.now().plusSeconds(7200))); activity.setLocationName((String) body.get("locationName")); activity.setLocationAddress((String) body.get("locationAddress")); - activity.setTotalLimit(((Number) body.get("totalLimit")).intValue()); - activity.setMinAge(((Number) body.get("minAge")).shortValue()); - activity.setMaxAge(((Number) body.get("maxAge")).shortValue()); + activity.setTotalLimit(toInt(body.get("totalLimit"), 50)); + // minAge/maxAge 为空表示不设年龄门槛(null 参与比较会 NPE,故用包装类型) + activity.setMinAge(toShort(body.get("minAge"))); + activity.setMaxAge(toShort(body.get("maxAge"))); Object regStart = body.get("registrationStartAt"); Object regEnd = body.get("registrationEndAt"); activity.setRegistrationStartAt(regStart != null ? Instant.parse((String) regStart) : null); activity.setRegistrationEndAt(regEnd != null ? Instant.parse((String) regEnd) : null); activity.setNeedReview(Boolean.parseBoolean(body.getOrDefault("needReview", "true").toString())); - activity.setActivityType((String) body.getOrDefault("activityType", "matchmaking")); + // Phase 14 修复:活动类型必须落在生产 chk_activity_type 白名单内。 + // 原实现直接把请求体原样写库 —— 非法类型在 H2 测试库里静默通过, + // 上到 PostgreSQL 才撞 CHECK 约束返回 500(典型的"只在 PG 上炸")。 + activity.setActivityType(requireActivityType((String) body.getOrDefault("activityType", "matchmaking"))); activity.setRegistrationPolicy((String) body.get("registrationPolicy")); activity.setTargetGroup((String) body.get("targetGroup")); activity.setIndustryScope((String) body.get("industryScope")); activity.setPostEventInterestEnabled(Boolean.parseBoolean(body.getOrDefault("postEventInterestEnabled", "false").toString())); activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT); activity.setCreatedBy(operatorId); + // Phase 14 修复:主办组织此前只写进 activity_organizations,activities.organizer_org_id 留空, + // 后台列表与详情里的"主办方"恒为空。 + String hostOrgId = (String) body.get("organizerOrgId"); + activity.setOrganizerOrgId(hostOrgId); activityMapper.insert(activity); // 保存主组织 - String orgId = (String) body.get("organizerOrgId"); - if (orgId != null) { + if (hostOrgId != null) { ActivityOrganization hostOrg = new ActivityOrganization(); hostOrg.setActivityId(activity.getId()); - hostOrg.setOrganizationId(orgId); + hostOrg.setOrganizationId(hostOrgId); hostOrg.setRole(Constants.ORG_ROLE_HOST); activityOrganizationMapper.insert(hostOrg); } @@ -486,7 +584,8 @@ public class ActivityService { ActivityOrganization org = new ActivityOrganization(); org.setActivityId(activityId); org.setOrganizationId(organizationId); - org.setRole(role != null ? role : Constants.ORG_ROLE_PARTNER); + // Phase 14 修复:角色必须落在生产 chk_org_role 白名单内(同 D3 根因) + org.setRole(requireOrgRole(role != null ? role : Constants.ORG_ROLE_PARTNER)); activityOrganizationMapper.insert(org); log.info("添加活动组织: activityId={}, orgId={}, role={}", activityId, organizationId, role); Map result = new java.util.HashMap<>(); @@ -514,6 +613,134 @@ public class ActivityService { return result; } + // ==================== Phase 14:类型化资格策略 ==================== + + /** + * 解析某活动的资格要求。 + * + *

Gate 落点:「matchmaking 与课堂/反诈类活动显示和资格策略不同」—— + * 相亲类要求实名 + 本地资格 + 性别名额;婚恋课堂 / 反诈宣传是公益科普, + * 不设实名与本地资格门槛,容量也只按总名额计算。 + */ + public Map resolveEligibility(Activity activity) { + String type = activity.getActivityType() == null + ? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType(); + Map m = new LinkedHashMap<>(); + m.put("activityType", type); + m.put("requiresIdentityVerified", TYPES_REQUIRING_IDENTITY.contains(type)); + m.put("requiresLocalEligibility", TYPES_REQUIRING_LOCAL_ELIGIBILITY.contains(type)); + m.put("usesGenderQuota", TYPES_WITH_GENDER_QUOTA.contains(type)); + return m; + } + + /** + * 报名前按活动类型校验资格。 + * + *

此前 {@code registration_policy} 字段被写入却**从未被读取**, + * 资格策略实际是空的(Gate 未落地);此处改为按类型强制执行。 + */ + private void assertEligible(String userId, Activity activity) { + String type = activity.getActivityType() == null + ? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType(); + + if (TYPES_REQUIRING_IDENTITY.contains(type)) { + Boolean verified = Boolean.FALSE; + try { + Object v = identityVerificationService.getIdentityStatus(userId).get("verified"); + verified = Boolean.TRUE.equals(v); + } catch (Exception e) { + log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage()); + } + if (!verified) { + throw new RuntimeException("该活动需完成实名认证后方可报名"); + } + } + + if (TYPES_REQUIRING_LOCAL_ELIGIBILITY.contains(type)) { + String st = null; + try { + st = localEligibilityService.getStatus(userId).getVerificationStatus(); + } catch (Exception e) { + log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage()); + } + if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) { + throw new RuntimeException("该活动需通过本地资格核验后方可报名"); + } + } + } + + /** 年龄范围校验(原实现为 TODO 空壳,minAge/maxAge 此前完全不生效) */ + private void assertAgeInRange(String userId, Activity activity) { + if (activity.getMinAge() == null && activity.getMaxAge() == null) return; + java.time.LocalDate birth = userProfileMapper.findByUserId(userId) + .map(p -> p.getBirthDate()).orElse(null); + if (birth == null) return; // 资料不全时不阻断报名 + int age = java.time.Period.between(birth, java.time.LocalDate.now()).getYears(); + if (activity.getMinAge() != null && age < activity.getMinAge()) { + throw new RuntimeException("报名年龄未满 " + activity.getMinAge() + " 周岁"); + } + if (activity.getMaxAge() != null && age > activity.getMaxAge()) { + throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限"); + } + } + + /** 校验并归一化活动类型;非法值直接拒绝,避免撞生产 CHECK 约束 */ + private String requireActivityType(String type) { + if (type == null || !VALID_ACTIVITY_TYPES.contains(type)) { + throw new IllegalArgumentException("非法的活动类型: " + type + + ",允许值:" + new TreeSet<>(VALID_ACTIVITY_TYPES)); + } + return type; + } + + /** 校验并归一化活动组织角色;非法值直接拒绝,避免撞生产 CHECK 约束 */ + private String requireOrgRole(String role) { + if (role == null || !VALID_ORG_ROLES.contains(role)) { + throw new IllegalArgumentException("非法的活动组织角色: " + role + + ",允许值:" + new TreeSet<>(VALID_ORG_ROLES)); + } + return role; + } + + /** 读取用户性别(用于性别名额扣减);资料缺失时返回 null,退化为按总名额约束 */ + private String genderOf(String userId) { + return userProfileMapper.findByUserId(userId).map(p -> p.getGender()).orElse(null); + } + + /** + * 容忍 null / 时间戳 / 本地无时区文本 / ISO 文本的时间字段。 + * + *

后台 el-date-picker 默认输出 {@code 2026-09-20T14:00:00}(无时区偏移), + * 直接 {@code Instant.parse} 会抛异常;若简单兜底成"现在",运营填的时间会被**静默丢弃** —— + * 比报错更危险。故无偏移时按服务器时区解释。 + */ + private static Instant parseInstant(Object raw, Instant fallback) { + if (raw == null) return fallback; + try { + if (raw instanceof Number n) return Instant.ofEpochMilli(n.longValue()); + String s = raw.toString().trim(); + if (s.isEmpty()) return fallback; + try { + return Instant.parse(s); + } catch (RuntimeException ignored) { + return java.time.LocalDateTime.parse(s) + .atZone(java.time.ZoneId.systemDefault()).toInstant(); + } + } catch (RuntimeException e) { + log.warn("非法时间字段,回落默认值: raw={}, err={}", raw, e.getMessage()); + return fallback; + } + } + + private static int toInt(Object raw, int fallback) { + return raw instanceof Number n ? n.intValue() : fallback; + } + + /** null 表示不设年龄门槛 —— 不能用 0 兜底,否则 age >= minAge 恒成立反而放行 */ + private static Short toShort(Object raw) { + return raw instanceof Number n ? n.shortValue() : null; + } + private void saveAuditLog(String operatorId, String action, String resourceType, String resourceId, String beforeData, String afterData) { AuditLog auditLog = new AuditLog(); diff --git a/backend/src/test/java/com/danyang/match/platform/activity/ActivityConstraintGuardTest.java b/backend/src/test/java/com/danyang/match/platform/activity/ActivityConstraintGuardTest.java new file mode 100644 index 0000000..1f8b283 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/activity/ActivityConstraintGuardTest.java @@ -0,0 +1,331 @@ +package com.danyang.match.platform.activity; + +import com.danyang.match.platform.activity.dto.ActivityListItemDTO; +import com.danyang.match.platform.activity.service.ActivityService; +import com.danyang.match.platform.shared.constant.Constants; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.regex.*; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 14:activity 模块「代码常量 ⊆ 生产 CHECK 白名单」守卫。 + * + *

核心纪律(Phase 13 确立、Phase 13A 加固): + * 守卫必须**直接解析 {@code db/migration/V*.sql} 原文**,绝不能在测试里抄一份白名单常量。 + * 抄副本的守卫只证明"今天抄的和昨天抄的一样",对 DDL 漂移**零反应**。 + * + *

CWD 陷阱(Phase 13A 实测):surefire 的 workingDirectory 是仓库根, + * 而 {@code backend/} 是它的**子目录不是祖先** —— 必须同时尝试两种候选后缀, + * 且定位失败要**大声抛异常**(静默返回 null 会让守卫变成恒真假绿)。 + */ +class ActivityConstraintGuardTest { + + private static final List MIGRATION_CANDIDATES = List.of( + "src/main/resources/db/migration", + "backend/src/main/resources/db/migration" + ); + + private static final Map> CACHE = new HashMap<>(); + + private static Path locateMigrationDir() { + Path cur = Path.of("").toAbsolutePath().normalize(); + List tried = new ArrayList<>(); + for (int i = 0; i < 6 && cur != null; i++) { + for (String rel : MIGRATION_CANDIDATES) { + Path candidate = cur.resolve(rel); + tried.add(candidate.toString()); + if (Files.isDirectory(candidate)) return candidate; + } + cur = cur.getParent(); + } + throw new IllegalStateException( + "守卫无法定位迁移目录;CWD=" + Path.of("").toAbsolutePath() + + ";已尝试:\n - " + String.join("\n - ", tried)); + } + + // ==================== 解析器自检 ==================== + + @Test + @DisplayName("解析器自检: 必须真的解析到 Phase 14 的两个约束(防止守卫恒真)") + void parserMustResolveActivityConstraints() { + Path dir = locateMigrationDir(); + long fileCount; + try (Stream s = Files.list(dir)) { + fileCount = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql")).count(); + } catch (IOException e) { + throw new IllegalStateException("列目录失败: " + dir, e); + } + assertTrue(fileCount >= 30, "解析到的迁移文件过少(" + fileCount + "),目录可能选错了: " + dir); + + for (String c : List.of("chk_activity_type", "chk_org_role")) { + Set vals = resolveCheckValues(c); + assertNotNull(vals, "解析器失效:未解析到约束 " + c + "(dir=" + dir + ", files=" + fileCount + ")"); + assertFalse(vals.isEmpty(), "解析器失效:约束 " + c + " 白名单为空"); + } + } + + private static Set requireWhitelist(String constraintName) { + Set allowed = resolveCheckValues(constraintName); + assertNotNull(allowed, "守卫失效:未从生产迁移解析到 " + constraintName + + "(迁移目录=" + locateMigrationDir() + ")。"); + return allowed; + } + + // ==================== 守卫 ==================== + + @Test + @DisplayName("守卫: VALID_ACTIVITY_TYPES ⊆ 生产 chk_activity_type 白名单") + void activityTypeConstantsWithinProductionWhitelist() throws Exception { + Set allowed = requireWhitelist("chk_activity_type"); + Set codeValues = readStringSetField("VALID_ACTIVITY_TYPES"); + + assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效"); + Set violations = new TreeSet<>(codeValues); + violations.removeAll(allowed); + assertTrue(violations.isEmpty(), + "代码写入的 activity_type 不在生产 CHECK 白名单内,真实 PG 上必然 500:\n - " + + String.join("\n - ", violations) + "\n生产白名单: " + allowed); + } + + @Test + @DisplayName("守卫: 生产白名单 ⊆ 代码常量(新增类型必须同步到代码,否则永远不可达)") + void productionWhitelistCoveredByCodeConstants() throws Exception { + Set allowed = requireWhitelist("chk_activity_type"); + Set codeValues = readStringSetField("VALID_ACTIVITY_TYPES"); + + Set missing = new TreeSet<>(allowed); + missing.removeAll(codeValues); + assertTrue(missing.isEmpty(), + "生产允许的活动类型未在代码中声明,代码中无法构造该类型:\n - " + String.join("\n - ", missing)); + } + + @Test + @DisplayName("守卫: VALID_ORG_ROLES ⊆ 生产 chk_org_role 白名单") + void orgRoleConstantsWithinProductionWhitelist() throws Exception { + Set allowed = requireWhitelist("chk_org_role"); + Set codeValues = readStringSetField("VALID_ORG_ROLES"); + + assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效"); + Set violations = new TreeSet<>(codeValues); + violations.removeAll(allowed); + assertTrue(violations.isEmpty(), + "代码写入的组织角色不在生产 CHECK 白名单内:\n - " + + String.join("\n - ", violations) + "\n生产白名单: " + allowed); + } + + @Test + @DisplayName("守卫: 三个资格策略集合的取值都必须是合法活动类型") + void policySetsUseOnlyValidActivityTypes() throws Exception { + Set validTypes = readStringSetField("VALID_ACTIVITY_TYPES"); + for (String field : List.of("TYPES_REQUIRING_IDENTITY", + "TYPES_REQUIRING_LOCAL_ELIGIBILITY", + "TYPES_WITH_GENDER_QUOTA")) { + Set policy = readStringSetField(field); + assertFalse(policy.isEmpty(), field + " 为空,资格策略未落地(Gate 未达成)"); + Set bad = new TreeSet<>(policy); + bad.removeAll(validTypes); + assertTrue(bad.isEmpty(), field + " 含非法活动类型: " + bad); + } + } + + @Test + @DisplayName("Gate 守卫: matchmaking 与反诈类的资格策略必须不同") + void matchmakingAndAntiFraudPoliciesMustDiffer() throws Exception { + Set needIdentity = readStringSetField("TYPES_REQUIRING_IDENTITY"); + Set needLocal = readStringSetField("TYPES_REQUIRING_LOCAL_ELIGIBILITY"); + Set genderQuota = readStringSetField("TYPES_WITH_GENDER_QUOTA"); + + assertAll( + () -> assertTrue(needIdentity.contains(Constants.ACTIVITY_TYPE_MATCHMAKING), + "matchmaking 必须要求实名"), + () -> assertTrue(needLocal.contains(Constants.ACTIVITY_TYPE_MATCHMAKING), + "matchmaking 必须要求本地资格"), + () -> assertTrue(genderQuota.contains(Constants.ACTIVITY_TYPE_MATCHMAKING), + "matchmaking 必须按性别名额扣减"), + () -> assertFalse(needIdentity.contains(Constants.ACTIVITY_TYPE_ANTI_FRAUD), + "反诈宣传是公益科普,不应要求实名 —— 否则两类活动策略无差异,Gate 未达成"), + () -> assertFalse(needLocal.contains(Constants.ACTIVITY_TYPE_ANTI_FRAUD), + "反诈宣传不应要求本地资格"), + () -> assertFalse(genderQuota.contains(Constants.ACTIVITY_TYPE_ANTI_FRAUD), + "反诈宣传不应按性别名额扣减") + ); + } + + // ==================== 小程序 / 卡片契约守卫 ==================== + + /** + * 小程序分类 Tabs 的 code 必须逐字等于生产 {@code chk_activity_type} 白名单取值。 + * + *

此前小程序写的是 {@code classroom},生产白名单里只有 {@code relationship_education} + * ——「婚恋课堂」Tab 恒为空白页,而**没有任何测试会发现**。 + */ + @Test + @DisplayName("守卫: 小程序分类 Tabs 的 code ⊆ 生产 chk_activity_type 白名单") + void miniappCategoryCodesWithinProductionWhitelist() throws Exception { + Set allowed = requireWhitelist("chk_activity_type"); + Set codes = readMiniappCategoryCodes(); + + assertFalse(codes.isEmpty(), "解析不到小程序分类 code,守卫失效"); + + Set bad = new TreeSet<>(codes); + bad.remove("all"); // 'all' 是「全部」哨兵,不落库 + bad.removeAll(allowed); + assertTrue(bad.isEmpty(), + "小程序分类 Tabs 的 code 与生产活动类型不匹配,点击后必然是空白列表:\n - " + + String.join("\n - ", bad) + "\n生产白名单: " + allowed); + } + + /** + * 生产白名单里的每种类型都应在小程序有入口,否则该类型活动用户永远筛不到。 + */ + @Test + @DisplayName("守卫: 生产活动类型都必须能在小程序 Tabs 中找到") + void everyProductionTypeHasMiniappTab() throws Exception { + Set allowed = requireWhitelist("chk_activity_type"); + Set codes = readMiniappCategoryCodes(); + + Set missing = new TreeSet<>(allowed); + missing.removeAll(codes); + assertTrue(missing.isEmpty(), + "以下活动类型在小程序分类 Tabs 中无入口,用户永远筛不到:\n - " + String.join("\n - ", missing)); + } + + /** + * 卡片组件读到的字段名必须真实存在于 {@link ActivityListItemDTO}。 + * + *

此前卡片读 {@code registeredCount} / {@code currentMaleCount} / {@code currentFemaleCount}, + * 后端一个都没有 —— "报名人数" 恒显示 0。 + */ + @Test + @DisplayName("守卫: 活动卡片读取的字段名必须存在于列表 DTO") + void cardFieldsExistInListItemDto() throws Exception { + Set dtoFields = new TreeSet<>(); + for (java.lang.reflect.Method m : ActivityListItemDTO.class.getDeclaredMethods()) { + String n = m.getName(); + if (n.startsWith("get") && m.getParameterCount() == 0) { + dtoFields.add(Character.toLowerCase(n.charAt(3)) + n.substring(4)); + } + if (n.startsWith("is") && m.getParameterCount() == 0 && m.getReturnType() == boolean.class) { + dtoFields.add(Character.toLowerCase(n.charAt(2)) + n.substring(3)); + } + } + assertFalse(dtoFields.isEmpty(), "解析不到 DTO 字段,守卫失效"); + + // 卡片 wxml/js 里直接以 activity. 形式读取的字段 + Set used = readCardActivityFields(); + assertFalse(used.isEmpty(), "解析不到卡片读取的字段,守卫失效"); + + Set unknown = new TreeSet<>(used); + unknown.removeAll(dtoFields); + assertTrue(unknown.isEmpty(), + "活动卡片读取的字段在 ActivityListItemDTO 中不存在,界面恒为空:\n - " + + String.join("\n - ", unknown) + "\nDTO 字段: " + dtoFields); + } + + private static Set readMiniappCategoryCodes() throws IOException { + Path ts = resolveRepoFile("miniapp/pages/activity/activity.ts"); + // 必须匹配 "categories: [" —— 只写 "categories:" 会先命中 + // ActivityPageData 接口里的 `categories: ActivityCategory[];` 声明(无码值)。 + String src = Files.readString(ts, StandardCharsets.UTF_8); + int start = src.indexOf("categories: ["); + if (start < 0) throw new IllegalStateException("activity.ts 中找不到 categories 数组字面量"); + int end = src.indexOf(']', start); + String block = src.substring(start, end < 0 ? src.length() : end); + Set codes = new TreeSet<>(); + Matcher m = Pattern.compile("code:\\s*'([^']+)'").matcher(block); + while (m.find()) codes.add(m.group(1)); + return codes; + } + + private static Set readCardActivityFields() throws IOException { + Set fields = new TreeSet<>(); + for (String rel : List.of("miniapp/components/activity-card/index.wxml", + "miniapp/components/activity-card/index.js")) { + String src = Files.readString(resolveRepoFile(rel), StandardCharsets.UTF_8); + // 必须先剥注释:注释里出现的 `activity.capacity`(说明"该字段不存在") + // 会被正则当成真实读取,造成守卫假阳性。 + String code = src + .replaceAll("(?s)", " ") + .replaceAll("(?s)/\\*.*?\\*/", " ") + .replaceAll("(?m)//.*$", " "); + Matcher m = Pattern.compile("activity\\.([A-Za-z_][A-Za-z0-9_]*)").matcher(code); + while (m.find()) fields.add(m.group(1)); + } + return fields; + } + + /** 定位仓库内文件(surefire CWD 可能是仓库根或 backend/) */ + private static Path resolveRepoFile(String rel) { + Path cur = Path.of("").toAbsolutePath().normalize(); + for (int i = 0; i < 6 && cur != null; i++) { + Path direct = cur.resolve(rel); + if (Files.isRegularFile(direct)) return direct; + Path viaBackend = cur.resolve("backend").resolve(rel); + if (Files.isRegularFile(viaBackend)) return viaBackend; + cur = cur.getParent(); + } + throw new IllegalStateException("守卫无法定位 " + rel + ";CWD=" + Path.of("").toAbsolutePath()); + } + + // ==================== 解析实现 ==================== + + private static Set resolveCheckValues(String constraintName) { + if (CACHE.containsKey(constraintName)) return CACHE.get(constraintName); + Set result = null; + try { + Path dir = locateMigrationDir(); + List files; + try (Stream s = Files.list(dir)) { + files = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql")) + .sorted(Comparator.comparing(p -> p.getFileName().toString())) + .toList(); + } + for (Path f : files) { + String flat = Files.readString(f, StandardCharsets.UTF_8).replaceAll("\\s+", " "); + + Pattern drop = Pattern.compile( + "DROP\\s+CONSTRAINT\\s+IF\\s+EXISTS\\s+" + Pattern.quote(constraintName), + Pattern.CASE_INSENSITIVE); + if (drop.matcher(flat).find()) result = null; + + Pattern add = Pattern.compile( + "ADD\\s+CONSTRAINT\\s+" + Pattern.quote(constraintName) + + "\\s+CHECK\\s*\\([^)]*IN\\s*\\(([^)]*)\\)", + Pattern.CASE_INSENSITIVE); + Matcher m = add.matcher(flat); + if (m.find()) { + Set vals = new TreeSet<>(); + Matcher lit = Pattern.compile("'([^']*)'").matcher(m.group(1)); + while (lit.find()) vals.add(lit.group(1)); + result = vals.isEmpty() ? null : vals; + } + } + } catch (IOException e) { + throw new IllegalStateException("解析迁移文件失败: " + e.getMessage(), e); + } + CACHE.put(constraintName, result); + return result; + } + + // ==================== 反射读常量 ==================== + + private static Object readField(String name) throws Exception { + Field f = ActivityService.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(null); + } + + @SuppressWarnings("unchecked") + private static Set readStringSetField(String name) throws Exception { + return (Set) readField(name); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase14Test.java b/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase14Test.java new file mode 100644 index 0000000..e4c5405 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase14Test.java @@ -0,0 +1,497 @@ +package com.danyang.match.platform.activity; + +import com.danyang.match.platform.activity.dto.ActivityListItemDTO; +import com.danyang.match.platform.activity.entity.Activity; +import com.danyang.match.platform.activity.entity.ActivityOrganization; +import com.danyang.match.platform.activity.entity.ActivityRegistration; +import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper; +import com.danyang.match.platform.activity.mapper.ActivityMapper; +import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper; +import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper; +import com.danyang.match.platform.activity.service.ActivityService; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.organization.entity.Organization; +import com.danyang.match.platform.organization.mapper.OrganizationMapper; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import com.danyang.match.platform.verification.entity.UserIdentity; +import com.danyang.match.platform.verification.mapper.UserIdentityMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 14:多类型公益活动 —— 真实运行时路径测试。 + * + *

测试保真度:走真实 Service 方法 + 真实落库;测试库 {@code schema-h2.sql} + * 已补齐 V023 的 {@code activity_type} 列、{@code chk_activity_type} / + * {@code chk_org_role} 约束,以及 activity_organizations / activity_capacity_groups 两表。 + * 因此非法类型/角色会在此处即暴露,而不是上到 PostgreSQL 才炸。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class ActivityPhase14Test { + + @Autowired private ActivityService activityService; + @Autowired private ActivityMapper activityMapper; + @Autowired private ActivityRegistrationMapper registrationMapper; + @Autowired private ActivityOrganizationMapper activityOrganizationMapper; + @Autowired private ActivityCapacityGroupMapper capacityGroupMapper; + @Autowired private OrganizationMapper organizationMapper; + @Autowired private UserMapper userMapper; + @Autowired private UserProfileMapper userProfileMapper; + @Autowired private UserIdentityMapper userIdentityMapper; + @Autowired private LocalEligibilityMapper localEligibilityMapper; + + // ==================== D1:Mapper 空壳方法导致三条路径必崩 ==================== + + @Test + @DisplayName("D1 回归: 活动详情不再因 Invalid bound statement 崩溃,且返回真实余量") + void activityDetailReturnsRemainingSeats() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 5, null, null, false, null, null); + + Map detail = activityService.getActivityDetail(a.getId(), "u-any"); + assertNotNull(detail); + assertEquals(5, ((Number) detail.get("remainingSeats")).intValue()); + assertFalse((Boolean) detail.get("isFull")); + assertEquals(Constants.ACTIVITY_TYPE_ANTI_FRAUD, detail.get("activityType")); + } + + // ==================== D2 + Gate:容量按配置正确扣减(原子)==================== + + @Test + @DisplayName("Gate: 总名额用尽后报名被拒(原子占位,非先读后写)") + void registerRespectsTotalLimitAtomically() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 1, null, null, false, null, null); + String u1 = newUser("male", 1990); + String u2 = newUser("male", 1990); + + assertNotNull(activityService.registerActivity(u1, a.getId(), "c1", "p1")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.registerActivity(u2, a.getId(), "c2", "p2")); + assertTrue(ex.getMessage().contains("名额已满"), + "第二个报名应因名额已满被拒,实际:" + ex.getMessage()); + } + + @Test + @DisplayName("Gate: 审核通过同样受名额约束(两个 pending 抢最后一个名额)") + void approveRespectsTotalLimitAtomically() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 1, null, null, true, null, null); + String u1 = newUser("male", 1990); + String u2 = newUser("male", 1990); + ActivityRegistration r1 = registerPending(a.getId(), u1); + ActivityRegistration r2 = registerPending(a.getId(), u2); + + assertEquals(Constants.REGISTRATION_STATUS_APPROVED, + activityService.approveRegistration(r1.getId(), "op-1").getStatus()); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.approveRegistration(r2.getId(), "op-1")); + assertTrue(ex.getMessage().contains("名额已满") || ex.getMessage().contains("状态已变更"), + "第二个审核应因名额已满被拒,实际:" + ex.getMessage()); + } + + @Test + @DisplayName("Gate: matchmaking 按性别名额扣减(男满后拒男、仍可收女)") + void genderQuotaIsEnforcedForMatchmaking() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, 1, 1, false, null, null); + String m1 = newVerifiedUser("male", 1990); + String m2 = newVerifiedUser("male", 1990); + String f1 = newVerifiedUser("female", 1990); + + assertNotNull(activityService.registerActivity(m1, a.getId(), "c", "p")); + assertNotNull(activityService.registerActivity(f1, a.getId(), "c", "p")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.registerActivity(m2, a.getId(), "c", "p")); + assertTrue(ex.getMessage().contains("名额已满"), + "男性名额已满后应拒绝第 2 位男性,实际:" + ex.getMessage()); + } + + @Test + @DisplayName("Gate: 课堂/反诈类不设性别名额,只受总名额约束(策略差异)") + void antiFraudIgnoresGenderQuota() { + // male_limit / female_limit 均为 null —— 仅 total_limit=2 生效 + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 2, null, null, false, null, null); + String m1 = newUser("male", 1990); + String m2 = newUser("male", 1990); + + assertNotNull(activityService.registerActivity(m1, a.getId(), "c", "p")); + assertDoesNotThrow(() -> activityService.registerActivity(m2, a.getId(), "c", "p"), + "反诈类活动不设性别名额,两位男性都应可报名"); + } + + // ==================== D4 + Gate:按类型的资格策略 ==================== + + @Test + @DisplayName("Gate: matchmaking 要求实名 + 本地资格(未实名被拒)") + void matchmakingRequiresIdentityAndLocalEligibility() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, 5, 5, false, null, null); + String u = newUser("male", 1990); // 未实名、未核验本地资格 + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.registerActivity(u, a.getId(), "c", "p")); + assertTrue(ex.getMessage().contains("实名"), + "matchmaking 应要求实名认证,实际:" + ex.getMessage()); + } + + @Test + @DisplayName("Gate: 反诈类不要求实名 —— 与 matchmaking 策略不同") + void antiFraudDoesNotRequireIdentity() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null); + String u = newUser("male", 1990); // 未实名 + + assertDoesNotThrow(() -> activityService.registerActivity(u, a.getId(), "c", "p"), + "反诈宣传是公益科普,不应设实名门槛"); + } + + @Test + @DisplayName("Gate: 实名+本地资格齐备时 matchmaking 可报名") + void matchmakingAllowsVerifiedUser() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, 5, 5, false, null, null); + String u = newVerifiedUser("male", 1990); + + Map res = activityService.registerActivity(u, a.getId(), "c", "p"); + assertEquals(Constants.REGISTRATION_STATUS_APPROVED, res.get("status")); + } + + @Test + @DisplayName("Gate: resolveEligibility 对 matchmaking 与反诈类返回不同策略") + void resolveEligibilityDiffersByType() { + Activity match = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, 5, 5, false, null, null); + Activity fraud = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null); + + Map m = activityService.resolveEligibility(match); + Map f = activityService.resolveEligibility(fraud); + + assertEquals(true, m.get("requiresIdentityVerified")); + assertEquals(true, m.get("requiresLocalEligibility")); + assertEquals(true, m.get("usesGenderQuota")); + + assertEquals(false, f.get("requiresIdentityVerified")); + assertEquals(false, f.get("requiresLocalEligibility")); + assertEquals(false, f.get("usesGenderQuota")); + } + + /** 年龄校验:原实现为 TODO 空壳,minAge/maxAge 完全不生效 */ + @Test + @DisplayName("D4: 年龄范围真正生效(原为 TODO 空实现)") + void ageRangeIsEnforced() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, (short) 30, (short) 45); + String tooYoung = newUser("male", LocalDate.now().getYear() - 20); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.registerActivity(tooYoung, a.getId(), "c", "p")); + assertTrue(ex.getMessage().contains("年龄"), "应拦截年龄不足的报名,实际:" + ex.getMessage()); + } + + // ==================== D3:白名单校验(生产 CHECK 约束)==================== + + @Test + @DisplayName("D3: 创建活动拒绝非法 activityType(避免撞生产 chk_activity_type)") + void createActivityRejectsUnknownType() { + Map body = baseAdminBody(); + body.put("activityType", "speed_dating"); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> activityService.createActivityFromAdmin(body, "op-1")); + assertTrue(ex.getMessage().contains("非法的活动类型")); + } + + @Test + @DisplayName("D3: 添加组织拒绝非法 role(避免撞生产 chk_org_role)") + void addActivityOrganizationRejectsUnknownRole() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> activityService.addActivityOrganization(a.getId(), "org-1", "sponsor", "op-1")); + assertTrue(ex.getMessage().contains("非法的活动组织角色")); + } + + @Test + @DisplayName("D3: 合法类型与角色可被接受") + void validTypeAndRoleAreAccepted() { + Map body = baseAdminBody(); + body.put("activityType", Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE); + Map created = activityService.createActivityFromAdmin(body, "op-1"); + assertEquals(Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE, created.get("activityType")); + + Map org = activityService.addActivityOrganization( + (String) created.get("id"), "org-1", Constants.ORG_ROLE_VENUE_PARTNER, "op-1"); + assertEquals(Constants.ORG_ROLE_VENUE_PARTNER, org.get("role")); + } + + // ==================== 后台动态表单:字段容错 ==================== + + @Test + @DisplayName("D11: 后台传无时区本地时间不得被静默丢弃(原 Instant.parse 直接失败)") + void adminLocalDateTimeIsNotSilentlyDropped() { + Map body = baseAdminBody(); + body.put("startAt", "2030-01-02T10:00:00"); + body.put("endAt", "2030-01-02T12:00:00"); + + Map created = activityService.createActivityFromAdmin(body, "op-1"); + Activity saved = activityMapper.selectById((String) created.get("id")); + assertNotNull(saved); + assertEquals(10, saved.getStartAt().atZone(java.time.ZoneId.systemDefault()).getHour(), + "运营填写的开始时间必须被真正保存,而不是静默回落成默认时间"); + assertEquals(12, saved.getEndAt().atZone(java.time.ZoneId.systemDefault()).getHour()); + } + + @Test + @DisplayName("D11: 后台漏填名额/年龄不得 500(原实现 NPE)") + void adminFormToleratesMissingOptionalFields() { + Map body = new java.util.HashMap<>(); + body.put("title", "t-" + System.nanoTime()); + body.put("activityType", Constants.ACTIVITY_TYPE_ANTI_FRAUD); + + Map created = assertDoesNotThrow( + () -> activityService.createActivityFromAdmin(body, "op-1")); + Activity saved = activityMapper.selectById((String) created.get("id")); + assertNotNull(saved); + assertNotNull(saved.getStartAt(), "缺省时间应有兜底"); + assertNull(saved.getMinAge(), "未填年龄门槛应为空(不设限),而非 0"); + } + + @Test + @DisplayName("D11: 主办组织需回填到 activities.organizer_org_id(原实现留空)") + void hostOrganizationIsPersistedOnActivity() { + Map body = baseAdminBody(); + body.put("organizerOrgId", "org-host-1"); + + Map created = activityService.createActivityFromAdmin(body, "op-1"); + Activity saved = activityMapper.selectById((String) created.get("id")); + assertEquals("org-host-1", saved.getOrganizerOrgId()); + assertFalse(activityService.getActivityOrganizations(saved.getId()).isEmpty()); + } + + // ==================== D5/D6:列表数据真实性 ==================== + + @Test + @DisplayName("D5: 男女人数为真实统计,不再是 approvedCount/2 编造") + void listCountsRealGendersNotHalfSplit() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null); + // 2 男 1 女 —— 原实现的 /2 会得到 male=1, female=2 + for (int i = 0; i < 2; i++) { + activityService.registerActivity(newUser("male", 1990), a.getId(), "c", "p"); + } + activityService.registerActivity(newUser("female", 1990), a.getId(), "c", "p"); + + List list = activityService.getActivities("u-x"); + ActivityListItemDTO dto = list.stream() + .filter(d -> a.getId().equals(d.getActivityId())).findFirst().orElseThrow(); + + assertEquals(2, dto.getMaleCount(), "应按 user_profiles.gender 真实统计男性"); + assertEquals(1, dto.getFemaleCount(), "应按 user_profiles.gender 真实统计女性"); + assertEquals(3, dto.getRegisteredCount(), "registeredCount 应为真实报名人数(小程序卡片读此字段)"); + assertEquals(7, dto.getRemainingSeats()); + } + + @Test + @DisplayName("D6: 列表 DTO 携带 activityType(小程序分类 Tabs 依赖)") + void listItemsCarryActivityType() { + newActivity(Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION, 10, null, null, false, null, null); + + List list = activityService.getActivities("u-x"); + assertFalse(list.isEmpty()); + ActivityListItemDTO dto = list.stream() + .filter(d -> Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION.equals(d.getActivityType())) + .findFirst().orElse(null); + assertNotNull(dto, "列表项必须带 activityType,否则小程序分类筛选恒无数据"); + } + + @Test + @DisplayName("D6: 按类型筛选生效,且拒绝非法类型") + void getActivitiesByTypeFiltersAndRejectsUnknown() { + newActivity(Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE, 10, null, null, false, null, null); + + List> hit = activityService.getActivitiesByType( + "u-x", Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE); + assertFalse(hit.isEmpty()); + assertEquals(Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE, hit.get(0).get("activityType")); + + List> miss = activityService.getActivitiesByType( + "u-x", Constants.ACTIVITY_TYPE_ANTI_FRAUD); + assertTrue(miss.isEmpty()); + + assertThrows(IllegalArgumentException.class, + () -> activityService.getActivitiesByType("u-x", "not_a_type")); + } + + // ==================== D10:软删活动导致 NPE ==================== + + @Test + @DisplayName("D10: 我的活动跳过已软删的活动,不再 NPE") + void myActivitiesSkipsDeletedActivity() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null); + String u = newUser("male", 1990); + activityService.registerActivity(u, a.getId(), "c", "p"); + + assertFalse(activityService.getMyActivities(u).isEmpty()); + + // 软删活动后应跳过而非 NPE + activityMapper.deleteById(a.getId()); + assertDoesNotThrow(() -> activityService.getMyActivities(u)); + assertTrue(activityService.getMyActivities(u).isEmpty()); + } + + // ==================== INV-020:主办组织关系不自动授予用户库访问 ==================== + + @Test + @DisplayName("INV-020: 活动组织接口只返回组织信息,不泄漏任何用户数据") + void activityOrganizationsExposeNoUserData() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, null, null, false, null, null); + Organization org = new Organization(); + org.setName("丹阳市妇联"); + org.setType("women_federation"); + org.setStatus("active"); + organizationMapper.insert(org); + activityService.addActivityOrganization(a.getId(), org.getId(), Constants.ORG_ROLE_HOST, "op-1"); + + List> orgs = activityService.getActivityOrganizations(a.getId()); + assertFalse(orgs.isEmpty()); + for (Map o : orgs) { + assertTrue(o.keySet().stream().noneMatch(k -> k.toLowerCase().contains("user")), + "组织接口不得返回任何用户字段,实际键:" + o.keySet()); + assertEquals("丹阳市妇联", o.get("name")); + } + } + + @Test + @DisplayName("INV-020: 报名列表(含用户资料)位于运营端且要求显式 activity.view 权限") + void registrationListRequiresOperatorPermission() throws Exception { + var cls = com.danyang.match.platform.activity.controller.ActivityOperatorController.class; + var pre = cls.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class); + assertNotNull(pre, "运营端活动控制器必须有类级 @PreAuthorize,不能依赖粗过滤器"); + assertTrue(pre.value().contains("activity.view")); + + // 用户端控制器不得暴露报名列表 + boolean userSideExposesRegistrations = java.util.Arrays.stream( + com.danyang.match.platform.activity.controller.ActivityController.class.getDeclaredMethods()) + .anyMatch(m -> m.getName().toLowerCase().contains("registration") + && m.getName().toLowerCase().contains("list")); + assertFalse(userSideExposesRegistrations, "用户端不得提供报名列表接口"); + } + + // ==================== 多组织与容量分组落库 ==================== + + @Test + @DisplayName("多组织关系与容量分组可正确落库并回读") + void multiOrgAndCapacityGroupsPersist() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, null, null, false, null, null); + + activityService.addActivityOrganization(a.getId(), "org-a", Constants.ORG_ROLE_HOST, "op-1"); + activityService.addActivityOrganization(a.getId(), "org-b", Constants.ORG_ROLE_PARTNER, "op-1"); + assertEquals(2, activityService.getActivityOrganizations(a.getId()).size()); + + activityService.addCapacityGroup(a.getId(), + Map.of("groupCode", "male_30_40", "capacity", 20, "ruleDefinition", "{\"gender\":\"male\"}"), "op-1"); + List> groups = activityService.getActivityCapacityGroups(a.getId()); + assertEquals(1, groups.size()); + assertEquals("male_30_40", groups.get(0).get("groupCode")); + assertEquals(20, ((Number) groups.get(0).get("capacity")).intValue()); + assertEquals(0, ((Number) groups.get(0).get("approvedCount")).intValue()); + } + + // ==================== 辅助 ==================== + + private Activity newActivity(String type, int totalLimit, Integer maleLimit, Integer femaleLimit, + boolean needReview, Short minAge, Short maxAge) { + Organization org = new Organization(); + org.setName("org-" + System.nanoTime()); + org.setType("other"); + org.setStatus("active"); + organizationMapper.insert(org); + + Activity a = new Activity(); + a.setTitle("act-" + System.nanoTime()); + a.setOrganizerOrgId(org.getId()); + a.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN); + a.setActivityType(type); + a.setTotalLimit(totalLimit); + a.setMaleLimit(maleLimit); + a.setFemaleLimit(femaleLimit); + a.setNeedReview(needReview); + a.setMinAge(minAge); + a.setMaxAge(maxAge); + a.setStartAt(Instant.now().plusSeconds(3600)); + a.setEndAt(Instant.now().plusSeconds(7200)); + activityMapper.insert(a); + return a; + } + + private String newUser(String gender, int birthYear) { + User u = new User(); + u.setWechatOpenid("p14_" + System.nanoTime()); + u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING); + u.setRecommendationEnabled(true); + userMapper.insert(u); + + UserProfile p = new UserProfile(); + p.setUserId(u.getId()); + p.setGender(gender); + p.setBirthDate(LocalDate.of(birthYear, 1, 1)); + userProfileMapper.insert(p); + return u.getId(); + } + + /** 已实名 + 已通过本地资格核验的用户 */ + private String newVerifiedUser(String gender, int birthYear) { + String userId = newUser(gender, birthYear); + + UserIdentity id = new UserIdentity(); + id.setUserId(userId); + id.setIdNumberHash("hash-" + System.nanoTime()); + id.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED); + id.setGender(gender); + id.setBirthDate(LocalDate.of(birthYear, 1, 1)); + userIdentityMapper.insert(id); + + LocalEligibility el = new LocalEligibility(); + el.setUserId(userId); + el.setEligibilityType("household"); + el.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED); + localEligibilityMapper.insert(el); + return userId; + } + + private ActivityRegistration registerPending(String activityId, String userId) { + Map r = activityService.registerActivity(userId, activityId, "c", "p"); + ActivityRegistration reg = new ActivityRegistration(); + reg.setId((String) r.get("registrationId")); + reg.setActivityId(activityId); + reg.setUserId(userId); + return reg; + } + + private Map baseAdminBody() { + Map body = new java.util.HashMap<>(); + body.put("title", "t-" + System.nanoTime()); + body.put("description", "d"); + body.put("startAt", Instant.now().plusSeconds(3600).toString()); + body.put("endAt", Instant.now().plusSeconds(7200).toString()); + body.put("locationName", "丹阳市"); + body.put("totalLimit", 10); + body.put("minAge", 20); + body.put("maxAge", 60); + body.put("activityType", Constants.ACTIVITY_TYPE_MATCHMAKING); + return body; + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/activity/ActivityServiceTest.java b/backend/src/test/java/com/danyang/match/platform/activity/ActivityServiceTest.java index 4192f0a..d6512eb 100644 --- a/backend/src/test/java/com/danyang/match/platform/activity/ActivityServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/activity/ActivityServiceTest.java @@ -25,17 +25,23 @@ class ActivityServiceTest { assertEquals("cancelled", Constants.REGISTRATION_STATUS_CANCELLED); } - @Test - void testNoOverbooking() { - // Core invariant: 活动最后一个名额不可超卖 - // 通过事务内原子检查保证 - assertTrue(true); // 占位测试 - } - - @Test - void testNoDuplicateRegistration() { - // Core invariant: 同一用户不能重复报名同一活动 - // 通过 UNIQUE(activity_id, user_id) 唯一约束保证 - assertTrue(true); // 占位测试 - } + // ======================================================================== + // Phase 14 复验:以下两条占位测试已**删除**。 + // + // testNoOverbooking -> assertTrue(true) // 占位测试 + // testNoDuplicateRegistration -> assertTrue(true) // 占位测试 + // + // 它们是典型的「假绿」:名字覆盖两条核心不变量,实际什么都不验证, + // 而真实实现恰恰是坏的 —— + // * 超卖保护:ActivityService 调用的是 ActivityMapper.countRemainingCapacity, + // 该方法既无 @Select 也无 XML,调用即抛 Invalid bound statement; + // 且即便修好,仍是「SELECT 余量 → INSERT」的先读后写,并发必然超卖。 + // * 重复报名:实际由 UNIQUE(activity_id, user_id) 保证,本测试从未验证过。 + // + // 真正的验证已迁至 ActivityPhase14Test: + // registerRespectsTotalLimitAtomically / approveRespectsTotalLimitAtomically / + // genderQuotaIsEnforcedForMatchmaking —— 均走真实落库路径。 + // + // 教训:占位测试不是"待补",而是会持续误导的假信号,必须删除而非保留。 + // ======================================================================== } diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java index 665213d..27f5e30 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java @@ -52,10 +52,8 @@ class EntityMigrationParityTest { * 任何人新增链路若依赖白名单外的表,必须先补 schema-h2.sql。 */ private static final Set TEST_SCHEMA_OPTIONAL_TABLES = Set.of( - "activity_capacity_groups", "activity_interests", "activity_mutual_connection_consents", - "activity_organizations", "appeals", "consent_records", "content_categories", @@ -64,7 +62,6 @@ class EntityMigrationParityTest { "operator_roles", "operators", "organization_relationships", - "organizations", "permissions", "reports", "review_items", @@ -107,7 +104,12 @@ class EntityMigrationParityTest { // Phase 13A:咨询两表此前 counseling_services 完全缺失、counseling_followups // 仅在可选白名单里(= 实际上没人测)。已补入 schema-h2.sql 并移入必需集合。 "counseling_services", - "counseling_followups" + "counseling_followups", + // Phase 14:活动组织与容量分组三表此前全在可选白名单(= 无人测), + // 导致 ActivityService 的类型/容量/多组织逻辑零覆盖。已补入 schema-h2.sql。 + "organizations", + "activity_organizations", + "activity_capacity_groups" ); /** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */ private static final Set BASE_ENTITY_COLUMNS = diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index ba48019..f8208b2 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -217,7 +217,54 @@ CREATE TABLE activities ( male_limit INT, female_limit INT, total_limit INT, min_age SMALLINT, max_age SMALLINT, registration_start_at TIMESTAMP WITH TIME ZONE, registration_end_at TIMESTAMP WITH TIME ZONE, need_review BOOLEAN, status VARCHAR(32), created_by VARCHAR(36), - created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE, + -- V023(Phase 14):多类型活动扩展列。此前测试库完全缺失, + -- 导致 Phase 14 全部新增逻辑零覆盖,且非法 activity_type 在测试期不可见 + -- (上到 PostgreSQL 才撞 chk_activity_type 返回 500)。 + activity_type VARCHAR(64) NOT NULL DEFAULT 'matchmaking', + registration_policy CLOB, + target_group VARCHAR(200), + industry_scope VARCHAR(200), + post_event_interest_enabled BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_activity_type CHECK (activity_type IN ( + 'matchmaking', 'interest_social', 'relationship_education', + 'premarital_guidance', 'marriage_custom_culture', 'anti_fraud' + )) +); + +-- ============================================================================ +-- Phase 14(V001 + V023):活动组织与容量分组 +-- 此前测试库完全缺失 organizations / activity_organizations / activity_capacity_groups, +-- 且 activities 缺 V023 全部新列 —— Phase 14 新增逻辑零覆盖。 +-- 约束与生产 V023 对齐(chk_org_role 白名单)。 +-- ============================================================================ + +-- V001: organizations 组织表(activity_organizations 的外键目标,且组织名用于活动详情展示) +CREATE TABLE organizations ( + id VARCHAR(36) PRIMARY KEY, name VARCHAR(200), type VARCHAR(32), + contact_name VARCHAR(100), contact_phone_enc VARCHAR(500), status VARCHAR(32), + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- V023: activity_organizations 活动多组织关联表 +CREATE TABLE activity_organizations ( + id VARCHAR(36) PRIMARY KEY, activity_id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL, + role VARCHAR(32) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT uk_activity_org UNIQUE (activity_id, organization_id), + CONSTRAINT chk_org_role CHECK (role IN ('host', 'organizer', 'co_organizer', 'partner', 'venue_partner')) +); + +-- V023: activity_capacity_groups 活动容量分组表 +CREATE TABLE activity_capacity_groups ( + id VARCHAR(36) PRIMARY KEY, activity_id VARCHAR(36) NOT NULL, + group_code VARCHAR(32) NOT NULL, rule_definition CLOB, + capacity INT NOT NULL DEFAULT 0, approved_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT uk_capacity_group UNIQUE (activity_id, group_code) ); -- ============================================================================ diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index 6bf2f10..1cb2b2a 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -5,6 +5,22 @@ > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > +> **2026-09-17 Phase 14 复验关闭:** EPIC-P14 已完成。复验发现 activity 模块存在**必崩级空壳**—— +> `ActivityMapper.countRemainingCapacity` **既无 `@Select` 也无 XML**,而 `ActivityService` 调用的 +> 正是它 → 活动详情/报名/审核三条路径必然抛 `Invalid bound statement`。修复 **11 处后端缺陷**: +> **D1(Critical,生产故障)** Mapper 空壳致三条路径必崩; +> **D2(Critical,并发)** 报名与审核「先读后写」→ 并发**超卖**,改单条原子 SQL; +> **D3(High,生产故障)** `activityType`/`orgRole` 未校验生产 CHECK 白名单(只在 PG 上炸); +> **D4(High,契约)** `ActivityListItemDTO` 无 `activityType` → 小程序分类 Tabs 恒无数据; +> D5 男女人数为 `approvedCount/2` 编造;D6 年龄门槛为 TODO 空壳;D7 `registration_policy` 只写不读; +> **D8 `POST /operator/activities` 丢弃类型/多组织/分组且把 `organizer_org_id` 错设为操作者 userId**; +> D9 三个运营写入端点错挂用户端控制器(双实现);D10 软删活动与缺字段致 NPE/500; +> D11 DTO 缺 `registeredCount`/`status`/`coverFileId`/`description`。 +> 另有 **3 处前端契约缺陷**(小程序分类 code `classroom` 不在白名单、卡片读 7 个不存在的字段、 +> admin-web 无活动管理页 → 新增 `ActivityManagementView.vue` 动态表单)与 +> **2 处测试保真度缺陷**(2 条 `assertTrue(true)` 占位假绿;测试库缺 V023 列与三张表、activity 五表零覆盖)。 +> 证据:`docs/Phase-14-V11-Recovery-Completion-Report-2026-09-17.md`。**Phase 15 需用户确认后启动。** +> > **2026-09-17 Phase 13A 复验关闭:** EPIC-P13A 已完成。复验发现 counseling 模块存在**双实现**—— > 线上权威 `CounselingService`(167 行)**零测试**,唯一被测的 `CounselingServiceApp`(280 行)是 > **孤儿类**(无注入点、无 HTTP 端点可达,生产永不执行)→ 已合并并删除孤儿类。修复 **9 处缺陷**: @@ -103,6 +119,7 @@ | Phase 12 Relationship Progress 复验 | 已完成(2026-09-16) | 见 `Phase-12-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处缺陷(**实体↔生产迁移系统性漂移 23 表 50 列**、双路径 episode/NPE、测试库缺 7 表致首页零覆盖、计划项 2 未实现),新增永久守卫 `EntityMigrationParityTest`,删除 2 个假测试类,后端 371 tests 全绿 | | Phase 13 公益红娘转介复验 | 已完成(2026-09-16) | 见 `Phase-13-V11-Recovery-Completion-Report-2026-09-16.md`;修复 7 处缺陷(**2 处生产故障级 CHECK 约束缺口致推荐接受/服务提交必崩**、推荐与转介三处越权、`getServiceDetail` 越权读、Mapper 缺 `@Select` 致必崩、前端 5 处契约缺口),测试库补 3 张 matchmaker 表,迁移基线 V038;新增 `MatchmakerReferralPhase13Test`(17 用例)+ 重写 `MatchmakerServiceTest`(6 用例,解析真实 DDL),删除 3 处假测试,后端 **391 tests 全绿**;**Phase 13A 尚未获准** | | Phase 13A 婚恋咨询与婚前辅导复验 | 已完成(2026-09-17) | 见 `Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md`;发现 counseling **双实现**(权威类零测试 / 孤儿类被单测),已合并删除孤儿类;修复 **9 处缺陷**(**D1 咨询正文写日志=INV-013 泄露**、D2 申请 userId 取请求体、**D3 分配先读后写并发静默覆盖**、D4 manager 可读 followups=INV-013 绕过、D5 无状态机、**D6 `/services/intro` 后端不存在致小程序 intro 页 100% 无数据**、D7 Admin 5 端点 404、D8 小程序字段名/幂等/静默失败、D9 测试库 counseling 零 CHECK 约束)+ **4 处测试基建缺陷**(守卫找不到 DDL 恒真假绿、全限定 `@TableField` 漏解析、权限守卫假阳性、测试自身逻辑缺陷);迁移基线 V039;新增 `CounselingPhase13ATest`(25)+ `CounselingConstraintGuardTest`(5,解析真实 DDL),两轮负向验证证明守卫会红;后端 **420 tests 全绿**;**Phase 14 尚未获准** | +| Phase 14 多类型公益活动复验 | 已完成(2026-09-17) | 见 `Phase-14-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **11 处后端缺陷**(**D1 `ActivityMapper.countRemainingCapacity` 无 SQL 空壳致详情/报名/审核必崩**、**D2 报名与审核先读后写并发超卖** → 改原子 SQL、D3 类型/角色未校验生产 CHECK 白名单、**D4 DTO 无 `activityType` 致小程序分类 Tabs 恒空**、D5 男女人数 `approvedCount/2` 编造、D6 年龄门槛空壳、D7 按类型资格策略为空、**D8 运营端建活动丢弃类型且把主办组织错设为操作者 userId**、D9 三个运营端点错挂用户端双实现、D10 软删活动/缺字段 NPE、D11 DTO 缺 4 字段)+ **3 处前端契约缺陷**(分类 code `classroom` 不在白名单、卡片读 7 个不存在字段、admin-web 无活动管理页)+ **2 处测试保真度缺陷**(2 条占位假绿、测试库缺 V023 列与三张表);新增 `ActivityPhase14Test`(23)+ `ActivityConstraintGuardTest`(9,解析真实 DDL **与前端源码**),两轮负向验证证明守卫会红;admin-web `vue-tsc` 0 error + `vite build` 通过;后端 **450 tests 全绿**;**Phase 15 尚未获准** | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | diff --git a/docs/Phase-14-V11-Recovery-Completion-Report-2026-09-17.md b/docs/Phase-14-V11-Recovery-Completion-Report-2026-09-17.md new file mode 100644 index 0000000..ba302ac --- /dev/null +++ b/docs/Phase-14-V11-Recovery-Completion-Report-2026-09-17.md @@ -0,0 +1,138 @@ +# 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(活动报名、审核与签到)未自动进入,等待用户确认。** diff --git a/docs/STATUS.md b/docs/STATUS.md index 659d015..147ed7d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,8 +1,8 @@ # 项目状态跟踪 — STATUS.md -> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 13A COMPLETE / GIT CLEAN`。** +> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 14 COMPLETE / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)复验已关闭**,七轮共修复 32 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 14 需用户确认后启动。** +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)复验已关闭**,八轮共修复 48 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 15 需用户确认后启动。** 最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) @@ -42,6 +42,8 @@ | 1.1.0-dev | 2026-09-08 | V1.1 基线搭建启动(Phase 01 进行中) | | 1.1.0-rc.3 | 2026-09-16 | Phase 08~12 复验关闭;新增 V037 对齐实体↔生产迁移;全量回归 371 tests 全绿 | | 1.1.0-rc.4 | 2026-09-16 | Phase 13 复验关闭(公益红娘转介,修复 7 处缺陷);新增 V038 扩展 matchmaker CHECK 白名单;全量回归 **391 tests** 全绿 | +| 1.1.0-rc.5 | 2026-09-17 | Phase 13A 复验关闭(婚恋咨询与婚前辅导,9 处缺陷 + 4 处测试基建缺陷);新增 V039;全量回归 **420 tests** 全绿 | +| 1.1.0-rc.6 | 2026-09-17 | Phase 14 复验关闭(多类型公益活动,11 处后端缺陷 + 3 处前端契约缺陷 + 2 处测试保真度缺陷);新增 `ActivityManagementView` 动态表单;全量回归 **450 tests** 全绿 | --- @@ -186,6 +188,22 @@ | ReviewService 集成本地资格审核通知 | ✅ | | 隐私安全文案(中性化,不暴露敏感信息) | ✅ | +### Phase 14 — 多类型公益活动 ✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过) +| 任务 | 状态 | +|------|------| +| ActivityType 分类 + 按类型报名策略(实名/本地资格/性别名额) | ✅ | +| 容量原子扣减(`insertIfCapacityAvailable` / `approveIfCapacityAvailable`) | ✅ 新增 | +| `activityType` / `orgRole` 生产 CHECK 白名单校验(D3) | ✅ 新增 | +| 多组织关系 + 容量分组落库与回读 | ✅ | +| 运营端:`/operator/activities` 建活动改走 V1.1;组织/分组 4 端点;列表补 `activityType` | ✅ | +| 用户端:删除 3 个错挂的运营写入端点(双实现) | ✅ | +| Miniapp:分类 Tabs code 对齐生产白名单(6 类)+ 类型化卡片 + 差异化资格提示 | ✅ | +| Admin:`ActivityManagementView.vue` 类型驱动动态表单 + `/activities` 路由与导航 | ✅ 新增 | +| INV-020:组织接口零用户字段;报名列表仅运营端且要求 `activity.view` | ✅ | +| 测试库补齐 V023 列/约束 + 三张缺失表;activity 五表移入必选集合 | ✅ | +| 测试:`ActivityPhase14Test`(23)+ `ActivityConstraintGuardTest`(9,解析真实 DDL 与前端源码) | ✅ 新增 | +| 全量回归 **450 tests 全绿** | ✅ | + ### Phase 13A — 公益婚恋咨询与婚前辅导(核心链路)✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过) | 任务 | 状态 | |------|------| diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md index 4d37da2..0bd474a 100644 --- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md +++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md @@ -351,12 +351,51 @@ D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `M **Files:** activity 模块、Miniapp activity 页面、Admin activity 页面。 -- [ ] 保留活动基础,完成 ActivityType 分类、按类型报名策略、capacity groups 和多组织关系。 -- [ ] 完成小程序分类 Tabs、类型化卡片、动态资格提示和后台动态表单。 -- [ ] 验证主办组织关系不自动授予用户库访问权限。 +- [x] 保留活动基础,完成 ActivityType 分类、按类型报名策略、capacity groups 和多组织关系。 +- [x] 完成小程序分类 Tabs、类型化卡片、动态资格提示和后台动态表单。 +- [x] 验证主办组织关系不自动授予用户库访问权限。 **Gate:** matchmaking 与课堂/反诈类活动显示和资格策略不同;容量按配置正确扣减。 +> **复验完成(2026-09-17)** — Tag:`phase14-recovery-2026-09-17` +> +> **11 处后端缺陷(D1~D11)**: +> - D1 **Critical 生产故障**:`ActivityMapper.countRemainingCapacity` 无 `@Select` 无 XML +> → 活动详情/报名/审核三条路径必抛 `Invalid bound statement`。 +> - D2 **Critical 并发**:报名与审核均为「先读后写」→ 并发超卖;改 +> `insertIfCapacityAvailable` / `approveIfCapacityAvailable` 单条原子 SQL。 +> - D3 High 生产故障:`activityType` / `orgRole` 未校验生产 `chk_activity_type` / +> `chk_org_role` 白名单("只在 PG 上炸")。 +> - D4 High 契约:`ActivityListItemDTO` 无 `activityType` → 小程序分类 Tabs 恒无数据。 +> - D5 Medium:男女人数为 `approvedCount/2` 编造 → 改按 `user_profiles.gender` 真实统计。 +> - D6 Medium:年龄门槛为 TODO 空壳,完全不生效。 +> - D7 Medium:`registration_policy` 只写不读,按类型资格策略为空(Gate 未落地)。 +> - D8 Medium:`POST /operator/activities` 丢弃类型/多组织/容量分组,且把 +> `organizer_org_id` 错设为操作者 userId → 后台动态表单无法创建带类型活动。 +> - D9 Medium:三个运营写入端点错挂在**用户端** `ActivityController`(双实现、零调用方)。 +> - D10 Medium NPE:软删活动致 `getMyActivities` NPE;后台漏填字段致创建活动 500。 +> - D11 Medium 契约:DTO 缺 `registeredCount`/`status`/`coverFileId`/`description` +> → 卡片报名人数恒 0、按钮恒"查看详情"、封面与摘要恒不显示。 +> +> **3 处前端契约缺陷(F1~F3)**:小程序分类 code `classroom` 不在生产白名单 +> (应为 `relationship_education`)且缺三类入口;卡片读取 7 个后端根本不存在的字段; +> admin-web 完全没有活动管理页(已新增 `ActivityManagementView.vue` 类型驱动动态表单)。 +> +> **2 处测试保真度缺陷(T1~T2)**:`testNoOverbooking` / +> `testNoDuplicateRegistration` 为 `assertTrue(true)` 占位假绿(已删除); +> 测试库缺 V023 列与三张表,且 activity 五表在 `EntityMigrationParityTest` +> 只属"可选白名单"(零覆盖)→ 已补齐并移入必选集合。 +> +> **负向验证两轮**:① 后端 —— 从 V023 删 `anti_fraud` + 报名退化为无容量检查 +> → 3 条用例精准变红;② 前端契约 —— 分类 code 改回 `classroom` + 卡片加回 +> `activity.suitableFor` → 3 条守卫精准变红。均已还原。 +> +> **全量回归实测**:`[WARNING] Tests run: 450, Failures: 0, Errors: 0, Skipped: 3` / +> `BUILD SUCCESS`(4 分 52 秒)。Phase 13A 基线 420 → 450(净 +30)。 +> admin-web `vue-tsc --noEmit` 0 error、`vite build` 通过。 +> **Phase 14 已关闭。Phase 15 未自动进入,等待用户确认。** +> 报告:`docs/Phase-14-V11-Recovery-Completion-Report-2026-09-17.md` + ### Phase 15:活动报名、审核与签到 — KEEP-VERIFY/EXTEND **Files:** activity registration、checkin 服务和 U29~U32 页面。 diff --git a/miniapp/components/activity-card/index.js b/miniapp/components/activity-card/index.js index ed6879f..a341cb5 100644 --- a/miniapp/components/activity-card/index.js +++ b/miniapp/components/activity-card/index.js @@ -4,6 +4,36 @@ * 不变量(AGENTS.md 第 1 条):feeType=paid 仅作展示字段,前端不实现任何支付入口。 * "公益免费"作为主要视觉标识突出展示。 */ +/** + * 活动类型 → 中文名。 + * + *

Phase 14:键必须**逐字等于**后端 {@code activities.activity_type} + * (生产约束 {@code chk_activity_type} 白名单)。守卫会解析真实 DDL 校验。 + */ +const ACTIVITY_TYPE_LABELS = { + matchmaking: '公益联谊', + interest_social: '兴趣社交', + relationship_education: '婚恋课堂', + premarital_guidance: '婚前辅导', + marriage_custom_culture: '婚俗文化', + anti_fraud: '反诈宣传', +}; + +/** + * 按类型的**差异化资格提示**(Gate 要求:matchmaking 与课堂/反诈类不同)。 + * + *

相亲类接触陌生人 → 实名 + 本地资格 + 性别名额; + * 婚恋课堂 / 婚俗文化 / 反诈宣传是公益科普 → 不设实名与本地资格门槛。 + */ +const ACTIVITY_TYPE_ELIGIBILITY = { + matchmaking: '需实名认证 · 丹阳本地资格', + premarital_guidance: '需实名认证', + interest_social: '按男女名额分别报名', + relationship_education: '公益科普 · 免费参与', + marriage_custom_culture: '公益科普 · 免费参与', + anti_fraud: '公益科普 · 免费参与', +}; + Component({ options: { addGlobalClass: true }, properties: { @@ -11,10 +41,68 @@ Component({ showStatusBadge: { type: Boolean, value: true }, compact: { type: Boolean, value: false }, // 紧凑模式(首页推荐位) }, + data: { + typeLabel: '', + eligibilityText: '', + registeredCount: 0, + capacity: 0, + cover: '', + startAtText: '', + }, + observers: { + /** 活动对象变化时重算派生展示字段(列表复用节点时 observer 同样触发) */ + 'activity': function (activity) { + if (!activity) { + this.setData({ + typeLabel: '', eligibilityText: '', registeredCount: 0, + capacity: 0, cover: '', startAtText: '', + }); + return; + } + const type = activity.activityType || ''; + // Phase 14 修复:原 wxml 读 registeredCount / currentMaleCount / currentFemaleCount, + // 后端三个字段都不存在 → "报名人数" 恒显示 0。改为后端真实字段 + 本地兜底。 + const registered = + typeof activity.registeredCount === 'number' + ? activity.registeredCount + : (activity.maleCount || 0) + (activity.femaleCount || 0); + this.setData({ + typeLabel: ACTIVITY_TYPE_LABELS[type] || '', + eligibilityText: ACTIVITY_TYPE_ELIGIBILITY[type] || '', + registeredCount: registered, + // 容量只认 totalLimit —— 原实现还有 activity.capacity 回退,但 DTO 并无该字段 + capacity: activity.totalLimit || 0, + cover: buildCover(activity), + startAtText: formatStartAt(activity.startAt), + }); + }, + }, methods: { onTap() { if (!this.data.activity) return; - this.triggerEvent('tap', { activityId: this.data.activity.activityId || this.data.activity.id }); + this.triggerEvent('tap', { activityId: this.data.activity.activityId }); }, }, }); + +/** 封面地址:优先后端给定 URL,否则按 uploadBase + /files/{coverFileId} 拼接 */ +function buildCover(activity) { + if (activity.coverUrl) return activity.coverUrl; + if (!activity.coverFileId) return ''; + try { + const app = getApp(); + const base = (app && app.globalData && app.globalData.uploadBase) || ''; + return base ? `${base}/files/${activity.coverFileId}` : ''; + } catch (e) { + return ''; + } +} + +/** 开始时间:后端返回 ISO 串,转成「M月D日 HH:mm」 */ +function formatStartAt(raw) { + if (!raw) return ''; + const d = new Date(typeof raw === 'string' ? raw.replace(/-/g, '/').replace(/T/, ' ').slice(0, 19) : raw); + if (isNaN(d.getTime())) return String(raw); + const pad = (n) => (n < 10 ? `0${n}` : `${n}`); + return `${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/miniapp/components/activity-card/index.wxml b/miniapp/components/activity-card/index.wxml index 3f6c23b..1aa07ba 100644 --- a/miniapp/components/activity-card/index.wxml +++ b/miniapp/components/activity-card/index.wxml @@ -1,17 +1,33 @@ + - + 公益活动 {{activity.title}} - {{activity.startAtText || activity.activityStartAt || activity.startAt}} - {{activity.locationName || activity.location || activity.address}} - 适合:{{activity.suitableFor}} - {{activity.intro || activity.description}} + + + {{typeLabel}} + {{eligibilityText}} + + {{startAtText}} + {{activity.locationName}} + {{activity.description}} - 报名人数 {{activity.registeredCount !== undefined ? activity.registeredCount : ((activity.currentMaleCount || 0) + (activity.currentFemaleCount || 0))}}/{{activity.capacity || activity.totalLimit}} + 报名人数 {{registeredCount}}/{{capacity}} {{activity.status === 'registration_open' ? '立即报名' : (activity.status === 'ongoing' ? '进行中' : '查看详情')}} diff --git a/miniapp/components/activity-card/index.wxss b/miniapp/components/activity-card/index.wxss index 94a8a56..1843fb5 100644 --- a/miniapp/components/activity-card/index.wxss +++ b/miniapp/components/activity-card/index.wxss @@ -1 +1,2 @@ .ui-act{overflow:hidden;border-radius:28rpx;background:#fff;box-shadow:0 10rpx 32rpx rgba(34,35,91,.08)}.ui-act__cover{position:relative;width:100%;height:240rpx;background:#F0F1F6}.ui-act__cover-img{display:block;width:100%;height:100%}.ui-act__fee-badge{position:absolute;top:18rpx;right:18rpx;padding:7rpx 15rpx;border-radius:22rpx;background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:#fff;font-size:18rpx;font-weight:600}.ui-act__body{padding:22rpx 24rpx}.ui-act__title{overflow:hidden;color:#222746;font-size:28rpx;font-weight:700;line-height:1.35;white-space:nowrap;text-overflow:ellipsis}.ui-act__meta{margin-top:7rpx;color:#767C94;font-size:20rpx;line-height:1.45}.ui-act__intro{display:-webkit-box;overflow:hidden;margin-top:12rpx;color:#626981;font-size:21rpx;line-height:1.55;-webkit-box-orient:vertical;-webkit-line-clamp:2}.ui-act__footer{display:flex;align-items:center;justify-content:space-between;margin-top:18rpx}.ui-act__capacity{color:#858A9E;font-size:19rpx}.ui-act__status-action{min-width:126rpx;height:56rpx;display:flex;align-items:center;justify-content:center;border-radius:28rpx;background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:#fff;font-size:21rpx;font-weight:650}.ui-act__status-action.disabled{background:#EFF0F5;color:#8E93A6}.ui-act--compact{display:flex;height:200rpx}.ui-act--compact .ui-act__cover{width:200rpx;height:200rpx;flex-shrink:0}.ui-act--compact .ui-act__body{flex:1;min-width:0;padding:16rpx 18rpx}.ui-act--compact .ui-act__intro{display:none} + .ui-act__type-row{display:flex;align-items:center;margin-top:10rpx;flex-wrap:wrap}.ui-act__type-badge{padding:4rpx 14rpx;border-radius:18rpx;background:#FFF1F0;color:#D9453C;font-size:18rpx;font-weight:600}.ui-act__eligibility{margin-left:12rpx;color:#858A9E;font-size:18rpx} diff --git a/miniapp/pages/activity/activity.ts b/miniapp/pages/activity/activity.ts index c82ed5d..6ebba71 100644 --- a/miniapp/pages/activity/activity.ts +++ b/miniapp/pages/activity/activity.ts @@ -52,12 +52,26 @@ Page({ hasMore: true, firstLoaded: false, activeCategory: 'all', + /** + * 分类 Tabs。 + * + *

Phase 14 修复:code 必须**逐字等于**后端 {@code activities.activity_type} + * 的取值(生产约束 {@code chk_activity_type} 白名单)。此前这里写的是 + * {@code classroom},而生产白名单里根本没有该值(婚恋课堂是 + * {@code relationship_education})—— 点「婚恋课堂」Tab 恒为空白页。 + * + *

守卫 {@code ActivityConstraintGuardTest#miniappCategoryCodesWithinProductionWhitelist} + * 会解析真实 DDL 校验本数组,改错即刻变红。 + */ categories: [ { code: 'all', label: '全部' }, { code: 'matchmaking', label: '公益联谊' }, - { code: 'classroom', label: '婚恋课堂' }, + { code: 'interest_social', label: '兴趣社交' }, + { code: 'relationship_education', label: '婚恋课堂' }, + { code: 'premarital_guidance', label: '婚前辅导' }, + { code: 'marriage_custom_culture', label: '婚俗文化' }, { code: 'anti_fraud', label: '反诈宣传' }, - ], + ] as ActivityCategory[], /** @type {{title:string, description:string, illustration:string, actions:Array}} */ emptyConfig: { title: '暂无进行中的活动',