diff --git a/admin-web/src/layouts/MainLayout.vue b/admin-web/src/layouts/MainLayout.vue
index 91ad008..6c6c294 100644
--- a/admin-web/src/layouts/MainLayout.vue
+++ b/admin-web/src/layouts/MainLayout.vue
@@ -171,6 +171,7 @@ const navigation: NavigationItem[] = [
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
+ { label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
{ label: '内容管理', path: '/content', permissions: ['content.view'], icon: Reading },
{ label: '善缘服务站', path: '/service-station', permissions: ['service_location.view'], icon: Location },
{ label: '举报风控', path: '/risk/reports', permissions: ['risk.report.view'], icon: Warning },
diff --git a/admin-web/src/modules/activity/ActivityCheckinView.vue b/admin-web/src/modules/activity/ActivityCheckinView.vue
new file mode 100644
index 0000000..b6ff1b9
--- /dev/null
+++ b/admin-web/src/modules/activity/ActivityCheckinView.vue
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ a.title }}
+ {{ typeText(a.activityType) }}
+
+
+
+
+ 刷新统计
+
+
+
+
+
+
+
+
+
+ 扫码签到
+
+
+ 确认签到
+ 清空
+
+
+
+
+
+
+
+ 手工签到(特殊情形)
+
+
+ 手工签到
+
+
+ 仅可操作被指派给自己的活动;主办/协办组织关系不授予签到权限。
+
+
+
+
+
+
+
+ 最近一次签到
+
+ {{ lastResult.userId }}
+ {{ lastResult.activityId }}
+
+
+ {{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
+
+
+ {{ formatTime(lastResult.checkedInAt) }}
+
+
+
+
+
+
+
+
+
diff --git a/admin-web/src/router/index.ts b/admin-web/src/router/index.ts
index e3784ab..c654803 100644
--- a/admin-web/src/router/index.ts
+++ b/admin-web/src/router/index.ts
@@ -20,6 +20,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'review', name: 'Review', component: () => import('@/modules/review/ReviewView.vue'), meta: { title: '资料审核', permissions: ['review.view'] } },
// V1.1 Phase 14:活动管理(类型化活动 + 动态表单 + 多组织 + 容量分组)
{ path: 'activities', name: 'ActivityManagement', component: () => import('@/modules/activity/ActivityManagementView.vue'), meta: { title: '活动管理', permissions: ['activity.view'] } },
+ { path: 'activities/checkin', name: 'ActivityCheckin', component: () => import('@/modules/activity/ActivityCheckinView.vue'), meta: { title: '活动签到', permissions: ['activity.checkin'] } },
{ path: 'activities/interests', name: 'ActivityInterests', component: () => import('@/modules/activity/ActivityInterestView.vue'), meta: { title: '活动互选管理', permissions: ['activity.review_registration'] } },
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
diff --git a/backend/src/main/java/com/danyang/match/platform/activity/entity/ActivityStaffAssignment.java b/backend/src/main/java/com/danyang/match/platform/activity/entity/ActivityStaffAssignment.java
new file mode 100644
index 0000000..f0e4cc6
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/activity/entity/ActivityStaffAssignment.java
@@ -0,0 +1,30 @@
+package com.danyang.match.platform.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.Instant;
+
+/**
+ * 活动工作人员指派
+ *
+ *
V1.1 Phase 15(V040):服务站操作员的活动维度数据范围。
+ * 主办/协办组织关系不授予活动操作权限(INV-020) —— 能否为某活动执行签到,
+ * 只取决于本表的显式指派,与 operator 所属组织无关。
+ *
+ *
注意:本表主键为 {@code (activity_id, operator_id)} 复合主键,
+ * 因此**不继承** {@code BaseEntity} —— BaseEntity 会引入 id / updated_at / deleted_at,
+ * 而 MyBatis-Plus 对非 null 字段一律写入列清单,那会让每次 insert 都在生产
+ * 撞 {@code column does not exist}(与本项目历次"只在 PG 上炸"同族)。
+ */
+@Data
+@TableName("activity_staff_assignments")
+public class ActivityStaffAssignment {
+
+ private String activityId;
+ private String operatorId;
+ /** checkin_staff / activity_manager */
+ private String assignmentRole;
+ private String createdBy;
+ private Instant createdAt;
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityCapacityGroupMapper.java b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityCapacityGroupMapper.java
index dc7e260..0b6e97f 100644
--- a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityCapacityGroupMapper.java
+++ b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityCapacityGroupMapper.java
@@ -3,7 +3,35 @@ package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
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;
@Mapper
public interface ActivityCapacityGroupMapper extends BaseMapper {
+
+ @Select("SELECT * FROM activity_capacity_groups WHERE activity_id = #{activityId} AND deleted_at IS NULL "
+ + "ORDER BY group_code")
+ List findByActivityId(@Param("activityId") String activityId);
+
+ /**
+ * **原子**占用分组名额:仅当 {@code approved_count < capacity} 时才 +1。
+ *
+ * Phase 15:容量分组此前只"落库回读",从未参与名额扣减 —— 运营配置了分组容量也形同虚设。
+ * 单条 UPDATE 由数据库保证原子性,避免 {@code SELECT → UPDATE} 在并发下超卖。
+ *
+ * @return 影响行数;0 表示该分组已无名额(或分组不存在)
+ */
+ @Update("UPDATE activity_capacity_groups SET approved_count = approved_count + 1, updated_at = NOW() "
+ + "WHERE activity_id = #{activityId} AND group_code = #{groupCode} "
+ + "AND deleted_at IS NULL AND approved_count < capacity")
+ int incrementIfAvailable(@Param("activityId") String activityId, @Param("groupCode") String groupCode);
+
+ /** 取消/拒绝 approved 报名时回退分组名额(不会退到负数) */
+ @Update("UPDATE activity_capacity_groups SET approved_count = GREATEST(0, approved_count - 1), updated_at = NOW() "
+ + "WHERE activity_id = #{activityId} AND group_code = #{groupCode} "
+ + "AND deleted_at IS NULL AND approved_count > 0")
+ int decrementIfPositive(@Param("activityId") String activityId, @Param("groupCode") String groupCode);
}
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 780dac3..6aedc9b 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
@@ -21,6 +21,16 @@ public interface ActivityRegistrationMapper extends BaseMapper findApprovedByActivity(String activityId);
+ /**
+ * 某活动的**全部**报名记录(含 pending)。
+ *
+ * Phase 15 修复:后台报名列表此前只查 {@code status='approved'},
+ * 审核页面因此永远看不到待审记录 —— 审核功能实际不可用。
+ */
+ @Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND deleted_at IS NULL "
+ + "ORDER BY registered_at ASC")
+ List findByActivity(@Param("activityId") String activityId);
+
@Select("SELECT * FROM activity_registrations WHERE user_id = #{userId} ORDER BY registered_at DESC LIMIT 20")
List findByUserId(String userId);
@@ -95,4 +105,75 @@ public interface ActivityRegistrationMapper extends BaseMapperPhase 15 D7(必崩):{@code UNIQUE(activity_id, user_id)} 在生产库一直存在,
+ * 而"取消后重新报名"走的是 {@code INSERT} —— 用户一旦取消就永远无法再报该活动,
+ * 直接撞唯一键 500。测试库此前缺该 UNIQUE,所以这条必崩路径零覆盖。
+ * 正确做法:复用原记录做条件 UPDATE,并**同样**带上容量条件。
+ *
+ * @return 影响行数;0 表示名额已满,或记录状态已被他人变更
+ */
+ @Update("UPDATE activity_registrations SET status = #{status}, registered_at = NOW(), "
+ + "reviewed_by = NULL, reviewed_at = NULL, reject_reason = NULL, "
+ + "checkin_status = 'not_checked_in', checkin_at = NULL, "
+ + "emergency_contact_name = #{contactName}, emergency_contact_phone_enc = #{contactPhone}, "
+ + "updated_at = NOW() "
+ + "WHERE id = #{id} AND status IN ('cancelled', 'rejected') 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 reuseIfCapacityAvailable(@Param("id") String id,
+ @Param("activityId") String activityId,
+ @Param("status") String status,
+ @Param("contactName") String contactName,
+ @Param("contactPhone") String contactPhone,
+ @Param("gender") String gender);
+
+ /**
+ * **原子**签到:仅当当前为 {@code not_checked_in} 时才置为已签到。
+ *
+ * Phase 15 D2:原实现为「读 → 判断 → updateById 无条件覆盖」,
+ * 并发扫码会重复写入 checkin_at 并重复记审计。此处合并为一条条件 UPDATE,
+ * 由数据库保证"重复签到幂等"。
+ *
+ * @return 影响行数;0 表示已签到(幂等命中)或记录已被取消
+ */
+ @Update("UPDATE activity_registrations SET checkin_status = 'checked_in', checkin_at = NOW(), updated_at = NOW() "
+ + "WHERE id = #{id} AND checkin_status = 'not_checked_in' AND deleted_at IS NULL")
+ int checkinIfNotCheckedIn(@Param("id") String id);
+
+ /**
+ * **原子**取消:仅当状态仍为 pending / approved 时才置为 cancelled。
+ *
+ * @return 影响行数;0 表示状态已被他人变更(如已被审核拒绝),调用方不得重复回补名额
+ */
+ @Update("UPDATE activity_registrations SET status = 'cancelled', updated_at = NOW() "
+ + "WHERE id = #{id} AND status IN ('pending', 'approved') AND deleted_at IS NULL")
+ int cancelIfActive(@Param("id") String id);
+
+ /**
+ * **原子**拒绝:仅当仍为 pending 时才置为 rejected。
+ *
+ *
与 {@link #approveIfCapacityAvailable} 对称 —— 审核并发时两人同时操作,
+ * 只有一个能生效,避免"拒绝后又被通过"。
+ */
+ @Update("UPDATE activity_registrations SET status = 'rejected', reject_reason = #{reason}, "
+ + "reviewed_by = #{operatorId}, reviewed_at = NOW(), updated_at = NOW() "
+ + "WHERE id = #{id} AND status = 'pending' AND deleted_at IS NULL")
+ int rejectIfPending(@Param("id") String id,
+ @Param("operatorId") String operatorId,
+ @Param("reason") String reason);
}
diff --git a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityStaffAssignmentMapper.java b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityStaffAssignmentMapper.java
new file mode 100644
index 0000000..2acf0f0
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityStaffAssignmentMapper.java
@@ -0,0 +1,24 @@
+package com.danyang.match.platform.activity.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.danyang.match.platform.activity.entity.ActivityStaffAssignment;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 活动工作人员指派(Phase 15 签到数据范围)
+ *
+ *
与 {@code service_location_operators} 的范式一致:
+ * 有指派记录的工作人员只能操作被指派的资源;没有任何指派记录的是平台级人员。
+ */
+@Mapper
+public interface ActivityStaffAssignmentMapper extends BaseMapper {
+
+ @Select("SELECT COUNT(*) FROM activity_staff_assignments WHERE operator_id = #{operatorId}")
+ int countAssignmentsForOperator(@Param("operatorId") String operatorId);
+
+ @Select("SELECT COUNT(*) FROM activity_staff_assignments "
+ + "WHERE activity_id = #{activityId} AND operator_id = #{operatorId}")
+ int canOperateActivity(@Param("activityId") String activityId, @Param("operatorId") String operatorId);
+}
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 e764fe7..e91152e 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
@@ -16,6 +16,7 @@ import com.danyang.match.platform.activity.entity.ActivityOrganization;
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper;
import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper;
+import com.danyang.match.platform.activity.mapper.ActivityStaffAssignmentMapper;
import com.danyang.match.platform.organization.entity.Organization;
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
import com.danyang.match.platform.verification.service.IdentityVerificationService;
@@ -84,6 +85,8 @@ public class ActivityService {
private final ActivityOrganizationMapper activityOrganizationMapper;
private final ActivityCapacityGroupMapper capacityGroupMapper;
private final OrganizationMapper organizationMapper;
+ private final ActivityStaffAssignmentMapper staffAssignmentMapper;
+ private final com.danyang.match.platform.shared.security.DataScopeGuard dataScopeGuard;
private final IdentityVerificationService identityVerificationService;
private final LocalEligibilityService localEligibilityService;
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
@@ -221,14 +224,21 @@ public class ActivityService {
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("活动未开放报名或已结束");
}
+ // Phase 15 修复:报名窗口此前完全不校验 —— registration_start_at / registration_end_at
+ // 形同虚设,活动一旦发布就永远可报名(运营设的截止时间无效)。
+ assertWithinRegistrationWindow(activity);
- // 检查是否已报名
- registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(existing -> {
- if (!Constants.REGISTRATION_STATUS_REJECTED.equals(existing.getStatus()) &&
- !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.getStatus())) {
- throw new RuntimeException("已经报名过该活动");
- }
- });
+ // 性别:容量分组与性别名额都依赖它
+ String gender = genderOf(userId);
+
+ // 已报名记录的处置:pending/approved 直接拒绝;cancelled/rejected 必须**复用**原记录
+ java.util.Optional existing =
+ registrationMapper.findByActivityAndUser(activityId, userId);
+ if (existing.isPresent()
+ && !Constants.REGISTRATION_STATUS_REJECTED.equals(existing.get().getStatus())
+ && !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.get().getStatus())) {
+ throw new RuntimeException("已经报名过该活动");
+ }
// V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同)
assertEligible(userId, activity);
@@ -242,27 +252,60 @@ public class ActivityService {
? Constants.REGISTRATION_STATUS_APPROVED
: Constants.REGISTRATION_STATUS_PENDING;
- ActivityRegistration registration = new ActivityRegistration();
- registration.setActivityId(activityId);
- registration.setUserId(userId);
- registration.setStatus(status);
- registration.setRegisteredAt(Instant.now());
- registration.setEmergencyContactName(emergencyContactName);
- registration.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
+ ActivityRegistration 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("活动名额已满");
+ if (existing.isPresent()) {
+ // Phase 15 修复(必崩):生产 UNIQUE(activity_id, user_id) 一直存在,
+ // 而"取消/拒绝后重新报名"走的却是 INSERT —— 用户取消一次即永远无法再报,直接撞唯一键 500。
+ // 测试库此前缺该 UNIQUE,所以这条必崩路径零覆盖。此处改为复用原记录。
+ ActivityRegistration old = existing.get();
+ if (autoApprove) {
+ String groupCode = occupyCapacityGroup(activityId, gender);
+ try {
+ if (registrationMapper.reuseIfCapacityAvailable(
+ old.getId(), activityId, status,
+ emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
+ throw new RuntimeException("活动名额已满");
+ }
+ } catch (RuntimeException e) {
+ if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
+ throw e;
+ }
+ } else {
+ // pending 不占名额
+ registrationMapper.reuseIfCapacityAvailable(
+ old.getId(), activityId, status,
+ emergencyContactName, emergencyContactPhoneEnc, gender);
}
- registration.setId(newId);
+ registration = registrationMapper.selectById(old.getId());
} else {
- // pending 不占用名额,插入即可(UNIQUE(activity_id,user_id) 防重复)
- registrationMapper.insert(registration);
+ ActivityRegistration fresh = new ActivityRegistration();
+ fresh.setActivityId(activityId);
+ fresh.setUserId(userId);
+ fresh.setStatus(status);
+ fresh.setRegisteredAt(Instant.now());
+ fresh.setEmergencyContactName(emergencyContactName);
+ fresh.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
+
+ if (autoApprove) {
+ String groupCode = occupyCapacityGroup(activityId, gender);
+ String newId = UUID.randomUUID().toString();
+ try {
+ if (registrationMapper.insertIfCapacityAvailable(
+ newId, activityId, userId, status,
+ emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
+ throw new RuntimeException("活动名额已满");
+ }
+ } catch (RuntimeException e) {
+ if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
+ throw e;
+ }
+ fresh.setId(newId);
+ } else {
+ // pending 不占用名额,插入即可(UNIQUE(activity_id,user_id) 防重复)
+ registrationMapper.insert(fresh);
+ }
+ registration = fresh;
}
// P17-T03: 线下活动风险检测
@@ -296,11 +339,22 @@ public class ActivityService {
throw new RuntimeException("当前状态不允许审核通过");
}
+ // Phase 15(计划项③):服务站操作员只能审核被指派给自己的活动
+ assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
+
// 原子审核:名额已满或状态已被他人变更时 affectedRows=0
- int affected = registrationMapper.approveIfCapacityAvailable(
- registrationId, registration.getActivityId(), operatorId,
- genderOf(registration.getUserId()));
+ String gender = genderOf(registration.getUserId());
+ String groupCode = occupyCapacityGroup(registration.getActivityId(), gender);
+ int affected;
+ try {
+ affected = registrationMapper.approveIfCapacityAvailable(
+ registrationId, registration.getActivityId(), operatorId, gender);
+ } catch (RuntimeException e) {
+ if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
+ throw e;
+ }
if (affected == 0) {
+ if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
throw new RuntimeException("活动名额已满或状态已变更,无法通过审核");
}
// 回读事务内最新状态(条件更新不回填实体)
@@ -326,12 +380,14 @@ public class ActivityService {
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核");
}
+ assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
- registration.setStatus(Constants.REGISTRATION_STATUS_REJECTED);
- registration.setRejectReason(reason);
- registration.setReviewedBy(operatorId);
- registration.setReviewedAt(Instant.now());
- registrationMapper.updateById(registration);
+ // Phase 15 修复:原实现为「读 pending → 全量 updateById」,两人同时审核时
+ // 一个通过、一个拒绝会互相覆盖。改为条件更新,后到者 affectedRows=0。
+ if (registrationMapper.rejectIfPending(registrationId, operatorId, reason) == 0) {
+ throw new RuntimeException("该报名状态已被他人变更,无法拒绝");
+ }
+ registration = registrationMapper.selectById(registrationId);
// 通知用户
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
@@ -354,9 +410,20 @@ public class ActivityService {
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不能取消");
}
- registration.setStatus(Constants.REGISTRATION_STATUS_CANCELLED);
- registrationMapper.updateById(registration);
+ // 原子取消:状态已被他人变更(如已被拒绝)时 affectedRows=0,不得重复回补名额
+ if (registrationMapper.cancelIfActive(registration.getId()) == 0) {
+ throw new RuntimeException("该报名状态已被他人变更,无法取消");
+ }
+ // 已通过的名额释放(总名额/性别名额由 SQL 实时统计自动释放;分组计数需显式回退)
+ if (Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
+ String gender = genderOf(userId);
+ String groupCode = resolveCapacityGroupCode(activityId, gender);
+ if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
+ }
+ registration = registrationMapper.selectById(registration.getId());
log.info("用户取消报名: userId={}, activityId={}", userId, activityId);
+ // Phase 15 修复:取消是唯一没有审计日志的写操作,无法追溯"谁取消了报名"
+ saveAuditLog(userId, "CANCEL_REGISTRATION", "activity_registration", registration.getId(), null, null);
return registration;
}
@@ -389,7 +456,9 @@ public class ActivityService {
* 获取活动报名列表(后台)
*/
public List