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:
@@ -1 +1,92 @@
|
||||
Page({data:{date:'',service:'请选择服务项目',services:['资料填写协助','公益红娘咨询','活动报名指导','婚恋咨询预约'],note:''},chooseService(e){this.setData({service:this.data.services[Number(e.detail.value)]})},chooseDate(e){this.setData({date:e.detail.value})},submit(){if(!this.data.date)return wx.showToast({title:'请选择预约日期',icon:'none'});wx.showModal({title:'预约已提交',content:'工作人员确认后会通过平台通知你。',showCancel:false,success(){wx.navigateTo({url:'/pages/my-appointments/my-appointments'})}})}})
|
||||
/**
|
||||
* U51 服务预约
|
||||
*
|
||||
* Phase 19:此前提交只弹本地 Modal(后端收不到任何记录),属"假成功"。
|
||||
* 现接入 POST /service-appointments:来源由服务端强制为 user_self,
|
||||
* 前端**不传** requestSource / createdByOperatorId / userId(防伪造运营代登记身份)。
|
||||
*
|
||||
* 站点上下文:从 U50 服务站页跳转时携带 `?serviceLocationId=`,本页解析后
|
||||
* 原样回传,保证预约真正落在该站点;站点名仅作展示,解析失败不阻断提交。
|
||||
*/
|
||||
const { serviceAppointmentService } = require('../../services');
|
||||
|
||||
const SERVICE_OPTIONS = [
|
||||
{ label: '到站资料协助', type: 'station_visit' },
|
||||
{ label: '公益红娘咨询', type: 'matchmaker' },
|
||||
{ label: '活动报名指导', type: 'station_visit' },
|
||||
{ label: '婚恋咨询预约', type: 'counselor_consultation' },
|
||||
];
|
||||
|
||||
Page({
|
||||
data: {
|
||||
services: SERVICE_OPTIONS.map((option) => option.label),
|
||||
service: '请选择服务项目',
|
||||
date: '',
|
||||
note: '',
|
||||
serviceLocationId: '',
|
||||
serviceLocationName: '',
|
||||
submitting: false,
|
||||
},
|
||||
selectedIndex: -1,
|
||||
onLoad(options) {
|
||||
const raw = options && options.serviceLocationId;
|
||||
const serviceLocationId = raw ? decodeURIComponent(raw).trim() : '';
|
||||
if (!serviceLocationId) {
|
||||
return;
|
||||
}
|
||||
this.setData({ serviceLocationId });
|
||||
this.resolveStationName(serviceLocationId);
|
||||
},
|
||||
async resolveStationName(serviceLocationId) {
|
||||
const location = await serviceAppointmentService.getCurrentServiceLocation();
|
||||
if (location && location.id === serviceLocationId) {
|
||||
this.setData({ serviceLocationName: location.name });
|
||||
} else {
|
||||
// 站点信息不可得时仍保留归属 ID,仅以占位文案展示,避免误导为空
|
||||
this.setData({ serviceLocationName: '已选服务站点' });
|
||||
}
|
||||
},
|
||||
chooseService(event) {
|
||||
const index = Number(event.detail.value);
|
||||
this.selectedIndex = index;
|
||||
this.setData({ service: SERVICE_OPTIONS[index].label });
|
||||
},
|
||||
chooseDate(event) {
|
||||
this.setData({ date: event.detail.value });
|
||||
},
|
||||
onNoteInput(event) {
|
||||
this.setData({ note: event.detail.value });
|
||||
},
|
||||
async submit() {
|
||||
if (this.selectedIndex < 0) {
|
||||
wx.showToast({ title: '请选择服务项目', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!this.data.date) {
|
||||
wx.showToast({ title: '请选择预约日期', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (this.data.submitting) {
|
||||
return;
|
||||
}
|
||||
this.setData({ submitting: true });
|
||||
try {
|
||||
await serviceAppointmentService.createAppointment({
|
||||
serviceLocationId: this.data.serviceLocationId || undefined,
|
||||
serviceType: SERVICE_OPTIONS[this.selectedIndex].type,
|
||||
preferredDate: this.data.date,
|
||||
userNote: this.data.note,
|
||||
});
|
||||
wx.showModal({
|
||||
title: '预约已提交',
|
||||
content: '工作人员确认后会通过平台通知你。',
|
||||
showCancel: false,
|
||||
success: () => wx.navigateTo({ url: '/pages/my-appointments/my-appointments' }),
|
||||
});
|
||||
} catch (error) {
|
||||
wx.showToast({ title: '提交失败,请稍后重试', icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ submitting: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
<view class="service-page"><view class="hero"><text class="eyebrow">U51 服务预约</text><text class="hero-title">预约到站服务</text><text class="hero-desc">选择合适的时间和服务项目,提交后等待工作人员确认。</text></view><view class="content"><view class="card"><view class="field"><text class="label">服务项目</text><picker range="{{services}}" bindchange="chooseService"><view class="select-box">{{service}}<text class="arrow" style="margin-left:auto">›</text></view></picker></view><view class="field"><text class="label">预约日期</text><picker mode="date" bindchange="chooseDate"><view class="select-box">{{date || '请选择日期'}}</view></picker></view><view class="field"><text class="label">希望沟通的事项(选填)</text><textarea class="textarea" placeholder="请简要说明,避免填写身份证号等敏感信息" maxlength="200"/></view><view class="notice">提交预约不代表已确认。工作人员确认后,预约状态会更新为“已确认”。</view></view></view><view class="fixed-action"><view class="primary-btn" bindtap="submit">提交预约</view></view></view>
|
||||
<view class="service-page"><view class="hero"><text class="eyebrow">U51 服务预约</text><text class="hero-title">预约到站服务</text><text class="hero-desc">选择合适的时间和服务项目,提交后等待工作人员确认。</text></view><view class="content"><view class="card"><view class="field" wx:if="{{serviceLocationId}}"><text class="label">服务站点</text><view class="select-box is-readonly">{{serviceLocationName || '已选服务站点'}}</view></view><view class="field"><text class="label">服务项目</text><picker range="{{services}}" bindchange="chooseService"><view class="select-box">{{service}}<text class="arrow" style="margin-left:auto">›</text></view></picker></view><view class="field"><text class="label">预约日期</text><picker mode="date" bindchange="chooseDate"><view class="select-box">{{date || '请选择日期'}}</view></picker></view><view class="field"><text class="label">希望沟通的事项(选填)</text><textarea class="textarea" value="{{note}}" bindinput="onNoteInput" placeholder="请简要说明,避免填写身份证号等敏感信息" maxlength="200"/></view><view class="notice">提交预约不代表已确认。工作人员确认后,预约状态会更新为“已确认”。</view></view></view><view class="fixed-action"><view class="primary-btn {{submitting ? 'is-disabled' : ''}}" bindtap="submit">{{submitting ? '提交中…' : '提交预约'}}</view></view></view>
|
||||
|
||||
Reference in New Issue
Block a user