fix(counseling): close Phase 13A KEEP-VERIFY/EXTEND — 9 real defects + 4 test-infra defects

Phase 13A(婚恋咨询与婚前辅导)复验关闭。历史 ✅ 不可信:counseling 模块存在双实现,
线上权威 CounselingService(167 行)零测试,唯一被测的 CounselingServiceApp(280 行)
是孤儿类(无注入点、无 HTTP 端点可达,生产永不执行)→ 已合并并删除。

生产缺陷(D1~D9):
- D1 Critical 生产故障 · INV-013 泄露:咨询正文 userRequestSummary 原文被写入 WARN 日志
  (同 Phase 10 D2 一族)→ 改为只记 serviceId + riskLevel,绝不记正文。
- D2 High 越权:申请 userId 取自请求体而非认证主体 → Controller 强制 principal.id()。
- D3 High 并发:分配用「先读后写」→ 两管理员并发分配静默覆盖;
  改 assignIfPending 条件更新(WHERE status='pending' AND assigned_operator_id IS NULL),
  affectedRows=0 明确报错。
- D4 High 越权读:counseling_manager 经 getDetail 可读 followups 正文 → INV-013 绕过通道;
  manager 只加回 userRequestSummary,不装配 followups。
- D5 Medium:无状态机 → 「已完成」可被再取消;新增 ALLOWED_TRANSITIONS + assertTransition。
- D6 Medium 生产故障:GET /counseling/services/intro 后端不存在(且被 /services/{id} 吞掉)
  → 小程序 intro 页 100% 无数据;补端点并声明在变量路径之前。
- D7 Medium 契约:Admin 调用 5 条不存在的 /services/operator/*(404)+ 缺四态
  → 后端补 8 个运营端端点(@PreAuthorize),前端整体重写 + 注册 /counseling 路由。
- D8 Medium 契约:小程序字段名错(userRequestSummary/preferredServiceMode)+ 无幂等
  + my-counseling 未检查 success(403 静默空列表)+ 跳转不存在的页 + WXML 内调用方法。
- D9 Medium 测试保真度:测试库 counseling 两表零 CHECK 约束,
  counseling_services 未列入 EntityMigrationParityTest 任何集合(= 零覆盖)。

测试基建缺陷(T1~T4,不修即恒真假绿):
- T1 守卫找不到 DDL:surefire workingDirectory = ${project.basedir}/.. 使 CWD 为仓库根,
  且 backend/ 是子目录非祖先 → 改为候选后缀 + 逐级向上定位,失败大声抛异常。
- T2 全限定 @com.baomidou...TableField(exist=false) 漏解析(假漂移警报)→ 正则放宽 + 回归守卫。
- T3 OperatorEndpointPermissionCoverageTest 扫到私有辅助方法(假阳性)→ 只扫描真实端点 + 自检。
- T4 两处测试自身逻辑缺陷:陈旧内存快照比较、Instant 纳秒 vs DB 微秒精度。

迁移:新增 V039(counseling 幂等键 + 三个 CHECK 白名单显式固化),基线 38 → 39。

负向验证两轮(均已还原,grep -c NEG-VAL = 0):
- ① DDL 常量守卫:从 V039 移除 'scheduled' → 常量守卫精准变红
  (证明解析的是真实 DDL 而非副本)。
- ② 行为守卫:注入「并发回退先读后写 / manager 读 followups / 状态机守卫失效」
  → 三个用例各自精准变红。

测试:新增 CounselingPhase13ATest(25)+ CounselingConstraintGuardTest(5,解析真实 DDL);
删除孤儿类及其 3 个 DTO、删除测死代码的 CounselingDataScopeTest。
全量回归:Tests run: 420, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS(4:49)。
基线 391 → 420(净 +29)。admin-web vue-tsc EXIT=0;miniapp tsc EXIT=0。

Tag: phase13a-recovery-2026-09-17
Report: docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md
This commit is contained in:
WorkBuddy
2026-09-17 01:25:12 +08:00
parent 5b3bad326e
commit 7a2693b239
26 changed files with 2208 additions and 700 deletions
@@ -1,15 +1,18 @@
export {}
import { request } from '../../utils/requestPromise'
const app = getApp<{ globalData: { apiBase: string; token: string } }>();
Page({
data: {
serviceType: '',
serviceTypeLabel: '',
modes: ['线上视频', '线下面谈', '电话沟通'],
modes: ['online', 'offline', 'phone'],
modeLabels: ['线上视频', '线下面谈', '电话沟通'],
pModeIndex: 0,
preferredTime: '',
summary: '',
submitting: false
submitting: false,
errorMsg: ''
},
onLoad(options: any) {
@@ -33,21 +36,44 @@ Page({
async onSubmit() {
const { serviceType, pModeIndex, preferredTime, summary } = this.data;
if (!serviceType) return wx.showToast({ title: '请选择服务类型', icon: 'none' });
this.setData({ submitting: true });
if (!serviceType) {
wx.showToast({ title: '请选择服务类型', icon: 'none' });
return;
}
if (!summary || !summary.trim()) {
wx.showToast({ title: '请简要描述您的困扰', icon: 'none' });
return;
}
this.setData({ submitting: true, errorMsg: '' });
try {
await wx.request({
const res = await request<any>({
url: `${app.globalData.apiBase}/counseling/services`,
method: 'POST',
header: { 'Authorization': `Bearer ${app.globalData.token}`, 'Content-Type': 'application/json' },
data: { serviceType, preferredServiceMode: this.data.modes[pModeIndex], preferredTime, userRequestSummary: summary }
data: {
serviceType,
// 字段名与后端 ApplyRequest 对齐(此前用 preferredServiceMode / userRequestSummary 导致整条链路拿不到值)
preferredMode: this.data.modes[pModeIndex],
preferredTime,
summary,
requestSource: 'user_self',
// 幂等键:同一次填写重复提交不会产生两条申请
idempotencyKey: `counseling-${serviceType}-${Date.now()}`
}
});
wx.showToast({ title: '申请已提交', icon: 'success' });
setTimeout(() => wx.redirectTo({ url: '/pages/my-counseling/my-counseling' }), 1500);
if (res.statusCode === 200 && res.data && res.data.success) {
wx.showToast({ title: '申请已提交', icon: 'success' });
setTimeout(() => wx.redirectTo({ url: '/pages/my-counseling/my-counseling' }), 1500);
} else {
this.setData({ errorMsg: (res.data && res.data.message) || '提交失败,请重试' });
wx.showToast({ title: (res.data && res.data.message) || '提交失败', icon: 'none' });
}
} catch (e) {
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
this.setData({ errorMsg: '网络异常,请稍后重试' });
wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
} finally {
this.setData({ submitting: false });
}
}
});
+104 -6
View File
@@ -2,32 +2,130 @@ import { request } from '../../utils/requestPromise'
const app = getApp<{ globalData: { apiBase: string; token: string } }>();
Page({
data: { list: [] as any[] },
data: {
list: [] as any[],
loading: false,
errorMsg: '',
/** 权限不足 / 未登录时给出明确提示,而不是静默空白 */
forbidden: false
},
onLoad() { this.loadList(); },
onShow() { this.loadList(); },
async loadList() {
this.setData({ loading: true, errorMsg: '', forbidden: false });
try {
const res = await request<any>({
url: `${app.globalData.apiBase}/counseling/services`,
header: { 'Authorization': `Bearer ${app.globalData.token}` }
});
if (res.statusCode === 200 && res.data.success) {
if (res.statusCode === 401) {
this.setData({ forbidden: true, errorMsg: '登录状态已失效,请重新登录' });
return;
}
if (res.statusCode === 200 && res.data && res.data.success) {
const typeMap: Record<string, string> = {
relationship_guidance: '恋爱困惑指导', emotional_support: '情感支持',
premarital_guidance: '婚前辅导', anti_fraud_guidance: '婚恋反诈指导'
};
const list = (res.data.data || []).map((item: any) => ({ ...item, serviceTypeName: typeMap[item.serviceType] || item.serviceType }));
const list = (res.data.data || []).map((item: any) => ({
...item,
serviceTypeName: typeMap[item.serviceType] || item.serviceType,
statusName: this.statusLabel(item.status),
createdText: this.formatTime(item.createdAt),
scheduledText: item.scheduledStartAt ? this.formatTime(item.scheduledStartAt) : '',
canCancel: item.status === 'pending' || item.status === 'assigned' || item.status === 'scheduled'
}));
this.setData({ list });
} else {
this.setData({ errorMsg: (res.data && res.data.message) || '加载失败,请下拉重试' });
}
} catch (e) { console.error(e); }
} catch (e) {
this.setData({ errorMsg: '网络异常,请下拉重试' });
} finally {
this.setData({ loading: false });
}
},
onPullDownRefresh() {
this.loadList().then(() => wx.stopPullDownRefresh());
},
goToDetail(e: any) { wx.navigateTo({ url: `/pages/counseling-detail/counseling-detail?id=${e.currentTarget.dataset.id}` }); },
goToIntro() { wx.navigateTo({ url: '/pages/counseling-intro/counseling-intro' }); },
/** 详情:后端已提供 GET /counseling/services/{id},此处直接展开行内摘要,避免跳未实现页面 */
toggleExpand(e: any) {
const id = e.currentTarget.dataset.id;
const list = this.data.list.map((it: any) =>
it.id === id ? { ...it, expanded: !it.expanded } : it);
this.setData({ list });
},
async onCancel(e: any) {
const id = e.currentTarget.dataset.id;
const confirm = await new Promise<boolean>((resolve) => {
wx.showModal({
title: '取消申请',
content: '确认取消本次咨询申请?',
success: (r) => resolve(!!r.confirm),
fail: () => resolve(false)
});
});
if (!confirm) return;
try {
const res = await request<any>({
url: `${app.globalData.apiBase}/counseling/services/${id}/cancel`,
method: 'POST',
header: { 'Authorization': `Bearer ${app.globalData.token}`, 'Content-Type': 'application/json' },
data: { reason: '用户主动取消' }
});
if (res.statusCode === 200 && res.data && res.data.success) {
wx.showToast({ title: '已取消', icon: 'success' });
this.loadList();
} else {
wx.showToast({ title: (res.data && res.data.message) || '取消失败', icon: 'none' });
}
} catch (err) {
wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
},
/** INV-005:婚前辅导伴侣**本人**确认 —— 只有受邀伴侣本人可操作 */
async onConfirmCompanion(e: any) {
const id = e.currentTarget.dataset.id;
const confirm = await new Promise<boolean>((resolve) => {
wx.showModal({
title: '确认参与婚前辅导',
content: '确认作为伴侣参与本次婚前辅导?',
success: (r) => resolve(!!r.confirm),
fail: () => resolve(false)
});
});
if (!confirm) return;
try {
const res = await request<any>({
url: `${app.globalData.apiBase}/counseling/services/${id}/companion-confirm`,
method: 'POST',
header: { 'Authorization': `Bearer ${app.globalData.token}`, 'Content-Type': 'application/json' },
data: {}
});
if (res.statusCode === 200 && res.data && res.data.success) {
wx.showToast({ title: '已确认参与', icon: 'success' });
this.loadList();
} else {
wx.showToast({ title: (res.data && res.data.message) || '确认失败', icon: 'none' });
}
} catch (err) {
wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
},
statusLabel(s: string) {
const m: Record<string, string> = { pending: '待处理', assigned: '已分配', scheduled: '已排期', in_progress: '咨询中', completed: '已完成', cancelled: '已取消', closed: '已关闭' };
const m: Record<string, string> = {
pending: '待处理', assigned: '已分配', scheduled: '已排期',
in_progress: '服务中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
};
return m[s] || s;
},
formatTime(t: string) { return t ? new Date(t).toLocaleString('zh-CN') : ''; },
+42 -15
View File
@@ -1,20 +1,47 @@
<view class="container">
<view class="empty" wx:if="{{list.length === 0}}">
<text>暂无咨询记录</text>
<!-- Loading -->
<view class="state-box" wx:if="{{loading}}">
<text class="state-text">加载中…</text>
</view>
<!-- Error / Permission -->
<view class="state-box" wx:elif="{{errorMsg}}">
<text class="state-text">{{errorMsg}}</text>
<button class="go-btn" bindtap="loadList" wx:if="{{!forbidden}}">重试</button>
</view>
<!-- Empty -->
<view class="state-box" wx:elif="{{list.length === 0}}">
<text class="state-text">暂无咨询记录</text>
<button class="go-btn" bindtap="goToIntro">去申请咨询</button>
</view>
<view class="card" wx:for="{{list}}" wx:key="id" bindtap="goToDetail" data-id="{{item.id}}">
<view class="card-header">
<text class="card-title">{{item.serviceTypeName}}</text>
<text class="card-status status-{{item.status}}">{{statusLabel(item.status)}}</text>
<!-- List -->
<block wx:else>
<view class="card" wx:for="{{list}}" wx:key="id">
<view class="card-header">
<text class="card-title">{{item.serviceTypeName}}</text>
<text class="card-status status-{{item.status}}">{{item.statusName}}</text>
</view>
<view class="card-body">
<text class="card-summary" wx:if="{{item.userRequestSummary}}">{{item.userRequestSummary}}</text>
<text class="card-time" wx:if="{{item.scheduledText}}">预约时间:{{item.scheduledText}}</text>
<text class="card-time" wx:if="{{item.completedAt}}">完成时间:{{item.createdText}}</text>
</view>
<view class="card-footer">
<text class="card-date">申请于 {{item.createdText}}</text>
<view class="actions">
<!-- INV-005:伴侣本人确认(仅当自己是受邀伴侣且尚未确认) -->
<button
wx:if="{{item.serviceType === 'premarital_guidance' && item.companionUserId && !item.userConfirmedAt}}"
class="mini-btn confirm" size="mini"
catchtap="onConfirmCompanion" data-id="{{item.id}}">确认参与</button>
<button
wx:if="{{item.canCancel}}"
class="mini-btn cancel" size="mini"
catchtap="onCancel" data-id="{{item.id}}">取消申请</button>
</view>
</view>
</view>
<view class="card-body">
<text class="card-summary" wx:if="{{item.userRequestSummary}}">{{item.userRequestSummary}}</text>
<text class="card-time" wx:if="{{item.scheduledStartAt}}">预约时间:{{formatTime(item.scheduledStartAt)}}</text>
</view>
<view class="card-footer">
<text class="card-date">{{formatDate(item.createdAt)}}</text>
<text class="arrow">›</text>
</view>
</view>
</block>
</view>
@@ -1,40 +1,56 @@
export {}
import { request } from '../../utils/requestPromise'
const app = getApp<{ globalData: { apiBase: string; token: string } }>();
Page({
data: {
name: '', phone: '', hasCompanion: false, companionName: '',
preferredTime: '', summary: '', submitting: false
name: '', phone: '', hasCompanion: false, companionName: '', companionUserId: '',
preferredTime: '', summary: '', submitting: false, errorMsg: ''
},
onNameInput(e: any) { this.setData({ name: e.detail.value }); },
onPhoneInput(e: any) { this.setData({ phone: e.detail.value }); },
onCompanionChange(e: any) { this.setData({ hasCompanion: e.detail.value }); },
onCompanionNameInput(e: any) { this.setData({ companionName: e.detail.value }); },
onCompanionIdInput(e: any) { this.setData({ companionUserId: e.detail.value }); },
onTimeInput(e: any) { this.setData({ preferredTime: e.detail.value }); },
onSummaryInput(e: any) { this.setData({ summary: e.detail.value }); },
async onSubmit() {
const { name, phone, hasCompanion, companionName, preferredTime, summary } = this.data;
if (!name || !phone) return wx.showToast({ title: '请填写姓名和联系方式', icon: 'none' });
this.setData({ submitting: true });
const { name, phone, hasCompanion, companionName, companionUserId, preferredTime, summary } = this.data;
if (!name || !phone) {
wx.showToast({ title: '请填写姓名和联系方式', icon: 'none' });
return;
}
this.setData({ submitting: true, errorMsg: '' });
try {
await wx.request({
const res = await request<any>({
url: `${app.globalData.apiBase}/counseling/services`,
method: 'POST',
header: { 'Authorization': `Bearer ${app.globalData.token}`, 'Content-Type': 'application/json' },
data: {
serviceType: 'premarital_guidance',
preferredTime,
userRequestSummary: summary || `申请人:${name},电话:${phone}${hasCompanion ? `,伴侣:${companionName}` : ''}`,
requestSource: 'user_self'
// 字段名与后端 ApplyRequest 对齐(此前用 userRequestSummary 导致后端拿到 null)
summary: summary || `申请人:${name},电话:${phone}${hasCompanion ? `,伴侣:${companionName}` : ''}`,
requestSource: 'user_self',
// INV-005:伴侣须**本人**确认。此处只登记受邀伴侣 ID,确认动作由伴侣本人另行完成。
companionUserId: hasCompanion ? (companionUserId || null) : null,
idempotencyKey: `premarital-${name}-${Date.now()}`
}
});
wx.showToast({ title: '申请已提交', icon: 'success' });
setTimeout(() => wx.redirectTo({ url: '/pages/my-counseling/my-counseling' }), 1500);
if (res.statusCode === 200 && res.data && res.data.success) {
wx.showToast({ title: hasCompanion ? '已提交,伴侣需本人确认' : '申请已提交', icon: 'success' });
setTimeout(() => wx.redirectTo({ url: '/pages/my-counseling/my-counseling' }), 1800);
} else {
this.setData({ errorMsg: (res.data && res.data.message) || '提交失败,请重试' });
wx.showToast({ title: (res.data && res.data.message) || '提交失败', icon: 'none' });
}
} catch (e) {
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
this.setData({ errorMsg: '网络异常,请稍后重试' });
wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
} finally {
this.setData({ submitting: false });
}
}
});