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 @@ + + + + + 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> getRegistrations(String activityId) { - return registrationMapper.findApprovedByActivity(activityId).stream() + // Phase 15 修复(必崩):原实现只查 status='approved',后台审核列表因此 + // **永远看不到 pending 记录** —— 审核功能实际不可用(无待审项可点)。 + return registrationMapper.findByActivity(activityId).stream() .map(reg -> { Map map = new HashMap<>(); map.put("registrationId", reg.getId()); @@ -707,6 +776,70 @@ public class ActivityService { return userProfileMapper.findByUserId(userId).map(p -> p.getGender()).orElse(null); } + // ==================== Phase 15:报名窗口、数据范围与容量分组 ==================== + + /** 报名窗口校验:registration_start_at / registration_end_at 此前完全不生效 */ + private void assertWithinRegistrationWindow(Activity activity) { + Instant now = Instant.now(); + if (activity.getRegistrationStartAt() != null && now.isBefore(activity.getRegistrationStartAt())) { + throw new RuntimeException("活动报名尚未开始"); + } + if (activity.getRegistrationEndAt() != null && now.isAfter(activity.getRegistrationEndAt())) { + throw new RuntimeException("活动报名已截止"); + } + } + + /** + * 活动维度的数据范围校验(计划项③)。 + * + *

服务站操作员只能操作被显式指派的活动;主办/协办组织关系不授予任何操作权限(INV-020)。 + * 没有任何指派记录的工作人员视为平台级人员,不受此限制。 + */ + private void assertActivityScope(String activityId, String operatorId, String message) { + if (operatorId == null) { + dataScopeGuard.deny(operatorId, "activity", activityId, message); + } + if (staffAssignmentMapper.countAssignmentsForOperator(operatorId) > 0 + && staffAssignmentMapper.canOperateActivity(activityId, operatorId) == 0) { + dataScopeGuard.deny(operatorId, "activity", activityId, message); + } + } + + /** + * 解析该用户应落入的容量分组;活动未配置分组时返回 null(表示不启用分组约束)。 + * + *

分组即配置:只要活动存在 {@code activity_capacity_groups} 记录就启用分组容量, + * 无需额外的开关列(生产 V023 没有 {@code capacity_group_enabled})。 + */ + private String resolveCapacityGroupCode(String activityId, String gender) { + java.util.List groups = capacityGroupMapper.findByActivityId(activityId); + if (groups.isEmpty()) return null; + for (ActivityCapacityGroup g : groups) { + if (g.getGroupCode() != null && g.getGroupCode().equals(gender)) return g.getGroupCode(); + } + for (ActivityCapacityGroup g : groups) { + if ("general".equals(g.getGroupCode())) return "general"; + } + return null; // 有分组但无匹配 → 不施加分组约束(避免误拒) + } + + /** + * 原子占用容量分组名额。 + * + *

Phase 15 修复:容量分组此前只"落库回读",{@code approved_count} 永远是 0 —— + * 运营配了分组容量也完全不生效。此处让分组真正参与扣减。 + * + * @return 被占用的 groupCode;null 表示该活动未启用分组约束 + */ + private String occupyCapacityGroup(String activityId, String gender) { + String groupCode = resolveCapacityGroupCode(activityId, gender); + if (groupCode == null) return null; + if (capacityGroupMapper.incrementIfAvailable(activityId, groupCode) == 0) { + throw new RuntimeException("该分组名额已满"); + } + return groupCode; + } + /** * 容忍 null / 时间戳 / 本地无时区文本 / ISO 文本的时间字段。 * diff --git a/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java b/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java index 19c0a72..094f705 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java @@ -4,10 +4,14 @@ import com.danyang.match.platform.activity.entity.Activity; import com.danyang.match.platform.activity.entity.ActivityRegistration; import com.danyang.match.platform.activity.mapper.ActivityMapper; import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper; +import com.danyang.match.platform.activity.mapper.ActivityStaffAssignmentMapper; import com.danyang.match.platform.audit.entity.AuditLog; import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.shared.security.DataScopeGuard; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -16,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Instant; import java.util.Base64; import java.util.HashMap; @@ -30,6 +35,13 @@ import java.util.concurrent.TimeUnit; @RequiredArgsConstructor public class CheckinService { + private static final Logger log = LoggerFactory.getLogger(CheckinService.class); + + /** Redis 中标记"已签发但未消费"的签到码 */ + private static final String TOKEN_STATE_ISSUED = "issued"; + /** Redis 中标记"已消费"的签到码(一次性,不可重放) */ + private static final String TOKEN_STATE_USED = "used"; + @Value("${checkin.token.secret:danyang-match-checkin-secret-change-in-production}") private String tokenSecret; @@ -38,15 +50,16 @@ public class CheckinService { private final ActivityRegistrationMapper registrationMapper; private final ActivityMapper activityMapper; + private final ActivityStaffAssignmentMapper staffAssignmentMapper; private final AuditLogMapper auditLogMapper; + private final DataScopeGuard dataScopeGuard; private final StringRedisTemplate redisTemplate; /** * 生成签到Token(短时有效,二维码内容) - * Token格式: base64(userId:activityId:timestamp: HMAC) + * Token格式: base64(userId:activityId:timestamp:HMAC) */ public Map generateCheckinToken(String userId, String activityId) { - // 检查是否有有效的报名记录 ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) .orElseThrow(() -> new RuntimeException("您没有报名该活动")); @@ -58,24 +71,21 @@ public class CheckinService { throw new RuntimeException("您已经签到过了"); } - // 检查活动是否在进行中 - Activity activity = activityMapper.selectById(activityId); - if (activity == null) { - throw new RuntimeException("活动不存在"); - } + Activity activity = requireCheckableActivity(activityId); + Instant now = Instant.now(); if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) { throw new RuntimeException("活动未开始或已结束,无法签到"); } - // 生成签名Token String payload = userId + ":" + activityId + ":" + now.toEpochMilli(); String signature = sign(payload); String token = Base64.getEncoder().encodeToString((payload + ":" + signature).getBytes(StandardCharsets.UTF_8)); - // 缓存Token(防止重放攻击) - String cacheKey = "checkin:token:" + token; - redisTemplate.opsForValue().set(cacheKey, "used", tokenTtlMinutes, TimeUnit.MINUTES); + // Phase 15 修复(必崩):此前此处写入的值为 "used",而 scanCheckin 见到 "used" 立即拒绝 —— + // 结果是**每一个刚生成的签到码都自判已使用**,扫码签到 100% 失败。 + // 生成时只能标记 issued,真正的消费标记在扫码成功后写入。 + redisTemplate.opsForValue().set(tokenKey(token), TOKEN_STATE_ISSUED, tokenTtlMinutes, TimeUnit.MINUTES); Map result = new HashMap<>(); result.put("token", token); @@ -87,125 +97,188 @@ public class CheckinService { /** * 工作人员扫码签到 + * + *

幂等:同一签到码重复扫码、或用户已签到时返回 {@code alreadyCheckedIn=true} 且不重复记审计。 */ @Transactional public Map scanCheckin(String token, String staffOperatorId) { - // 1. 解码Token String decoded; try { decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); } catch (Exception e) { - throw new RuntimeException("无效的签到码"); + throw new IllegalArgumentException("无效的签到码"); } String[] parts = decoded.split(":"); if (parts.length != 4) { - throw new RuntimeException("无效的签到码格式"); + throw new IllegalArgumentException("无效的签到码格式"); } String userId = parts[0]; String activityId = parts[1]; - long timestamp = Long.parseLong(parts[2]); + long timestamp; + try { + timestamp = Long.parseLong(parts[2]); + } catch (NumberFormatException e) { + // Phase 15 修复:此前非法时间戳会抛 NumberFormatException → 500 而非 400 + throw new IllegalArgumentException("无效的签到码格式"); + } String signature = parts[3]; - // 2. 验证签名 - String expectedPayload = userId + ":" + activityId + ":" + timestamp; - String expectedSignature = sign(expectedPayload); - if (!expectedSignature.equals(signature)) { - throw new RuntimeException("签到码签名验证失败"); + // 恒定时间比较,避免通过响应时间侧信道逐字节爆破签名 + if (!MessageDigest.isEqual(sign(userId + ":" + activityId + ":" + timestamp).getBytes(StandardCharsets.UTF_8), + signature.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalArgumentException("签到码签名验证失败"); } - // 3. 检查Token是否已使用(防重放) - String cacheKey = "checkin:token:" + token; - String cached = redisTemplate.opsForValue().get(cacheKey); - if ("used".equals(cached)) { - throw new RuntimeException("该签到码已使用过"); + if (Instant.now().isAfter(Instant.ofEpochMilli(timestamp).plusSeconds(tokenTtlMinutes * 60L))) { + throw new IllegalArgumentException("签到码已过期"); } - // 4. 检查Token是否过期 - Instant tokenTime = Instant.ofEpochMilli(timestamp); - if (Instant.now().isAfter(tokenTime.plusSeconds(tokenTtlMinutes * 60L))) { - throw new RuntimeException("签到码已过期"); - } + // Gate「签到不可跨活动复用」:码里带的 activityId 决定作用域, + // 工作人员必须对该活动有操作权限(显式指派),否则拒绝并记审计。 + assertCheckinScope(activityId, staffOperatorId); - // 5. 检查报名记录 ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) - .orElseThrow(() -> new RuntimeException("未找到报名记录")); + .orElseThrow(() -> new IllegalArgumentException("未找到报名记录")); if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { - throw new RuntimeException("报名未审核通过,无法签到"); + throw new IllegalArgumentException("报名未审核通过,无法签到"); } + String cacheKey = tokenKey(token); + + // 幂等:无论拿着哪个码,只要该用户已签到就直接返回成功,避免现场重复扫码报错 if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) { - throw new RuntimeException("该用户已经签到"); + redisTemplate.opsForValue().set(cacheKey, TOKEN_STATE_USED, 1, TimeUnit.HOURS); + Map idempotent = new HashMap<>(); + idempotent.put("success", true); + idempotent.put("userId", userId); + idempotent.put("activityId", activityId); + idempotent.put("alreadyCheckedIn", true); + idempotent.put("checkedInAt", registration.getCheckinAt()); + return idempotent; } - // 6. 执行签到 - registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN); - registration.setCheckinAt(Instant.now()); - registrationMapper.updateById(registration); + // 一次性:已消费的码不可被**换人**重放 + if (TOKEN_STATE_USED.equals(redisTemplate.opsForValue().get(cacheKey))) { + throw new IllegalArgumentException("该签到码已使用过"); + } - // 7. 标记Token为已使用 - redisTemplate.opsForValue().set(cacheKey, "used", 1, TimeUnit.HOURS); + // 原子签到:affectedRows=0 即并发下已被他人签到 + int affected = registrationMapper.checkinIfNotCheckedIn(registration.getId()); - // 8. 审计 - saveAuditLog(staffOperatorId, "CHECKIN", "activity_registration", registration.getId(), null, null); + redisTemplate.opsForValue().set(cacheKey, TOKEN_STATE_USED, 1, TimeUnit.HOURS); Map result = new HashMap<>(); result.put("success", true); result.put("userId", userId); result.put("activityId", activityId); - result.put("checkedInAt", Instant.now()); + result.put("alreadyCheckedIn", affected == 0); + if (affected > 0) { + Instant checkedInAt = Instant.now(); + saveAuditLog(staffOperatorId, "CHECKIN", "activity_registration", registration.getId(), null, null); + result.put("checkedInAt", checkedInAt); + } else { + ActivityRegistration latest = registrationMapper.selectById(registration.getId()); + result.put("checkedInAt", latest == null ? null : latest.getCheckinAt()); + } return result; } /** * 手工签到(工作人员手动输入) + * + *

Phase 15 修复:原实现既无活动存在性/状态校验,也无工作人员数据范围校验 —— + * 任意持 {@code activity.checkin} 权限的人可以对任意活动的任意用户签到(违反 INV-020 精神)。 */ @Transactional public Map manualCheckin(String userId, String activityId, String staffOperatorId) { + requireCheckableActivity(activityId); + assertCheckinScope(activityId, staffOperatorId); + ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) - .orElseThrow(() -> new RuntimeException("未找到报名记录")); + .orElseThrow(() -> new IllegalArgumentException("未找到报名记录")); if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { - throw new RuntimeException("报名未审核通过"); + throw new IllegalArgumentException("报名未审核通过,无法签到"); } - if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) { - throw new RuntimeException("该用户已经签到"); - } - - registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN); - registration.setCheckinAt(Instant.now()); - registrationMapper.updateById(registration); - - saveAuditLog(staffOperatorId, "MANUAL_CHECKIN", "activity_registration", registration.getId(), null, null); + int affected = registrationMapper.checkinIfNotCheckedIn(registration.getId()); Map result = new HashMap<>(); result.put("success", true); - result.put("checkedInAt", Instant.now()); + result.put("userId", userId); + result.put("activityId", activityId); + result.put("alreadyCheckedIn", affected == 0); + if (affected > 0) { + saveAuditLog(staffOperatorId, "MANUAL_CHECKIN", "activity_registration", registration.getId(), null, null); + result.put("checkedInAt", Instant.now()); + } else { + ActivityRegistration latest = registrationMapper.selectById(registration.getId()); + result.put("checkedInAt", latest == null ? null : latest.getCheckinAt()); + } return result; } /** * 获取活动签到统计 + * + *

Phase 15 修复:活动不存在/已软删时原实现直接 NPE; + * 且 {@code remainingCount} 用「总名额 − 已签到」计算,语义上是"空座"而非"未签到人数"。 */ public Map getCheckinStats(String activityId) { - ActivityRegistrationMapper regMapper = registrationMapper; - // 统计已签到人数 - long checkedInCount = registrationMapper.findApprovedByActivity(activityId).stream() + Activity activity = activityMapper.selectById(activityId); + if (activity == null) { + throw new IllegalArgumentException("活动不存在"); + } + + java.util.List approved = registrationMapper.findApprovedByActivity(activityId); + long checkedInCount = approved.stream() .filter(r -> Constants.CHECKIN_STATUS_CHECKED_IN.equals(r.getCheckinStatus())) .count(); - Activity activity = activityMapper.selectById(activityId); Map stats = new HashMap<>(); stats.put("activityId", activityId); - stats.put("totalApproved", registrationMapper.findApprovedByActivity(activityId).size()); + stats.put("totalApproved", approved.size()); stats.put("checkedInCount", checkedInCount); - stats.put("remainingCount", Math.max(0, (activity.getTotalLimit() != null ? activity.getTotalLimit() : 0) - checkedInCount)); + // 语义修正:运营关心的是"还有多少已通过的人没到场" + stats.put("notCheckedInCount", approved.size() - checkedInCount); return stats; } + /** + * 签到数据范围校验(Phase 15 计划项③)。 + * + *

服务站操作员只能操作**被显式指派**的活动;主办/协办组织关系不授予任何操作权限(INV-020)。 + * 没有任何指派记录的工作人员视为平台级活动管理员,不受此限制。 + */ + private void assertCheckinScope(String activityId, String operatorId) { + if (operatorId == null) { + dataScopeGuard.deny(operatorId, "activity", activityId, "无法识别签到操作人"); + } + if (staffAssignmentMapper.countAssignmentsForOperator(operatorId) > 0 + && staffAssignmentMapper.canOperateActivity(activityId, operatorId) == 0) { + dataScopeGuard.deny(operatorId, "activity", activityId, "只能为被指派的活动执行签到"); + } + } + + /** 活动必须存在且未被取消,否则签到无意义 */ + private Activity requireCheckableActivity(String activityId) { + Activity activity = activityMapper.selectById(activityId); + if (activity == null) { + throw new IllegalArgumentException("活动不存在"); + } + if (Constants.ACTIVITY_STATUS_CANCELLED.equals(activity.getStatus())) { + throw new IllegalArgumentException("活动已取消,无法签到"); + } + return activity; + } + + private static String tokenKey(String token) { + return "checkin:token:" + token; + } + /** * 计算HMAC-SHA256签名 */ diff --git a/backend/src/main/resources/db/migration/V040__add_activity_staff_assignments.sql b/backend/src/main/resources/db/migration/V040__add_activity_staff_assignments.sql new file mode 100644 index 0000000..ce5868e --- /dev/null +++ b/backend/src/main/resources/db/migration/V040__add_activity_staff_assignments.sql @@ -0,0 +1,21 @@ +-- ============================================================ +-- V040: V1.1 Phase 15 - 活动工作人员指派(签到数据范围) +-- ============================================================ +-- 规则:主办/协办组织关系**不**自动授予活动操作权限(INV-020)。 +-- 服务站操作员只能操作被显式指派的活动或站点任务; +-- 未被指派任何活动的工作人员(平台级活动管理员)不受此限制。 +CREATE TABLE IF NOT EXISTS activity_staff_assignments ( + activity_id UUID NOT NULL REFERENCES activities(id), + operator_id UUID NOT NULL REFERENCES operators(id), + assignment_role VARCHAR(32) NOT NULL DEFAULT 'checkin_staff', + created_by UUID REFERENCES operators(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (activity_id, operator_id) +); + +CREATE INDEX IF NOT EXISTS idx_activity_staff_operator + ON activity_staff_assignments(operator_id, activity_id); + +ALTER TABLE activity_staff_assignments + ADD CONSTRAINT chk_activity_staff_role + CHECK (assignment_role IN ('checkin_staff', 'activity_manager')); diff --git a/backend/src/test/java/com/danyang/match/platform/activity/ActivityConstraintGuardTest.java b/backend/src/test/java/com/danyang/match/platform/activity/ActivityConstraintGuardTest.java index 1f8b283..2b6b624 100644 --- a/backend/src/test/java/com/danyang/match/platform/activity/ActivityConstraintGuardTest.java +++ b/backend/src/test/java/com/danyang/match/platform/activity/ActivityConstraintGuardTest.java @@ -231,6 +231,79 @@ class ActivityConstraintGuardTest { + String.join("\n - ", unknown) + "\nDTO 字段: " + dtoFields); } + // ==================== Phase 15:报名表约束漂移守卫 ==================== + + /** + * 生产的 {@code UNIQUE(activity_id, user_id)} 必须在测试库中有对应物。 + * + *

Phase 15 D7:生产一直有该唯一键,而测试库没有 —— 于是 + * 「取消后重新报名」撞唯一键的必崩缺陷在测试期完全不可见,只在 PostgreSQL 上炸。 + */ + @Test + @DisplayName("守卫: 报名表 UNIQUE(activity_id,user_id) 必须在测试库 schema 中存在") + void registrationUniqueConstraintExistsInTestSchema() throws IOException { + String prod = readProductionRegistrationDdl(); + assertTrue(Pattern.compile("UNIQUE\\s*\\(\\s*activity_id\\s*,\\s*user_id\\s*\\)", Pattern.CASE_INSENSITIVE) + .matcher(prod).find(), + "生产 V010 未找到 UNIQUE(activity_id, user_id) —— 守卫前提失效"); + + String test = Files.readString(resolveRepoFile("backend/src/test/resources/schema-h2.sql"), + StandardCharsets.UTF_8); + int idx = test.indexOf("CREATE TABLE activity_registrations"); + assertTrue(idx >= 0, "测试库缺少 activity_registrations 表"); + String block = test.substring(idx, test.indexOf(";", idx)); + assertTrue(Pattern.compile("(UNIQUE|PRIMARY KEY)\\s*\\(\\s*activity_id\\s*,\\s*user_id\\s*\\)", + Pattern.CASE_INSENSITIVE).matcher(block).find(), + "测试库 activity_registrations 缺少 UNIQUE(activity_id, user_id):\n" + + "取消/拒绝后重新报名会撞唯一键,而此缺陷在测试期不可见"); + } + + /** 生产的 status / checkin_status 白名单必须与测试库一致 */ + @Test + @DisplayName("守卫: 报名表两个 CHECK 白名单在测试库与生产一致") + void registrationCheckConstraintsMatchBetweenSchemas() throws IOException { + String prod = readProductionRegistrationDdl(); + java.util.Set prodStatus = extractInClauseAfter(prod, "chk_reg_status"); + java.util.Set prodCheckin = extractInClauseAfter(prod, "chk_checkin_status"); + assertFalse(prodStatus.isEmpty() && prodCheckin.isEmpty(), "生产 DDL 中未定位到两个 CHECK"); + + String test = Files.readString(resolveRepoFile("backend/src/test/resources/schema-h2.sql"), + StandardCharsets.UTF_8); + int idx = test.indexOf("CREATE TABLE activity_registrations"); + String block = test.substring(idx, test.indexOf(";", idx)); + java.util.Set testStatus = extractInClauseAfter(block, "chk_reg_status"); + java.util.Set testCheckin = extractInClauseAfter(block, "chk_checkin_status"); + + assertEquals(prodStatus, testStatus, "报名状态白名单在生产与测试库之间漂移"); + assertEquals(prodCheckin, testCheckin, "签到状态白名单在生产与测试库之间漂移"); + } + + /** 读取定义 activity_registrations 的生产迁移脚本原文 */ + private static String readProductionRegistrationDdl() throws IOException { + Path dir = locateMigrationDir(); + try (var stream = Files.list(dir)) { + for (Path p : stream.filter(x -> x.getFileName().toString().endsWith(".sql")).sorted().toList()) { + String sql = Files.readString(p, StandardCharsets.UTF_8); + if (sql.contains("CREATE TABLE") && sql.contains("activity_registrations")) return sql; + } + } + throw new IllegalStateException("未找到定义 activity_registrations 的生产迁移脚本"); + } + + /** 抽取 {@code ... IN ('a','b')} 中的值集合 */ + private static java.util.Set extractInClauseAfter(String sql, String constraintName) { + int idx = sql.indexOf(constraintName); + if (idx < 0) return java.util.Set.of(); + int open = sql.indexOf("IN (", idx); + if (open < 0) return java.util.Set.of(); + int close = sql.indexOf(")", open); + String block = sql.substring(open + 4, close); + java.util.Set values = new java.util.TreeSet<>(); + Matcher m = Pattern.compile("'([^']+)'").matcher(block); + while (m.find()) values.add(m.group(1)); + return values; + } + private static Set readMiniappCategoryCodes() throws IOException { Path ts = resolveRepoFile("miniapp/pages/activity/activity.ts"); // 必须匹配 "categories: [" —— 只写 "categories:" 会先命中 diff --git a/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase15Test.java b/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase15Test.java new file mode 100644 index 0000000..4a442ed --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase15Test.java @@ -0,0 +1,534 @@ +package com.danyang.match.platform.activity; + +import com.danyang.match.platform.activity.entity.Activity; +import com.danyang.match.platform.activity.entity.ActivityCapacityGroup; +import com.danyang.match.platform.activity.entity.ActivityRegistration; +import com.danyang.match.platform.activity.entity.ActivityStaffAssignment; +import com.danyang.match.platform.activity.entity.ActivityOrganization; +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.mapper.ActivityStaffAssignmentMapper; +import com.danyang.match.platform.activity.service.ActivityService; +import com.danyang.match.platform.activity.service.CheckinService; +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +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 org.junit.jupiter.api.BeforeEach; +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.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Phase 15:活动报名、审核与签到 —— 真实运行时路径测试。 + * + *

测试保真度:走真实 Service + 真实落库;测试库 {@code schema-h2.sql} 已补齐 + * 生产的 {@code UNIQUE(activity_id, user_id)}、{@code chk_reg_status} / + * {@code chk_checkin_status} 约束以及 {@code activity_staff_assignments} 表。 + * 因此「取消后重报名撞唯一键」这类必崩缺陷会在此处即暴露,而不是上到 PostgreSQL 才炸。 + * + *

Redis 用最小 Map 替身:签到码只需 get/set 语义,不需要真实 Redis。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class ActivityPhase15Test { + + @Autowired private ActivityService activityService; + @Autowired private CheckinService checkinService; + @Autowired private ActivityMapper activityMapper; + @Autowired private ActivityRegistrationMapper registrationMapper; + @Autowired private ActivityCapacityGroupMapper capacityGroupMapper; + @Autowired private ActivityStaffAssignmentMapper staffAssignmentMapper; + @Autowired private ActivityOrganizationMapper activityOrganizationMapper; + @Autowired private OrganizationMapper organizationMapper; + @Autowired private UserMapper userMapper; + @Autowired private UserProfileMapper userProfileMapper; + @Autowired private AuditLogMapper auditLogMapper; + + @MockBean private StringRedisTemplate redisTemplate; + + /** Redis 最小替身:签到码只需 get / set(k,v,ttl,unit) 语义 */ + private final Map tokenStore = new HashMap<>(); + + @BeforeEach + @SuppressWarnings("unchecked") + void stubRedis() { + tokenStore.clear(); + ValueOperations ops = mock(ValueOperations.class); + when(ops.get(anyString())).thenAnswer(inv -> tokenStore.get(inv.getArgument(0, String.class))); + doAnswer(inv -> { + tokenStore.put(inv.getArgument(0, String.class), inv.getArgument(1, String.class)); + return null; + }).when(ops).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); + when(redisTemplate.opsForValue()).thenReturn(ops); + } + + // ==================== Gate:重复请求与并发报名不超额 ==================== + + @Test + @DisplayName("Gate: 取消报名后可以重新报名(原实现撞 UNIQUE 唯一键 500,用户永久无法再报)") + void canReRegisterAfterCancel() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 5, null, null, false); + String u = newUser("male", 1990); + + Map first = activityService.registerActivity(u, a.getId(), "c1", "p1"); + String firstId = (String) first.get("registrationId"); + + activityService.cancelRegistration(u, a.getId()); + assertEquals(Constants.REGISTRATION_STATUS_CANCELLED, + registrationMapper.findByActivityAndUser(a.getId(), u).orElseThrow().getStatus()); + + // 重新报名必须成功(原实现:INSERT 撞 UNIQUE(activity_id,user_id) → 500) + Map second = activityService.registerActivity(u, a.getId(), "c2", "p2"); + assertEquals(Constants.REGISTRATION_STATUS_APPROVED, second.get("status")); + // 复用同一条记录,而不是新增重复行 + assertEquals(firstId, second.get("registrationId")); + assertEquals(1, registrationMapper.findByActivity(a.getId()).size()); + } + + @Test + @DisplayName("Gate: 审核被拒后可以重新报名(同样曾撞 UNIQUE 唯一键)") + void canReRegisterAfterReject() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 5, null, null, true); + String u = newUser("male", 1990); + + ActivityRegistration pending = registerPending(a.getId(), u); + activityService.rejectRegistration(pending.getId(), "op-1", "资料不全"); + assertEquals(Constants.REGISTRATION_STATUS_REJECTED, + registrationMapper.selectById(pending.getId()).getStatus()); + + Map again = activityService.registerActivity(u, a.getId(), "c", "p"); + assertEquals(Constants.REGISTRATION_STATUS_PENDING, again.get("status")); + assertEquals(1, registrationMapper.findByActivity(a.getId()).size()); + } + + @Test + @DisplayName("Gate: 已报名(pending/approved)重复提交被拒,且不产生重复行") + void duplicateRegistrationIsRejected() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 5, null, null, true); + String u = newUser("male", 1990); + + activityService.registerActivity(u, a.getId(), "c", "p"); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.registerActivity(u, a.getId(), "c", "p")); + assertTrue(ex.getMessage().contains("已经报名过"), "实际:" + ex.getMessage()); + assertEquals(1, registrationMapper.findByActivity(a.getId()).size()); + } + + @Test + @DisplayName("Gate: 名额用尽后取消一个,释放的名额可被他人使用") + void cancelledSeatIsReleased() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 1, null, null, false); + String u1 = newUser("male", 1990); + String u2 = newUser("male", 1990); + + activityService.registerActivity(u1, a.getId(), "c", "p"); + assertThrows(RuntimeException.class, () -> activityService.registerActivity(u2, a.getId(), "c", "p")); + + activityService.cancelRegistration(u1, a.getId()); + assertEquals(Constants.REGISTRATION_STATUS_APPROVED, + activityService.registerActivity(u2, a.getId(), "c", "p").get("status")); + } + + // ==================== 计划项②:容量分组与非 matchmaking 场景 ==================== + + @Test + @DisplayName("计划项②: 容量分组真正参与扣减(分组名额满即拒,approved_count 递增)") + void capacityGroupIsEnforced() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION, 100, null, null, false); + newCapacityGroup(a.getId(), "male", 1); + + String m1 = newUser("male", 1990); + String m2 = newUser("male", 1990); + String f1 = newUser("female", 1990); + + activityService.registerActivity(m1, a.getId(), "c", "p"); + // 分组 male 容量为 1 → 第二位男性被拒(此前分组只落库回读,完全不生效) + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.registerActivity(m2, a.getId(), "c", "p")); + assertTrue(ex.getMessage().contains("名额已满"), "实际:" + ex.getMessage()); + + // 女性不受 male 分组限制(未配置 female 分组 → 不施加约束) + assertEquals(Constants.REGISTRATION_STATUS_APPROVED, + activityService.registerActivity(f1, a.getId(), "c", "p").get("status")); + + ActivityCapacityGroup g = capacityGroupMapper.findByActivityId(a.getId()).get(0); + assertEquals(1, g.getApprovedCount(), "分组 approved_count 应真实递增"); + } + + @Test + @DisplayName("计划项②: 取消已通过报名时回退容量分组计数") + void cancelReleasesCapacityGroup() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION, 100, null, null, false); + newCapacityGroup(a.getId(), "male", 1); + String m1 = newUser("male", 1990); + String m2 = newUser("male", 1990); + + activityService.registerActivity(m1, a.getId(), "c", "p"); + assertEquals(1, capacityGroupMapper.findByActivityId(a.getId()).get(0).getApprovedCount()); + + activityService.cancelRegistration(m1, a.getId()); + assertEquals(0, capacityGroupMapper.findByActivityId(a.getId()).get(0).getApprovedCount(), + "取消后分组名额应回退"); + + // 回退后另一位男性可以报名 + assertEquals(Constants.REGISTRATION_STATUS_APPROVED, + activityService.registerActivity(m2, a.getId(), "c", "p").get("status")); + } + + @Test + @DisplayName("计划项②: 非 matchmaking 活动(婚恋课堂)不要求实名与本地资格") + void nonMatchmakingActivityHasNoIdentityGate() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION, 10, null, null, false); + String u = newUser("male", 1990); // 未实名、未核验本地资格 + assertEquals(Constants.REGISTRATION_STATUS_APPROVED, + activityService.registerActivity(u, a.getId(), "c", "p").get("status")); + } + + // ==================== 报名窗口与后台审核列表 ==================== + + @Test + @DisplayName("报名截止时间生效(此前 registration_end_at 完全不校验)") + void registrationWindowIsEnforced() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false); + a.setRegistrationEndAt(Instant.now().minusSeconds(60)); + activityMapper.updateById(a); + String u = newUser("male", 1990); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.registerActivity(u, a.getId(), "c", "p")); + assertTrue(ex.getMessage().contains("报名已截止"), "实际:" + ex.getMessage()); + } + + @Test + @DisplayName("后台报名列表必须包含 pending 记录(此前只查 approved,审核页无待审项)") + void registrationListIncludesPending() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, true); + String u = newUser("male", 1990); + registerPending(a.getId(), u); + + List> list = activityService.getRegistrations(a.getId()); + assertEquals(1, list.size(), "后台应能看到待审记录"); + assertEquals(Constants.REGISTRATION_STATUS_PENDING, list.get(0).get("status")); + } + + // ==================== 审核与取消的原子性 ==================== + + @Test + @DisplayName("审核并发:拒绝后不能再通过(条件更新,非全量覆盖)") + void approveAfterRejectIsRejected() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, true); + String u = newUser("male", 1990); + ActivityRegistration r = registerPending(a.getId(), u); + + activityService.rejectRegistration(r.getId(), "op-1", "不符合条件"); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.approveRegistration(r.getId(), "op-2")); + assertTrue(ex.getMessage().contains("不允许审核") || ex.getMessage().contains("名额已满"), + "实际:" + ex.getMessage()); + assertEquals(Constants.REGISTRATION_STATUS_REJECTED, + registrationMapper.selectById(r.getId()).getStatus()); + } + + @Test + @DisplayName("重复取消被拒(状态已被他人变更时不得二次回补名额)") + void duplicateCancelIsRejected() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false); + String u = newUser("male", 1990); + activityService.registerActivity(u, a.getId(), "c", "p"); + + activityService.cancelRegistration(u, a.getId()); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> activityService.cancelRegistration(u, a.getId())); + assertTrue(ex.getMessage().contains("不能取消") || ex.getMessage().contains("已被他人变更"), + "实际:" + ex.getMessage()); + } + + @Test + @DisplayName("取消报名必须留审计日志(此前是唯一无审计的写操作)") + void cancelWritesAuditLog() { + Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false); + String u = newUser("male", 1990); + ActivityRegistration r = registrationMapper.selectById( + (String) activityService.registerActivity(u, a.getId(), "c", "p").get("registrationId")); + + activityService.cancelRegistration(u, a.getId()); + + boolean audited = auditLogMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(AuditLog::getAction, "CANCEL_REGISTRATION") + .eq(AuditLog::getResourceId, r.getId())).size() > 0; + assertTrue(audited, "取消报名应写入 CANCEL_REGISTRATION 审计"); + } + + // ==================== Gate:签到不可跨活动复用 ==================== + + @Test + @DisplayName("Gate: 用户生成的签到码能被工作人员成功扫码(原实现一生成即作废,100% 失败)") + void generatedTokenCanBeScanned() { + Activity a = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + String u = newUser("male", 1990); + activityService.registerActivity(u, a.getId(), "c", "p"); + + Map issued = checkinService.generateCheckinToken(u, a.getId()); + String token = (String) issued.get("token"); + assertNotNull(token); + + Map res = checkinService.scanCheckin(token, "op-platform"); + assertEquals(Boolean.TRUE, res.get("success")); + assertEquals(Boolean.FALSE, res.get("alreadyCheckedIn")); + assertEquals(Constants.CHECKIN_STATUS_CHECKED_IN, + registrationMapper.findByActivityAndUser(a.getId(), u).orElseThrow().getCheckinStatus()); + } + + @Test + @DisplayName("Gate: 重复扫同一签到码幂等成功,不重复记签到") + void duplicateScanIsIdempotent() { + Activity a = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + String u = newUser("male", 1990); + activityService.registerActivity(u, a.getId(), "c", "p"); + String token = (String) checkinService.generateCheckinToken(u, a.getId()).get("token"); + + checkinService.scanCheckin(token, "op-platform"); + Map again = checkinService.scanCheckin(token, "op-platform"); + + assertEquals(Boolean.TRUE, again.get("success")); + assertEquals(Boolean.TRUE, again.get("alreadyCheckedIn"), "重复扫码应幂等命中"); + } + + @Test + @DisplayName("Gate: 签到码不可跨活动复用 —— A 活动的码不会签到 B 活动的报名") + void checkinTokenDoesNotCrossActivities() { + Activity a1 = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + Activity a2 = newOngoingActivity(Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION, 10, null, null); + String u = newUser("male", 1990); + activityService.registerActivity(u, a1.getId(), "c", "p"); + activityService.registerActivity(u, a2.getId(), "c", "p"); + + String tokenA1 = (String) checkinService.generateCheckinToken(u, a1.getId()).get("token"); + checkinService.scanCheckin(tokenA1, "op-platform"); + + assertEquals(Constants.CHECKIN_STATUS_CHECKED_IN, + registrationMapper.findByActivityAndUser(a1.getId(), u).orElseThrow().getCheckinStatus()); + assertEquals(Constants.CHECKIN_STATUS_NOT_CHECKED_IN, + registrationMapper.findByActivityAndUser(a2.getId(), u).orElseThrow().getCheckinStatus(), + "A 活动的签到码不得签到 B 活动的报名"); + } + + @Test + @DisplayName("签到码为签名串,不含身份证/手机号等敏感明文") + void tokenContainsNoSensitivePlaintext() { + Activity a = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + String u = newUser("male", 1990); + activityService.registerActivity(u, a.getId(), "紧急联系人", "13800000000"); + + String token = (String) checkinService.generateCheckinToken(u, a.getId()).get("token"); + String decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); + + assertFalse(decoded.contains("13800000000"), "签到码不得含手机号明文"); + assertFalse(decoded.contains("紧急联系人"), "签到码不得含联系人姓名明文"); + assertEquals(4, decoded.split(":").length, "格式应为 userId:activityId:timestamp:signature"); + } + + @Test + @DisplayName("过期签到码被拒") + void expiredTokenIsRejected() { + Activity a = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + String u = newUser("male", 1990); + activityService.registerActivity(u, a.getId(), "c", "p"); + + // 伪造一个"很久以前"签发的码(签名用同一个密钥,故只能靠时间戳过期) + String token = (String) checkinService.generateCheckinToken(u, a.getId()).get("token"); + String decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); + String[] parts = decoded.split(":"); + String forged = Base64.getEncoder().encodeToString( + (parts[0] + ":" + parts[1] + ":" + (System.currentTimeMillis() - 10 * 60 * 60 * 1000L) + + ":" + parts[3]).getBytes(StandardCharsets.UTF_8)); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> checkinService.scanCheckin(forged, "op-platform")); + assertTrue(ex.getMessage().contains("过期") || ex.getMessage().contains("签名"), + "实际:" + ex.getMessage()); + } + + @Test + @DisplayName("签到统计语义正确(未签到人数 = 已通过 − 已签到),活动不存在时报错而非 NPE") + void checkinStatsAreCorrect() { + Activity a = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + String u1 = newUser("male", 1990); + String u2 = newUser("female", 1990); + activityService.registerActivity(u1, a.getId(), "c", "p"); + activityService.registerActivity(u2, a.getId(), "c", "p"); + + Map stats = checkinService.getCheckinStats(a.getId()); + assertEquals(2, ((Number) stats.get("totalApproved")).intValue()); + assertEquals(0, ((Number) stats.get("checkedInCount")).intValue()); + assertEquals(2, ((Number) stats.get("notCheckedInCount")).intValue()); + + checkinService.manualCheckin(u1, a.getId(), "op-platform"); + stats = checkinService.getCheckinStats(a.getId()); + assertEquals(1, ((Number) stats.get("checkedInCount")).intValue()); + assertEquals(1, ((Number) stats.get("notCheckedInCount")).intValue()); + + assertThrows(IllegalArgumentException.class, () -> checkinService.getCheckinStats("no-such-activity")); + } + + // ==================== 计划项③:服务站操作员只操作指定活动 ==================== + + @Test + @DisplayName("计划项③: 被指派到别处的服务站操作员不能为未指派活动签到") + void unassignedStationOperatorCannotCheckin() { + Activity a1 = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + Activity a2 = newOngoingActivity(Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION, 10, null, null); + String u = newUser("male", 1990); + activityService.registerActivity(u, a1.getId(), "c", "p"); + + // 该操作员只被指派到 a2 + assignStaff(a2.getId(), "op-station"); + assertEquals(1, staffAssignmentMapper.canOperateActivity(a2.getId(), "op-station")); + assertEquals(0, staffAssignmentMapper.canOperateActivity(a1.getId(), "op-station")); + + assertThrows(AccessDeniedException.class, + () -> checkinService.manualCheckin(u, a1.getId(), "op-station")); + + // 审核同样受活动范围约束:该操作员不能审核 a1 的待审报名 + Activity a3 = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, true); + ActivityRegistration pending = registerPending(a3.getId(), newUser("male", 1990)); + assertThrows(AccessDeniedException.class, + () -> activityService.approveRegistration(pending.getId(), "op-station")); + } + + @Test + @DisplayName("计划项③: 被指派到该活动的操作员可以签到;无指派记录的平台级人员不受限") + void assignedOperatorAndPlatformStaffCanCheckin() { + Activity a = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + String u1 = newUser("male", 1990); + String u2 = newUser("female", 1990); + activityService.registerActivity(u1, a.getId(), "c", "p"); + activityService.registerActivity(u2, a.getId(), "c", "p"); + + assignStaff(a.getId(), "op-station"); + assertEquals(Boolean.TRUE, checkinService.manualCheckin(u1, a.getId(), "op-station").get("success")); + // 无任何指派记录 → 视为平台级活动管理员 + assertEquals(Boolean.TRUE, checkinService.manualCheckin(u2, a.getId(), "op-platform").get("success")); + } + + @Test + @DisplayName("计划项③: 组织关系(主办方)不授予签到权限 —— INV-020") + void hostOrganizationDoesNotGrantCheckinPermission() { + Activity a = newOngoingActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null); + String u = newUser("male", 1990); + activityService.registerActivity(u, a.getId(), "c", "p"); + + // operator 属于主办组织(用 organizationId 直接当 operatorId 模拟"同组织人员") + String hostOrgId = a.getOrganizerOrgId(); + assignStaff(hostOrgId, "op-of-host-org"); // 指派的是组织、不是活动 → 仍应被拒 + + assertThrows(AccessDeniedException.class, + () -> checkinService.manualCheckin(u, a.getId(), "op-of-host-org")); + } + + // ==================== 辅助 ==================== + + private Activity newActivity(String type, int totalLimit, Integer maleLimit, Integer femaleLimit, + boolean needReview) { + 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.setStartAt(Instant.now().plusSeconds(3600)); + a.setEndAt(Instant.now().plusSeconds(7200)); + activityMapper.insert(a); + return a; + } + + /** 正在进行中的活动:签到窗口要求 now 落在 [startAt, endAt] 内 */ + private Activity newOngoingActivity(String type, int totalLimit, Integer maleLimit, Integer femaleLimit) { + Activity a = newActivity(type, totalLimit, maleLimit, femaleLimit, false); + a.setStartAt(Instant.now().minusSeconds(60)); + a.setEndAt(Instant.now().plusSeconds(3600)); + activityMapper.updateById(a); + return a; + } + + private String newUser(String gender, int birthYear) { + User u = new User(); + u.setWechatOpenid("p15_" + 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 ActivityRegistration registerPending(String activityId, String userId) { + Map r = activityService.registerActivity(userId, activityId, "c", "p"); + return registrationMapper.selectById((String) r.get("registrationId")); + } + + private void newCapacityGroup(String activityId, String groupCode, int capacity) { + ActivityCapacityGroup g = new ActivityCapacityGroup(); + g.setActivityId(activityId); + g.setGroupCode(groupCode); + g.setCapacity(capacity); + g.setApprovedCount(0); + capacityGroupMapper.insert(g); + } + + private void assignStaff(String activityId, String operatorId) { + ActivityStaffAssignment s = new ActivityStaffAssignment(); + s.setActivityId(activityId); + s.setOperatorId(operatorId); + s.setAssignmentRole("checkin_staff"); + staffAssignmentMapper.insert(s); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/activity/CheckinServiceTest.java b/backend/src/test/java/com/danyang/match/platform/activity/CheckinServiceTest.java index f67d501..b783e0a 100644 --- a/backend/src/test/java/com/danyang/match/platform/activity/CheckinServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/activity/CheckinServiceTest.java @@ -1,33 +1,125 @@ package com.danyang.match.platform.activity.service; +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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import static org.junit.jupiter.api.Assertions.*; +/** + * 签到服务契约守卫(纯静态,不起 Spring 上下文)。 + * + *

Phase 15 修复:本类此前有 3 条 {@code assertTrue(true); // 占位测试}, + * 恰好覆盖「重复签到」「Token 过期」「二维码不含敏感明文」三条核心不变量 —— 全是假绿。 + * 现改为解析**真实源码与真实生产 DDL** 的守卫:任何回归都会变红。 + */ class CheckinServiceTest { @Test void testCheckinStatusConstants() { - assertEquals("not_checked_in", com.danyang.match.platform.shared.constant.Constants.CHECKIN_STATUS_NOT_CHECKED_IN); - assertEquals("checked_in", com.danyang.match.platform.shared.constant.Constants.CHECKIN_STATUS_CHECKED_IN); + assertEquals("not_checked_in", Constants.CHECKIN_STATUS_NOT_CHECKED_IN); + assertEquals("checked_in", Constants.CHECKIN_STATUS_CHECKED_IN); } @Test - void testTokenDoesNotContainSensitiveData() { - // Core invariant: 二维码不包含身份证/手机号/userId明文 - // Token是HMAC签名,无法反推用户信息 - assertTrue(true); // 占位测试 + @DisplayName("签到状态常量必须落在生产 chk_checkin_status 白名单内(解析真实 V010 SQL)") + void checkinConstantsWithinProductionWhitelist() throws IOException { + String ddl = readMigrationDdl(); + java.util.Set allowed = extractInClause(ddl, "chk_checkin_status"); + assertFalse(allowed.isEmpty(), "未能在生产 DDL 中定位 chk_checkin_status"); + assertTrue(allowed.contains(Constants.CHECKIN_STATUS_NOT_CHECKED_IN), + "生产白名单缺少 not_checked_in:" + allowed); + assertTrue(allowed.contains(Constants.CHECKIN_STATUS_CHECKED_IN), + "生产白名单缺少 checked_in:" + allowed); } @Test - void testDuplicateCheckinPrevention() { - // Core invariant: 重复签到幂等,返回已签到而非新建记录 - assertTrue(true); // 占位测试 + @DisplayName("D1 回归守卫:生成签到码时不得把 Token 直接标记为已消费(曾导致扫码 100% 失败)") + void generateMustNotMarkTokenAsUsed() throws IOException { + String src = Files.readString(resolveRepoFile( + "backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java"), + StandardCharsets.UTF_8); + + // 定位 generateCheckinToken 方法体 + int start = src.indexOf("public Map generateCheckinToken"); + assertTrue(start > 0, "未找到 generateCheckinToken"); + int end = src.indexOf("public Map scanCheckin", start); + String body = src.substring(start, end > 0 ? end : src.length()); + + assertTrue(body.contains("redisTemplate.opsForValue().set"), + "签到码应写入 Redis 以便一次性消费"); + assertFalse(body.contains("TOKEN_STATE_USED"), + "generateCheckinToken 不得写入 TOKEN_STATE_USED —— 否则每个刚生成的码都自判已使用,扫码必然失败"); + assertTrue(body.contains("TOKEN_STATE_ISSUED"), + "签发状态应为 TOKEN_STATE_ISSUED"); } @Test - void testTokenExpiration() { - // Token过期后不可用 - assertTrue(true); // 占位测试 + @DisplayName("签到必须走条件更新(原子),不能是 updateById 全量覆盖") + void checkinMustBeConditionalUpdate() throws IOException { + String src = Files.readString(resolveRepoFile( + "backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java"), + StandardCharsets.UTF_8); + assertTrue(src.contains("checkinIfNotCheckedIn"), + "签到必须调用原子条件更新 checkinIfNotCheckedIn,重复签到才不会覆盖 checkin_at"); + } + + @Test + @DisplayName("二维码只承载 userId:activityId:timestamp:签名,不含联系人/手机号等敏感明文") + void tokenPayloadCarriesNoSensitiveFields() throws IOException { + String src = Files.readString(resolveRepoFile( + "backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java"), + StandardCharsets.UTF_8); + int idx = src.indexOf("String payload ="); + assertTrue(idx > 0, "未找到 payload 构造"); + String payloadLine = src.substring(idx, src.indexOf(";", idx)); + assertTrue(payloadLine.contains("userId") && payloadLine.contains("activityId"), + "payload 应包含定位必需的 userId 与 activityId"); + assertFalse(payloadLine.contains("emergency"), "payload 不得包含紧急联系人信息"); + assertFalse(payloadLine.contains("Phone"), "payload 不得包含手机号"); + assertFalse(payloadLine.contains("idNumber"), "payload 不得包含身份证号"); + } + + // ==================== 解析实现 ==================== + + /** 读取定义 activity_registrations 的生产迁移脚本原文 */ + private static String readMigrationDdl() throws IOException { + Path dir = Paths.get("backend/src/main/resources/db/migration"); + try (var stream = Files.list(dir)) { + for (Path p : stream.filter(p -> p.getFileName().toString().endsWith(".sql")).sorted().toList()) { + String sql = Files.readString(p, StandardCharsets.UTF_8); + if (sql.contains("chk_checkin_status")) return sql; + } + } + throw new IllegalStateException("未找到含 chk_checkin_status 的生产迁移脚本"); + } + + /** 抽取 {@code CONSTRAINT CHECK (col IN ('a', 'b'))} 中的值集合 */ + private static java.util.Set extractInClause(String sql, String constraintName) { + int idx = sql.indexOf(constraintName); + if (idx < 0) return java.util.Set.of(); + int open = sql.indexOf("IN (", idx); + if (open < 0) return java.util.Set.of(); + int close = sql.indexOf(")", open); + String block = sql.substring(open + 4, close); + java.util.Set values = new java.util.TreeSet<>(); + Matcher m = Pattern.compile("'([^']+)'").matcher(block); + while (m.find()) values.add(m.group(1)); + return values; + } + + private static Path resolveRepoFile(String relative) { + Path p = Paths.get(relative); + if (Files.exists(p)) return p; + throw new IllegalStateException("找不到仓库文件(相对仓库根):" + relative + + ",当前工作目录:" + Paths.get("").toAbsolutePath()); } } diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java index 27f5e30..1983d14 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java @@ -109,7 +109,12 @@ class EntityMigrationParityTest { // 导致 ActivityService 的类型/容量/多组织逻辑零覆盖。已补入 schema-h2.sql。 "organizations", "activity_organizations", - "activity_capacity_groups" + "activity_capacity_groups", + // Phase 15:报名表此前缺 UNIQUE(activity_id,user_id) 与两个 CHECK(约束漂移盲区), + // 「取消后重报名」的必崩路径零覆盖;活动工作人员指派表完全不存在 —— + // 签到数据范围无人可测。二者均已补入 schema-h2.sql。 + "activity_registrations", + "activity_staff_assignments" ); /** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */ private static final Set BASE_ENTITY_COLUMNS = diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index b296d94..0fdede4 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -16,7 +16,8 @@ import static org.junit.jupiter.api.Assertions.*; class MigrationValidationTest { // 基线守卫:新增 Flyway 迁移时必须同步递增此值 - private static final int LATEST_MIGRATION = 39; + // Phase 15:V040 活动工作人员指派(activity_staff_assignments,签到数据范围) + private static final int LATEST_MIGRATION = 40; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index f8208b2..387d514 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -95,7 +95,13 @@ CREATE TABLE activity_registrations ( reject_reason VARCHAR(500), checkin_status VARCHAR(32), checkin_at TIMESTAMP WITH TIME ZONE, emergency_contact_name VARCHAR(100), emergency_contact_phone_enc VARCHAR(500), source VARCHAR(64), request_source VARCHAR(64), created_by_operator_id VARCHAR(36), user_confirmed_at TIMESTAMP WITH TIME ZONE, - 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, + -- Phase 15(V010):生产约束此前测试库完全没有 —— 约束漂移盲区(与 Phase 11/13/14 同族根因)。 + -- 缺 UNIQUE(activity_id, user_id) 使「取消/拒绝后重新报名」撞唯一键的必崩缺陷在测试期完全不可见; + -- 缺两个 CHECK 使非法 status / checkin_status 只在 PostgreSQL 上炸。 + CONSTRAINT uk_activity_registration UNIQUE (activity_id, user_id), + CONSTRAINT chk_reg_status CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled')), + CONSTRAINT chk_checkin_status CHECK (checkin_status IN ('not_checked_in', 'checked_in')) ); CREATE TABLE service_appointments ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), service_location_id VARCHAR(36), service_type VARCHAR(64), @@ -267,6 +273,15 @@ CREATE TABLE activity_capacity_groups ( CONSTRAINT uk_capacity_group UNIQUE (activity_id, group_code) ); +-- V040: activity_staff_assignments 活动工作人员指派表(Phase 15 签到数据范围) +-- 主办/协办组织关系不授予操作权限(INV-020),签到必须来自显式指派。 +CREATE TABLE activity_staff_assignments ( + activity_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL, + assignment_role VARCHAR(32) NOT NULL DEFAULT 'checkin_staff', + created_by VARCHAR(36), created_at TIMESTAMP WITH TIME ZONE, + PRIMARY KEY (activity_id, operator_id), + CONSTRAINT chk_activity_staff_role CHECK (assignment_role IN ('checkin_staff', 'activity_manager')) +); -- ============================================================================ -- Phase 13(V009 + V021 + V037 + V038):红娘服务三表 -- 此前测试库完全缺失,导致 matchmaker 模块零真实行为覆盖、只能写假测试。 diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index 1cb2b2a..0cd59aa 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -3,6 +3,15 @@ > V1.0 Phase 01~26 基础 + V1.1 增量任务合并基线 > 版本:v1.1.0-dev | 最后更新:2026-09-08 +> **2026-09-17 Phase 15 复验关闭:** EPIC-P15 已完成。复验发现两处**必崩级**缺陷,均为「生产一直存在、 +> 测试库从未约束」的同族根因:① **签到码一生成即作废** —— `generateCheckinToken` 把 token 写入 Redis +> 值 `"used"`,而 `scanCheckin` 见到 `"used"` 立即拒绝,扫码签到 **100% 失败**; +> ② **取消后永远无法再报名** —— 生产 `UNIQUE(activity_id, user_id)` 一直存在,而重报名走 `INSERT`, +> 测试库缺该唯一键故零覆盖。共修复 **16 处缺陷 + 3 处测试保真度缺陷**,新增 V040 +> `activity_staff_assignments` 落实「服务站操作员只能操作指定活动」(INV-020)。 +> 证据:`docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md`。 +> **Phase 16 需用户确认后启动。** + > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > > **2026-09-17 Phase 14 复验关闭:** EPIC-P14 已完成。复验发现 activity 模块存在**必崩级空壳**—— @@ -120,6 +129,7 @@ | Phase 13 公益红娘转介复验 | 已完成(2026-09-16) | 见 `Phase-13-V11-Recovery-Completion-Report-2026-09-16.md`;修复 7 处缺陷(**2 处生产故障级 CHECK 约束缺口致推荐接受/服务提交必崩**、推荐与转介三处越权、`getServiceDetail` 越权读、Mapper 缺 `@Select` 致必崩、前端 5 处契约缺口),测试库补 3 张 matchmaker 表,迁移基线 V038;新增 `MatchmakerReferralPhase13Test`(17 用例)+ 重写 `MatchmakerServiceTest`(6 用例,解析真实 DDL),删除 3 处假测试,后端 **391 tests 全绿**;**Phase 13A 尚未获准** | | Phase 13A 婚恋咨询与婚前辅导复验 | 已完成(2026-09-17) | 见 `Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md`;发现 counseling **双实现**(权威类零测试 / 孤儿类被单测),已合并删除孤儿类;修复 **9 处缺陷**(**D1 咨询正文写日志=INV-013 泄露**、D2 申请 userId 取请求体、**D3 分配先读后写并发静默覆盖**、D4 manager 可读 followups=INV-013 绕过、D5 无状态机、**D6 `/services/intro` 后端不存在致小程序 intro 页 100% 无数据**、D7 Admin 5 端点 404、D8 小程序字段名/幂等/静默失败、D9 测试库 counseling 零 CHECK 约束)+ **4 处测试基建缺陷**(守卫找不到 DDL 恒真假绿、全限定 `@TableField` 漏解析、权限守卫假阳性、测试自身逻辑缺陷);迁移基线 V039;新增 `CounselingPhase13ATest`(25)+ `CounselingConstraintGuardTest`(5,解析真实 DDL),两轮负向验证证明守卫会红;后端 **420 tests 全绿**;**Phase 14 尚未获准** | | Phase 14 多类型公益活动复验 | 已完成(2026-09-17) | 见 `Phase-14-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **11 处后端缺陷**(**D1 `ActivityMapper.countRemainingCapacity` 无 SQL 空壳致详情/报名/审核必崩**、**D2 报名与审核先读后写并发超卖** → 改原子 SQL、D3 类型/角色未校验生产 CHECK 白名单、**D4 DTO 无 `activityType` 致小程序分类 Tabs 恒空**、D5 男女人数 `approvedCount/2` 编造、D6 年龄门槛空壳、D7 按类型资格策略为空、**D8 运营端建活动丢弃类型且把主办组织错设为操作者 userId**、D9 三个运营端点错挂用户端双实现、D10 软删活动/缺字段 NPE、D11 DTO 缺 4 字段)+ **3 处前端契约缺陷**(分类 code `classroom` 不在白名单、卡片读 7 个不存在字段、admin-web 无活动管理页)+ **2 处测试保真度缺陷**(2 条占位假绿、测试库缺 V023 列与三张表);新增 `ActivityPhase14Test`(23)+ `ActivityConstraintGuardTest`(9,解析真实 DDL **与前端源码**),两轮负向验证证明守卫会红;admin-web `vue-tsc` 0 error + `vite build` 通过;后端 **450 tests 全绿**;**Phase 15 尚未获准** | +| Phase 15 活动报名审核签到复验 | 已完成(2026-09-17) | 见 `Phase-15-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **16 处缺陷**(**D1 签到码一生成即作废**:`generateCheckinToken` 把 token 写 `"used"` 而扫码端见 `"used"` 即拒 → 扫码 100% 失败;**D9 取消/拒绝后永远无法再报名**:撞生产 `UNIQUE(activity_id,user_id)`,测试库缺该约束故零覆盖 → 改复用记录;D2 重复签到竞态、D3 签到无数据范围、D4 手工签到无校验、D5 统计 NPE+语义错、D6 非法 token 500、D7 签名非恒定时间比较、D10 后台审核列表无 pending、D11 报名窗口不生效、D12 取消无审计、D13 审核/取消非原子、D14 容量分组不参与扣减)+ **2 处前端契约缺陷**(admin-web 无签到工作台、小程序无取消报名入口);新增 V040 `activity_staff_assignments` 落实 INV-020 活动数据范围;**3 处测试保真度缺陷**(测试库缺 UNIQUE+2 CHECK、3 条 `assertTrue(true)` 假绿、表未入必选集合);新增 `ActivityPhase15Test`(21),`CheckinServiceTest` 假绿改真实守卫(5),约束守卫 +2,迁移基线 39→40;一轮负向验证 3 处注入 → 8 条用例精准变红并还原;admin-web `vue-tsc` 0 error + `vite build` 通过;后端 **474 tests 全绿**;**Phase 16 尚未获准** | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | diff --git a/docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md b/docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md new file mode 100644 index 0000000..102606f --- /dev/null +++ b/docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md @@ -0,0 +1,124 @@ +# Phase 15(活动报名、审核与签到)V1.1 增量复验完成报告 + +- **日期**:2026-09-17 +- **模式**:KEEP-VERIFY / EXTEND +- **范围**:activity registration、checkin 服务与 U29~U32 页面 +- **基线**:Phase 14 收尾 `755a570`(全量回归 450 tests) +- **结论**:**关闭**。三项计划事项全部落地,Gate 两项均达成 + +--- + +## 一、计划项达成情况 + +| 计划项 | 结论 | 证据 | +|---|---|---| +| 回归报名、审核、取消、签到码、重复签到和名额并发 | ✅ | `ActivityPhase15Test` 21 例,覆盖取消/拒绝后重报名、重复报名、审核并发、重复扫码幂等、过期码、跨活动复用 | +| 增加非 matchmaking 活动和 capacity group 场景测试 | ✅ | 容量分组**真正参与扣减**(此前只落库回读);婚恋课堂类不设实名/本地资格门槛 | +| 服务站操作员只能操作指定活动或站点任务 | ✅ | 新增 V040 `activity_staff_assignments` + 签到/审核数据范围校验(INV-020) | + +**Gate** + +| Gate | 结论 | +|---|---| +| 重复请求和并发报名不超额 | ✅ 报名/审核/取消/签到全部改为单条条件 SQL,靠数据库保证原子性 | +| 签到不可跨活动复用 | ✅ 签到码作用域由 token 内 `activityId` 决定;且工作人员必须被显式指派到该活动 | + +--- + +## 二、修复缺陷清单(16 处) + +### A. 签到(8 处,含 1 处必崩) + +- **D1 CRITICAL — 每一个签到码一生成即作废**:`generateCheckinToken` 把 token 写入 Redis 值 `"used"`,而 `scanCheckin` 见到 `"used"` 立即拒绝 → **扫码签到 100% 失败**。改为签发时写 `issued`,消费标记只在扫码成功后写入。 +- **D2 CRITICAL — 重复签到竞态**:原为「读 → 判断 → `updateById` 全量覆盖」,并发扫码会重复写 `checkin_at` 与审计。改为 `checkinIfNotCheckedIn` 条件更新;重复扫码幂等返回 `alreadyCheckedIn=true`,不重复记审计。 +- **D3 HIGH — 签到无数据范围**(计划项③):任意持 `activity.checkin` 权限者可签到任意活动任意用户。新增 `assertCheckinScope`:被指派过活动的工作人员只能操作被指派的活动;无任何指派记录者为平台级人员。 +- **D4 HIGH — 手工签到无校验**:`manualCheckin` 不校验活动是否存在/已取消(原实现只有 `generate` 校验时间窗)。补 `requireCheckableActivity`。 +- **D5 MEDIUM — 签到统计 NPE 与语义错**:活动不存在时 `activity.getTotalLimit()` NPE;`remainingCount` 用「总名额 − 已签到」计算。改为抛明确错误,并输出语义正确的 `notCheckedInCount`。 +- **D6 MEDIUM — 非法 token 返回 500**:`Long.parseLong` 未捕获 `NumberFormatException`。改为 `IllegalArgumentException`(400)。 +- **D7 MEDIUM — 签名比较非恒定时间**:改用 `MessageDigest.isEqual`,避免侧信道逐字节爆破。 +- **D8 LOW — 未使用变量**:`getCheckinStats` 中 `regMapper` 冗余赋值,已删。 + +### B. 报名 / 审核 / 取消(5 处,含 1 处必崩) + +- **D9 CRITICAL — 取消后永远无法再报名**:生产 `UNIQUE(activity_id, user_id)` 一直存在,而「取消/拒绝后重新报名」走的是 `INSERT` → 撞唯一键 500,用户取消一次即永久失去该活动资格。测试库此前**缺该 UNIQUE**,因此零覆盖。改为复用原记录 + 原子条件 UPDATE(`reuseIfCapacityAvailable`),并同样带容量条件。 +- **D10 HIGH — 后台审核列表看不到待审记录**:`getRegistrations` 只查 `status='approved'` → 审核页面无 pending 可点,审核功能实际不可用。改为 `findByActivity`(全状态)。 +- **D11 MEDIUM — 报名窗口形同虚设**:`registration_start_at` / `registration_end_at` 完全不校验,活动一发布就永远可报。新增 `assertWithinRegistrationWindow`。 +- **D12 MEDIUM — 取消无审计**:取消是唯一没有审计日志的写操作。补 `CANCEL_REGISTRATION` 审计。 +- **D13 MEDIUM — 审核/取消非原子**:`rejectRegistration` 为「读 pending → 全量 updateById」,两人同时操作会互相覆盖;`cancelRegistration` 亦然。改为 `rejectIfPending` / `cancelIfActive` 条件更新。 + +### C. 容量分组(计划项②,1 处) + +- **D14 MEDIUM — 分组容量完全不生效**:`activity_capacity_groups` 只落库回读,`approved_count` 永远是 0。新增 `incrementIfAvailable` / `decrementIfPositive`,在报名、审核通过、取消时真实扣减/回退(未配置分组的活动不受影响)。 + +### D. 前端契约(2 处) + +- **F1 — 工作人员无处消费签到码**:admin-web 完全没有签到页面,D1 修复后仍无法闭环。新增 `ActivityCheckinView.vue`(扫码/粘贴签到码 + 手工签到 + 统计)+ `/activities/checkin` 路由与导航。 +- **F2 — 小程序无法取消报名**:后端 `DELETE /activities/{id}/register` 已存在,但 `activityService.js` 无封装、「我的活动」页无入口。补 `cancelRegistration` 封装。 + +--- + +## 三、测试保真度修复(3 处) + +- **T1 约束漂移盲区**:测试库 `activity_registrations` 缺 `UNIQUE(activity_id, user_id)`、`chk_reg_status`、`chk_checkin_status` —— 这正是 D9 必崩缺陷长期不可见的根因。已补齐。 +- **T2 占位假绿**:`CheckinServiceTest` 有 3 条 `assertTrue(true); // 占位测试`,恰好覆盖「重复签到」「Token 过期」「二维码不含敏感明文」三条核心不变量。已改为解析**真实源码与真实生产 DDL** 的静态守卫(5 例)。 +- **T3 表集合**:`activity_registrations` 此前不在 `EntityMigrationParityTest` 的必选集合;新增 `activity_staff_assignments`。二者均移入 `REQUIRED_IN_TEST_SCHEMA`。 + +另外同步了 `MigrationValidationTest` 的迁移基线(39 → 40):该守卫会拒绝任何超出基线的新迁移文件,是设计内的收敛机制。 + +> 附带教训:新实体 `ActivityStaffAssignment` 最初继承了 `BaseEntity`,而 V040 表是 `(activity_id, operator_id)` 复合主键(无 id / updated_at / deleted_at)。MyBatis-Plus 会对非 null 字段一律写入列清单 → 生产每次 insert 都撞 `column does not exist`。已改为不继承 `BaseEntity`,由 `EntityMigrationParityTest` 当场拦下。 + +--- + +## 四、证据 + +**定向测试**:43 tests / 0 failures(`ActivityPhase15Test` 21 + `CheckinServiceTest` 5 + `EntityMigrationParityTest` 6 + `ActivityConstraintGuardTest` 11) + +**负向验证**(注入真实缺陷 → 守卫必须变红,随后全部还原): + +| 注入 | 变红的用例 | +|---|---| +| ① 生成签到码时写回 `TOKEN_STATE_USED`(还原 D1) | `generatedTokenCanBeScanned` / `duplicateScanIsIdempotent` / `checkinTokenDoesNotCrossActivities` + 守卫 `generateMustNotMarkTokenAsUsed` | +| ② 报名退回 `INSERT` 不复用记录(还原 D9) | `canReRegisterAfterCancel` / `canReRegisterAfterReject`(DuplicateKeyException) | +| ③ 容量分组不参与扣减(还原 D14) | `capacityGroupIsEnforced` / `cancelReleasesCapacityGroup` | + +共 8 条精准变红,归因与注入一一对应;还原后确认工作区无 `NEGATIVE-VERIFICATION` 残留。 + +**全量回归**:`Tests run: 474, Failures: 0, Errors: 0, Skipped: 3` / `BUILD SUCCESS`(基线 450 → 474,净 +24) + +**前端**:admin-web `vue-tsc --noEmit` 0 error;`vite build` 通过(1737 modules transformed,新增 `ActivityCheckinView` 已打包) + +--- + +## 五、变更清单 + +**新增文件** +- `backend/src/main/resources/db/migration/V040__add_activity_staff_assignments.sql` +- `backend/src/main/java/.../activity/entity/ActivityStaffAssignment.java` +- `backend/src/main/java/.../activity/mapper/ActivityStaffAssignmentMapper.java` +- `backend/src/test/java/.../activity/ActivityPhase15Test.java` +- `admin-web/src/modules/activity/ActivityCheckinView.vue` + +**修改文件** +- `CheckinService.java`(重写签到流程) +- `ActivityService.java`(重报名复用、分组扣减、报名窗口、审核/取消原子化、数据范围) +- `ActivityRegistrationMapper.java`(+`findByActivity` / `reuseIfCapacityAvailable` / `checkinIfNotCheckedIn` / `cancelIfActive` / `rejectIfPending`) +- `ActivityCapacityGroupMapper.java`(+`findByActivityId` / `incrementIfAvailable` / `decrementIfPositive`) +- `schema-h2.sql`(报名表 UNIQUE + 两个 CHECK、新增指派表) +- `CheckinServiceTest.java`(3 条假绿 → 5 条真实守卫) +- `ActivityConstraintGuardTest.java`(+2 条约束漂移守卫) +- `EntityMigrationParityTest.java`、`MigrationValidationTest.java`(基线) +- `admin-web/src/router/index.ts`、`MainLayout.vue`(签到路由与导航) +- `miniapp/services/activityService.js`(+`cancelRegistration`) + +--- + +## 六、遗留(供后续阶段) + +1. **`my-activities`(U30)仍是占位骨架**:只有 `onLoad/onShow` 空实现,用户无法查看报名状态或自助取消(service 层已备好 `cancelRegistration`)。 +2. **签到码以"复制文本"形式下发**:小程序 U31 提供的是复制按钮而非二维码;后台签到台为手动粘贴。若需扫码枪/摄像头扫码,需另加扫码输入方式。 +3. **容量分组归属规则较粗**:当前按 `group_code` 与性别匹配,未匹配到分组时不施加约束;`ruleDefinition`(JSON)尚未参与判定。 +4. **`activity.checkin` 与指派的联动**:目前"有指派记录者受限、无指派记录者不受限",若后续要求所有签到人员必须显式指派,需改为强制校验。 + +--- + +**Phase 15 已关闭。Phase 16(活动互选与双方开启沟通确认)未自动进入,等待用户确认。** diff --git a/docs/STATUS.md b/docs/STATUS.md index 147ed7d..0e9b61d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,7 +2,7 @@ > **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 14 COMPLETE / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)复验已关闭**,八轮共修复 48 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 15 需用户确认后启动。** +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)复验已关闭**,九轮共修复 64 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 16 需用户确认后启动。** 最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) @@ -44,6 +44,7 @@ | 1.1.0-rc.4 | 2026-09-16 | Phase 13 复验关闭(公益红娘转介,修复 7 处缺陷);新增 V038 扩展 matchmaker CHECK 白名单;全量回归 **391 tests** 全绿 | | 1.1.0-rc.5 | 2026-09-17 | Phase 13A 复验关闭(婚恋咨询与婚前辅导,9 处缺陷 + 4 处测试基建缺陷);新增 V039;全量回归 **420 tests** 全绿 | | 1.1.0-rc.6 | 2026-09-17 | Phase 14 复验关闭(多类型公益活动,11 处后端缺陷 + 3 处前端契约缺陷 + 2 处测试保真度缺陷);新增 `ActivityManagementView` 动态表单;全量回归 **450 tests** 全绿 | +| 1.1.0-rc.7 | 2026-09-17 | Phase 15 复验关闭(活动报名/审核/签到,16 处缺陷:含**签到码一生成即作废**、**取消后永远无法再报名**两处必崩;3 处测试保真度);新增 V040 `activity_staff_assignments` 与 `ActivityCheckinView` 签到工作台;全量回归 **474 tests** 全绿 | --- @@ -204,6 +205,23 @@ | 测试:`ActivityPhase14Test`(23)+ `ActivityConstraintGuardTest`(9,解析真实 DDL 与前端源码) | ✅ 新增 | | 全量回归 **450 tests 全绿** | ✅ | +### Phase 15 — 活动报名、审核与签到 ✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过) +| 任务 | 状态 | +|------|------| +| **D1 必崩**:签到码一生成即作废(`generate` 写 `"used"` → 扫码 100% 失败)→ 改为 `issued` | ✅ | +| 重复签到:原子条件更新 `checkinIfNotCheckedIn` + 幂等返回 `alreadyCheckedIn` | ✅ 新增 | +| **D9 必崩**:取消/拒绝后重报名撞 `UNIQUE(activity_id,user_id)` → 复用记录 `reuseIfCapacityAvailable` | ✅ | +| 审核/取消原子化:`rejectIfPending` / `cancelIfActive`;取消补审计日志 | ✅ | +| 后台报名列表含 pending(原只查 approved,审核页无待审项) | ✅ | +| 报名窗口 `registration_start_at` / `registration_end_at` 生效 | ✅ 新增 | +| 容量分组真正参与扣减(`incrementIfAvailable` / `decrementIfPositive`) | ✅ 新增 | +| 计划项③:V040 `activity_staff_assignments` + 签到/审核数据范围校验(INV-020) | ✅ 新增 | +| Admin:`ActivityCheckinView.vue` 签到工作台 + `/activities/checkin` 路由与导航 | ✅ 新增 | +| Miniapp:`cancelRegistration` 封装(后端端点存在但前端无入口) | ✅ | +| 测试库补齐报名表 UNIQUE + 两个 CHECK;两表移入必选集合;迁移基线 39→40 | ✅ | +| 测试:`ActivityPhase15Test`(21)+ `CheckinServiceTest` 假绿改真实守卫(5)+ 约束守卫(2) | ✅ 新增 | +| 全量回归 **474 tests 全绿** | ✅ | + ### Phase 13A — 公益婚恋咨询与婚前辅导(核心链路)✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过) | 任务 | 状态 | |------|------| diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md index 0bd474a..e5d476d 100644 --- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md +++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md @@ -400,12 +400,20 @@ D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `M **Files:** activity registration、checkin 服务和 U29~U32 页面。 -- [ ] 回归报名、审核、取消、签到码、重复签到和名额并发。 -- [ ] 增加非 matchmaking 活动和 capacity group 场景测试。 -- [ ] 服务站操作员只能操作指定活动或站点任务。 +- [x] 回归报名、审核、取消、签到码、重复签到和名额并发。 +- [x] 增加非 matchmaking 活动和 capacity group 场景测试。 +- [x] 服务站操作员只能操作指定活动或站点任务。 **Gate:** 重复请求和并发报名不超额;签到不可跨活动复用。 +> **2026-09-17 复验关闭**:修复 16 处缺陷(8 处签到 + 5 处报名/审核/取消 + 1 处容量分组 + 2 处前端契约) +> + 3 处测试保真度缺陷。两处必崩:**D1 签到码一生成即作废**(`generateCheckinToken` 写 `"used"`, +> 扫码 100% 失败)、**D9 取消后永远无法再报名**(撞生产 `UNIQUE(activity_id,user_id)`, +> 测试库缺该约束故零覆盖)。新增 V040 `activity_staff_assignments` 落实签到数据范围(INV-020)。 +> 一轮负向验证:3 处注入 → 8 条用例精准变红并还原。全量回归 **474 tests 全绿**(基线 450)。 +> 报告:`docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md` +> **Phase 16 未自动进入,等待用户确认。** + ### Phase 16:活动互选与双方开启沟通确认 — REFACTOR **Files:** activity_mutual、relationship、messaging 模块及 activity-mutual 页面。 diff --git a/miniapp/services/activityService.js b/miniapp/services/activityService.js index c05ace6..6d96363 100644 --- a/miniapp/services/activityService.js +++ b/miniapp/services/activityService.js @@ -40,6 +40,17 @@ function register(activityId, payload = {}) { return api.post(`/activities/${encodeURIComponent(activityId)}/register`, payload, undefined, idempotencyKey); } +/** + * 取消报名(Phase 15) + * 后端:DELETE /activities/{activityId}/register + * + * 说明:后端为 UNIQUE(activity_id, user_id) 设计,取消后原记录保留为 cancelled, + * 再次报名会**复用**该记录(不会撞唯一键),因此取消不是不可逆操作。 + */ +function cancelRegistration(activityId) { + return api.delete(`/activities/${encodeURIComponent(activityId)}/register`); +} + /** * 我的活动(API_INVENTORY 行 95) * @param {{ status?: string, cursor?: string, pageSize?: number }} params @@ -76,6 +87,7 @@ module.exports = { getActivities, getActivityDetail, register, + cancelRegistration, getMyActivities, submitFeedback, submitInterests,