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

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

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

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

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

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

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

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

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

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

Regression: 474 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 450). admin-web vue-tsc 0 error, vite build OK.
This commit is contained in:
WorkBuddy
2026-09-17 15:07:52 +08:00
parent 755a570849
commit 29112eed6e
21 changed files with 1602 additions and 116 deletions
+1
View File
@@ -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>
+1
View File
@@ -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'] } },