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:
@@ -171,6 +171,7 @@ const navigation: NavigationItem[] = [
|
||||
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
|
||||
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
|
||||
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
|
||||
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
|
||||
{ label: '内容管理', path: '/content', permissions: ['content.view'], icon: Reading },
|
||||
{ label: '善缘服务站', path: '/service-station', permissions: ['service_location.view'], icon: Location },
|
||||
{ label: '举报风控', path: '/risk/reports', permissions: ['risk.report.view'], icon: Warning },
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>活动签到工作台</span>
|
||||
<el-tag v-if="stats" type="info" size="small">
|
||||
已通过 {{ stats.totalApproved }} · 已签到 {{ stats.checkedInCount }} · 未到场
|
||||
{{ stats.notCheckedInCount }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 活动选择:签到必须绑定活动,避免跨活动误签 -->
|
||||
<el-form :inline="true" class="pick-form">
|
||||
<el-form-item label="当前活动">
|
||||
<el-select v-model="activityId" filterable placeholder="选择活动" style="width: 320px"
|
||||
@change="loadStats">
|
||||
<el-option v-for="a in activities" :key="a.id" :label="a.title" :value="a.id">
|
||||
<span>{{ a.title }}</span>
|
||||
<span class="opt-type">{{ typeText(a.activityType) }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button :disabled="!activityId" @click="loadStats">刷新统计</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="!activityId" type="warning" :closable="false" show-icon
|
||||
title="请先选择活动:签到码的作用域由活动决定,不可跨活动复用。" />
|
||||
|
||||
<el-row :gutter="16" v-else>
|
||||
<!-- 扫码 / 粘贴签到码 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="inner-card">
|
||||
<template #header><span>扫码签到</span></template>
|
||||
<el-input v-model="token" type="textarea" :rows="4"
|
||||
placeholder="粘贴用户小程序上的签到码(一次性,有效期 30 分钟)" />
|
||||
<div class="btn-row">
|
||||
<el-button type="primary" :loading="scanning" :disabled="!token"
|
||||
@click="scan">确认签到</el-button>
|
||||
<el-button @click="token = ''">清空</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 手工签到 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="inner-card">
|
||||
<template #header><span>手工签到(特殊情形)</span></template>
|
||||
<el-input v-model="manualUserId" placeholder="输入用户 ID" />
|
||||
<div class="btn-row">
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserId"
|
||||
@click="manual">手工签到</el-button>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
仅可操作被指派给自己的活动;主办/协办组织关系不授予签到权限。
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 签到结果 -->
|
||||
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
|
||||
<template #header><span>最近一次签到</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="用户 ID">{{ lastResult.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本次是否为重复签到">
|
||||
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
|
||||
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="签到时间">{{ formatTime(lastResult.checkedInAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* U32 活动签到工作台(Phase 15)
|
||||
*
|
||||
* 后端端点:
|
||||
* - POST /api/v1/activities/operator/checkin/scan 扫码签到(一次性 token)
|
||||
* - POST /api/v1/activities/operator/{id}/checkin/manual 手工签到
|
||||
* - GET /api/v1/activities/operator/{id}/checkin-stats 签到统计
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const activities = ref<any[]>([])
|
||||
const activityId = ref('')
|
||||
const token = ref('')
|
||||
const manualUserId = ref('')
|
||||
const stats = ref<any>(null)
|
||||
const lastResult = ref<any>(null)
|
||||
const scanning = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const TYPE_TEXT: Record<string, string> = {
|
||||
matchmaking: '公益联谊',
|
||||
interest_social: '兴趣社交',
|
||||
relationship_education: '婚恋课堂',
|
||||
premarital_guidance: '婚前辅导',
|
||||
marriage_custom_culture: '婚俗文化',
|
||||
anti_fraud: '婚恋反诈',
|
||||
}
|
||||
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
|
||||
|
||||
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` })
|
||||
|
||||
const formatTime = (v: any) => {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
async function loadActivities() {
|
||||
const res: any = await get('/operator/activities', {}, authHeader())
|
||||
activities.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
stats.value = null
|
||||
if (!activityId.value) return
|
||||
try {
|
||||
const res: any = await get(`/activities/operator/${activityId.value}/checkin-stats`, {}, authHeader())
|
||||
stats.value = res.data
|
||||
} catch (e) {
|
||||
stats.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
if (!token.value.trim()) return
|
||||
scanning.value = true
|
||||
try {
|
||||
const res: any = await post('/activities/operator/checkin/scan', { token: token.value.trim() }, authHeader())
|
||||
lastResult.value = res.data
|
||||
token.value = ''
|
||||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(重复扫码已幂等处理)' : '签到成功')
|
||||
await loadStats()
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manual() {
|
||||
if (!activityId.value || !manualUserId.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await post(
|
||||
`/activities/operator/${activityId.value}/checkin/manual`,
|
||||
{ userId: manualUserId.value.trim() },
|
||||
authHeader())
|
||||
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value }
|
||||
manualUserId.value = ''
|
||||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
|
||||
await loadStats()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadActivities)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pick-form {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.inner-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.btn-row {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-hint {
|
||||
margin-top: 8px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.opt-type {
|
||||
float: right;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.result-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'review', name: 'Review', component: () => import('@/modules/review/ReviewView.vue'), meta: { title: '资料审核', permissions: ['review.view'] } },
|
||||
// V1.1 Phase 14:活动管理(类型化活动 + 动态表单 + 多组织 + 容量分组)
|
||||
{ path: 'activities', name: 'ActivityManagement', component: () => import('@/modules/activity/ActivityManagementView.vue'), meta: { title: '活动管理', permissions: ['activity.view'] } },
|
||||
{ path: 'activities/checkin', name: 'ActivityCheckin', component: () => import('@/modules/activity/ActivityCheckinView.vue'), meta: { title: '活动签到', permissions: ['activity.checkin'] } },
|
||||
{ path: 'activities/interests', name: 'ActivityInterests', component: () => import('@/modules/activity/ActivityInterestView.vue'), meta: { title: '活动互选管理', permissions: ['activity.review_registration'] } },
|
||||
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
|
||||
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
|
||||
|
||||
+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'));
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+104
-12
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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 =
|
||||
|
||||
+2
-1
@@ -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/";
|
||||
|
||||
@@ -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 模块零真实行为覆盖、只能写假测试。
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
> V1.0 Phase 01~26 基础 + V1.1 增量任务合并基线
|
||||
> 版本:v1.1.0-dev | 最后更新:2026-09-08
|
||||
|
||||
> **2026-09-17 Phase 15 复验关闭:** EPIC-P15 已完成。复验发现两处**必崩级**缺陷,均为「生产一直存在、
|
||||
> 测试库从未约束」的同族根因:① **签到码一生成即作废** —— `generateCheckinToken` 把 token 写入 Redis
|
||||
> 值 `"used"`,而 `scanCheckin` 见到 `"used"` 立即拒绝,扫码签到 **100% 失败**;
|
||||
> ② **取消后永远无法再报名** —— 生产 `UNIQUE(activity_id, user_id)` 一直存在,而重报名走 `INSERT`,
|
||||
> 测试库缺该唯一键故零覆盖。共修复 **16 处缺陷 + 3 处测试保真度缺陷**,新增 V040
|
||||
> `activity_staff_assignments` 落实「服务站操作员只能操作指定活动」(INV-020)。
|
||||
> 证据:`docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md`。
|
||||
> **Phase 16 需用户确认后启动。**
|
||||
|
||||
> **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。
|
||||
>
|
||||
> **2026-09-17 Phase 14 复验关闭:** EPIC-P14 已完成。复验发现 activity 模块存在**必崩级空壳**——
|
||||
@@ -120,6 +129,7 @@
|
||||
| Phase 13 公益红娘转介复验 | 已完成(2026-09-16) | 见 `Phase-13-V11-Recovery-Completion-Report-2026-09-16.md`;修复 7 处缺陷(**2 处生产故障级 CHECK 约束缺口致推荐接受/服务提交必崩**、推荐与转介三处越权、`getServiceDetail` 越权读、Mapper 缺 `@Select` 致必崩、前端 5 处契约缺口),测试库补 3 张 matchmaker 表,迁移基线 V038;新增 `MatchmakerReferralPhase13Test`(17 用例)+ 重写 `MatchmakerServiceTest`(6 用例,解析真实 DDL),删除 3 处假测试,后端 **391 tests 全绿**;**Phase 13A 尚未获准** |
|
||||
| Phase 13A 婚恋咨询与婚前辅导复验 | 已完成(2026-09-17) | 见 `Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md`;发现 counseling **双实现**(权威类零测试 / 孤儿类被单测),已合并删除孤儿类;修复 **9 处缺陷**(**D1 咨询正文写日志=INV-013 泄露**、D2 申请 userId 取请求体、**D3 分配先读后写并发静默覆盖**、D4 manager 可读 followups=INV-013 绕过、D5 无状态机、**D6 `/services/intro` 后端不存在致小程序 intro 页 100% 无数据**、D7 Admin 5 端点 404、D8 小程序字段名/幂等/静默失败、D9 测试库 counseling 零 CHECK 约束)+ **4 处测试基建缺陷**(守卫找不到 DDL 恒真假绿、全限定 `@TableField` 漏解析、权限守卫假阳性、测试自身逻辑缺陷);迁移基线 V039;新增 `CounselingPhase13ATest`(25)+ `CounselingConstraintGuardTest`(5,解析真实 DDL),两轮负向验证证明守卫会红;后端 **420 tests 全绿**;**Phase 14 尚未获准** |
|
||||
| Phase 14 多类型公益活动复验 | 已完成(2026-09-17) | 见 `Phase-14-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **11 处后端缺陷**(**D1 `ActivityMapper.countRemainingCapacity` 无 SQL 空壳致详情/报名/审核必崩**、**D2 报名与审核先读后写并发超卖** → 改原子 SQL、D3 类型/角色未校验生产 CHECK 白名单、**D4 DTO 无 `activityType` 致小程序分类 Tabs 恒空**、D5 男女人数 `approvedCount/2` 编造、D6 年龄门槛空壳、D7 按类型资格策略为空、**D8 运营端建活动丢弃类型且把主办组织错设为操作者 userId**、D9 三个运营端点错挂用户端双实现、D10 软删活动/缺字段 NPE、D11 DTO 缺 4 字段)+ **3 处前端契约缺陷**(分类 code `classroom` 不在白名单、卡片读 7 个不存在字段、admin-web 无活动管理页)+ **2 处测试保真度缺陷**(2 条占位假绿、测试库缺 V023 列与三张表);新增 `ActivityPhase14Test`(23)+ `ActivityConstraintGuardTest`(9,解析真实 DDL **与前端源码**),两轮负向验证证明守卫会红;admin-web `vue-tsc` 0 error + `vite build` 通过;后端 **450 tests 全绿**;**Phase 15 尚未获准** |
|
||||
| Phase 15 活动报名审核签到复验 | 已完成(2026-09-17) | 见 `Phase-15-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **16 处缺陷**(**D1 签到码一生成即作废**:`generateCheckinToken` 把 token 写 `"used"` 而扫码端见 `"used"` 即拒 → 扫码 100% 失败;**D9 取消/拒绝后永远无法再报名**:撞生产 `UNIQUE(activity_id,user_id)`,测试库缺该约束故零覆盖 → 改复用记录;D2 重复签到竞态、D3 签到无数据范围、D4 手工签到无校验、D5 统计 NPE+语义错、D6 非法 token 500、D7 签名非恒定时间比较、D10 后台审核列表无 pending、D11 报名窗口不生效、D12 取消无审计、D13 审核/取消非原子、D14 容量分组不参与扣减)+ **2 处前端契约缺陷**(admin-web 无签到工作台、小程序无取消报名入口);新增 V040 `activity_staff_assignments` 落实 INV-020 活动数据范围;**3 处测试保真度缺陷**(测试库缺 UNIQUE+2 CHECK、3 条 `assertTrue(true)` 假绿、表未入必选集合);新增 `ActivityPhase15Test`(21),`CheckinServiceTest` 假绿改真实守卫(5),约束守卫 +2,迁移基线 39→40;一轮负向验证 3 处注入 → 8 条用例精准变红并还原;admin-web `vue-tsc` 0 error + `vite build` 通过;后端 **474 tests 全绿**;**Phase 16 尚未获准** |
|
||||
| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 |
|
||||
| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 |
|
||||
| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 |
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Phase 15(活动报名、审核与签到)V1.1 增量复验完成报告
|
||||
|
||||
- **日期**:2026-09-17
|
||||
- **模式**:KEEP-VERIFY / EXTEND
|
||||
- **范围**:activity registration、checkin 服务与 U29~U32 页面
|
||||
- **基线**:Phase 14 收尾 `755a570`(全量回归 450 tests)
|
||||
- **结论**:**关闭**。三项计划事项全部落地,Gate 两项均达成
|
||||
|
||||
---
|
||||
|
||||
## 一、计划项达成情况
|
||||
|
||||
| 计划项 | 结论 | 证据 |
|
||||
|---|---|---|
|
||||
| 回归报名、审核、取消、签到码、重复签到和名额并发 | ✅ | `ActivityPhase15Test` 21 例,覆盖取消/拒绝后重报名、重复报名、审核并发、重复扫码幂等、过期码、跨活动复用 |
|
||||
| 增加非 matchmaking 活动和 capacity group 场景测试 | ✅ | 容量分组**真正参与扣减**(此前只落库回读);婚恋课堂类不设实名/本地资格门槛 |
|
||||
| 服务站操作员只能操作指定活动或站点任务 | ✅ | 新增 V040 `activity_staff_assignments` + 签到/审核数据范围校验(INV-020) |
|
||||
|
||||
**Gate**
|
||||
|
||||
| Gate | 结论 |
|
||||
|---|---|
|
||||
| 重复请求和并发报名不超额 | ✅ 报名/审核/取消/签到全部改为单条条件 SQL,靠数据库保证原子性 |
|
||||
| 签到不可跨活动复用 | ✅ 签到码作用域由 token 内 `activityId` 决定;且工作人员必须被显式指派到该活动 |
|
||||
|
||||
---
|
||||
|
||||
## 二、修复缺陷清单(16 处)
|
||||
|
||||
### A. 签到(8 处,含 1 处必崩)
|
||||
|
||||
- **D1 CRITICAL — 每一个签到码一生成即作废**:`generateCheckinToken` 把 token 写入 Redis 值 `"used"`,而 `scanCheckin` 见到 `"used"` 立即拒绝 → **扫码签到 100% 失败**。改为签发时写 `issued`,消费标记只在扫码成功后写入。
|
||||
- **D2 CRITICAL — 重复签到竞态**:原为「读 → 判断 → `updateById` 全量覆盖」,并发扫码会重复写 `checkin_at` 与审计。改为 `checkinIfNotCheckedIn` 条件更新;重复扫码幂等返回 `alreadyCheckedIn=true`,不重复记审计。
|
||||
- **D3 HIGH — 签到无数据范围**(计划项③):任意持 `activity.checkin` 权限者可签到任意活动任意用户。新增 `assertCheckinScope`:被指派过活动的工作人员只能操作被指派的活动;无任何指派记录者为平台级人员。
|
||||
- **D4 HIGH — 手工签到无校验**:`manualCheckin` 不校验活动是否存在/已取消(原实现只有 `generate` 校验时间窗)。补 `requireCheckableActivity`。
|
||||
- **D5 MEDIUM — 签到统计 NPE 与语义错**:活动不存在时 `activity.getTotalLimit()` NPE;`remainingCount` 用「总名额 − 已签到」计算。改为抛明确错误,并输出语义正确的 `notCheckedInCount`。
|
||||
- **D6 MEDIUM — 非法 token 返回 500**:`Long.parseLong` 未捕获 `NumberFormatException`。改为 `IllegalArgumentException`(400)。
|
||||
- **D7 MEDIUM — 签名比较非恒定时间**:改用 `MessageDigest.isEqual`,避免侧信道逐字节爆破。
|
||||
- **D8 LOW — 未使用变量**:`getCheckinStats` 中 `regMapper` 冗余赋值,已删。
|
||||
|
||||
### B. 报名 / 审核 / 取消(5 处,含 1 处必崩)
|
||||
|
||||
- **D9 CRITICAL — 取消后永远无法再报名**:生产 `UNIQUE(activity_id, user_id)` 一直存在,而「取消/拒绝后重新报名」走的是 `INSERT` → 撞唯一键 500,用户取消一次即永久失去该活动资格。测试库此前**缺该 UNIQUE**,因此零覆盖。改为复用原记录 + 原子条件 UPDATE(`reuseIfCapacityAvailable`),并同样带容量条件。
|
||||
- **D10 HIGH — 后台审核列表看不到待审记录**:`getRegistrations` 只查 `status='approved'` → 审核页面无 pending 可点,审核功能实际不可用。改为 `findByActivity`(全状态)。
|
||||
- **D11 MEDIUM — 报名窗口形同虚设**:`registration_start_at` / `registration_end_at` 完全不校验,活动一发布就永远可报。新增 `assertWithinRegistrationWindow`。
|
||||
- **D12 MEDIUM — 取消无审计**:取消是唯一没有审计日志的写操作。补 `CANCEL_REGISTRATION` 审计。
|
||||
- **D13 MEDIUM — 审核/取消非原子**:`rejectRegistration` 为「读 pending → 全量 updateById」,两人同时操作会互相覆盖;`cancelRegistration` 亦然。改为 `rejectIfPending` / `cancelIfActive` 条件更新。
|
||||
|
||||
### C. 容量分组(计划项②,1 处)
|
||||
|
||||
- **D14 MEDIUM — 分组容量完全不生效**:`activity_capacity_groups` 只落库回读,`approved_count` 永远是 0。新增 `incrementIfAvailable` / `decrementIfPositive`,在报名、审核通过、取消时真实扣减/回退(未配置分组的活动不受影响)。
|
||||
|
||||
### D. 前端契约(2 处)
|
||||
|
||||
- **F1 — 工作人员无处消费签到码**:admin-web 完全没有签到页面,D1 修复后仍无法闭环。新增 `ActivityCheckinView.vue`(扫码/粘贴签到码 + 手工签到 + 统计)+ `/activities/checkin` 路由与导航。
|
||||
- **F2 — 小程序无法取消报名**:后端 `DELETE /activities/{id}/register` 已存在,但 `activityService.js` 无封装、「我的活动」页无入口。补 `cancelRegistration` 封装。
|
||||
|
||||
---
|
||||
|
||||
## 三、测试保真度修复(3 处)
|
||||
|
||||
- **T1 约束漂移盲区**:测试库 `activity_registrations` 缺 `UNIQUE(activity_id, user_id)`、`chk_reg_status`、`chk_checkin_status` —— 这正是 D9 必崩缺陷长期不可见的根因。已补齐。
|
||||
- **T2 占位假绿**:`CheckinServiceTest` 有 3 条 `assertTrue(true); // 占位测试`,恰好覆盖「重复签到」「Token 过期」「二维码不含敏感明文」三条核心不变量。已改为解析**真实源码与真实生产 DDL** 的静态守卫(5 例)。
|
||||
- **T3 表集合**:`activity_registrations` 此前不在 `EntityMigrationParityTest` 的必选集合;新增 `activity_staff_assignments`。二者均移入 `REQUIRED_IN_TEST_SCHEMA`。
|
||||
|
||||
另外同步了 `MigrationValidationTest` 的迁移基线(39 → 40):该守卫会拒绝任何超出基线的新迁移文件,是设计内的收敛机制。
|
||||
|
||||
> 附带教训:新实体 `ActivityStaffAssignment` 最初继承了 `BaseEntity`,而 V040 表是 `(activity_id, operator_id)` 复合主键(无 id / updated_at / deleted_at)。MyBatis-Plus 会对非 null 字段一律写入列清单 → 生产每次 insert 都撞 `column does not exist`。已改为不继承 `BaseEntity`,由 `EntityMigrationParityTest` 当场拦下。
|
||||
|
||||
---
|
||||
|
||||
## 四、证据
|
||||
|
||||
**定向测试**:43 tests / 0 failures(`ActivityPhase15Test` 21 + `CheckinServiceTest` 5 + `EntityMigrationParityTest` 6 + `ActivityConstraintGuardTest` 11)
|
||||
|
||||
**负向验证**(注入真实缺陷 → 守卫必须变红,随后全部还原):
|
||||
|
||||
| 注入 | 变红的用例 |
|
||||
|---|---|
|
||||
| ① 生成签到码时写回 `TOKEN_STATE_USED`(还原 D1) | `generatedTokenCanBeScanned` / `duplicateScanIsIdempotent` / `checkinTokenDoesNotCrossActivities` + 守卫 `generateMustNotMarkTokenAsUsed` |
|
||||
| ② 报名退回 `INSERT` 不复用记录(还原 D9) | `canReRegisterAfterCancel` / `canReRegisterAfterReject`(DuplicateKeyException) |
|
||||
| ③ 容量分组不参与扣减(还原 D14) | `capacityGroupIsEnforced` / `cancelReleasesCapacityGroup` |
|
||||
|
||||
共 8 条精准变红,归因与注入一一对应;还原后确认工作区无 `NEGATIVE-VERIFICATION` 残留。
|
||||
|
||||
**全量回归**:`Tests run: 474, Failures: 0, Errors: 0, Skipped: 3` / `BUILD SUCCESS`(基线 450 → 474,净 +24)
|
||||
|
||||
**前端**:admin-web `vue-tsc --noEmit` 0 error;`vite build` 通过(1737 modules transformed,新增 `ActivityCheckinView` 已打包)
|
||||
|
||||
---
|
||||
|
||||
## 五、变更清单
|
||||
|
||||
**新增文件**
|
||||
- `backend/src/main/resources/db/migration/V040__add_activity_staff_assignments.sql`
|
||||
- `backend/src/main/java/.../activity/entity/ActivityStaffAssignment.java`
|
||||
- `backend/src/main/java/.../activity/mapper/ActivityStaffAssignmentMapper.java`
|
||||
- `backend/src/test/java/.../activity/ActivityPhase15Test.java`
|
||||
- `admin-web/src/modules/activity/ActivityCheckinView.vue`
|
||||
|
||||
**修改文件**
|
||||
- `CheckinService.java`(重写签到流程)
|
||||
- `ActivityService.java`(重报名复用、分组扣减、报名窗口、审核/取消原子化、数据范围)
|
||||
- `ActivityRegistrationMapper.java`(+`findByActivity` / `reuseIfCapacityAvailable` / `checkinIfNotCheckedIn` / `cancelIfActive` / `rejectIfPending`)
|
||||
- `ActivityCapacityGroupMapper.java`(+`findByActivityId` / `incrementIfAvailable` / `decrementIfPositive`)
|
||||
- `schema-h2.sql`(报名表 UNIQUE + 两个 CHECK、新增指派表)
|
||||
- `CheckinServiceTest.java`(3 条假绿 → 5 条真实守卫)
|
||||
- `ActivityConstraintGuardTest.java`(+2 条约束漂移守卫)
|
||||
- `EntityMigrationParityTest.java`、`MigrationValidationTest.java`(基线)
|
||||
- `admin-web/src/router/index.ts`、`MainLayout.vue`(签到路由与导航)
|
||||
- `miniapp/services/activityService.js`(+`cancelRegistration`)
|
||||
|
||||
---
|
||||
|
||||
## 六、遗留(供后续阶段)
|
||||
|
||||
1. **`my-activities`(U30)仍是占位骨架**:只有 `onLoad/onShow` 空实现,用户无法查看报名状态或自助取消(service 层已备好 `cancelRegistration`)。
|
||||
2. **签到码以"复制文本"形式下发**:小程序 U31 提供的是复制按钮而非二维码;后台签到台为手动粘贴。若需扫码枪/摄像头扫码,需另加扫码输入方式。
|
||||
3. **容量分组归属规则较粗**:当前按 `group_code` 与性别匹配,未匹配到分组时不施加约束;`ruleDefinition`(JSON)尚未参与判定。
|
||||
4. **`activity.checkin` 与指派的联动**:目前"有指派记录者受限、无指派记录者不受限",若后续要求所有签到人员必须显式指派,需改为强制校验。
|
||||
|
||||
---
|
||||
|
||||
**Phase 15 已关闭。Phase 16(活动互选与双方开启沟通确认)未自动进入,等待用户确认。**
|
||||
+19
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 14 COMPLETE / GIT CLEAN`。**
|
||||
>
|
||||
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)复验已关闭**,八轮共修复 48 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 15 需用户确认后启动。**
|
||||
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)复验已关闭**,九轮共修复 64 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 16 需用户确认后启动。**
|
||||
|
||||
最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点)
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
| 1.1.0-rc.4 | 2026-09-16 | Phase 13 复验关闭(公益红娘转介,修复 7 处缺陷);新增 V038 扩展 matchmaker CHECK 白名单;全量回归 **391 tests** 全绿 |
|
||||
| 1.1.0-rc.5 | 2026-09-17 | Phase 13A 复验关闭(婚恋咨询与婚前辅导,9 处缺陷 + 4 处测试基建缺陷);新增 V039;全量回归 **420 tests** 全绿 |
|
||||
| 1.1.0-rc.6 | 2026-09-17 | Phase 14 复验关闭(多类型公益活动,11 处后端缺陷 + 3 处前端契约缺陷 + 2 处测试保真度缺陷);新增 `ActivityManagementView` 动态表单;全量回归 **450 tests** 全绿 |
|
||||
| 1.1.0-rc.7 | 2026-09-17 | Phase 15 复验关闭(活动报名/审核/签到,16 处缺陷:含**签到码一生成即作废**、**取消后永远无法再报名**两处必崩;3 处测试保真度);新增 V040 `activity_staff_assignments` 与 `ActivityCheckinView` 签到工作台;全量回归 **474 tests** 全绿 |
|
||||
|
||||
---
|
||||
|
||||
@@ -204,6 +205,23 @@
|
||||
| 测试:`ActivityPhase14Test`(23)+ `ActivityConstraintGuardTest`(9,解析真实 DDL 与前端源码) | ✅ 新增 |
|
||||
| 全量回归 **450 tests 全绿** | ✅ |
|
||||
|
||||
### Phase 15 — 活动报名、审核与签到 ✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过)
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
| **D1 必崩**:签到码一生成即作废(`generate` 写 `"used"` → 扫码 100% 失败)→ 改为 `issued` | ✅ |
|
||||
| 重复签到:原子条件更新 `checkinIfNotCheckedIn` + 幂等返回 `alreadyCheckedIn` | ✅ 新增 |
|
||||
| **D9 必崩**:取消/拒绝后重报名撞 `UNIQUE(activity_id,user_id)` → 复用记录 `reuseIfCapacityAvailable` | ✅ |
|
||||
| 审核/取消原子化:`rejectIfPending` / `cancelIfActive`;取消补审计日志 | ✅ |
|
||||
| 后台报名列表含 pending(原只查 approved,审核页无待审项) | ✅ |
|
||||
| 报名窗口 `registration_start_at` / `registration_end_at` 生效 | ✅ 新增 |
|
||||
| 容量分组真正参与扣减(`incrementIfAvailable` / `decrementIfPositive`) | ✅ 新增 |
|
||||
| 计划项③:V040 `activity_staff_assignments` + 签到/审核数据范围校验(INV-020) | ✅ 新增 |
|
||||
| Admin:`ActivityCheckinView.vue` 签到工作台 + `/activities/checkin` 路由与导航 | ✅ 新增 |
|
||||
| Miniapp:`cancelRegistration` 封装(后端端点存在但前端无入口) | ✅ |
|
||||
| 测试库补齐报名表 UNIQUE + 两个 CHECK;两表移入必选集合;迁移基线 39→40 | ✅ |
|
||||
| 测试:`ActivityPhase15Test`(21)+ `CheckinServiceTest` 假绿改真实守卫(5)+ 约束守卫(2) | ✅ 新增 |
|
||||
| 全量回归 **474 tests 全绿** | ✅ |
|
||||
|
||||
### Phase 13A — 公益婚恋咨询与婚前辅导(核心链路)✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过)
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
|
||||
@@ -400,12 +400,20 @@ D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `M
|
||||
|
||||
**Files:** activity registration、checkin 服务和 U29~U32 页面。
|
||||
|
||||
- [ ] 回归报名、审核、取消、签到码、重复签到和名额并发。
|
||||
- [ ] 增加非 matchmaking 活动和 capacity group 场景测试。
|
||||
- [ ] 服务站操作员只能操作指定活动或站点任务。
|
||||
- [x] 回归报名、审核、取消、签到码、重复签到和名额并发。
|
||||
- [x] 增加非 matchmaking 活动和 capacity group 场景测试。
|
||||
- [x] 服务站操作员只能操作指定活动或站点任务。
|
||||
|
||||
**Gate:** 重复请求和并发报名不超额;签到不可跨活动复用。
|
||||
|
||||
> **2026-09-17 复验关闭**:修复 16 处缺陷(8 处签到 + 5 处报名/审核/取消 + 1 处容量分组 + 2 处前端契约)
|
||||
> + 3 处测试保真度缺陷。两处必崩:**D1 签到码一生成即作废**(`generateCheckinToken` 写 `"used"`,
|
||||
> 扫码 100% 失败)、**D9 取消后永远无法再报名**(撞生产 `UNIQUE(activity_id,user_id)`,
|
||||
> 测试库缺该约束故零覆盖)。新增 V040 `activity_staff_assignments` 落实签到数据范围(INV-020)。
|
||||
> 一轮负向验证:3 处注入 → 8 条用例精准变红并还原。全量回归 **474 tests 全绿**(基线 450)。
|
||||
> 报告:`docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md`
|
||||
> **Phase 16 未自动进入,等待用户确认。**
|
||||
|
||||
### Phase 16:活动互选与双方开启沟通确认 — REFACTOR
|
||||
|
||||
**Files:** activity_mutual、relationship、messaging 模块及 activity-mutual 页面。
|
||||
|
||||
@@ -40,6 +40,17 @@ function register(activityId, payload = {}) {
|
||||
return api.post(`/activities/${encodeURIComponent(activityId)}/register`, payload, undefined, idempotencyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消报名(Phase 15)
|
||||
* 后端:DELETE /activities/{activityId}/register
|
||||
*
|
||||
* 说明:后端为 UNIQUE(activity_id, user_id) 设计,取消后原记录保留为 cancelled,
|
||||
* 再次报名会**复用**该记录(不会撞唯一键),因此取消不是不可逆操作。
|
||||
*/
|
||||
function cancelRegistration(activityId) {
|
||||
return api.delete(`/activities/${encodeURIComponent(activityId)}/register`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的活动(API_INVENTORY 行 95)
|
||||
* @param {{ status?: string, cursor?: string, pageSize?: number }} params
|
||||
@@ -76,6 +87,7 @@ module.exports = {
|
||||
getActivities,
|
||||
getActivityDetail,
|
||||
register,
|
||||
cancelRegistration,
|
||||
getMyActivities,
|
||||
submitFeedback,
|
||||
submitInterests,
|
||||
|
||||
Reference in New Issue
Block a user