fix(activity): close Phase 14 KEEP-VERIFY/EXTEND — 11 backend + 3 frontend-contract + 2 test-fidelity defects

Phase 14 (multi-type public-welfare activities) verification closure.

Backend (D1-D11):
- D1 CRITICAL: ActivityMapper.countRemainingCapacity had no @Select and no XML
  -> activity detail / register / approve all threw Invalid bound statement.
  Removed the shell; unified on ActivityRegistrationMapper.
- D2 CRITICAL: register & approve were read-then-write -> oversold under
  concurrency. Replaced with single atomic SQL (insertIfCapacityAvailable /
  approveIfCapacityAvailable, affectedRows==0 => full).
- D3 HIGH: activityType / orgRole never validated against production
  chk_activity_type / chk_org_role -> 500 only on PostgreSQL.
- D4 HIGH: ActivityListItemDTO had no activityType -> miniapp category tabs
  always empty (Gate failure).
- D5: male/female counts were fabricated as approvedCount/2; now real
  aggregation over user_profiles.gender.
- D6: minAge/maxAge was a TODO no-op; added assertAgeInRange.
- D7: registration_policy write-only; per-type eligibility now enforced
  (identity / local eligibility / gender quota).
- D8: POST /operator/activities dropped type, multi-org and capacity groups
  and set organizer_org_id to the operator user id; now delegates to
  createActivityFromAdmin (single implementation).
- D9: three operator write endpoints were mounted on the user-side
  ActivityController (dual implementation, zero callers) -> moved.
- D10: NPE on soft-deleted activity in getMyActivities; NPE/500 on missing
  admin form fields (Instant.parse(null)); tolerant time parsing added.
- D11: DTO lacked registeredCount / status / coverFileId / description
  -> card always showed 0 registered, "view detail", no cover, no summary.

Frontend contract (F1-F3):
- F1: miniapp category code "classroom" is not in the production whitelist
  (should be relationship_education); three types had no tab at all.
- F2: ui-activity-card read 7 fields that do not exist on the backend;
  now reads only real DTO fields, plus per-type badge and eligibility hint.
- F3: admin-web had no activity management page at all; added
  ActivityManagementView.vue (type-driven dynamic form, multi-org,
  capacity groups, publish/cancel) + /activities route and nav entry.

Test fidelity (T1-T2):
- T1: deleted two assertTrue(true) placeholder tests (testNoOverbooking,
  testNoDuplicateRegistration) that covered the exact broken invariants.
- T2: test schema lacked V023 columns/constraints and three tables; the
  activity tables were only in the OPTIONAL set (zero coverage) -> schema
  completed and moved to REQUIRED_IN_TEST_SCHEMA.

Tests: ActivityPhase14Test (23 cases), ActivityConstraintGuardTest (9 cases,
parses real db/migration/V*.sql plus miniapp source). Two negative-verification
rounds proved both guard families actually turn red.

Regression: 450 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 420). admin-web vue-tsc 0 error, vite build OK.
This commit is contained in:
WorkBuddy
2026-09-17 14:02:00 +08:00
parent 7a2693b239
commit 755a570849
22 changed files with 2164 additions and 136 deletions
+1 -1
View File
@@ -170,7 +170,7 @@ const navigation: NavigationItem[] = [
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
{ label: '活动管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: Calendar },
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], 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,453 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>活动管理</span>
<el-button type="primary" @click="openCreate">新建活动</el-button>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="title" label="活动名称" min-width="180" />
<el-table-column prop="activityType" label="活动类型" width="130">
<template #default="{ row }">
<el-tag size="small">{{ typeText(row.activityType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'registration_open' ? 'success' : 'info'">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="totalLimit" label="名额" width="80" />
<el-table-column prop="startAt" label="开始时间" width="170">
<template #default="{ row }">{{ formatTime(row.startAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button size="small" type="success" :disabled="row.status !== 'draft'"
@click="publish(row)">发布</el-button>
<el-button size="small" @click="openOrgs(row)">组织/分组</el-button>
<el-button size="small" type="danger"
:disabled="row.status !== 'draft' && row.status !== 'registration_open'"
@click="cancel(row)">取消</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- ==================== 动态表单:字段随活动类型变化 ==================== -->
<el-dialog v-model="formVisible" :title="editId ? '编辑活动' : '新建活动'" width="720px">
<el-form :model="form" label-width="120px">
<el-form-item label="活动类型" required>
<el-select v-model="form.activityType" style="width: 100%">
<el-option v-for="t in ACTIVITY_TYPES" :key="t.code" :label="t.label" :value="t.code" />
</el-select>
<div class="form-hint">{{ typeHint(form.activityType) }}</div>
</el-form-item>
<el-form-item label="活动名称" required>
<el-input v-model="form.title" placeholder="例如:丹阳市青年公益联谊第 12 期" />
</el-form-item>
<el-form-item label="活动简介">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="开始时间" required>
<el-date-picker v-model="form.startAt" type="datetime" placeholder="选择开始时间"
value-format="YYYY-MM-DDTHH:mm:ss" style="width: 100%" />
</el-form-item>
<el-form-item label="结束时间" required>
<el-date-picker v-model="form.endAt" type="datetime" placeholder="选择结束时间"
value-format="YYYY-MM-DDTHH:mm:ss" style="width: 100%" />
</el-form-item>
<el-form-item label="地点名称"><el-input v-model="form.locationName" /></el-form-item>
<el-form-item label="详细地址"><el-input v-model="form.locationAddress" /></el-form-item>
<!-- 动态区 1:仅相亲 / 兴趣社交按性别名额 -->
<template v-if="usesGenderQuota">
<el-form-item label="男性名额"><el-input-number v-model="form.maleLimit" :min="0" /></el-form-item>
<el-form-item label="女性名额"><el-input-number v-model="form.femaleLimit" :min="0" /></el-form-item>
</template>
<el-form-item label="总名额"><el-input-number v-model="form.totalLimit" :min="1" /></el-form-item>
<!-- 动态区 2:年龄门槛(选填,留空=不限) -->
<el-form-item label="年龄下限">
<el-input-number v-model="form.minAge" :min="18" :max="80" :value-on-clear="null" placeholder="不限" />
</el-form-item>
<el-form-item label="年龄上限">
<el-input-number v-model="form.maxAge" :min="18" :max="80" :value-on-clear="null" placeholder="不限" />
</el-form-item>
<!-- 动态区 3:仅相亲 / 兴趣社交有互选环节 -->
<el-form-item v-if="form.postEventInterestEnabled !== undefined && hasPostEventInterest" label="活动后互选">
<el-switch v-model="form.postEventInterestEnabled" />
</el-form-item>
<el-form-item label="报名需审核">
<el-switch v-model="form.needReview" />
</el-form-item>
<el-form-item label="目标群体">
<el-input v-model="form.targetGroup" placeholder="例如:丹阳市机关企事业单位青年" />
</el-form-item>
<el-form-item label="主办组织">
<el-select v-model="form.organizerOrgId" clearable style="width: 100%" placeholder="选择主办组织">
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
</el-form-item>
<el-form-item label="协作组织">
<div v-for="(p, i) in form.partnerOrganizations" :key="i" class="partner-row">
<el-select v-model="p.organizationId" placeholder="组织" style="width: 220px">
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
<el-select v-model="p.role" style="width: 150px">
<el-option v-for="r in ORG_ROLES" :key="r.code" :label="r.label" :value="r.code" />
</el-select>
<el-button size="small" type="danger" @click="removePartner(i)">删除</el-button>
</div>
<el-button size="small" @click="addPartner">+ 添加协作组织</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
<!-- ==================== 多组织 / 容量分组 ==================== -->
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
<h4>组织关系</h4>
<el-table :data="orgs" border size="small">
<el-table-column prop="organizationId" label="组织ID" min-width="160" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">{{ roleText(row.role) }}</template>
</el-table-column>
</el-table>
<div class="inline-form">
<el-select v-model="newOrg.organizationId" placeholder="组织" style="width: 200px">
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
<el-select v-model="newOrg.role" style="width: 140px">
<el-option v-for="r in ORG_ROLES" :key="r.code" :label="r.label" :value="r.code" />
</el-select>
<el-button size="small" type="primary" @click="addOrg">添加组织</el-button>
</div>
<h4 style="margin-top: 18px">容量分组</h4>
<el-table :data="groups" border size="small">
<el-table-column prop="groupCode" label="分组代码" min-width="140" />
<el-table-column prop="capacity" label="容量" width="80" />
<el-table-column prop="approvedCount" label="已通过" width="80" />
<el-table-column prop="ruleDefinition" label="规则" min-width="180" />
</el-table>
<div class="inline-form">
<el-input v-model="newGroup.groupCode" placeholder="如 male_30_40" style="width: 180px" />
<el-input-number v-model="newGroup.capacity" :min="1" style="width: 120px" />
<el-input v-model="newGroup.ruleDefinition" placeholder='如 {"gender":"male"}' style="width: 200px" />
<el-button size="small" type="primary" @click="addGroup">添加分组</el-button>
</div>
<template #footer>
<el-button @click="orgVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
/**
* 活动管理(V1.1 Phase 14)
*
* 关键点:**动态表单** —— 字段随 activityType 变化:
* - matchmaking / interest_social:显示男女名额 + 活动后互选(有互选环节)
* - relationship_education / marriage_custom_culture / anti_fraud:公益科普,
* 不显示性别名额与互选,资格门槛也不同(见 typeHint)
* - premarital_guidance:需实名,无互选
*
* 类型与角色枚举必须与后端生产 CHECK 白名单一致:
* ActivityConstraintGuardTest 会解析 db/migration/V*.sql 校验后端常量,
* 本页的下拉项同样不得偏离该白名单。
*/
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { get, post, put } from '@/api/client'
const ACTIVITY_TYPES = [
{ code: 'matchmaking', label: '公益联谊' },
{ code: 'interest_social', label: '兴趣社交' },
{ code: 'relationship_education', label: '婚恋课堂' },
{ code: 'premarital_guidance', label: '婚前辅导' },
{ code: 'marriage_custom_culture', label: '婚俗文化' },
{ code: 'anti_fraud', label: '反诈宣传' },
]
const ORG_ROLES = [
{ code: 'host', label: '主办' },
{ code: 'organizer', label: '承办' },
{ code: 'co_organizer', label: '协办' },
{ code: 'partner', label: '合作方' },
{ code: 'venue_partner', label: '场地合作方' },
]
const TYPE_TEXT: Record<string, string> = Object.fromEntries(ACTIVITY_TYPES.map((t) => [t.code, t.label]))
const ROLE_TEXT: Record<string, string> = Object.fromEntries(ORG_ROLES.map((r) => [r.code, r.label]))
/** 资格策略提示:与后端 ActivityService#resolveEligibility 保持一致 */
const TYPE_HINT: Record<string, string> = {
matchmaking: '需实名认证 + 丹阳本地资格核验;按男女名额分别扣减;活动后开放互选',
interest_social: '按男女名额分别扣减;活动后开放互选',
premarital_guidance: '需实名认证;只按总名额',
relationship_education: '公益科普,不设实名与本地资格门槛;只按总名额',
marriage_custom_culture: '公益科普,不设实名与本地资格门槛;只按总名额',
anti_fraud: '公益科普,不设实名与本地资格门槛;只按总名额',
}
const POST_EVENT_TYPES = ['matchmaking', 'interest_social']
const loading = ref(false)
const saving = ref(false)
const tableData = ref<any[]>([])
const organizations = ref<any[]>([])
const formVisible = ref(false)
const editId = ref('')
const form = ref(emptyForm())
const orgVisible = ref(false)
const currentId = ref('')
const orgs = ref<any[]>([])
const groups = ref<any[]>([])
const newOrg = ref({ organizationId: '', role: 'partner' })
const newGroup = ref({ groupCode: '', capacity: 20, ruleDefinition: '' })
const usesGenderQuota = computed(() => ['matchmaking', 'interest_social'].includes(form.value.activityType))
const hasPostEventInterest = computed(() => POST_EVENT_TYPES.includes(form.value.activityType))
function emptyForm() {
return {
activityType: 'matchmaking',
title: '',
description: '',
startAt: '',
endAt: '',
locationName: '',
locationAddress: '',
totalLimit: 50,
maleLimit: 25,
femaleLimit: 25,
minAge: undefined as number | undefined,
maxAge: undefined as number | undefined,
needReview: true,
postEventInterestEnabled: false,
targetGroup: '',
organizerOrgId: '',
partnerOrganizations: [] as Array<{ organizationId: string; role: string }>,
}
}
function authHeader() {
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
}
async function loadData() {
loading.value = true
try {
const res: any = await get('/operator/activities', {}, authHeader())
tableData.value = res.data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function loadOrganizations() {
try {
const res: any = await get('/operator/organizations', {}, authHeader())
organizations.value = res.data || []
} catch (e) {
console.error(e)
}
}
function openCreate() {
editId.value = ''
form.value = emptyForm()
formVisible.value = true
}
function addPartner() {
form.value.partnerOrganizations.push({ organizationId: '', role: 'partner' })
}
function removePartner(i: number) {
form.value.partnerOrganizations.splice(i, 1)
}
async function save() {
if (!form.value.title) {
ElMessage.warning('请填写活动名称')
return
}
if (!form.value.startAt || !form.value.endAt) {
ElMessage.warning('请填写开始与结束时间')
return
}
saving.value = true
try {
const body: Record<string, unknown> = { ...form.value }
// 非性别名额类型不下发男女名额,避免后端误按性别限制
if (!usesGenderQuota.value) {
delete body.maleLimit
delete body.femaleLimit
}
await post('/operator/activities', body, authHeader())
ElMessage.success('活动已创建')
formVisible.value = false
loadData()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '创建失败')
} finally {
saving.value = false
}
}
async function publish(row: any) {
try {
await put(`/operator/activities/${row.id}/publish`, {}, authHeader())
ElMessage.success('已发布')
loadData()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '发布失败')
}
}
async function cancel(row: any) {
try {
await put(`/operator/activities/${row.id}/cancel`, {}, authHeader())
ElMessage.success('已取消')
loadData()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '取消失败')
}
}
async function openOrgs(row: any) {
currentId.value = row.id
orgVisible.value = true
await Promise.all([loadOrgs(), loadGroups()])
}
async function loadOrgs() {
try {
const res: any = await get(`/operator/activities/${currentId.value}/organizations`, {}, authHeader())
orgs.value = res.data || []
} catch (e) {
console.error(e)
}
}
async function loadGroups() {
try {
const res: any = await get(`/operator/activities/${currentId.value}/capacity-groups`, {}, authHeader())
groups.value = res.data || []
} catch (e) {
console.error(e)
}
}
async function addOrg() {
if (!newOrg.value.organizationId) {
ElMessage.warning('请选择组织')
return
}
try {
await post(`/operator/activities/${currentId.value}/organizations`, newOrg.value, authHeader())
ElMessage.success('已添加')
await loadOrgs()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '添加失败')
}
}
async function addGroup() {
if (!newGroup.value.groupCode) {
ElMessage.warning('请填写分组代码')
return
}
try {
await post(`/operator/activities/${currentId.value}/capacity-groups`, newGroup.value, authHeader())
ElMessage.success('已添加')
await loadGroups()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '添加失败')
}
}
function typeText(t?: string) {
return (t && TYPE_TEXT[t]) || t || '-'
}
function roleText(r?: string) {
return (r && ROLE_TEXT[r]) || r || '-'
}
function typeHint(t?: string) {
return (t && TYPE_HINT[t]) || ''
}
function statusText(s?: string) {
const map: Record<string, string> = {
draft: '草稿',
registration_open: '报名中',
registration_closed: '报名结束',
ongoing: '进行中',
ended: '已结束',
cancelled: '已取消',
}
return (s && map[s]) || s || '-'
}
function formatTime(t?: string) {
return t ? new Date(t).toLocaleString('zh-CN') : '-'
}
onMounted(() => {
loadData()
loadOrganizations()
})
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-hint {
margin-top: 4px;
color: #8a90a2;
font-size: 12px;
line-height: 1.5;
}
.partner-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.inline-form {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
</style>
+2
View File
@@ -18,6 +18,8 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { title: '工作台' } },
{ 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/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'] } },