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
@@ -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 模块零真实行为覆盖、只能写假测试。