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
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
/**
* Phase 19(统一运营后台)门控:后台角色导航契约。
*
* 背景:本轮复验发现 MainLayout 的 navigation 数组**缺少多个业务入口**——
* `/service-appointment`(服务站运营人员的本职工作)、`/banner`、`/service-program`、
* `/appeals`、`/audit`、`/matchmaker` 都只有路由没有菜单项。结果是这些角色登录后
* **看不到自己的本职入口**(Gate「七类后台角色只看到自己的工作」被击穿为"连自己的工作也看不到")。
*
* 本门控把两件事变成可自动验证的不变量:
* 1. 关键业务域必须在菜单中有入口(删菜单项立即变红);
* 2. 菜单不得指向不存在的路由(避免点进去 404 / 白屏)。
*/
const root = resolve(import.meta.dirname, '..')
const read = (p) => readFileSync(resolve(root, p), 'utf8')
const layout = read('admin-web/src/layouts/MainLayout.vue')
const router = read('admin-web/src/router/index.ts')
// —— 解析 navigation 数组 ——
const NAV_ENTRY =
/\{\s*label:\s*'([^']+)',\s*path:\s*'([^']+)',\s*permissions:\s*\[([^\]]*)\][^}]*\}/g
const navigation = []
for (const m of layout.matchAll(NAV_ENTRY)) {
const perms = [...m[3].matchAll(/'([^']+)'/g)].map((x) => x[1])
navigation.push({ label: m[1], path: m[2], permissions: perms })
}
assert.ok(navigation.length >= 18, `导航项数量异常偏少(${navigation.length}),解析可能失效`)
// —— 1. 关键业务域必须有菜单入口(path, 该入口所需的其中之一权限)——
const requiredMenuEntries = [
['/review', 'review.view'],
['/users', 'user.view'],
['/activities', 'activity.view'],
['/activities/checkin', 'activity.checkin'],
['/activities/interests', 'activity.review_registration'],
['/referral', 'referral.manage'],
['/matchmaker', 'matchmaker.service.view'],
['/counseling', 'counseling.service.view'],
['/banner', 'banner.view'],
['/content', 'content.view'],
['/service-station', 'service_location.view'],
['/service-appointment', 'service_appointment.view'],
['/service-program', 'service_program.manage'],
['/risk/reports', 'risk.report.view'],
['/appeals', 'risk.report.process'],
['/analytics', 'analytics.view'],
['/organizations', 'organization.manage'],
['/audit', 'user.view'],
['/operators', 'permission.manage'],
]
const byPath = new Map(navigation.map((n) => [n.path, n]))
for (const [path, perm] of requiredMenuEntries) {
const entry = byPath.get(path)
assert.ok(entry, `后台菜单缺少业务入口 ${path}(角色将看不到自己的本职工作)`)
assert.ok(
entry.permissions.includes(perm),
`菜单 ${path} 的权限应为 ${perm},实际 ${JSON.stringify(entry.permissions)}`,
)
}
// —— 2. 菜单不得指向不存在的路由 ——
const routerPaths = new Set(
[...router.matchAll(/path:\s*'([^']+)'/g)].map((m) => m[1]),
)
for (const entry of navigation) {
if (entry.path === '/dashboard') continue
assert.ok(
routerPaths.has(entry.path) || routerPaths.has(entry.path.replace(/^\//, '')),
`菜单 ${entry.label} 指向不存在的路由 ${entry.path}`,
)
}
// —— 3. 菜单项路径不得重复(防止同一入口被登记两次导致角色看到重复项)——
const seen = new Set()
for (const entry of navigation) {
assert.ok(!seen.has(entry.path), `菜单路径重复:${entry.path}`)
seen.add(entry.path)
}
// —— 4. 角色名判定必须覆盖七类后台角色(缺分支会让角色被降级显示为"运营人员")——
for (const marker of [
'平台管理员',
'咨询管理员',
'内容编辑员',
'风控运营人员',
'公益咨询人员',
'服务站运营人员',
'资料审核人员',
]) {
assert.ok(layout.includes(marker), `角色名判定缺少「${marker}」分支`)
}
console.log('Admin role navigation contracts passed')