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
+28
View File
@@ -0,0 +1,28 @@
/**
* 公益内容(U53 婚恋课堂 / U54 内容详情)
*
* 边界(Phase 19):用户端**只做文章列表与详情**——不做 Feed、不做评论、不做点赞。
* 因此本层只提供分类 / 列表 / 详情三个只读方法。
*/
const api = require('../utils/api');
/** 内容分类 */
function getCategories() {
return api.get('/content/categories');
}
/** 文章列表(可按分类过滤) */
function getArticles(params = {}) {
return api.get('/content/articles', params);
}
/** 文章详情 */
function getArticleDetail(id) {
return api.get(`/content/articles/${id}`);
}
module.exports = {
getCategories,
getArticles,
getArticleDetail,
};
+12
View File
@@ -20,6 +20,13 @@ const matchmakerService = require('./matchmakerService');
const safetyService = require('./safetyService');
const appealService = require('./appealService');
const appConfigService = require('./appConfigService');
// Phase 19:公益服务链路(服务预约 / 专项服务 / 内容 / 服务记录)
const serviceAppointmentService = require('./serviceAppointmentService');
const serviceProgramService = require('./serviceProgramService');
const contentService = require('./contentService');
const serviceHistoryService = require('./serviceHistoryService');
// Phase 19:服务站(U50)—— 临时公告只读接入
const serviceStationService = require('./serviceStationService');
module.exports = {
authService,
@@ -34,4 +41,9 @@ module.exports = {
safetyService,
appealService,
appConfigService,
serviceAppointmentService,
serviceProgramService,
contentService,
serviceHistoryService,
serviceStationService,
};
@@ -0,0 +1,80 @@
/**
* 服务预约(U51 服务预约 / U52 我的预约)
*
* 归属与契约边界(Phase 19):
* - 用户端只能读写本人预约:详情/列表/取消全部由服务端按登录主体过滤,
* 越权访问一律返回空或 404,前端不得以任何参数指定他人 userId。
* - 提交预约**只传服务内容**。来源(source)由服务端强制为 user_self,
* 前端禁止传 requestSource / createdByOperatorId / userId(伪造运营代登记身份)。
* - 运营代登记走独立端点(后端 /service-appointments/operator/register),前端不在本层暴露。
*/
const api = require('../utils/api');
/**
* 读取当前服务站点(U51 预约上下文)
*
* 服务站页(U50)跳转时会带 `?serviceLocationId=xxx`,本方法用于把该 ID 还原成
* 可展示的站点名。仅**展示用途**:真正的归属由服务端按登录主体校验,
* 展示失败(无站点 / 接口异常)不影响提交,返回 null。
* @returns {Promise<{id: string, name: string} | null>}
*/
async function getCurrentServiceLocation() {
try {
const home = await api.get('/home');
const location = home && home.serviceLocation;
if (!location || !location.id) {
return null;
}
return { id: location.id, name: location.name || '已选服务站点' };
} catch (err) {
return null;
}
}
/**
* 我的预约列表
* @returns {Promise<Array<object>>}
*/
function getMyAppointments() {
return api.get('/service-appointments');
}
/**
* 预约详情(越权由服务端返回 404)
* @param {string} id
*/
function getAppointmentDetail(id) {
return api.get(`/service-appointments/${id}`);
}
/**
* 提交预约
* @param {{ serviceLocationId?: string, serviceType: string, preferredDate: string,
* preferredTimeSlot?: string, userNote?: string }} payload
*/
function createAppointment(payload) {
return api.post('/service-appointments', {
serviceLocationId: payload.serviceLocationId,
serviceType: payload.serviceType,
preferredDate: payload.preferredDate,
preferredTimeSlot: payload.preferredTimeSlot,
userNote: payload.userNote,
});
}
/**
* 取消我的预约
* @param {string} id
* @param {string} [reason]
*/
function cancelAppointment(id, reason) {
return api.post(`/service-appointments/${id}/cancel`, { reason: reason || '' });
}
module.exports = {
getCurrentServiceLocation,
getMyAppointments,
getAppointmentDetail,
createAppointment,
cancelAppointment,
};
+15
View File
@@ -0,0 +1,15 @@
/**
* 公益服务记录(U64)
*
* 只返回当前用户本人的服务时间线摘要(类别/标题/状态/时间)。
* 服务端已排除咨询正文与内部备注(INV-013 最小可见),前端不得展示任何内部字段。
*/
const api = require('../utils/api');
function getMyServiceHistory() {
return api.get('/me/service-history');
}
module.exports = {
getMyServiceHistory,
};
+34
View File
@@ -0,0 +1,34 @@
/**
* 专项公益服务项目(U61 专项公益服务介绍)
*
* 边界:项目成员身份只用于服务办理,**不得**成为用户价值标签(Phase 19 / INV 语义)。
* 因此本层只暴露项目信息与本人申请状态,不提供"按项目成员筛选用户"之类能力。
*/
const api = require('../utils/api');
/** 可申请/进行中的专项服务项目列表 */
function getPrograms(params = {}) {
return api.get('/service-programs', params);
}
/** 项目详情 */
function getProgramDetail(id) {
return api.get(`/service-programs/${id}`);
}
/** 申请加入项目 */
function applyToProgram(id) {
return api.post(`/service-programs/${id}/apply`, {});
}
/** 我的项目申请 */
function getMyApplications() {
return api.get('/service-programs/my');
}
module.exports = {
getPrograms,
getProgramDetail,
applyToProgram,
getMyApplications,
};
+52
View File
@@ -0,0 +1,52 @@
/**
* 善缘服务站(U50)服务层。
*
* Phase 19:U50 的内容清单包含「临时公告 / 开放时间 / 服务项目 / 预约到站」,
* 但公告此前**全仓没有任何存储与接口**,页面也无落点。本轮补齐后端
* (V042 + 运营端五个端点 + 用户端只读端点)后由此接入。
*
* 分层:按 services/index.js 的约定,页面不直接 require utils/api,
* 统一从本模块取数据(本站点主体信息来自首页聚合,公告来自服务站公告端点)。
*
* 契约:
* GET /home → 当前服务站主体信息
* GET /service-locations/{id}/announcements → 仅 status=published 且未过期
* 草稿/归档/过期由服务端过滤,前端不得再做"猜测式"过滤,
* 否则等于把未定稿内容拿到客户端。
*/
const api = require('../utils/api');
/**
* 当前展示的服务站(首页聚合给出的唯一站点)。
* @returns {Promise<object|null>}
*/
async function getStation() {
const home = await api.get('/home');
return (home && home.serviceLocation) || null;
}
/**
* 某服务站的可见临时公告。
*
* 公告属**增强信息**:请求失败时返回空数组而不抛出,
* 避免"公告接口挂了导致整个服务站详情页变成错误页"。
* @param {string} locationId
* @returns {Promise<Array<{id: string, title: string, content?: string}>>}
*/
async function getVisibleAnnouncements(locationId) {
if (!locationId) {
return [];
}
try {
const list = await api.get(`/service-locations/${encodeURIComponent(locationId)}/announcements`);
return Array.isArray(list) ? list : [];
} catch (err) {
console.warn('加载服务站公告失败', err);
return [];
}
}
module.exports = {
getStation,
getVisibleAnnouncements,
};