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
@@ -1 +1,64 @@
Page({data:{appointments:[{id:'1',title:'资料填写协助',date:'2026-09-12 14:30',station:'善缘丹阳公益服务站',status:'已确认'}]},cancel(){wx.showModal({title:'取消预约',content:'确定取消本次服务预约吗?',confirmText:'确认取消',confirmColor:'#D94A4A'})}})
/**
* U52 我的预约
*
* Phase 19:此前是硬编码假预约(取消只弹 Modal,不改数据)。
* 现接入 GET /service-appointments(服务端按登录主体过滤)+ POST /{id}/cancel。
*/
const { serviceAppointmentService } = require('../../services');
const STATUS_LABEL = {
submitted: '待确认',
pending_confirmation: '待确认',
confirmed: '已确认',
completed: '已完成',
cancelled: '已取消',
};
Page({
data: {
loading: true,
error: '',
appointments: [],
},
onShow() {
this.load();
},
async load() {
this.setData({ loading: true, error: '' });
try {
const list = await serviceAppointmentService.getMyAppointments();
const appointments = (list || []).map((item) => ({
id: item.id,
title: item.serviceLocationName || '善缘丹阳公益服务站',
status: STATUS_LABEL[item.status] || item.status || '',
date: [item.preferredDate, item.preferredTimeSlot].filter(Boolean).join(' ') || '时间待确认',
station: item.serviceLocationName || '',
cancellable: item.status !== 'completed' && item.status !== 'cancelled',
}));
this.setData({ appointments, loading: false });
} catch (error) {
this.setData({ loading: false, error: '加载失败,请稍后重试' });
}
},
cancel(event) {
const id = event.currentTarget.dataset.id;
wx.showModal({
title: '取消预约',
content: '确定取消本次服务预约吗?',
confirmText: '确认取消',
confirmColor: '#D94A4A',
success: async (res) => {
if (!res.confirm) {
return;
}
try {
await serviceAppointmentService.cancelAppointment(id, '用户取消');
wx.showToast({ title: '已取消', icon: 'success' });
this.load();
} catch (error) {
wx.showToast({ title: '取消失败,请稍后重试', icon: 'none' });
}
},
});
},
});
@@ -1 +1 @@
<view class="service-page"><view class="hero"><text class="eyebrow">我的公益服务</text><text class="hero-title">我的预约</text><text class="hero-desc">查看预约状态、到站时间与服务地点。</text></view><view class="content"><view wx:if="{{appointments.length}}" class="block"><view wx:for="{{appointments}}" wx:key="id" class="card"><view style="display:flex;justify-content:space-between"><text class="feature-title">{{item.title}}</text><text class="tag">{{item.status}}</text></view><text class="meta">{{item.date}}</text><text class="meta">{{item.station}}</text><view class="secondary-btn" style="margin-top:24rpx" bindtap="cancel">取消预约</view></view></view><view wx:else class="card empty-panel"><view class="empty-symbol">约</view><text class="empty-title">还没有服务预约</text><text class="meta">需要线下帮助时,可以预约善缘服务站。</text></view></view></view>
<view class="service-page"><view class="hero"><text class="eyebrow">我的公益服务</text><text class="hero-title">我的预约</text><text class="hero-desc">查看预约状态、到站时间与服务地点。</text></view><view class="content"><view wx:if="{{loading}}" class="card empty-panel"><view class="empty-symbol">…</view><text class="empty-title">正在加载</text></view><view wx:elif="{{error}}" class="card empty-panel"><view class="empty-symbol">!</view><text class="empty-title">{{error}}</text><text class="meta">请检查网络后重试。</text></view><view wx:elif="{{appointments.length}}" class="block"><view wx:for="{{appointments}}" wx:key="id" class="card"><view style="display:flex;justify-content:space-between"><text class="feature-title">{{item.title}}</text><text class="tag">{{item.status}}</text></view><text class="meta">{{item.date}}</text><text class="meta">{{item.station}}</text><view wx:if="{{item.cancellable}}" class="secondary-btn" style="margin-top:24rpx" data-id="{{item.id}}" bindtap="cancel">取消预约</view></view></view><view wx:else class="card empty-panel"><view class="empty-symbol">约</view><text class="empty-title">还没有服务预约</text><text class="meta">需要线下帮助时,可以预约善缘服务站。</text></view></view></view>