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