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,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,
|
||||
};
|
||||
Reference in New Issue
Block a user