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.
221 lines
11 KiB
JavaScript
221 lines
11 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import { existsSync, readFileSync } from 'node:fs'
|
||
import { resolve } from 'node:path'
|
||
|
||
const root = resolve(import.meta.dirname, '..')
|
||
const read = (path) => readFileSync(resolve(root, path), 'utf8')
|
||
/**
|
||
* 需要读取页面运行源时先断言存在。
|
||
* 直接 readFileSync 会抛裸 ENOENT 栈,看不出「契约引用了陈旧文件名」这一真实原因——
|
||
* 页面在 2026-09-14 全量 TS 化后,本契约曾因仍引用 .js 而崩溃(而非给出可读断言)。
|
||
*/
|
||
const readSource = (path) => {
|
||
assert.ok(existsSync(resolve(root, path)), `契约引用的运行源不存在: ${path}(页面已迁移?请更新契约路径)`)
|
||
return read(path)
|
||
}
|
||
/** 断言页面运行源为 TypeScript 且不存在同名 .js 双源 */
|
||
const readPageLogic = (page) => {
|
||
const ts = `miniapp/pages/${page}/${page}.ts`
|
||
assert.equal(
|
||
existsSync(resolve(root, `miniapp/pages/${page}/${page}.js`)),
|
||
false,
|
||
`${page} 存在 .js 与 .ts 双源,违反单一运行源约定`,
|
||
)
|
||
return readSource(ts)
|
||
}
|
||
const app = JSON.parse(read('miniapp/app.json'))
|
||
const corePages = [
|
||
'index',
|
||
'discover',
|
||
'messages',
|
||
'activity',
|
||
'mine',
|
||
'matchmaker',
|
||
'counseling-intro',
|
||
'premarital-intro',
|
||
'content',
|
||
'anti-fraud',
|
||
'service-station',
|
||
]
|
||
const quickEntryCodes = [
|
||
'find_object',
|
||
'matchmaker',
|
||
'activities',
|
||
'counseling',
|
||
'classroom',
|
||
'premarital',
|
||
'anti_fraud',
|
||
'service_station',
|
||
]
|
||
|
||
assert.deepEqual(
|
||
app.tabBar.list.map((item) => item.text),
|
||
['首页', '找对象', '消息', '活动', '我的'],
|
||
'五个 Tab 的顺序发生偏移',
|
||
)
|
||
|
||
for (const page of corePages) {
|
||
const base = `miniapp/pages/${page}/${page}`
|
||
for (const extension of ['.json', '.wxml', '.wxss']) {
|
||
assert.ok(existsSync(resolve(root, base + extension)), `${page}${extension} 不存在`)
|
||
}
|
||
const template = read(base + '.wxml')
|
||
assert.match(template, /reference-page/, `${page} 未接入参考图页面骨架`)
|
||
assert.doesNotMatch(template, /[\u{1F300}-\u{1FAFF}]/u, `${page} 含 Emoji`)
|
||
// 首批 11 页必须为 TypeScript 单一运行源(readPageLogic 会同时断言 .js 不存在)
|
||
readPageLogic(page)
|
||
}
|
||
|
||
const homeLogic = read('miniapp/pages/index/index.ts')
|
||
let previousIndex = -1
|
||
for (const code of quickEntryCodes) {
|
||
const index = homeLogic.indexOf(code)
|
||
assert.ok(index >= 0, `首页缺少入口 ${code}`)
|
||
assert.ok(index > previousIndex, `首页入口 ${code} 顺序错误`)
|
||
previousIndex = index
|
||
}
|
||
|
||
const discoverTemplate = read('miniapp/pages/discover/discover.wxml')
|
||
assert.match(discoverTemplate, /discover-filter-chips/, '找对象页缺少参考图胶囊筛选区')
|
||
assert.match(discoverTemplate, /candidate-list/, '找对象页缺少参考图候选人列表')
|
||
assert.match(discoverTemplate, /\/assets\/icons\/ui\/search\.png/, '找对象页必须使用独立搜索图标')
|
||
|
||
const messagesTemplate = read('miniapp/pages/messages/messages.wxml')
|
||
const messagesLogic = readPageLogic('messages')
|
||
assert.match(messagesTemplate, /message-shortcuts/, '消息页缺少四项快捷入口')
|
||
assert.match(messagesTemplate, /conversation-list/, '消息页缺少会话列表容器')
|
||
assert.match(messagesTemplate, /ui-conv__badge|unreadTotal/, '消息页缺少未读提示')
|
||
assert.match(messagesTemplate, /互相喜欢后/, '消息页缺少无 Mutual 状态说明')
|
||
assert.match(messagesLogic, /canSendMessage/, '消息页进入聊天前必须校验服务端会话能力')
|
||
|
||
const activityTemplate = read('miniapp/pages/activity/activity.wxml')
|
||
const activityCard = read('miniapp/components/activity-card/index.wxml')
|
||
const activityLogic = readPageLogic('activity')
|
||
assert.match(activityTemplate, /activity-categories/, '活动页缺少活动分类')
|
||
assert.match(activityTemplate, /activity-calendar/, '活动页缺少日历入口')
|
||
assert.match(activityCard, /ui-act__capacity/, '活动卡缺少人数和容量')
|
||
assert.match(activityCard, /ui-act__status-action/, '活动卡缺少状态按钮')
|
||
assert.doesNotMatch(activityLogic, /matchStore|to\.chat/, '活动列表不得直接创建 Match 或进入聊天')
|
||
|
||
const mineTemplate = read('miniapp/pages/mine/mine.wxml')
|
||
assert.match(mineTemplate, /profile-hero/, '我的页缺少个人头部')
|
||
assert.match(mineTemplate, /profile-stats/, '我的页缺少四项统计')
|
||
assert.match(mineTemplate, /我在善缘丹阳/, '我的页缺少公益身份卡')
|
||
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')
|
||
const premaritalTemplate = read('miniapp/pages/premarital-intro/premarital-intro.wxml')
|
||
for (const template of [matchmakerTemplate, counselingTemplate, premaritalTemplate]) {
|
||
assert.match(template, /ui-service-hero/, 'B 组服务页必须统一使用 service-hero')
|
||
}
|
||
for (const label of ['公益免费', '真实匹配', '隐私保护']) {
|
||
assert.ok(matchmakerTemplate.includes(label), `红娘页缺少服务承诺 ${label}`)
|
||
}
|
||
assert.match(matchmakerTemplate, /activeService|申请状态/, '红娘页缺少申请状态')
|
||
assert.doesNotMatch(matchmakerLogic, /matchStore\.(?:like|pass)\s*\(/, '红娘不得替用户 Like 或 Pass')
|
||
assert.match(counselingTemplate, /service-topics/, '咨询页缺少四项咨询主题')
|
||
assert.match(counselingTemplate, /预约咨询/, '咨询页缺少预约按钮')
|
||
assert.match(counselingTemplate, /服务状态/, '咨询页缺少服务状态')
|
||
assert.match(premaritalTemplate, /service-topics/, '婚前辅导页缺少四项辅导主题')
|
||
assert.match(premaritalTemplate, /预约辅导/, '婚前辅导页缺少预约按钮')
|
||
assert.match(premaritalTemplate, /服务状态/, '婚前辅导页缺少服务状态')
|
||
|
||
const contentTemplate = read('miniapp/pages/content/content.wxml')
|
||
const contentLogic = readPageLogic('content')
|
||
assert.match(contentTemplate, /content-categories/, '婚恋课堂缺少分类导航')
|
||
assert.match(contentTemplate, /content-list/, '婚恋课堂缺少内容列表')
|
||
assert.match(contentTemplate, /viewCount/, '婚恋课堂缺少真实浏览量')
|
||
assert.match(contentTemplate, /publishAtText/, '婚恋课堂缺少发布日期')
|
||
assert.match(contentLogic, /\/content\/categories/, '婚恋课堂分类必须来自正式接口')
|
||
assert.match(contentLogic, /\/content\/articles/, '婚恋课堂文章必须来自正式接口')
|
||
assert.doesNotMatch(contentLogic, /id:\s*['"]1['"]/, '婚恋课堂不得保留演示文章')
|
||
|
||
const antiFraudTemplate = read('miniapp/pages/anti-fraud/anti-fraud.wxml')
|
||
assert.match(antiFraudTemplate, /ui-service-hero/, '婚恋反诈必须使用独立主视觉')
|
||
assert.match(antiFraudTemplate, /risk-grid/, '婚恋反诈缺少四类风险')
|
||
assert.match(antiFraudTemplate, /举报可疑行为/, '婚恋反诈缺少举报入口')
|
||
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, /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} 必须通过微信运行版本识别开发环境`)
|
||
assert.match(envSource, /envVersion\s*===\s*['"]develop['"]/, `${envFile} 必须将 develop 环境指向本地 API`)
|
||
}
|
||
|
||
const theme = read('miniapp/app.wxss')
|
||
for (const token of [
|
||
'--mini-night-start',
|
||
'--mini-night-end',
|
||
'--mini-accent-pink',
|
||
'--mini-accent-coral',
|
||
'--mini-card-bg',
|
||
'--mini-card-radius',
|
||
'--mini-shadow',
|
||
]) {
|
||
assert.ok(theme.includes(token), `缺少全局 Token ${token}`)
|
||
}
|
||
|
||
console.log('Miniapp reference contracts passed')
|