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:
@@ -1,45 +1,83 @@
|
||||
<template>
|
||||
<div class="counseling-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益婚恋咨询管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>公益婚恋咨询管理</span>
|
||||
<el-button size="small" @click="reloadAll">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 权限不足时明确提示,而不是静默显示空表 -->
|
||||
<el-alert
|
||||
v-if="!canViewAny"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前账号没有咨询服务权限"
|
||||
description="如需查看,请联系管理员分配 counseling.service.view 权限。"
|
||||
/>
|
||||
|
||||
<el-tabs v-else v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- 待分配队列(仅 counseling.service.assign 可见) -->
|
||||
<el-tab-pane v-if="canAssign" label="待分配队列" name="queue">
|
||||
<el-table :data="pendingQueue" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-skeleton v-if="loadingQueue" :rows="4" animated />
|
||||
<el-empty v-else-if="queueError" :description="queueError">
|
||||
<el-button size="small" @click="loadQueue">重试</el-button>
|
||||
</el-empty>
|
||||
<el-empty v-else-if="!pendingQueue.length" description="暂无待分配的咨询申请" />
|
||||
<el-table v-else :data="pendingQueue" stripe>
|
||||
<el-table-column prop="serviceType" label="服务类型" width="150">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="requestSource" label="来源" width="140">
|
||||
<template #default="{ row }"><el-tag size="small">{{ requestSourceLabel(row.requestSource) }}</el-tag></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ requestSourceLabel(row.requestSource) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userRequestSummary" label="问题描述" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="申请时间" width="160">
|
||||
<el-table-column prop="userRequestSummary" label="问题描述" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.userRequestSummary || '(无)' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="申请时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="openAssign(row)">分配咨询师</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="我的咨询(Counselor)" name="assigned">
|
||||
<el-table :data="assignedServices" stripe v-loading="loading">
|
||||
|
||||
<!-- 我的服务(Counselor) -->
|
||||
<el-tab-pane label="我的咨询(咨询师)" name="assigned">
|
||||
<el-skeleton v-if="loadingAssigned" :rows="4" animated />
|
||||
<el-empty v-else-if="assignedError" :description="assignedError">
|
||||
<el-button size="small" @click="loadAssigned">重试</el-button>
|
||||
</el-empty>
|
||||
<el-empty v-else-if="!assignedServices.length" description="暂无分配给您的咨询服务" />
|
||||
<el-table v-else :data="assignedServices" stripe>
|
||||
<el-table-column prop="serviceType" label="类型" width="140">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scheduledStartAt" label="预约时间" width="160">
|
||||
<el-table-column prop="scheduledStartAt" label="预约时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.scheduledStartAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<el-table-column label="操作" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canProcess && row.status==='assigned'" size="small" @click="scheduleCounseling(row)">排期</el-button>
|
||||
<el-button v-if="canProcess && row.status==='scheduled'" size="small" type="success" @click="startCounseling(row)">开始</el-button>
|
||||
<el-button v-if="canCreateNote && row.status==='in_progress'" size="small" type="primary" @click="showFollowup(row)">跟进</el-button>
|
||||
<el-button v-if="canProcess && row.status==='in_progress'" size="small" type="warning" @click="completeCounseling(row)">完成</el-button>
|
||||
<el-button v-if="canProcess && row.status === 'assigned'" size="small"
|
||||
@click="doSchedule(row)">排期</el-button>
|
||||
<el-button v-if="canProcess && row.status === 'scheduled'" size="small" type="success"
|
||||
@click="doStart(row)">开始</el-button>
|
||||
<el-button v-if="canCreateNote && row.status === 'in_progress'" size="small" type="primary"
|
||||
@click="showFollowup(row)">跟进</el-button>
|
||||
<el-button v-if="canProcess && row.status === 'in_progress'" size="small" type="warning"
|
||||
@click="doComplete(row)">完成</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -49,29 +87,33 @@
|
||||
|
||||
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="咨询师"><el-select v-model="assignForm.counselorId" placeholder="选择咨询师">
|
||||
<el-option label="张咨询师" value="counselor01" />
|
||||
<el-option label="李咨询师" value="counselor02" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="咨询师">
|
||||
<el-input v-model="assignForm.counselorId" placeholder="请输入咨询师工号 / ID" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doAssign">确认分配</el-button>
|
||||
<el-button @click="assignDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="doAssign">确认分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="500px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="跟进类型"><el-select v-model="followupForm.type">
|
||||
<el-option label="电话回访" value="phone_call" />
|
||||
<el-option label="面谈" value="face_to_face" />
|
||||
<el-option label="线上沟通" value="online_chat" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="跟进内容"><el-input v-model="followupForm.content" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="跟进类型">
|
||||
<el-select v-model="followupForm.type">
|
||||
<el-option label="电话回访" value="phone_call" />
|
||||
<el-option label="面谈" value="face_to_face" />
|
||||
<el-option label="线上沟通" value="online_chat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进内容">
|
||||
<el-input v-model="followupForm.content" type="textarea" :rows="4"
|
||||
placeholder="仅参与本次咨询的咨询师与申请人本人可见" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="followupDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doFollowup">保存</el-button>
|
||||
<el-button @click="followupDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="doFollowup">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -80,15 +122,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canViewAny = computed(() =>
|
||||
authStore.permissions.some((p) => p.startsWith('counseling.')))
|
||||
const canAssign = computed(() => authStore.permissions.includes('counseling.service.assign'))
|
||||
const canProcess = computed(() => authStore.permissions.includes('counseling.service.process'))
|
||||
const canCreateNote = computed(() => authStore.permissions.includes('counseling.note.create'))
|
||||
|
||||
const activeTab = ref(canAssign.value ? 'queue' : 'assigned')
|
||||
const loading = ref(false)
|
||||
const loadingQueue = ref(false)
|
||||
const loadingAssigned = ref(false)
|
||||
const submitting = ref(false)
|
||||
const queueError = ref('')
|
||||
const assignedError = ref('')
|
||||
const pendingQueue = ref<any[]>([])
|
||||
const assignedServices = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
@@ -102,11 +151,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
premarital_guidance: '婚前辅导', anti_fraud_guidance: '婚恋反诈指导'
|
||||
}
|
||||
const REQUEST_SOURCE_LABEL: Record<string, string> = {
|
||||
user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助'
|
||||
user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助',
|
||||
activity_followup: '活动后续跟进', service_station: '服务站转介'
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待分配', assigned: '已分配', scheduled: '已排期',
|
||||
in_progress: '咨询中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
|
||||
in_progress: '服务中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
|
||||
}
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
pending: 'warning', assigned: 'info', scheduled: 'primary',
|
||||
@@ -119,42 +169,144 @@ function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function loadQueue() {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/counseling/services/operator/queue'); pendingQueue.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadAssigned() {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/counseling/services/operator/assigned'); assignedServices.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function openAssign(row: any) { currentId.value = row.id; assignForm.value.counselorId = ''; assignDialogVisible.value = true }
|
||||
async function doAssign() {
|
||||
try { await post(`/counseling/services/operator/${currentId.value}/assign`, { counselorId: assignForm.value.counselorId }); ElMessage.success('分配成功'); assignDialogVisible.value = false; loadQueue() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function scheduleCounseling(row: any) {
|
||||
const now = new Date(); now.setHours(now.getHours() + 2)
|
||||
const end = new Date(now); end.setHours(end.getHours() + 1)
|
||||
try { await post(`/counseling/services/operator/${row.id}/schedule`, { startAt: now.toISOString(), endAt: end.toISOString() }); ElMessage.success('排期成功'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function startCounseling(row: any) {
|
||||
try { await post(`/counseling/services/operator/${row.id}/start`, {}); ElMessage.success('已开始'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
function showFollowup(row: any) { currentId.value = row.id; followupForm.value = { type: 'phone_call', content: '' }; followupDialogVisible.value = true }
|
||||
async function doFollowup() {
|
||||
try { await post(`/counseling/services/operator/${currentId.value}/followup`, { followupType: followupForm.value.type, content: followupForm.value.content }); ElMessage.success('跟进记录已保存'); followupDialogVisible.value = false; loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function completeCounseling(row: any) {
|
||||
try { await post(`/counseling/services/operator/${row.id}/complete`, {}); ElMessage.success('已完成'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
function errText(e: any, fallback: string) {
|
||||
return e?.response?.data?.message || e?.message || fallback
|
||||
}
|
||||
|
||||
onMounted(() => { if (canAssign.value) loadQueue(); loadAssigned() })
|
||||
async function loadQueue() {
|
||||
loadingQueue.value = true
|
||||
queueError.value = ''
|
||||
try {
|
||||
const res: any = await get('/counseling/services/operator/queue')
|
||||
pendingQueue.value = res?.data || []
|
||||
} catch (e: any) {
|
||||
queueError.value = errText(e, '加载待分配队列失败')
|
||||
} finally {
|
||||
loadingQueue.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssigned() {
|
||||
loadingAssigned.value = true
|
||||
assignedError.value = ''
|
||||
try {
|
||||
const res: any = await get('/counseling/services/operator/assigned')
|
||||
assignedServices.value = res?.data || []
|
||||
} catch (e: any) {
|
||||
assignedError.value = errText(e, '加载我的咨询失败')
|
||||
} finally {
|
||||
loadingAssigned.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reloadAll() {
|
||||
if (canAssign.value) loadQueue()
|
||||
loadAssigned()
|
||||
}
|
||||
|
||||
function onTabChange(name: string | number) {
|
||||
if (name === 'queue') loadQueue()
|
||||
else if (name === 'assigned') loadAssigned()
|
||||
}
|
||||
|
||||
function openAssign(row: any) {
|
||||
currentId.value = row.id
|
||||
assignForm.value.counselorId = ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doAssign() {
|
||||
if (!assignForm.value.counselorId) {
|
||||
ElMessage.warning('请填写咨询师 ID')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post(`/counseling/services/operator/${currentId.value}/assign`,
|
||||
{ counselorId: assignForm.value.counselorId })
|
||||
ElMessage.success('分配成功')
|
||||
assignDialogVisible.value = false
|
||||
loadQueue()
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
// 并发分配冲突(条件更新未命中)会带明确 message
|
||||
ElMessage.error(errText(e, '分配失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doSchedule(row: any) {
|
||||
const start = new Date()
|
||||
start.setHours(start.getHours() + 2)
|
||||
const end = new Date(start)
|
||||
end.setHours(end.getHours() + 1)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将为该申请排期:${formatDateTime(start.toISOString())} ~ ${formatDateTime(end.toISOString())}`,
|
||||
'确认排期', { type: 'info' })
|
||||
} catch { return }
|
||||
try {
|
||||
await post(`/counseling/services/operator/${row.id}/schedule`,
|
||||
{ startAt: start.toISOString(), endAt: end.toISOString() })
|
||||
ElMessage.success('排期成功')
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(errText(e, '排期失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function doStart(row: any) {
|
||||
try {
|
||||
await post(`/counseling/services/operator/${row.id}/start`, {})
|
||||
ElMessage.success('已开始服务')
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(errText(e, '操作失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function showFollowup(row: any) {
|
||||
currentId.value = row.id
|
||||
followupForm.value = { type: 'phone_call', content: '' }
|
||||
followupDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doFollowup() {
|
||||
if (!followupForm.value.content.trim()) {
|
||||
ElMessage.warning('请填写跟进内容')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post(`/counseling/services/operator/${currentId.value}/followup`,
|
||||
{ followupType: followupForm.value.type, content: followupForm.value.content })
|
||||
ElMessage.success('跟进记录已保存')
|
||||
followupDialogVisible.value = false
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(errText(e, '保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doComplete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将本次服务标记为已完成?完成后不可再修改。', '确认完成', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await post(`/counseling/services/operator/${row.id}/complete`, {})
|
||||
ElMessage.success('已完成')
|
||||
loadAssigned()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(errText(e, '操作失败'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { reloadAll() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user