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
@@ -3,18 +3,38 @@
<el-tabs v-model="activeTab">
<el-tab-pane label="内容分类" name="categories">
<el-card>
<template #header><div class="card-header"><span>内容分类</span><el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button></div></template>
<el-table :data="categories" stripe>
<template #header>
<div class="card-header">
<span>内容分类</span>
<el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
title="分类不做删除:文章通过分类ID引用它,删除会让历史文章分类悬空。不再使用的分类请「停用」,停用后小程序端不再下发,但后台仍可见并可重新启用。"
style="margin-bottom: 12px"
/>
<el-table :data="categories" stripe v-loading="categoryLoading">
<el-table-column prop="name" label="分类名称" />
<el-table-column prop="code" label="编码" width="150" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':''">{{ row.status }}</el-tag></template>
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ categoryStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button size="small" @click="editCategory(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(row)">删除</el-button>
<el-button
size="small"
:type="row.status === 'active' ? 'warning' : 'success'"
@click="toggleCategoryStatus(row)"
>
{{ row.status === 'active' ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
@@ -22,21 +42,26 @@
</el-tab-pane>
<el-tab-pane label="文章内容" name="articles">
<el-card>
<template #header><div class="card-header"><span>文章内容</span><el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button></div></template>
<template #header>
<div class="card-header">
<span>文章内容</span>
<el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button>
</div>
</template>
<el-table :data="articles" stripe v-loading="loading">
<el-table-column prop="title" label="标题" />
<el-table-column label="分类" width="120">
<template #default="{ row }">{{ categoryLabel(row.category_id) }}</template>
<template #default="{ row }">{{ categoryLabel(row.categoryId) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="view_count" label="阅读量" width="80" />
<el-table-column prop="viewCount" label="阅读量" width="80" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="editArticle(row)">编辑</el-button>
<el-button v-if="row.status==='draft'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
<el-button v-if="row.status==='published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
<el-button v-if="row.status !== 'published'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
<el-button v-if="row.status === 'published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
</template>
</el-table-column>
</el-table>
@@ -47,30 +72,35 @@
<!-- 分类Dialog -->
<el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新建分类'" width="500px">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="categoryForm.name" /></el-form-item>
<el-form-item label="编码"><el-input v-model="categoryForm.code" /></el-form-item>
<el-form-item label="名称" required><el-input v-model="categoryForm.name" maxlength="128" show-word-limit /></el-form-item>
<el-form-item label="编码" required>
<el-input v-model="categoryForm.code" maxlength="64" show-word-limit placeholder="唯一标识,如 classroom" />
<div class="field-hint">编码全局唯一,重复会被拒绝</div>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="categoryForm.sortOrder" :min="0" /></el-form-item>
<el-form-item label="说明"><el-input v-model="categoryForm.description" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible=false">取消</el-button>
<el-button @click="categoryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveCategory">保存</el-button>
</template>
</el-dialog>
<!-- 文章Dialog -->
<el-dialog v-model="articleDialogVisible" :title="editingArticleId ? '编辑文章' : '新建文章'" width="700px">
<el-form :model="articleForm" label-width="80px">
<el-form-item label="标题"><el-input v-model="articleForm.title" /></el-form-item>
<el-form :model="articleForm" label-width="80px" v-loading="articleDetailLoading">
<el-form-item label="标题" required><el-input v-model="articleForm.title" maxlength="200" show-word-limit /></el-form-item>
<el-form-item label="分类">
<el-select v-model="articleForm.categoryId">
<el-select v-model="articleForm.categoryId" clearable placeholder="请选择分类">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="内容"><el-input v-model="articleForm.content" type="textarea" :rows="8" /></el-form-item>
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" maxlength="500" show-word-limit /></el-form-item>
<el-form-item label="正文"><el-input v-model="articleForm.content" type="textarea" :rows="10" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="articleForm.sortOrder" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="articleDialogVisible=false">取消</el-button>
<el-button @click="articleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveArticle">保存</el-button>
</template>
</el-dialog>
@@ -79,76 +109,149 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put, del } from '@/api/client'
import { get, post, put } from '@/api/client'
import { ElMessage } from 'element-plus'
const activeTab = ref('categories')
const loading = ref(false)
const categoryLoading = ref(false)
const articleDetailLoading = ref(false)
const categories = ref<any[]>([])
const articles = ref<any[]>([])
const categoryDialogVisible = ref(false)
const editingCategoryId = ref<string | null>(null)
const categoryForm = ref({ name: '', code: '', sortOrder: 0 })
const categoryForm = ref({ name: '', code: '', description: '', sortOrder: 0 })
const articleDialogVisible = ref(false)
const editingArticleId = ref<string | null>(null)
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '' })
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '', sortOrder: 0 })
const CATEGORY_STATUS_LABEL: Record<string, string> = { active: '启用中', inactive: '已停用' }
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', published: '已发布', offline: '已下架' }
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', published: 'success', offline: 'danger' }
const STATUS_LABEL: Record<string, string> = { draft: '草稿', published: '已发布', offline: '已下架' }
const STATUS_TAG: Record<string, string> = { draft: 'info', published: 'success', offline: 'danger' }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function categoryStatusLabel(s: string) { return CATEGORY_STATUS_LABEL[s] || s }
function categoryLabel(id: string) { const c = categories.value.find(x => x.id === id); return c ? c.name : '-' }
// 错误提示统一由 api/client 的响应拦截器弹出,catch 里不再重复 toast
async function loadCategories() {
const res: any = await get('/content/categories')
categories.value = res.data || []
categoryLoading.value = true
try {
// operator 接口:含已停用分类,否则停用后无法在后台重新启用
const res: any = await get('/content/operator/categories')
categories.value = res.data || []
} catch (e) { console.error(e) } finally { categoryLoading.value = false }
}
async function loadArticles() {
loading.value = true
try {
const res: any = await get('/content/articles')
// operator 接口:含全部状态。公开接口只给 published,草稿不出现在列表里 → 无法点「发布」
const res: any = await get('/content/operator/articles')
articles.value = res.data || []
} finally { loading.value = false }
} catch (e) { console.error(e) } finally { loading.value = false }
}
function validateCategory(): boolean {
if (!categoryForm.value.name.trim()) { ElMessage.error('请填写分类名称'); return false }
if (!categoryForm.value.code.trim()) { ElMessage.error('请填写分类编码'); return false }
return true
}
async function saveCategory() {
if (!validateCategory()) return
try {
if (editingCategoryId.value) await put(`/content/operator/categories/${editingCategoryId.value}`, categoryForm.value)
else await post('/content/operator/categories', categoryForm.value)
ElMessage.success('保存成功')
categoryDialogVisible.value = false
loadCategories()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
} catch (e) { console.error(e) }
}
async function editCategory(row: any) { editingCategoryId.value = row.id; Object.assign(categoryForm.value, row); categoryDialogVisible.value = true }
async function deleteCategory(row: any) {
try { await del(`/content/operator/categories/${row.id}`); ElMessage.success('删除成功'); loadCategories() }
catch (e) { ElMessage.error('删除失败') }
function editCategory(row: any) {
editingCategoryId.value = row.id
categoryForm.value = {
name: row.name || '', code: row.code || '',
description: row.description || '', sortOrder: row.sortOrder ?? 0,
}
categoryDialogVisible.value = true
}
async function toggleCategoryStatus(row: any) {
const next = row.status === 'active' ? 'inactive' : 'active'
try {
await put(`/content/operator/categories/${row.id}/status`, { status: next })
ElMessage.success(next === 'active' ? '已启用' : '已停用')
loadCategories()
} catch (e) { console.error(e) }
}
function validateArticle(): boolean {
if (!articleForm.value.title.trim()) { ElMessage.error('请填写文章标题'); return false }
return true
}
async function saveArticle() {
if (!validateArticle()) return
try {
if (editingArticleId.value) await put(`/content/operator/articles/${editingArticleId.value}`, articleForm.value)
else await post('/content/operator/articles', articleForm.value)
ElMessage.success('保存成功')
articleDialogVisible.value = false
loadArticles()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
}
async function editArticle(row: any) { editingArticleId.value = row.id; Object.assign(articleForm.value, { title: row.title, categoryId: row.category_id, summary: row.summary, content: row.content }); articleDialogVisible.value = true }
async function publishArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
catch (e) { ElMessage.error('发布失败') }
}
async function offlineArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
catch (e) { ElMessage.error('下架失败') }
} catch (e) { console.error(e) }
}
function openCategoryDialog() { editingCategoryId.value = null; Object.assign(categoryForm.value, { name: '', code: '', sortOrder: 0 }); categoryDialogVisible.value = true }
function openArticleDialog() { editingArticleId.value = null; Object.assign(articleForm.value, { title: '', categoryId: '', summary: '', content: '' }); articleDialogVisible.value = true }
async function editArticle(row: any) {
editingArticleId.value = row.id
articleForm.value = {
title: row.title || '', categoryId: row.categoryId || '',
summary: row.summary || '', content: '', sortOrder: row.sortOrder ?? 0,
}
articleDialogVisible.value = true
// 列表 DTO 不含正文(有意保持轻量),必须拉详情才能回填正文
articleDetailLoading.value = true
try {
const res: any = await get(`/content/operator/articles/${row.id}`)
const detail = res.data || {}
articleForm.value = {
title: detail.title || '', categoryId: detail.categoryId || '',
summary: detail.summary || '', content: detail.content || '',
sortOrder: detail.sortOrder ?? 0,
}
} catch (e) { console.error(e) } finally { articleDetailLoading.value = false }
}
async function publishArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
catch (e) { console.error(e) }
}
async function offlineArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
catch (e) { console.error(e) }
}
function openCategoryDialog() {
editingCategoryId.value = null
categoryForm.value = { name: '', code: '', description: '', sortOrder: 0 }
categoryDialogVisible.value = true
}
function openArticleDialog() {
editingArticleId.value = null
articleForm.value = { title: '', categoryId: '', summary: '', content: '', sortOrder: 0 }
articleDialogVisible.value = true
}
onMounted(() => { loadCategories(); loadArticles() })
</script>
<style
scoped></style>
<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; }
</style>