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,4 +1,4 @@
|
||||
import api from '../../utils/api'
|
||||
import { serviceStationService } from '../../services'
|
||||
|
||||
interface ServiceLocation {
|
||||
id: string
|
||||
@@ -13,15 +13,24 @@ interface ServiceLocation {
|
||||
longitude?: number | string
|
||||
}
|
||||
|
||||
interface ServiceAnnouncement {
|
||||
id: string
|
||||
title: string
|
||||
content?: string
|
||||
expiresAt?: string | null
|
||||
}
|
||||
|
||||
interface ServiceStationPageData {
|
||||
serviceLocation: ServiceLocation | null
|
||||
announcements: ServiceAnnouncement[]
|
||||
loading: boolean
|
||||
loadError: boolean
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
serviceLocation: null,
|
||||
serviceLocation: null as ServiceLocation | null,
|
||||
announcements: [] as ServiceAnnouncement[],
|
||||
loading: true,
|
||||
loadError: false,
|
||||
},
|
||||
@@ -33,8 +42,14 @@ Page({
|
||||
async loadStation() {
|
||||
this.setData({ loading: true, loadError: false })
|
||||
try {
|
||||
const home = await api.get<{ serviceLocation?: ServiceLocation }>('/home')
|
||||
this.setData({ serviceLocation: home.serviceLocation || null, loading: false })
|
||||
const station = await serviceStationService.getStation()
|
||||
if (!station) {
|
||||
this.setData({ serviceLocation: null, announcements: [], loading: false })
|
||||
return
|
||||
}
|
||||
// 公告是增强信息:失败降级为空数组,不阻断站点主体信息展示
|
||||
const announcements = await serviceStationService.getVisibleAnnouncements(station.id as string)
|
||||
this.setData({ serviceLocation: station as ServiceLocation, announcements, loading: false })
|
||||
} catch (error) {
|
||||
console.error('加载服务站失败', error)
|
||||
this.setData({ loading: false, loadError: true })
|
||||
@@ -81,6 +96,19 @@ Page({
|
||||
wx.navigateTo({ url: `/pages/service-appointment/service-appointment${query}` })
|
||||
},
|
||||
|
||||
/** 公告详情:临时公告正文较长,用弹窗完整展示,避免卡片被截断 */
|
||||
onShowAnnouncement(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string
|
||||
const target = (this.data.announcements as ServiceAnnouncement[]).find((item) => item.id === id)
|
||||
if (!target) return
|
||||
wx.showModal({
|
||||
title: target.title || '临时公告',
|
||||
content: target.content || target.title || '',
|
||||
showCancel: false,
|
||||
confirmText: '知道了',
|
||||
})
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.loadStation()
|
||||
},
|
||||
|
||||
@@ -13,6 +13,22 @@
|
||||
<view class="station-info-row"><image src="/assets/icons/home/activity.png" mode="aspectFit" /><view><text class="station-info-label">开放时间</text><text class="station-info-value">{{serviceLocation.openingHoursText || '以平台通知为准'}}</text></view></view>
|
||||
</view>
|
||||
|
||||
<view class="block" wx:if="{{announcements.length}}">
|
||||
<text class="block-title">临时公告</text>
|
||||
<view class="station-announcements">
|
||||
<view
|
||||
wx:for="{{announcements}}"
|
||||
wx:key="id"
|
||||
class="station-announcement"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="onShowAnnouncement"
|
||||
>
|
||||
<view class="station-announcement-head"><text class="station-announcement-dot"></text><text class="station-announcement-title">{{item.title}}</text></view>
|
||||
<text class="station-announcement-body">{{item.content || item.title}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="block"><text class="block-title">服务项目</text><view class="station-services"><view wx:for="{{serviceLocation.services}}" wx:key="*this" class="station-service"><image src="/assets/icons/home/matchmaker.png" mode="aspectFit" /><text>{{item}}</text></view></view></view>
|
||||
|
||||
<view class="privacy-note"><image src="/assets/icons/home/anti-fraud.png" mode="aspectFit" /><text>服务站人员仅处理现场预约与指定服务,不会浏览无关用户资料。</text></view>
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
@import "../../styles/service-page.wxss";
|
||||
.station-name-card{display:flex;align-items:center;gap:18rpx;margin-top:24rpx;padding:24rpx;border-radius:26rpx;background:linear-gradient(135deg,#F1FFFA,#FFF7F9);box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.station-name-card>image{width:66rpx;height:66rpx}.station-name-card>view{display:flex;flex:1;flex-direction:column}.station-name{color:#283056;font-size:27rpx;font-weight:700}.station-short-name{margin-top:6rpx;color:#7E859B;font-size:19rpx}.station-info-card{overflow:hidden;margin-top:18rpx;border-radius:26rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.station-info-row{display:flex;align-items:center;gap:16rpx;min-height:104rpx;padding:14rpx 20rpx;border-bottom:1rpx solid #EFF0F5}.station-info-row:last-child{border-bottom:0}.station-info-row>image:first-child{width:42rpx;height:42rpx;flex:none}.station-info-row>view{display:flex;flex:1;flex-direction:column}.station-info-label{color:#303654;font-size:22rpx;font-weight:650}.station-info-value{margin-top:5rpx;color:#858A9E;font-size:19rpx;line-height:1.45}.station-info-row .station-arrow{width:24rpx;height:24rpx}.station-services{display:grid;grid-template-columns:repeat(2,1fr);gap:14rpx;margin-top:18rpx}.station-service{display:flex;align-items:center;gap:12rpx;min-height:74rpx;padding:12rpx 16rpx;border-radius:20rpx;background:#fff;color:#505874;font-size:20rpx;box-shadow:0 6rpx 20rpx rgba(35,35,95,.05)}.station-service image{width:34rpx;height:34rpx}.map-button{display:flex;align-items:center;justify-content:center;gap:10rpx;height:78rpx;margin-top:20rpx;border:1rpx solid #E5E7F0;border-radius:39rpx;background:#fff;color:#53607F;font-size:22rpx}.map-button image{width:34rpx;height:34rpx}
|
||||
/* 临时公告:仅当服务端返回「已发布且未过期」公告时渲染 */
|
||||
.station-announcements{display:flex;flex-direction:column;gap:14rpx;margin-top:18rpx}
|
||||
.station-announcement{padding:18rpx 20rpx;border-radius:20rpx;background:linear-gradient(135deg,#FFF7EC,#FFF7F9);box-shadow:0 6rpx 20rpx rgba(35,35,95,.05)}
|
||||
.station-announcement-head{display:flex;align-items:center;gap:10rpx}
|
||||
.station-announcement-dot{width:12rpx;height:12rpx;flex:none;border-radius:50%;background:#E8A33D}
|
||||
.station-announcement-title{color:#5A4520;font-size:22rpx;font-weight:700}
|
||||
.station-announcement-body{display:block;margin-top:8rpx;color:#8A7C63;font-size:19rpx;line-height:1.5}
|
||||
|
||||
Reference in New Issue
Block a user