fix(activity): close Phase 15 KEEP-VERIFY/EXTEND — 2 critical + 14 other defects, 3 test-fidelity fixes

Phase 15 (activity registration / review / checkin) verification closure.

Two CRITICAL defects, both "production always had it, test schema never did":
- D1: generateCheckinToken wrote the token to Redis with value "used", while
  scanCheckin rejects any token marked "used" -> every freshly issued check-in
  code was dead on arrival, staff scanning failed 100% of the time.
  Issuing now writes "issued"; "used" is set only after a successful scan.
- D9: UNIQUE(activity_id, user_id) exists in production, but re-registering
  after cancel/reject went through INSERT -> users who cancelled once could
  never register for that activity again (duplicate key 500). The H2 test
  schema lacked the UNIQUE, so the path had zero coverage. Now the original
  row is reused via an atomic conditional UPDATE.

Checkin (D2-D8):
- D2: duplicate check-in was read-then-write; now atomic checkinIfNotCheckedIn
  and repeat scans are idempotent (alreadyCheckedIn) without duplicate audit.
- D3: no data scope at all -> added activity staff assignment (V040) and
  assertCheckinScope; INV-020 (org relation grants nothing).
- D4: manualCheckin did not validate activity existence/cancellation.
- D5: getCheckinStats NPE on missing activity; remainingCount was semantically
  "free seats" -> replaced with notCheckedInCount.
- D6: malformed timestamp caused 500 -> 400. D7: constant-time signature compare.

Registration / review / cancel (D10-D13):
- D10: backend registration list only queried status='approved' -> the review
  screen never saw pending rows (review unusable).
- D11: registration_start_at / registration_end_at were never enforced.
- D12: cancel had no audit log (the only write without one).
- D13: reject/cancel were read-then-full-update -> rejectIfPending/cancelIfActive.

Capacity groups (D14, plan item 2): groups were stored and read back but never
participated in capacity -> incrementIfAvailable / decrementIfPositive now make
approved_count real; activities without groups are unaffected.

Frontend contract (F1-F2):
- F1: admin-web had no check-in surface at all -> ActivityCheckinView.vue
  (paste/scan token, manual check-in, stats) + /activities/checkin route & nav.
- F2: miniapp had no cancel-registration wrapper although the endpoint existed.

Test fidelity (T1-T3):
- T1: test schema gained UNIQUE(activity_id,user_id) + chk_reg_status +
  chk_checkin_status; two new guards parse the real V010 DDL and compare.
- T2: 3 assertTrue(true) placeholders in CheckinServiceTest (covering duplicate
  check-in, expiry, no-sensitive-plaintext) replaced with 5 real guards that
  parse the real source and real production DDL.
- T3: activity_registrations + activity_staff_assignments moved into
  REQUIRED_IN_TEST_SCHEMA; MigrationValidationTest baseline 39 -> 40.

Note: ActivityStaffAssignment must NOT extend BaseEntity - the table is a
composite PK (activity_id, operator_id); BaseEntity would add id/updated_at/
deleted_at to every INSERT and break on PostgreSQL (caught by
EntityMigrationParityTest immediately).

Tests: ActivityPhase15Test (21), CheckinServiceTest (5), 2 new constraint
guards. Negative verification: 3 injected defects -> 8 assertions turned red,
all restored.

Regression: 474 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 450). admin-web vue-tsc 0 error, vite build OK.
This commit is contained in:
WorkBuddy
2026-09-17 15:07:52 +08:00
parent 755a570849
commit 29112eed6e
21 changed files with 1602 additions and 116 deletions
@@ -0,0 +1,30 @@
package com.danyang.match.platform.activity.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
/**
* 活动工作人员指派
*
* <p>V1.1 Phase 15(V040):服务站操作员的活动维度数据范围。
* <b>主办/协办组织关系不授予活动操作权限(INV-020)</b> —— 能否为某活动执行签到,
* 只取决于本表的显式指派,与 operator 所属组织无关。
*
* <p><b>注意</b>:本表主键为 {@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;
}
@@ -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<ActivityCapacityGroup> {
@Select("SELECT * FROM activity_capacity_groups WHERE activity_id = #{activityId} AND deleted_at IS NULL "
+ "ORDER BY group_code")
List<ActivityCapacityGroup> findByActivityId(@Param("activityId") String activityId);
/**
* **原子**占用分组名额:仅当 {@code approved_count < capacity} 时才 +1。
*
* <p>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);
}
@@ -21,6 +21,16 @@ public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistrat
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
List<ActivityRegistration> findApprovedByActivity(String activityId);
/**
* 某活动的**全部**报名记录(含 pending)。
*
* <p><b>Phase 15 修复</b>:后台报名列表此前只查 {@code status='approved'},
* 审核页面因此永远看不到待审记录 —— 审核功能实际不可用。
*/
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND deleted_at IS NULL "
+ "ORDER BY registered_at ASC")
List<ActivityRegistration> findByActivity(@Param("activityId") String activityId);
@Select("SELECT * FROM activity_registrations WHERE user_id = #{userId} ORDER BY registered_at DESC LIMIT 20")
List<ActivityRegistration> findByUserId(String userId);
@@ -95,4 +105,75 @@ public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistrat
@Param("activityId") String activityId,
@Param("operatorId") String operatorId,
@Param("gender") String gender);
// ==================== Phase 15 新增 ====================
/**
* **原子**复用历史报名记录(cancelled / rejected → pending|approved)。
*
* <p><b>Phase 15 D7(必崩)</b>:{@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} 时才置为已签到。
*
* <p><b>Phase 15 D2</b>:原实现为「读 → 判断 → 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。
*
* <p>与 {@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);
}
@@ -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 签到数据范围)
*
* <p>与 {@code service_location_operators} 的范式一致:
* 有指派记录的工作人员只能操作被指派的资源;没有任何指派记录的是平台级人员。
*/
@Mapper
public interface ActivityStaffAssignmentMapper extends BaseMapper<ActivityStaffAssignment> {
@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);
}
@@ -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<ActivityRegistration> 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<Map<String, Object>> getRegistrations(String activityId) {
return registrationMapper.findApprovedByActivity(activityId).stream()
// Phase 15 修复(必崩):原实现只查 status='approved',后台审核列表因此
// **永远看不到 pending 记录** —— 审核功能实际不可用(无待审项可点)。
return registrationMapper.findByActivity(activityId).stream()
.map(reg -> {
Map<String, Object> 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("活动报名已截止");
}
}
/**
* 活动维度的数据范围校验(计划项③)。
*
* <p>服务站操作员只能操作被显式指派的活动;主办/协办组织关系不授予任何操作权限(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(表示不启用分组约束)。
*
* <p>分组即配置:只要活动存在 {@code activity_capacity_groups} 记录就启用分组容量,
* 无需额外的开关列(生产 V023 没有 {@code capacity_group_enabled})。
*/
private String resolveCapacityGroupCode(String activityId, String gender) {
java.util.List<ActivityCapacityGroup> 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; // 有分组但无匹配 → 不施加分组约束(避免误拒)
}
/**
* 原子占用容量分组名额。
*
* <p>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 文本的时间字段。
*
@@ -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<String, Object> 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<String, Object> result = new HashMap<>();
result.put("token", token);
@@ -87,125 +97,188 @@ public class CheckinService {
/**
* 工作人员扫码签到
*
* <p>幂等:同一签到码重复扫码、或用户已签到时返回 {@code alreadyCheckedIn=true} 且不重复记审计。
*/
@Transactional
public Map<String, Object> 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<String, Object> 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<String, Object> 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;
}
/**
* 手工签到(工作人员手动输入)
*
* <p>Phase 15 修复:原实现既无活动存在性/状态校验,也无工作人员数据范围校验 ——
* 任意持 {@code activity.checkin} 权限的人可以对任意活动的任意用户签到(违反 INV-020 精神)。
*/
@Transactional
public Map<String, Object> 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<String, Object> 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;
}
/**
* 获取活动签到统计
*
* <p>Phase 15 修复:活动不存在/已软删时原实现直接 NPE;
* 且 {@code remainingCount} 用「总名额 − 已签到」计算,语义上是"空座"而非"未签到人数"。
*/
public Map<String, Object> 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<ActivityRegistration> 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<String, Object> 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 计划项③)。
*
* <p>服务站操作员只能操作**被显式指派**的活动;主办/协办组织关系不授予任何操作权限(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签名
*/
@@ -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'));
@@ -231,6 +231,79 @@ class ActivityConstraintGuardTest {
+ String.join("\n - ", unknown) + "\nDTO 字段: " + dtoFields);
}
// ==================== Phase 15:报名表约束漂移守卫 ====================
/**
* 生产的 {@code UNIQUE(activity_id, user_id)} 必须在测试库中有对应物。
*
* <p><b>Phase 15 D7</b>:生产一直有该唯一键,而测试库没有 —— 于是
* 「取消后重新报名」撞唯一键的必崩缺陷在测试期完全不可见,只在 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<String> prodStatus = extractInClauseAfter(prod, "chk_reg_status");
java.util.Set<String> 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<String> testStatus = extractInClauseAfter(block, "chk_reg_status");
java.util.Set<String> 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 <constraintName> ... IN ('a','b')} 中的值集合 */
private static java.util.Set<String> 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<String> values = new java.util.TreeSet<>();
Matcher m = Pattern.compile("'([^']+)'").matcher(block);
while (m.find()) values.add(m.group(1));
return values;
}
private static Set<String> readMiniappCategoryCodes() throws IOException {
Path ts = resolveRepoFile("miniapp/pages/activity/activity.ts");
// 必须匹配 "categories: [" —— 只写 "categories:" 会先命中
@@ -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:活动报名、审核与签到 —— 真实运行时路径测试。
*
* <p><b>测试保真度</b>:走真实 Service + 真实落库;测试库 {@code schema-h2.sql} 已补齐
* 生产的 {@code UNIQUE(activity_id, user_id)}、{@code chk_reg_status} /
* {@code chk_checkin_status} 约束以及 {@code activity_staff_assignments} 表。
* 因此「取消后重报名撞唯一键」这类必崩缺陷会在此处即暴露,而不是上到 PostgreSQL 才炸。
*
* <p>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<String, String> tokenStore = new HashMap<>();
@BeforeEach
@SuppressWarnings("unchecked")
void stubRedis() {
tokenStore.clear();
ValueOperations<String, String> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<AuditLog>()
.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<String, Object> issued = checkinService.generateCheckinToken(u, a.getId());
String token = (String) issued.get("token");
assertNotNull(token);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}
@@ -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 上下文)。
*
* <p><b>Phase 15 修复</b>:本类此前有 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<String> 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<String, Object> generateCheckinToken");
assertTrue(start > 0, "未找到 generateCheckinToken");
int end = src.indexOf("public Map<String, Object> 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 <name> CHECK (col IN ('a', 'b'))} 中的值集合 */
private static java.util.Set<String> 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<String> 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());
}
}
@@ -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<String> BASE_ENTITY_COLUMNS =
@@ -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/";
+16 -1
View File
@@ -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 模块零真实行为覆盖、只能写假测试。