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:
@@ -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>
|
||||
Reference in New Issue
Block a user