fix(activity): close Phase 14 KEEP-VERIFY/EXTEND — 11 backend + 3 frontend-contract + 2 test-fidelity defects

Phase 14 (multi-type public-welfare activities) verification closure.

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

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

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

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

Regression: 450 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 420). admin-web vue-tsc 0 error, vite build OK.
This commit is contained in:
WorkBuddy
2026-09-17 14:02:00 +08:00
parent 7a2693b239
commit 755a570849
22 changed files with 2164 additions and 136 deletions
@@ -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<String, Object> 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<String, String> 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<String, Object> 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'))。
// ------------------------------------------------------------------
}
@@ -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
*
* <p><b>Phase 14 修复</b>:原实现把请求体的 activityType / registrationPolicy /
* 多组织 / 容量分组**全部丢弃**,且把 {@code organizer_org_id} 错设为操作者 userId。
* 后台动态表单因此无法创建带类型的活动 —— Gate「分类 Tabs 有数据」根本不成立。
* 现统一委托 {@link ActivityService#createActivityFromAdmin},只保留一条实现。
*/
@PostMapping
@PreAuthorize("hasAuthority('activity.create')")
@Transactional
public ApiResponse<Activity> createActivity(
public ApiResponse<?> createActivity(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, Object> 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<List<Map<String, Object>>> 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<String, String> 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<List<Map<String, Object>>> 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<String, Object> 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());
@@ -12,6 +12,26 @@ public class ActivityListItemDTO {
private String activityId;
private String title;
private String coverUrl;
/**
* V1.1 Phase 14:封面文件 ID。
*
* <p><b>修复</b>:{@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:活动状态。
*
* <p><b>修复</b>:卡片按钮按 {@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:活动类型。
*
* <p><b>修复</b>:小程序 {@code pages/activity/activity.ts} 的分类 Tabs 读取
* {@code item.activityType},但后端列表 DTO 此前**根本没有该字段** ——
* 分类筛选恒无数据,Gate「matchmaking 与课堂/反诈类活动显示不同」直接失败。
*/
private String activityType;
/**
* V1.1 Phase 14:已报名人数。
*
* <p><b>修复</b>:小程序 {@code ui-activity-card} 读 {@code activity.registeredCount}
* (回退 {@code currentMaleCount + currentFemaleCount}),而后端从未提供这三个字段 ——
* 卡片上的"报名人数 x/y"恒为 {@code 0/y}。此处由真实性别统计汇总。
*/
private int registeredCount;
}
@@ -10,17 +10,32 @@ import java.util.List;
@Mapper
public interface ActivityMapper extends BaseMapper<Activity> {
int countRemainingCapacity(String activityId);
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL ORDER BY start_at ASC")
List<Activity> 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<Activity> 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<Activity> 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);
/**
* 统计某活动已通过报名的**真实性别构成**。
*
* <p><b>Phase 14 修复</b>:原 {@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);
}
@@ -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<ActivityRegistrat
List<ActivityRegistration> findByUserId(String userId);
/**
* 原子检查名额:返回剩余名额数
* 原子检查名额:返回剩余名额数。
*
* <p><b>Phase 14 说明</b>:本方法是全模块**唯一**有 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 报名。
*
* <p>把「查余量」与「写记录」合并为一条 SQL,由数据库保证原子性,
* 避免 {@code SELECT COUNT → INSERT} 的先读后写在并发下超卖。
*
* <p>性别名额按**配置**生效:{@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);
}
@@ -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<String> 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<String> 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);
/**
* 需要**实名认证**才能报名的类型。
*
* <p>Gate 要求「matchmaking 与课堂/反诈类活动资格策略不同」:
* 相亲类接触陌生人、必须实名;婚恋课堂 / 反诈宣传是公益普适科普,不设实名门槛。
*/
public static final Set<String> TYPES_REQUIRING_IDENTITY = Set.of(
Constants.ACTIVITY_TYPE_MATCHMAKING,
Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE);
/** 需要**本地资格**才能报名的类型(丹阳户籍/工作/居住) */
public static final Set<String> TYPES_REQUIRING_LOCAL_ELIGIBILITY = Set.of(
Constants.ACTIVITY_TYPE_MATCHMAKING);
/** 需要按**性别名额**分别扣减的类型(课堂/反诈类只按总名额) */
public static final Set<String> 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<String, Object> 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<ActivityRegistration>()
.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<Map<String, Object>> getActivitiesByType(String userId, String activityType) {
List<Activity> activities = activityMapper.findOpenActivities();
// Phase 14 修复:类型必须落在生产 chk_activity_type 白名单内,
// 否则下推到 SQL 后只会静默返回空列表,掩盖前端传错值的问题。
if (activityType == null || !VALID_ACTIVITY_TYPES.contains(activityType)) {
throw new IllegalArgumentException("非法的活动类型: " + activityType);
}
// 原实现为「全表捞取 + 内存 filter」;改为下推到 SQL。
List<Activity> activities = activityMapper.findOpenActivitiesByType(activityType);
return activities.stream()
.filter(a -> activityType != null && activityType.equals(a.getActivityType()))
.map(a -> (Map<String, Object>) 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<String, Object> result = new java.util.HashMap<>();
@@ -514,6 +613,134 @@ public class ActivityService {
return result;
}
// ==================== Phase 14:类型化资格策略 ====================
/**
* 解析某活动的资格要求。
*
* <p><b>Gate 落点</b>:「matchmaking 与课堂/反诈类活动显示和资格策略不同」——
* 相亲类要求实名 + 本地资格 + 性别名额;婚恋课堂 / 反诈宣传是公益科普,
* 不设实名与本地资格门槛,容量也只按总名额计算。
*/
public Map<String, Object> resolveEligibility(Activity activity) {
String type = activity.getActivityType() == null
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType();
Map<String, Object> 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;
}
/**
* 报名前按活动类型校验资格。
*
* <p>此前 {@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 文本的时间字段。
*
* <p>后台 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();
@@ -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 白名单」守卫。
*
* <p><b>核心纪律(Phase 13 确立、Phase 13A 加固)</b>:
* 守卫必须**直接解析 {@code db/migration/V*.sql} 原文**,绝不能在测试里抄一份白名单常量。
* 抄副本的守卫只证明"今天抄的和昨天抄的一样",对 DDL 漂移**零反应**。
*
* <p><b>CWD 陷阱(Phase 13A 实测)</b>:surefire 的 workingDirectory 是仓库根,
* 而 {@code backend/} 是它的**子目录不是祖先** —— 必须同时尝试两种候选后缀,
* 且定位失败要**大声抛异常**(静默返回 null 会让守卫变成恒真假绿)。
*/
class ActivityConstraintGuardTest {
private static final List<String> MIGRATION_CANDIDATES = List.of(
"src/main/resources/db/migration",
"backend/src/main/resources/db/migration"
);
private static final Map<String, Set<String>> CACHE = new HashMap<>();
private static Path locateMigrationDir() {
Path cur = Path.of("").toAbsolutePath().normalize();
List<String> 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<Path> 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<String> vals = resolveCheckValues(c);
assertNotNull(vals, "解析器失效:未解析到约束 " + c + "(dir=" + dir + ", files=" + fileCount + ")");
assertFalse(vals.isEmpty(), "解析器失效:约束 " + c + " 白名单为空");
}
}
private static Set<String> requireWhitelist(String constraintName) {
Set<String> allowed = resolveCheckValues(constraintName);
assertNotNull(allowed, "守卫失效:未从生产迁移解析到 " + constraintName
+ "(迁移目录=" + locateMigrationDir() + ")。");
return allowed;
}
// ==================== 守卫 ====================
@Test
@DisplayName("守卫: VALID_ACTIVITY_TYPES ⊆ 生产 chk_activity_type 白名单")
void activityTypeConstantsWithinProductionWhitelist() throws Exception {
Set<String> allowed = requireWhitelist("chk_activity_type");
Set<String> codeValues = readStringSetField("VALID_ACTIVITY_TYPES");
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
Set<String> 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<String> allowed = requireWhitelist("chk_activity_type");
Set<String> codeValues = readStringSetField("VALID_ACTIVITY_TYPES");
Set<String> 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<String> allowed = requireWhitelist("chk_org_role");
Set<String> codeValues = readStringSetField("VALID_ORG_ROLES");
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
Set<String> 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<String> validTypes = readStringSetField("VALID_ACTIVITY_TYPES");
for (String field : List.of("TYPES_REQUIRING_IDENTITY",
"TYPES_REQUIRING_LOCAL_ELIGIBILITY",
"TYPES_WITH_GENDER_QUOTA")) {
Set<String> policy = readStringSetField(field);
assertFalse(policy.isEmpty(), field + " 为空,资格策略未落地(Gate 未达成)");
Set<String> bad = new TreeSet<>(policy);
bad.removeAll(validTypes);
assertTrue(bad.isEmpty(), field + " 含非法活动类型: " + bad);
}
}
@Test
@DisplayName("Gate 守卫: matchmaking 与反诈类的资格策略必须不同")
void matchmakingAndAntiFraudPoliciesMustDiffer() throws Exception {
Set<String> needIdentity = readStringSetField("TYPES_REQUIRING_IDENTITY");
Set<String> needLocal = readStringSetField("TYPES_REQUIRING_LOCAL_ELIGIBILITY");
Set<String> 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} 白名单取值。
*
* <p>此前小程序写的是 {@code classroom},生产白名单里只有 {@code relationship_education}
* ——「婚恋课堂」Tab 恒为空白页,而**没有任何测试会发现**。
*/
@Test
@DisplayName("守卫: 小程序分类 Tabs 的 code ⊆ 生产 chk_activity_type 白名单")
void miniappCategoryCodesWithinProductionWhitelist() throws Exception {
Set<String> allowed = requireWhitelist("chk_activity_type");
Set<String> codes = readMiniappCategoryCodes();
assertFalse(codes.isEmpty(), "解析不到小程序分类 code,守卫失效");
Set<String> 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<String> allowed = requireWhitelist("chk_activity_type");
Set<String> codes = readMiniappCategoryCodes();
Set<String> missing = new TreeSet<>(allowed);
missing.removeAll(codes);
assertTrue(missing.isEmpty(),
"以下活动类型在小程序分类 Tabs 中无入口,用户永远筛不到:\n - " + String.join("\n - ", missing));
}
/**
* 卡片组件读到的字段名必须真实存在于 {@link ActivityListItemDTO}。
*
* <p>此前卡片读 {@code registeredCount} / {@code currentMaleCount} / {@code currentFemaleCount},
* 后端一个都没有 —— "报名人数" 恒显示 0。
*/
@Test
@DisplayName("守卫: 活动卡片读取的字段名必须存在于列表 DTO")
void cardFieldsExistInListItemDto() throws Exception {
Set<String> 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.<field> 形式读取的字段
Set<String> used = readCardActivityFields();
assertFalse(used.isEmpty(), "解析不到卡片读取的字段,守卫失效");
Set<String> unknown = new TreeSet<>(used);
unknown.removeAll(dtoFields);
assertTrue(unknown.isEmpty(),
"活动卡片读取的字段在 ActivityListItemDTO 中不存在,界面恒为空:\n - "
+ String.join("\n - ", unknown) + "\nDTO 字段: " + dtoFields);
}
private static Set<String> 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<String> codes = new TreeSet<>();
Matcher m = Pattern.compile("code:\\s*'([^']+)'").matcher(block);
while (m.find()) codes.add(m.group(1));
return codes;
}
private static Set<String> readCardActivityFields() throws IOException {
Set<String> 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<String> resolveCheckValues(String constraintName) {
if (CACHE.containsKey(constraintName)) return CACHE.get(constraintName);
Set<String> result = null;
try {
Path dir = locateMigrationDir();
List<Path> files;
try (Stream<Path> 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<String> 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<String> readStringSetField(String name) throws Exception {
return (Set<String>) readField(name);
}
}
@@ -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:多类型公益活动 —— 真实运行时路径测试。
*
* <p><b>测试保真度</b>:走真实 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<String, Object> 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<String, Object> 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<String, Object> m = activityService.resolveEligibility(match);
Map<String, Object> 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<String, Object> 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<String, Object> body = baseAdminBody();
body.put("activityType", Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE);
Map<String, Object> created = activityService.createActivityFromAdmin(body, "op-1");
assertEquals(Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE, created.get("activityType"));
Map<String, Object> 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<String, Object> body = baseAdminBody();
body.put("startAt", "2030-01-02T10:00:00");
body.put("endAt", "2030-01-02T12:00:00");
Map<String, Object> 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<String, Object> body = new java.util.HashMap<>();
body.put("title", "t-" + System.nanoTime());
body.put("activityType", Constants.ACTIVITY_TYPE_ANTI_FRAUD);
Map<String, Object> 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<String, Object> body = baseAdminBody();
body.put("organizerOrgId", "org-host-1");
Map<String, Object> 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<ActivityListItemDTO> 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<ActivityListItemDTO> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> orgs = activityService.getActivityOrganizations(a.getId());
assertFalse(orgs.isEmpty());
for (Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> baseAdminBody() {
Map<String, Object> 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;
}
}
@@ -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 —— 均走真实落库路径。
//
// 教训:占位测试不是"待补",而是会持续误导的假信号,必须删除而非保留。
// ========================================================================
}
@@ -52,10 +52,8 @@ class EntityMigrationParityTest {
* 任何人新增链路若依赖白名单外的表,必须先补 schema-h2.sql。
*/
private static final Set<String> 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<String> BASE_ENTITY_COLUMNS =
+48 -1
View File
@@ -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)
);
-- ============================================================================