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:
@@ -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')
|
||||
@@ -105,6 +105,28 @@ for (const label of ['我的红娘', '我的咨询', '我的活动', '我的预
|
||||
assert.ok(mineTemplate.includes(label), `我的页缺少入口 ${label}`)
|
||||
}
|
||||
|
||||
// U37「我的」四组结构(《页面级功能清单与交互流程修订版》第三十一章信息架构)
|
||||
// 缺组会让入口找不到归属,缺入口会让已实现的页面变成不可达死页(Phase 19 的 U49 就是如此)。
|
||||
const mineGroups = mineTemplate.match(/ui-section-header\s+title="[^"]*"/g) || []
|
||||
assert.strictEqual(mineGroups.length, 4, `我的页必须是四组结构,实际 ${mineGroups.length} 组`)
|
||||
for (const group of ['我的资料与缘分', '我的公益服务', '安全与隐私', '公益信息']) {
|
||||
assert.ok(mineTemplate.includes(`title="${group}"`), `我的页缺少分组「${group}」`)
|
||||
}
|
||||
for (const label of ['公益服务中心', '公益服务记录', '黑名单', '婚恋课堂', '婚恋反诈', '善缘服务站', '关于善缘丹阳']) {
|
||||
assert.ok(mineTemplate.includes(label), `我的页缺少入口 ${label}`)
|
||||
}
|
||||
// 每个 bindtap 必须在同名 .ts 里真的实现了,否则是"点不动的入口"
|
||||
const mineLogic = readPageLogic('mine')
|
||||
const mineHandlers = new Set(
|
||||
[...mineTemplate.matchAll(/bind(?:tap|action|confirm|cancel)="([A-Za-z0-9_]+)"/g)].map((m) => m[1]),
|
||||
)
|
||||
for (const handler of mineHandlers) {
|
||||
assert.ok(
|
||||
new RegExp(`\\b${handler}\\s*\\(`).test(mineLogic),
|
||||
`我的页入口绑定了未实现的处理器 ${handler}(死链)`,
|
||||
)
|
||||
}
|
||||
|
||||
const matchmakerTemplate = read('miniapp/pages/matchmaker/matchmaker.wxml')
|
||||
const matchmakerLogic = readPageLogic('matchmaker')
|
||||
const counselingTemplate = read('miniapp/pages/counseling-intro/counseling-intro.wxml')
|
||||
@@ -142,14 +164,40 @@ assert.match(antiFraudTemplate, /安全求助/, '婚恋反诈缺少求助入口'
|
||||
|
||||
const stationTemplate = read('miniapp/pages/service-station/service-station.wxml')
|
||||
const stationLogic = readPageLogic('service-station')
|
||||
const stationService = readSource('miniapp/services/serviceStationService.js')
|
||||
for (const label of ['地址', '联系电话', '开放时间', '服务项目', '地图定位', '预约到站服务']) {
|
||||
assert.ok(stationTemplate.includes(label), `服务站缺少 ${label}`)
|
||||
}
|
||||
assert.match(stationLogic, /wx\.makePhoneCall/, '服务站电话必须使用微信原生拨号')
|
||||
assert.match(stationLogic, /wx\.openLocation/, '服务站定位必须使用微信原生地图')
|
||||
assert.match(stationLogic, /api\.get.*['"]\/home['"]/, '服务站必须读取真实首页服务站数据')
|
||||
assert.match(stationLogic, /serviceStationService/, '服务站页面必须通过 services 层取数(不得直连 utils/api)')
|
||||
assert.doesNotMatch(stationLogic, /utils\/api/, '服务站页面不得直接依赖 utils/api(分层约定见 services/index.js)')
|
||||
// Phase 19:站点主体信息来自首页聚合。断言落在**服务层**——页面已按分层约定
|
||||
// 收敛到 serviceStationService,若仍断言页面源码会将「分层重构」误判为「不再读真实数据」。
|
||||
assert.match(stationService, /api\.get\(\s*['"]\/home['"]\s*\)/, '服务站必须读取真实首页服务站数据')
|
||||
assert.doesNotMatch(stationService, /00000000/, '服务站不得保留演示电话')
|
||||
assert.doesNotMatch(stationLogic, /00000000/, '服务站不得保留演示电话')
|
||||
|
||||
// Phase 19:U50「临时公告」。此前全仓无公告存储与接口,本轮补齐后端 + 用户端只读端点。
|
||||
assert.ok(stationTemplate.includes('临时公告'), '服务站缺少临时公告区块(U50 内容清单明确要求)')
|
||||
assert.match(stationService, /\/service-locations\/\$\{[^}]*\}\/announcements/,
|
||||
'公告必须来自正式接口 /service-locations/{id}/announcements')
|
||||
assert.match(stationLogic, /getVisibleAnnouncements/, 'U50 必须接入可见公告服务方法')
|
||||
// 公告为增强信息:失败必须降级为空数组,不得把整页打成错误页
|
||||
assert.match(stationService, /catch[\s\S]{0,200}return \[\]/,
|
||||
'公告加载失败必须降级为空数组(公告挂了不该让服务站详情页整体报错)')
|
||||
{
|
||||
const stationHandlers = new Set(
|
||||
[...stationTemplate.matchAll(/bind(?:tap|action|confirm|cancel)="([A-Za-z0-9_]+)"/g)].map((m) => m[1]),
|
||||
)
|
||||
for (const handler of stationHandlers) {
|
||||
assert.ok(
|
||||
new RegExp(`\\b${handler}\\s*\\(`).test(stationLogic),
|
||||
`服务站页 bindtap="${handler}" 在 .ts 中没有对应实现(点了没反应)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const envFile of ['miniapp/env.js', 'miniapp/env.ts']) {
|
||||
const envSource = read(envFile)
|
||||
assert.match(envSource, /getAccountInfoSync/, `${envFile} 必须通过微信运行版本识别开发环境`)
|
||||
|
||||
Reference in New Issue
Block a user