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:
+30
@@ -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;
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
|
||||
+81
@@ -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);
|
||||
}
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
+169
-36
@@ -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 文本的时间字段。
|
||||
*
|
||||
|
||||
+134
-61
@@ -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'));
|
||||
Reference in New Issue
Block a user