fix(ops-backend/service-station): close Phase 19 KEEP-VERIFY — 25 defects (unreachable announcements + station write privilege escalation; banner 500 + soft-delete leak; content publish dead-end; program approval dead-end/draft leak/zero counts/missing withdraw; zero-coverage test schema) ; add V042 announcement chain + 34 guard tests; full regression 602 green

Backend
- Service Station: expose the 「临时公告」 chain end-to-end (V042 table/service existed
  but had ZERO endpoints -> both admin and miniapp could never reach it): 5 operator
  endpoints (list/create/update/publish/archive) + 1 user-facing read endpoint.
- Fix privilege escalation: create/update were guarded only by the class-level
  service_location.view, making V015's service_location.manage a never-used dead
  permission -> read-only roles could edit stations. Permissions moved to method level.
- Add ServiceLocationOperatorController to OperatorEndpointPermissionCoverageTest
  (it was NOT scanned, which is why the escalation stayed invisible).
- Add create/update validation (address NOT NULL + status CHECK only blew up on PG).
- Banner: validation failures threw bare RuntimeException -> 500; now VALIDATION_ERROR
  -> 400. Add deleted_at IS NULL to both home queries (soft-deleted banners stayed live).
- Content: operator list/detail split (admin «publish» was a dead end because it reused
  the published-only public API); category deactivate/reactivate; article body on edit.
- Service Program: operator member list now includes pending (approval reachable);
  active-only public visibility (draft leak); memberCount/pendingCount aggregation;
  withdraw/removeMember; reuse row on re-apply (UNIQUE(program_id,user_id) crash).
- MeController: add the missing GET /me/service-history route.

Schema / guards
- schema-h2.sql: add service_location_announcements (+ chk_announcement_status) and
  content_categories (+ uk_content_category_code, chk_category_status); move both into
  REQUIRED_IN_TEST_SCHEMA. MigrationValidationTest baseline 41 -> 42.
- V11PermissionMatrixContractTest now scans ALL migrations instead of hardcoding V015+V029.
- New: ServiceLocationAnnouncementPhase19IntegrationTest (24),
  ServiceLocationControllerPermissionContractTest (5),
  ServiceLocationAnnouncementConstraintParityTest (5, prod DDL <-> test schema <->
  backend whitelist <-> admin labels).

Frontend
- admin-web: ServiceStationView gains announcement management (statuses, create/edit,
  publish/archive with confirm, expiry); write buttons gated by service_location.manage.
- miniapp: U50 renders the 临时公告 block via new serviceStationService; page no longer
  talks to utils/api directly; announcement failure degrades to [] instead of erroring.
- ui-tests: U37 four-group + U50 announcement contracts; /home assertion retargeted to
  the service layer.

Verification
- Negative verification: 3 injected defects (drop soft-delete filter / drop test-schema
  CHECK / downgrade write endpoint to view) -> 4 named tests fail precisely, no
  collateral failures; restored, NEG-VAL residue = 0.
- Backend full regression: 602 tests, 0 failures, 0 errors, 3 skipped (baseline 420).
- admin-web: vue-tsc --noEmit EXIT=0, vite build EXIT=0. Miniapp: tsc --noEmit EXIT=0.
- ui-tests 14/14 PASS.
This commit is contained in:
WorkBuddy
2026-09-17 22:21:31 +08:00
parent 3f60611a74
commit 72b4e9b846
70 changed files with 5847 additions and 308 deletions
@@ -7,31 +7,42 @@
<el-button type="primary" @click="openCreate">新建 Banner</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
title="Banner 只允许跳转站内。目标值填写站内标识(如活动ID、文章ID、页面标识 home),填写 http(s):// 等外链会被拒绝。"
style="margin-bottom: 12px"
/>
<el-table :data="banners" v-loading="loading" stripe>
<el-table-column prop="title" label="标题" width="150" />
<el-table-column prop="targetType" label="跳转类型" width="120">
<el-table-column prop="buttonText" label="按钮文案" width="110">
<template #default="{ row }">{{ row.buttonText || '-' }}</template>
</el-table-column>
<el-table-column prop="targetType" label="跳转类型" width="110">
<template #default="{ row }">
<el-tag size="small">{{ bannerTargetTypeLabel(row.targetType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="audienceScope" label="展示范围" width="120">
<el-table-column prop="audienceScope" label="展示范围" width="110">
<template #default="{ row }">
<el-tag size="small" :type="audienceScopeTag(row.audienceScope)">{{ audienceScopeLabel(row.audienceScope) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="95">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column prop="sortOrder" label="排序" width="70" />
<el-table-column label="时间范围" width="200">
<template #default="{ row }">
<span>{{ formatDateTime(row.startAt) }} ~ {{ formatDateTime(row.endAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<el-table-column label="操作" width="230">
<template #default="{ row }">
<el-button size="small" @click="openPreview(row)">预览</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="warning" @click="toggleStatus(row)">
{{ row.status === 'active' ? '下架' : '上架' }}
@@ -44,8 +55,9 @@
<!-- 新建/编辑 Dialog -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑 Banner' : '新建 Banner'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="副标题"><el-input v-model="form.subtitle" /></el-form-item>
<el-form-item label="标题" required><el-input v-model="form.title" maxlength="200" show-word-limit /></el-form-item>
<el-form-item label="副标题"><el-input v-model="form.subtitle" maxlength="300" show-word-limit /></el-form-item>
<el-form-item label="按钮文案"><el-input v-model="form.buttonText" maxlength="64" show-word-limit placeholder="如:立即体验(留空则不显示按钮)" /></el-form-item>
<el-form-item label="跳转类型">
<el-select v-model="form.targetType">
<el-option label="公益活动" value="activity" />
@@ -58,7 +70,10 @@
<el-option label="无" value="none" />
</el-select>
</el-form-item>
<el-form-item label="目标值"><el-input v-model="form.targetValue" placeholder="活动ID/文章ID等" /></el-form-item>
<el-form-item label="目标值" :required="needsExplicitTarget">
<el-input v-model="form.targetValue" maxlength="200" :placeholder="targetValuePlaceholder" />
<div class="field-hint">仅填站内标识,禁止 http(s):// 等站外地址</div>
</el-form-item>
<el-form-item label="展示范围">
<el-select v-model="form.audienceScope">
<el-option label="全部用户" value="all" />
@@ -68,40 +83,118 @@
<el-option label="成功牵手" value="successful" />
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" /></el-form-item>
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="草稿" value="draft" />
<el-option label="生效中" value="active" />
<el-option label="已下架" value="inactive" />
</el-select>
<div class="field-hint">「定时」「已过期」由排期自动流转,无需手工选择</div>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" />
<div class="field-hint">数值越小越靠前</div>
</el-form-item>
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" placeholder="留空表示立即生效" /></el-form-item>
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" placeholder="留空表示长期有效" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button @click="previewFromForm">预览</el-button>
<el-button type="primary" @click="saveBanner">保存</el-button>
</template>
</el-dialog>
<!-- 预览 Dialog:模拟小程序首页 Banner 卡片 -->
<el-dialog v-model="previewVisible" title="Banner 预览(模拟小程序首页)" width="420px">
<div class="preview-stage">
<div class="preview-card">
<div class="preview-tag">{{ audienceScopeLabel(previewBanner.audienceScope) }}</div>
<div class="preview-title">{{ previewBanner.title || '(未填写标题)' }}</div>
<div class="preview-subtitle">{{ previewBanner.subtitle || '' }}</div>
<div v-if="previewBanner.buttonText" class="preview-button">{{ previewBanner.buttonText }}</div>
</div>
</div>
<el-descriptions :column="1" border size="small" style="margin-top: 16px">
<el-descriptions-item label="跳转去向">{{ previewTargetText }}</el-descriptions-item>
<el-descriptions-item label="展示状态">{{ statusLabel(previewBanner.status) }}</el-descriptions-item>
<el-descriptions-item label="展示时间">
{{ formatDateTime(previewBanner.startAt) }} ~ {{ formatDateTime(previewBanner.endAt) }}
</el-descriptions-item>
<el-descriptions-item label="排序">{{ previewBanner.sortOrder ?? 0 }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="previewVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const banners = ref<any[]>([])
const dialogVisible = ref(false)
const previewVisible = ref(false)
const editingId = ref<string | null>(null)
const form = ref({ title: '', subtitle: '', targetType: 'none', targetValue: '', audienceScope: 'all', sortOrder: 0, startAt: '', endAt: '' })
const previewBanner = ref<any>({})
const EMPTY_FORM = {
title: '', subtitle: '', buttonText: '', targetType: 'none', targetValue: '',
audienceScope: 'all', status: 'active', sortOrder: 0, startAt: '', endAt: '',
}
const form = ref({ ...EMPTY_FORM })
const TARGET_TYPE_LABEL: Record<string, string> = { activity: '活动', matchmaker: '红娘', counseling: '咨询', premarital: '婚前辅导', service_location: '服务站', article: '文章', internal_page: '内部页', none: '无' }
const TARGET_TYPE_DEST: Record<string, string> = {
activity: '公益活动列表', matchmaker: '公益红娘', counseling: '婚恋咨询',
premarital: '婚前辅导', service_location: '善缘服务站',
article: '文章详情', internal_page: '站内页面', none: '无跳转(仅展示)',
}
const NEEDS_EXPLICIT_TARGET = ['internal_page', 'article']
const AUDIENCE_SCOPE_LABEL: Record<string, string> = { all: '全部', onboarding: '未注册', searching: '寻找中', getting_to_know: '初步了解', successful: '成功牵手' }
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', active: '生效中', inactive: '已下架', expired: '已过期' }
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', active: 'success', inactive: '', expired: 'danger' }
/** 与后端 assertInternalTarget 保持一致的禁外链前缀 */
const EXTERNAL_LINK_PREFIXES = ['http://', 'https://', '//', 'javascript:', 'data:text/html']
const needsExplicitTarget = computed(() => NEEDS_EXPLICIT_TARGET.includes(form.value.targetType))
const targetValuePlaceholder = computed(() => (form.value.targetType === 'article'
? '填写文章ID'
: form.value.targetType === 'internal_page' ? '填写站内页面标识,如 home' : '可留空,默认进入对应列表页'))
function bannerTargetTypeLabel(t: string) { return TARGET_TYPE_LABEL[t] || t }
function audienceScopeLabel(s: string) { return AUDIENCE_SCOPE_LABEL[s] || s }
function audienceScopeTag(s: string) { return s === 'all' ? 'success' : 'info' }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusLabel(s: string) { return STATUS_LABEL[s] || s || '-' }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
function isExternalLink(value: string) {
const normalized = (value || '').trim().toLowerCase()
return EXTERNAL_LINK_PREFIXES.some((prefix) => normalized.startsWith(prefix))
}
function previewTargetText(banner: any) {
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
if (!banner.targetValue) return dest
return `${dest} · ${banner.targetValue}`
}
function assignForm(source: any) {
form.value = {
title: source.title || '',
subtitle: source.subtitle || '',
buttonText: source.buttonText || '',
targetType: source.targetType || 'none',
targetValue: source.targetValue || '',
audienceScope: source.audienceScope || 'all',
status: source.status || 'active',
sortOrder: source.sortOrder ?? 0,
startAt: source.startAt || '',
endAt: source.endAt || '',
}
}
async function loadBanners() {
loading.value = true
@@ -111,10 +204,60 @@ async function loadBanners() {
} catch (e) { console.error(e) } finally { loading.value = false }
}
function openCreate() { editingId.value = null; Object.assign(form.value, { title: '', subtitle: '', targetType: 'none', targetValue: '', audienceScope: 'all', sortOrder: 0, startAt: '', endAt: '' }); dialogVisible.value = true }
function openEdit(row: any) { editingId.value = row.id; Object.assign(form.value, { title: row.title, subtitle: row.subtitle, targetType: row.targetType, targetValue: row.targetValue, audienceScope: row.audienceScope, sortOrder: row.sortOrder, startAt: row.startAt, endAt: row.endAt }); dialogVisible.value = true }
function openCreate() {
editingId.value = null
assignForm({})
dialogVisible.value = true
}
function openEdit(row: any) {
editingId.value = row.id
assignForm(row)
dialogVisible.value = true
}
/**
* 与后端同源的校验。前端拦一道是为了让运营即时看到原因,
* 不作为唯一防线(后端 HomeBannerOperatorController.validate 仍是权威)。
* @returns 校验通过返回 true
*/
function validateForm(): boolean {
const value = form.value
if (!value.title.trim()) { ElMessage.error('请填写 Banner 标题'); return false }
if (isExternalLink(value.targetValue)) {
ElMessage.error('Banner 不允许跳转站外地址,请填写站内标识')
return false
}
if (NEEDS_EXPLICIT_TARGET.includes(value.targetType) && !value.targetValue.trim()) {
ElMessage.error(`跳转类型为「${bannerTargetTypeLabel(value.targetType)}」时必须填写站内目标值`)
return false
}
if (value.startAt && value.endAt && new Date(value.startAt) > new Date(value.endAt)) {
ElMessage.error('展示开始时间不能晚于结束时间')
return false
}
if (value.sortOrder != null && value.sortOrder < 0) {
ElMessage.error('排序值不能为负数')
return false
}
return true
}
function openPreview(row: any) {
if (isExternalLink(row.targetValue)) {
ElMessage.error('该 Banner 目标值疑似站外地址,已阻止预览')
return
}
previewBanner.value = row
previewVisible.value = true
}
function previewFromForm() {
if (!validateForm()) return
previewBanner.value = { ...form.value }
previewVisible.value = true
}
async function saveBanner() {
if (!validateForm()) return
try {
if (editingId.value) {
await put(`/operator/banners/${editingId.value}`, form.value)
@@ -125,15 +268,32 @@ async function saveBanner() {
}
dialogVisible.value = false
loadBanners()
} catch (e) { ElMessage.error('操作失败') }
} catch (e) {
// 错误提示由 api/client 的响应拦截器统一弹出,此处不重复 toast
console.error(e)
}
}
async function toggleStatus(row: any) {
try {
const newStatus = row.status === 'active' ? 'inactive' : 'active'
await put(`/operator/banners/${row.id}`, { ...row, status: newStatus })
// 只回传可编辑字段:id/createdBy/updatedAt 等由服务端维护,避免把服务端字段带回写坏
await put(`/operator/banners/${row.id}`, {
title: row.title,
subtitle: row.subtitle,
buttonText: row.buttonText,
targetType: row.targetType,
targetValue: row.targetValue,
audienceScope: row.audienceScope,
status: newStatus,
sortOrder: row.sortOrder,
startAt: row.startAt,
endAt: row.endAt,
})
await loadBanners()
} catch (e) { ElMessage.error('操作失败') }
} catch (e) {
console.error(e)
}
}
onMounted(loadBanners)
@@ -141,4 +301,22 @@ onMounted(loadBanners)
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
.preview-stage { padding: 20px; background: #f5f6f8; border-radius: 12px; }
.preview-card {
padding: 20px 18px;
border-radius: 16px;
background: linear-gradient(135deg, #ffe9df, #fff7f2);
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
}
.preview-tag {
display: inline-block; padding: 2px 10px; border-radius: 999px;
background: rgba(255, 255, 255, 0.85); color: #c8553d; font-size: 12px;
}
.preview-title { margin-top: 10px; font-size: 17px; font-weight: 700; color: #2c2c34; }
.preview-subtitle { margin-top: 6px; font-size: 12px; line-height: 1.6; color: #6b6b78; }
.preview-button {
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
background: linear-gradient(135deg, #ff7d6e, #ff5c82); color: #fff; font-size: 12px; font-weight: 600;
}
</style>