Files
danyang-match/ui-tests/miniapp-reference-contracts.mjs
T
WorkBuddy 72b4e9b846 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.
2026-09-17 22:21:31 +08:00

221 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')