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>
|
||||
|
||||
+170
-48
@@ -1,33 +1,48 @@
|
||||
package com.danyang.match.platform.counseling.controller;
|
||||
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.service.CounselingService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 婚恋咨询 / 婚前辅导 端点
|
||||
* 婚恋咨询 / 婚前辅导 端点(Phase 13A)
|
||||
*
|
||||
* 用户端:
|
||||
* POST /api/v1/counseling/services — 申请咨询/辅导
|
||||
* GET /api/v1/counseling/services — 我的服务历史
|
||||
* GET /api/v1/counseling/services/{id} — 查看详情(自动脱敏 INV-013)
|
||||
* 用户端(UserPrincipal):
|
||||
* POST /counseling/services — 申请(userId 取自**认证主体**,非请求体)
|
||||
* GET /counseling/services — 我的服务历史
|
||||
* GET /counseling/services/intro — 服务介绍(**必须声明在 /{id} 之前**)
|
||||
* GET /counseling/services/{id} — 详情(自动脱敏 INV-013)
|
||||
* POST /counseling/services/{id}/cancel — 取消(仅本人 + 合法状态迁移)
|
||||
* POST /counseling/services/{id}/companion-confirm— 婚前辅导伴侣本人确认(INV-005)
|
||||
*
|
||||
* Counselor / Manager 端:
|
||||
* GET /api/v1/counseling/assigned — 我被分配的(INV-012)
|
||||
* PUT /api/v1/counseling/services/{id}/assign — counseling_manager 分配
|
||||
* PUT /api/v1/counseling/services/{id}/schedule — assigned Counselor 排期(INV-012)
|
||||
* PUT /api/v1/counseling/services/{id}/complete — assigned Counselor 完成(INV-012)
|
||||
* 运营端(OperatorPrincipal,对应 Admin Web 契约):
|
||||
* GET /counseling/services/operator/queue — 待分配队列(manager)
|
||||
* GET /counseling/services/operator/assigned — 我被分配的(INV-012)
|
||||
* POST /counseling/services/operator/{id}/assign — 分配(counseling.service.assign)
|
||||
* POST /counseling/services/operator/{id}/schedule— 排期(assigned)
|
||||
* POST /counseling/services/operator/{id}/start — 开始(assigned)
|
||||
* POST /counseling/services/operator/{id}/followup— 添加跟进(assigned)
|
||||
* POST /counseling/services/operator/{id}/complete— 完成(assigned)
|
||||
* GET /counseling/services/operator/{id} — 运营端详情(脱敏/全文按角色)
|
||||
*
|
||||
* 权限由 CounselingServiceRecord.getDetail() / assertAssigned() 在 Service 层守卫,
|
||||
* Controller 层只做路由 + principal 透传。
|
||||
* 路由顺序注意:`/services/intro` 是**字面量**路径,必须声明在 `/services/{id}` **之前**,
|
||||
* 否则会被 `{id}` 吞掉(intro 被当作 serviceId 解析)。
|
||||
*
|
||||
* 权限双保险:Service 层做数据域校验(INV-012 / INV-013),Controller 层加 @PreAuthorize
|
||||
* 做粗粒度权限门禁。两层都必须有 —— 只有 Controller 会漏掉数据域,只有 Service 会漏掉接口级鉴权。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/counseling")
|
||||
@@ -40,90 +55,197 @@ public class CounselingController {
|
||||
|
||||
@PostMapping("/services")
|
||||
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(counselingService.apply(
|
||||
principal.id(),
|
||||
principal.id(), // 修复:userId 来自认证主体,不取请求体
|
||||
req.serviceType,
|
||||
req.summary,
|
||||
req.summary != null ? req.summary : req.userRequestSummary,
|
||||
req.requestSource,
|
||||
req.preferredMode,
|
||||
req.preferredTime));
|
||||
req.preferredMode != null ? req.preferredMode : req.preferredServiceMode,
|
||||
req.preferredTime,
|
||||
req.companionUserId,
|
||||
req.idempotencyKey));
|
||||
}
|
||||
|
||||
@GetMapping("/services")
|
||||
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(counselingService.myServices(principal.id(), limit));
|
||||
List<CounselingServiceRecord> list = counselingService.myServices(principal.id(), limit);
|
||||
// 列表场景剥离正文与 followups(INV-013)
|
||||
list.forEach(CounselingService::sanitize);
|
||||
return ApiResponse.ok(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务介绍 —— **必须声明在 /services/{id} 之前**,否则被 {id} 吞掉。
|
||||
* 该端点原本缺省,导致两个小程序 intro 页 100% 拿不到数据。
|
||||
*/
|
||||
@GetMapping("/services/intro")
|
||||
public ApiResponse<List<Map<String, Object>>> intro() {
|
||||
return ApiResponse.ok(counselingService.getServiceIntro());
|
||||
}
|
||||
|
||||
@GetMapping("/services/{id}")
|
||||
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
CounselingServiceRecord svc = counselingService.getDetail(principal.id(), principal.role(), id);
|
||||
if (svc == null) return ApiResponse.forbidden();
|
||||
return ApiResponse.ok(svc);
|
||||
}
|
||||
|
||||
// ==================== Counselor / Manager 端 ====================
|
||||
|
||||
@GetMapping("/assigned")
|
||||
public ApiResponse<List<CounselingServiceRecord>> assigned(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@PostMapping("/services/{id}/cancel")
|
||||
public ApiResponse<CounselingServiceRecord> cancel(@PathVariable String id,
|
||||
@RequestBody(required = false) CancelRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(counselingService.assignedTo(principal.id()));
|
||||
return ApiResponse.ok(counselingService.cancel(id, principal.id(),
|
||||
req != null ? req.reason : null));
|
||||
}
|
||||
|
||||
@PutMapping("/services/{id}/assign")
|
||||
public ApiResponse<Void> assign(@PathVariable String id,
|
||||
@RequestBody AssignRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
/** INV-005:婚前辅导伴侣**本人**确认 —— companionUserId 取自认证主体,不可代确认 */
|
||||
@PostMapping("/services/{id}/companion-confirm")
|
||||
public ApiResponse<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
// INV-012 加强:assign 只能由 counseling_manager 执行
|
||||
if (!"counseling_manager".equals(principal.role())) {
|
||||
return ApiResponse.forbidden("只有咨询管理人员可以分配服务");
|
||||
return ApiResponse.ok(counselingService.confirmCompanion(id, principal.id()));
|
||||
}
|
||||
|
||||
// ==================== 运营端(Admin Web 契约) ====================
|
||||
|
||||
@GetMapping("/services/operator/queue")
|
||||
@PreAuthorize("hasAuthority('counseling.service.assign')")
|
||||
public ApiResponse<List<CounselingServiceRecord>> operatorQueue(@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
List<CounselingServiceRecord> list = counselingService.pendingQueue();
|
||||
list.forEach(CounselingService::sanitize);
|
||||
return ApiResponse.ok(list);
|
||||
}
|
||||
|
||||
@GetMapping("/services/operator/assigned")
|
||||
@PreAuthorize("hasAuthority('counseling.service.process')")
|
||||
public ApiResponse<List<CounselingServiceRecord>> operatorAssigned(@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
List<CounselingServiceRecord> list = counselingService.assignedTo(principal.id());
|
||||
// INV-013:列表不装配 followups 正文
|
||||
list.forEach(CounselingService::sanitize);
|
||||
return ApiResponse.ok(list);
|
||||
}
|
||||
|
||||
@GetMapping("/services/operator/{id}")
|
||||
@PreAuthorize("hasAuthority('counseling.service.view')")
|
||||
public ApiResponse<CounselingServiceRecord> operatorDetail(@PathVariable String id,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
CounselingServiceRecord svc = counselingService.getDetailForCounselor(
|
||||
principal.id(), roleOf(principal), id);
|
||||
if (svc == null) return ApiResponse.forbidden();
|
||||
return ApiResponse.ok(svc);
|
||||
}
|
||||
|
||||
@PostMapping("/services/operator/{id}/assign")
|
||||
@PreAuthorize("hasAuthority('counseling.service.assign')")
|
||||
public ApiResponse<CounselingServiceRecord> assign(@PathVariable String id,
|
||||
@RequestBody AssignRequest req,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
// 兼容前端两种字段名(counselorId / assignedOperatorId)
|
||||
String counselorId = req.counselorId != null ? req.counselorId : req.assignedOperatorId;
|
||||
if (counselorId == null || counselorId.isBlank()) {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "必须指定咨询师");
|
||||
}
|
||||
counselingService.assign(id, req.assignedOperatorId);
|
||||
return ApiResponse.ok();
|
||||
return ApiResponse.ok(counselingService.assign(id, counselorId));
|
||||
}
|
||||
|
||||
@PutMapping("/services/{id}/schedule")
|
||||
public ApiResponse<Void> schedule(@PathVariable String id,
|
||||
@RequestBody ScheduleRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@PostMapping("/services/operator/{id}/schedule")
|
||||
@PreAuthorize("hasAuthority('counseling.service.process')")
|
||||
public ApiResponse<CounselingServiceRecord> schedule(@PathVariable String id,
|
||||
@RequestBody ScheduleRequest req,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
counselingService.schedule(id, principal.id(), req.start, req.end);
|
||||
return ApiResponse.ok();
|
||||
Instant start = req.startAt != null ? req.startAt : req.start;
|
||||
Instant end = req.endAt != null ? req.endAt : req.end;
|
||||
return ApiResponse.ok(counselingService.schedule(id, principal.id(), start, end));
|
||||
}
|
||||
|
||||
@PutMapping("/services/{id}/complete")
|
||||
public ApiResponse<Void> complete(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@PostMapping("/services/operator/{id}/start")
|
||||
@PreAuthorize("hasAuthority('counseling.service.process')")
|
||||
public ApiResponse<CounselingServiceRecord> start(@PathVariable String id,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
counselingService.complete(id, principal.id());
|
||||
return ApiResponse.ok();
|
||||
return ApiResponse.ok(counselingService.start(id, principal.id()));
|
||||
}
|
||||
|
||||
@PostMapping("/services/operator/{id}/followup")
|
||||
@PreAuthorize("hasAuthority('counseling.note.create')")
|
||||
public ApiResponse<CounselingFollowup> followup(@PathVariable String id,
|
||||
@RequestBody FollowupRequest req,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(counselingService.addFollowup(
|
||||
id, principal.id(), req.followupType, req.content, req.resultCode, req.nextFollowupAt));
|
||||
}
|
||||
|
||||
@PostMapping("/services/operator/{id}/complete")
|
||||
@PreAuthorize("hasAuthority('counseling.service.process')")
|
||||
public ApiResponse<CounselingServiceRecord> complete(@PathVariable String id,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(counselingService.complete(id, principal.id()));
|
||||
}
|
||||
|
||||
/** OperatorPrincipal 没有直接暴露 role,用权限推导角色语义(供 Service 层分层用) */
|
||||
private String roleOf(OperatorPrincipal principal) {
|
||||
if (principal.hasPermission("counseling.service.assign")) return "counseling_manager";
|
||||
return "counselor";
|
||||
}
|
||||
|
||||
// ==================== DTO ====================
|
||||
|
||||
@Data
|
||||
public static class ApplyRequest {
|
||||
private String serviceType; // counseling / premarital
|
||||
private String serviceType; // 白名单:见 V022 chk_counseling_service_type
|
||||
/** 用户自述(INV-013 敏感)。兼容前端 summary / userRequestSummary 两种命名。 */
|
||||
private String summary;
|
||||
private String requestSource; // user_self / matchmaker_referral
|
||||
private String preferredMode; // online / offline
|
||||
private String userRequestSummary;
|
||||
private String requestSource; // user_self / matchmaker_referral / ...
|
||||
/** 兼容前端 preferredMode / preferredServiceMode 两种命名。 */
|
||||
private String preferredMode;
|
||||
private String preferredServiceMode;
|
||||
private String preferredTime;
|
||||
/** 婚前辅导:平台内伴侣 user id(须对方本人确认,INV-005) */
|
||||
private String companionUserId;
|
||||
private String idempotencyKey;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CancelRequest {
|
||||
private String reason;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AssignRequest {
|
||||
/** 兼容前端两种字段名 */
|
||||
private String counselorId;
|
||||
private String assignedOperatorId;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ScheduleRequest {
|
||||
/** 兼容前端两种字段名 */
|
||||
private Instant startAt;
|
||||
private Instant endAt;
|
||||
private Instant start;
|
||||
private Instant end;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class FollowupRequest {
|
||||
private String followupType;
|
||||
private String content;
|
||||
private String resultCode;
|
||||
private Instant nextFollowupAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.danyang.match.platform.counseling.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 咨询响应DTO
|
||||
*/
|
||||
@Data
|
||||
public class CounselingDTO {
|
||||
private String id;
|
||||
private String userId;
|
||||
private String serviceType;
|
||||
private String requestSource;
|
||||
private String userRequestSummary;
|
||||
private String status;
|
||||
private String assignedOperatorId;
|
||||
private String assignedOperatorName;
|
||||
private String preferredServiceMode;
|
||||
private String preferredTime;
|
||||
private Instant scheduledStartAt;
|
||||
private Instant scheduledEndAt;
|
||||
private String companionUserId;
|
||||
private String companionUserName;
|
||||
private Instant completedAt;
|
||||
private String closedReason;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
private List<FollowupDTO> followups;
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.danyang.match.platform.counseling.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户申请咨询请求
|
||||
*/
|
||||
@Data
|
||||
public class CounselingRequest {
|
||||
private String serviceType; // relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance
|
||||
private String requestSource;
|
||||
private String userRequestSummary;
|
||||
private String preferredServiceMode;
|
||||
private String preferredTime;
|
||||
/** 伴侣用户ID(婚前辅导时可选填写) */
|
||||
private String companionUserId;
|
||||
private String createdByOperatorId;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.danyang.match.platform.counseling.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
public class FollowupDTO {
|
||||
private String id;
|
||||
private String followupType;
|
||||
private String content;
|
||||
private String resultCode;
|
||||
private Instant nextFollowupAt;
|
||||
private Instant createdAt;
|
||||
}
|
||||
+27
@@ -45,4 +45,31 @@ public class CounselingServiceRecord extends BaseEntity {
|
||||
private String createdByOperatorId;
|
||||
/** 关闭原因(用户取消 / 运营关闭) */
|
||||
private String closedReason;
|
||||
/** 同意书版本(合规留痕) */
|
||||
private String consentVersion;
|
||||
/** 同意时间 */
|
||||
private java.time.Instant consentedAt;
|
||||
|
||||
/**
|
||||
* 客户端幂等键。
|
||||
*
|
||||
* 刻意**不放在 BaseEntity**:BaseEntity 已被 47 个实体继承,
|
||||
* 在其中加列会一次性要求 47 张表都补列(侵入面过大且无必要)。
|
||||
* 幂等语义按业务模块各自按需声明。
|
||||
* 唯一性由 V039 的 (user_id, idempotency_key) 约束保证。
|
||||
*/
|
||||
private String idempotencyKey;
|
||||
|
||||
/** 伴侣本人确认时间(INV-005:只有受邀伴侣本人可写) */
|
||||
private java.time.Instant userConfirmedAt;
|
||||
|
||||
/**
|
||||
* 跟进记录 —— **敏感(INV-013)**。
|
||||
*
|
||||
* 仅「完整视图」路径(申请者本人 / assigned Counselor)才装配该字段;
|
||||
* 脱敏路径(红娘、普通 operator、manager)必须为 null。
|
||||
* 该字段不对应 counseling_services 的任何列,禁止让 MyBatis-Plus 写库。
|
||||
*/
|
||||
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||
private java.util.List<CounselingFollowup> followups;
|
||||
}
|
||||
|
||||
+8
@@ -3,6 +3,7 @@ package com.danyang.match.platform.counseling.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
@@ -12,4 +13,11 @@ public interface CounselingFollowupMapper extends BaseMapper<CounselingFollowup>
|
||||
|
||||
@Select("SELECT * FROM counseling_followups WHERE counseling_id = #{counselingId} ORDER BY created_at DESC")
|
||||
List<CounselingFollowup> findByCounselingId(String counselingId);
|
||||
|
||||
/**
|
||||
* INV-013:跟进记录正文只能经由**完整视图**(申请者本人 / assigned Counselor)读取。
|
||||
* 语义与 {@link #findByCounselingId} 一致,方法名按服务层意图命名。
|
||||
*/
|
||||
@Select("SELECT * FROM counseling_followups WHERE counseling_id = #{serviceId} ORDER BY created_at DESC")
|
||||
List<CounselingFollowup> findByServiceId(@Param("serviceId") String serviceId);
|
||||
}
|
||||
|
||||
+23
-2
@@ -5,6 +5,7 @@ import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -39,10 +40,30 @@ public interface CounselingServiceMapper extends BaseMapper<CounselingServiceRec
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<CounselingServiceRecord> findByAssignedOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
/** INV-013: 脱敏查询 — 给 Matchmaker 用,跳过敏感字段 */
|
||||
/** INV-013: 脱敏查询 — 给 Matchmaker / manager 用,跳过敏感字段 */
|
||||
@Select("SELECT id, user_id, service_type, request_source, organization_id, assigned_operator_id, status, " +
|
||||
"preferred_service_mode, preferred_time, scheduled_start_at, scheduled_end_at, completed_at, " +
|
||||
"created_at, updated_at " +
|
||||
"companion_user_id, user_confirmed_at, created_at, updated_at " +
|
||||
"FROM counseling_services WHERE id = #{id}")
|
||||
CounselingServiceRecord findSanitized(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* 幂等查询:同一用户 + 同一幂等键只应存在一条记录。
|
||||
*/
|
||||
@Select("SELECT * FROM counseling_services WHERE user_id = #{userId} " +
|
||||
"AND idempotency_key = #{idempotencyKey} AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
||||
CounselingServiceRecord findByIdempotencyKey(@Param("userId") String userId,
|
||||
@Param("idempotencyKey") String idempotencyKey);
|
||||
|
||||
/**
|
||||
* 并发安全的分配:**条件更新**。
|
||||
*
|
||||
* 只在状态仍为 pending 且尚未分配时生效;返回受影响行数。
|
||||
* 两个管理员并发分配同一单时,仅一个能拿到 affected=1,
|
||||
* 另一个拿到 0 → 服务层明确报"已被分配",而不是静默覆盖对方的分派。
|
||||
*/
|
||||
@Update("UPDATE counseling_services SET assigned_operator_id = #{counselorId}, status = 'assigned', " +
|
||||
"updated_at = NOW() WHERE id = #{serviceId} AND status = 'pending' " +
|
||||
"AND assigned_operator_id IS NULL AND deleted_at IS NULL")
|
||||
int assignIfPending(@Param("serviceId") String serviceId, @Param("counselorId") String counselorId);
|
||||
}
|
||||
|
||||
+289
-41
@@ -1,8 +1,12 @@
|
||||
package com.danyang.match.platform.counseling.service;
|
||||
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.risk.service.RiskReferralService;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -12,23 +16,33 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 婚恋咨询 / 婚前辅导 服务
|
||||
* 婚恋咨询 / 婚前辅导 服务(Phase 13A 权威实现)
|
||||
*
|
||||
* Phase 13A 核心不变量:
|
||||
* - INV-012: Counselor 只能读取 Assigned Service
|
||||
* - INV-013: Matchmaker 不能读取 Counseling 正文(user_request_summary + followups.content)
|
||||
* 权威性说明:
|
||||
* 本类是 counseling 模块**唯一的业务实现**。`CounselingServiceApp` 是历史孤儿类
|
||||
* (无任何注入点、无 HTTP 端点可达),其流程已合并至此,该类已被删除。
|
||||
* 新增改动**不要**再往 CounselingServiceApp 里加。
|
||||
*
|
||||
* ── 不变量 ──────────────────────────────────────────────────────────────
|
||||
* INV-012 Counselor 只能读取与操作 Assigned Service
|
||||
* INV-013 Matchmaker / 普通 operator 不能读取 Counseling 正文
|
||||
* (user_request_summary + followups.content),仅可见脱敏摘要
|
||||
* INV-005 任何工作人员不能替用户确认(婚前辅导伴侣须由对方本人确认)
|
||||
* ────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* 权限模型(谁能看到什么):
|
||||
* ┌───────────────────────────┬────────────┬────────────────────┬──────────┐
|
||||
* │ 角色 │ 列表 │ 详情(脱敏) │ 详情(全文)│
|
||||
* │ 角色 │ 列表 │ 详情(脱敏) │ 详情(全文)│
|
||||
* ├───────────────────────────┼────────────┼────────────────────┼──────────┤
|
||||
* │ 申请人 user │ ✅ 自己的 │ ✅ 自己的 │ ✅ │
|
||||
* │ 申请人 user │ ✅ 自己的 │ ✅ 自己的 │ ✅ │
|
||||
* │ assigned Counselor │ ✅ 被分配的 │ ✅ 被分配的 │ ✅ │
|
||||
* │ Matchmaker (红娘) │ ❌ │ ✅ 脱敏版(无summary)│ ❌ │
|
||||
* │ counseling_manager │ ✅ │ ✅ │ ✅ │
|
||||
* │ counselor 非本人 │ ❌ │ ❌ │ ❌ │
|
||||
* │ Matchmaker (红娘) │ ❌ │ ✅ 脱敏版(无正文) │ ❌ │
|
||||
* │ counseling_manager │ ✅ 队列 │ ✅ 含申请摘要 │ ❌(无followups)│
|
||||
* │ service_station_operator │ ❌ │ ✅ 脱敏版 │ ❌ │
|
||||
* └───────────────────────────┴────────────┴────────────────────┴──────────┘
|
||||
*/
|
||||
@@ -38,22 +52,65 @@ public class CounselingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CounselingService.class);
|
||||
|
||||
/** 合法状态机:key = 当前状态,value = 允许迁移到的状态集合 */
|
||||
private static final Map<String, Set<String>> ALLOWED_TRANSITIONS = Map.of(
|
||||
"pending", Set.of("assigned", "cancelled", "closed"),
|
||||
"assigned", Set.of("scheduled", "in_progress", "cancelled", "closed"),
|
||||
"scheduled", Set.of("in_progress", "cancelled", "closed"),
|
||||
"in_progress", Set.of("completed", "closed"),
|
||||
"completed", Set.of(),
|
||||
"cancelled", Set.of(),
|
||||
"closed", Set.of()
|
||||
);
|
||||
|
||||
/** service_type 白名单 — 与 V022 chk_counseling_service_type 对齐 */
|
||||
private static final Set<String> VALID_SERVICE_TYPES = Set.of(
|
||||
"relationship_guidance", "emotional_support",
|
||||
"premarital_guidance", "anti_fraud_guidance");
|
||||
|
||||
/** request_source 白名单 — 与 V022 chk_request_source 对齐 */
|
||||
private static final Set<String> VALID_REQUEST_SOURCES = Set.of(
|
||||
"user_self", "matchmaker_referral", "activity_followup",
|
||||
"service_station", "operator_assisted");
|
||||
|
||||
private final CounselingServiceMapper mapper;
|
||||
private final CounselingFollowupMapper followupMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final RiskReferralService riskReferralService;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
|
||||
// ==================== 用户端:申请 ====================
|
||||
|
||||
/**
|
||||
* 用户申请婚恋咨询 / 婚前辅导
|
||||
*
|
||||
* @param userId 当前用户
|
||||
* @param serviceType "counseling" / "premarital"
|
||||
* @param summary 用户自述(INV-013: 红娘不可见)
|
||||
* @param requestSource "user_self" / "matchmaker_referral"
|
||||
* @param userId 当前用户 —— 必须由**认证主体**传入,绝不可取自请求体
|
||||
* @param serviceType 白名单内取值
|
||||
* @param summary 用户自述(INV-013: 红娘不可见,且**不得写入日志**)
|
||||
* @param requestSource 白名单内取值
|
||||
* @param idempotencyKey 幂等键;同一用户 + 同一键重复提交返回既有记录
|
||||
*/
|
||||
@Transactional
|
||||
public CounselingServiceRecord apply(String userId, String serviceType, String summary,
|
||||
String requestSource, String preferredMode, String preferredTime) {
|
||||
String requestSource, String preferredMode,
|
||||
String preferredTime, String companionUserId,
|
||||
String idempotencyKey) {
|
||||
if (serviceType == null || !VALID_SERVICE_TYPES.contains(serviceType)) {
|
||||
throw new IllegalArgumentException("不支持的服务类型: " + serviceType);
|
||||
}
|
||||
if (requestSource != null && !VALID_REQUEST_SOURCES.contains(requestSource)) {
|
||||
throw new IllegalArgumentException("不支持的申请来源: " + requestSource);
|
||||
}
|
||||
|
||||
// 幂等:同用户 + 同幂等键 → 直接返回既有记录,不重复落库
|
||||
if (idempotencyKey != null && !idempotencyKey.isBlank()) {
|
||||
CounselingServiceRecord existing = mapper.findByIdempotencyKey(userId, idempotencyKey);
|
||||
if (existing != null) {
|
||||
log.info("咨询申请幂等命中: userId={}, key={}", userId, idempotencyKey);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
CounselingServiceRecord svc = new CounselingServiceRecord();
|
||||
svc.setId(UUID.randomUUID().toString());
|
||||
svc.setUserId(userId);
|
||||
@@ -63,10 +120,28 @@ public class CounselingService {
|
||||
svc.setStatus("pending");
|
||||
svc.setPreferredServiceMode(preferredMode);
|
||||
svc.setPreferredTime(preferredTime);
|
||||
svc.setCompanionUserId(companionUserId);
|
||||
if (idempotencyKey != null && !idempotencyKey.isBlank()) {
|
||||
svc.setIdempotencyKey(idempotencyKey);
|
||||
}
|
||||
mapper.insert(svc);
|
||||
|
||||
// P11-T04: Counseling assigned 通知(pending 状态,等分配后再发 assigned 通知)
|
||||
log.info("CounselingServiceRecord submitted: userId={}, type={}, source={}", userId, serviceType, requestSource);
|
||||
// P17-T01: 咨询内容风险检测。
|
||||
// INV-013 加固:**绝不记录 summary 原文**,只记风险级别与记录 ID。
|
||||
// (修复前:log.warn("... summary={}", summary) 会把正文写进日志 → 同 Phase 10 D2 一族。)
|
||||
try {
|
||||
String riskLevel = riskReferralService.detectCounselingRisk(userId, summary);
|
||||
if ("high".equals(riskLevel)) {
|
||||
log.warn("咨询内容触发高危风险: userId={}, serviceId={}, riskLevel=high",
|
||||
userId, svc.getId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("咨询风险检测异常: serviceId={}, err={}", svc.getId(), e.getMessage());
|
||||
}
|
||||
|
||||
// P11-T04: 该状态为 pending,等分配后再发 assigned 通知
|
||||
log.info("CounselingServiceRecord submitted: userId={}, type={}, source={}",
|
||||
userId, serviceType, svc.getRequestSource());
|
||||
return svc;
|
||||
}
|
||||
|
||||
@@ -75,84 +150,233 @@ public class CounselingService {
|
||||
return mapper.findByUser(userId, Math.min(limit, 50));
|
||||
}
|
||||
|
||||
// ==================== Counselor 端:分配 + 排期 + 跟进 ====================
|
||||
// ==================== 用户端:取消 ====================
|
||||
|
||||
/** INV-012: 分配给 Counselor — 只有 counseling_manager 能调 */
|
||||
/**
|
||||
* 用户取消自己的咨询申请
|
||||
* 仅 pending / assigned / scheduled 可取消(合法状态迁移)。
|
||||
*/
|
||||
@Transactional
|
||||
public void assign(String serviceId, String counselorId) {
|
||||
public CounselingServiceRecord cancel(String serviceId, String userId, String reason) {
|
||||
CounselingServiceRecord svc = mapper.findById(serviceId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
|
||||
svc.setAssignedOperatorId(counselorId);
|
||||
svc.setStatus("assigned");
|
||||
|
||||
if (!userId.equals(svc.getUserId())) {
|
||||
// 不泄露"记录存在但非你所有"这一事实
|
||||
throw new IllegalStateException("服务不存在");
|
||||
}
|
||||
assertTransition(svc.getStatus(), "cancelled");
|
||||
|
||||
svc.setStatus("cancelled");
|
||||
svc.setClosedReason(reason);
|
||||
mapper.updateById(svc);
|
||||
notificationService.counselingAssigned(svc.getUserId(), serviceId);
|
||||
return svc;
|
||||
}
|
||||
|
||||
// ==================== Counselor 端:分配 + 排期 + 跟进 ====================
|
||||
|
||||
/**
|
||||
* INV-012: 分配给 Counselor — 只有 counseling_manager 能调
|
||||
*
|
||||
* 并发加固:使用**条件更新**(WHERE status='pending')而非"先读后写",
|
||||
* 两个管理员同时分配同一单时,只有一个能成功,另一个影响行数为 0 → 明确报错。
|
||||
*/
|
||||
@Transactional
|
||||
public CounselingServiceRecord assign(String serviceId, String counselorId) {
|
||||
mapper.findById(serviceId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
|
||||
|
||||
int affected = mapper.assignIfPending(serviceId, counselorId);
|
||||
if (affected == 0) {
|
||||
// 条件更新未命中 = 已被他人分派或状态已变 —— 不能盲目覆盖
|
||||
throw new IllegalStateException("该服务已被分配或状态已变更,请刷新后重试");
|
||||
}
|
||||
|
||||
CounselingServiceRecord updated = mapper.findById(serviceId).orElseThrow();
|
||||
notificationService.counselingAssigned(updated.getUserId(), serviceId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** INV-012: 排期 — 只有 assigned Counselor 能调 */
|
||||
@Transactional
|
||||
public void schedule(String serviceId, String counselorId, Instant start, Instant end) {
|
||||
public CounselingServiceRecord schedule(String serviceId, String counselorId, Instant start, Instant end) {
|
||||
CounselingServiceRecord svc = assertAssigned(serviceId, counselorId);
|
||||
if (start == null || end == null) {
|
||||
throw new IllegalArgumentException("排期时间不能为空");
|
||||
}
|
||||
if (!end.isAfter(start)) {
|
||||
throw new IllegalArgumentException("结束时间必须晚于开始时间");
|
||||
}
|
||||
assertTransition(svc.getStatus(), "scheduled");
|
||||
|
||||
svc.setStatus("scheduled");
|
||||
svc.setScheduledStartAt(start);
|
||||
svc.setScheduledEndAt(end);
|
||||
mapper.updateById(svc);
|
||||
// Phase 11:婚前辅导(premarital)与婚恋咨询共用排期流程,但通知类型分开,
|
||||
// 便于后续按服务类型做差异化触达与统计(P11-V11-T07)。
|
||||
if ("premarital".equals(svc.getServiceType())) {
|
||||
notificationService.premaritalScheduled(svc.getUserId(), serviceId,
|
||||
start.toString() + " ~ " + end.toString());
|
||||
|
||||
// Phase 11:婚前辅导(premarital_guidance)与婚恋咨询共用排期流程,但通知类型分开。
|
||||
String time = start + " ~ " + end;
|
||||
if ("premarital_guidance".equals(svc.getServiceType())) {
|
||||
notificationService.premaritalScheduled(svc.getUserId(), serviceId, time);
|
||||
} else {
|
||||
notificationService.counselingScheduled(svc.getUserId(), serviceId,
|
||||
start.toString() + " ~ " + end.toString());
|
||||
notificationService.counselingScheduled(svc.getUserId(), serviceId, time);
|
||||
}
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** INV-012: 开始服务 — 只有 assigned Counselor 能调 */
|
||||
@Transactional
|
||||
public CounselingServiceRecord start(String serviceId, String counselorId) {
|
||||
CounselingServiceRecord svc = assertAssigned(serviceId, counselorId);
|
||||
assertTransition(svc.getStatus(), "in_progress");
|
||||
svc.setStatus("in_progress");
|
||||
mapper.updateById(svc);
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** INV-012: 完成 — 只有 assigned Counselor 能调 */
|
||||
@Transactional
|
||||
public void complete(String serviceId, String counselorId) {
|
||||
public CounselingServiceRecord complete(String serviceId, String counselorId) {
|
||||
CounselingServiceRecord svc = assertAssigned(serviceId, counselorId);
|
||||
assertTransition(svc.getStatus(), "completed");
|
||||
svc.setStatus("completed");
|
||||
svc.setCompletedAt(Instant.now());
|
||||
mapper.updateById(svc);
|
||||
notificationService.counselingCompleted(svc.getUserId(), serviceId);
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** Counselor 查询被分配的列表 */
|
||||
/** INV-012: 添加跟进记录 — 只有 assigned Counselor 能调 */
|
||||
@Transactional
|
||||
public CounselingFollowup addFollowup(String serviceId, String counselorId,
|
||||
String followupType, String content,
|
||||
String resultCode, Instant nextFollowupAt) {
|
||||
assertAssigned(serviceId, counselorId);
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new IllegalArgumentException("跟进内容不能为空");
|
||||
}
|
||||
CounselingFollowup fu = new CounselingFollowup();
|
||||
fu.setId(UUID.randomUUID().toString());
|
||||
fu.setCounselingId(serviceId);
|
||||
fu.setOperatorId(counselorId);
|
||||
fu.setFollowupType(followupType);
|
||||
fu.setContent(content); // 敏感:INV-013 受保护,仅 assigned Counselor / 申请者本人可读
|
||||
fu.setResultCode(resultCode);
|
||||
fu.setNextFollowupAt(nextFollowupAt);
|
||||
followupMapper.insert(fu);
|
||||
return fu;
|
||||
}
|
||||
|
||||
/** Counselor 查询被分配的列表(INV-012) */
|
||||
public List<CounselingServiceRecord> assignedTo(String counselorId) {
|
||||
return mapper.findAssignedTo(counselorId);
|
||||
}
|
||||
|
||||
/** Manager 查询待分配队列 */
|
||||
public List<CounselingServiceRecord> pendingQueue() {
|
||||
return mapper.findPendingAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务详情 — 带权限检查
|
||||
*
|
||||
* @param requesterId 调用者用户/操作人 ID
|
||||
* @param requesterId 调用者用户/操作人 ID
|
||||
* @param requesterRole 调用者角色(决定返回脱敏还是全文)
|
||||
* @param serviceId 服务 ID
|
||||
*/
|
||||
public CounselingServiceRecord getDetail(String requesterId, String requesterRole, String serviceId) {
|
||||
CounselingServiceRecord full = mapper.findFull(serviceId);
|
||||
if (full == null) return null;
|
||||
|
||||
boolean isUser = full.getUserId().equals(requesterId);
|
||||
boolean isUser = requesterId.equals(full.getUserId());
|
||||
boolean isAssignedCounselor = requesterId.equals(full.getAssignedOperatorId());
|
||||
boolean isManager = "counseling_manager".equals(requesterRole);
|
||||
boolean isMatchmaker = "matchmaker".equals(requesterRole) || "operator".equals(requesterRole);
|
||||
|
||||
// 有权限看全文的情况
|
||||
if (isUser || isAssignedCounselor || isManager) {
|
||||
return full; // 完整返回,包含 user_request_summary
|
||||
// 完整视图:仅申请者本人与 assigned Counselor
|
||||
if (isUser || isAssignedCounselor) {
|
||||
return attachFollowups(full);
|
||||
}
|
||||
|
||||
// INV-013: Matchmaker / 普通 operator 只能看脱敏版
|
||||
// INV-013: Matchmaker / 普通 operator 只能看脱敏版(无 user_request_summary,无 followups)
|
||||
if (isMatchmaker) {
|
||||
return mapper.findSanitized(serviceId);
|
||||
}
|
||||
|
||||
// counseling_manager:可看申请摘要(用于分派判断),
|
||||
// 但**不返回 followups 正文** —— 避免经理成为 INV-013 的绕过通道。
|
||||
if (isManager) {
|
||||
CounselingServiceRecord sanitized = mapper.findSanitized(serviceId);
|
||||
if (sanitized != null) {
|
||||
sanitized.setUserRequestSummary(full.getUserRequestSummary());
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// 其他人一律无权限
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 内部工具 ====================
|
||||
/**
|
||||
* INV-012 的读侧:Counselor 只能取自己被分配的服务。
|
||||
* 非 assigned counselor 请求他人服务 → DataScopeGuard 拒绝并记审计。
|
||||
*/
|
||||
public CounselingServiceRecord getDetailForCounselor(String requesterId, String requesterRole, String serviceId) {
|
||||
CounselingServiceRecord svc = getDetail(requesterId, requesterRole, serviceId);
|
||||
if (svc == null) return null;
|
||||
|
||||
boolean isAssignedCounselor = requesterId.equals(svc.getAssignedOperatorId());
|
||||
boolean isUser = requesterId.equals(svc.getUserId());
|
||||
boolean isManagerOrMatchmaker = "counseling_manager".equals(requesterRole)
|
||||
|| "matchmaker".equals(requesterRole) || "operator".equals(requesterRole);
|
||||
if (!isAssignedCounselor && !isUser && !isManagerOrMatchmaker) {
|
||||
dataScopeGuard.deny(requesterId, "counseling_service", serviceId,
|
||||
"INV-012: 只能查看分配给自己的咨询服务");
|
||||
return null;
|
||||
}
|
||||
return svc;
|
||||
}
|
||||
|
||||
// ==================== 婚前辅导:伴侣确认 ====================
|
||||
|
||||
/**
|
||||
* INV-005: 邀请平台内伴侣参与婚前辅导 —— **必须由伴侣本人确认**,不得由他人代确认。
|
||||
*
|
||||
* @param serviceId 服务 ID
|
||||
* @param companionUserId 当前登录用户(待确认为伴侣的一方,取自认证主体)
|
||||
*/
|
||||
@Transactional
|
||||
public CounselingServiceRecord confirmCompanion(String serviceId, String companionUserId) {
|
||||
CounselingServiceRecord svc = mapper.findById(serviceId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
|
||||
|
||||
if (!"premarital_guidance".equals(svc.getServiceType())) {
|
||||
throw new IllegalStateException("仅婚前辅导支持伴侣确认");
|
||||
}
|
||||
if (svc.getCompanionUserId() == null || !svc.getCompanionUserId().equals(companionUserId)) {
|
||||
throw new IllegalStateException("您不是该服务的受邀伴侣");
|
||||
}
|
||||
if (svc.getUserConfirmedAt() != null) {
|
||||
return svc; // 幂等:已确认则直接返回
|
||||
}
|
||||
svc.setUserConfirmedAt(Instant.now());
|
||||
mapper.updateById(svc);
|
||||
return svc;
|
||||
}
|
||||
|
||||
// ==================== 脱敏 / 内部工具 ====================
|
||||
|
||||
/** 列表/摘要场景:剥离 followups 与正文,仅保留必要字段(INV-013) */
|
||||
public static void sanitize(CounselingServiceRecord svc) {
|
||||
if (svc == null) return;
|
||||
svc.setUserRequestSummary(null);
|
||||
svc.setFollowups(null);
|
||||
}
|
||||
|
||||
private CounselingServiceRecord attachFollowups(CounselingServiceRecord svc) {
|
||||
// INV-013:仅完整视图路径才加载 followups 正文
|
||||
svc.setFollowups(followupMapper.findByServiceId(svc.getId()));
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** INV-012: 断言 counselorId 是 assigned_operator_id */
|
||||
private CounselingServiceRecord assertAssigned(String serviceId, String counselorId) {
|
||||
@@ -164,11 +388,35 @@ public class CounselingService {
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** 合法状态迁移断言 */
|
||||
private void assertTransition(String from, String to) {
|
||||
Set<String> allowed = ALLOWED_TRANSITIONS.getOrDefault(from, Set.of());
|
||||
if (!allowed.contains(to)) {
|
||||
throw new IllegalStateException("非法的状态迁移: " + from + " → " + to);
|
||||
}
|
||||
}
|
||||
|
||||
/** service_type → 标签名 */
|
||||
public static String typeLabel(String serviceType) {
|
||||
return Map.of(
|
||||
"counseling", "婚恋咨询",
|
||||
"premarital", "婚前辅导"
|
||||
"relationship_guidance", "恋爱困惑指导",
|
||||
"emotional_support", "情感支持",
|
||||
"premarital_guidance", "婚前辅导",
|
||||
"anti_fraud_guidance", "婚恋反诈指导"
|
||||
).getOrDefault(serviceType, "公益服务");
|
||||
}
|
||||
|
||||
/** P13A-T05: 咨询介绍(原 CounselingServiceApp.getServiceIntro) */
|
||||
public List<Map<String, Object>> getServiceIntro() {
|
||||
return List.of(
|
||||
Map.of("type", "relationship_guidance", "name", "恋爱困惑指导",
|
||||
"description", "针对恋爱中的困惑、沟通问题提供专业指导"),
|
||||
Map.of("type", "emotional_support", "name", "情感支持",
|
||||
"description", "为情感困扰、焦虑情绪提供陪伴式倾听与疏导"),
|
||||
Map.of("type", "premarital_guidance", "name", "婚前辅导",
|
||||
"description", "为即将步入婚姻的情侣提供婚前沟通与相处指导,可邀请伴侣共同参与"),
|
||||
Map.of("type", "anti_fraud_guidance", "name", "婚恋反诈指导",
|
||||
"description", "提供婚恋诈骗识别与防范指导")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-292
@@ -1,292 +0,0 @@
|
||||
package com.danyang.match.platform.counseling.service;
|
||||
|
||||
import com.danyang.match.platform.counseling.dto.CounselingDTO;
|
||||
import com.danyang.match.platform.counseling.dto.CounselingRequest;
|
||||
import com.danyang.match.platform.counseling.dto.FollowupDTO;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 公益婚恋咨询服务
|
||||
* V1.1 Phase 13A
|
||||
* INV-012: Counselor只能访问被分配的服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CounselingServiceApp {
|
||||
|
||||
private final CounselingServiceMapper counselingMapper;
|
||||
private final CounselingFollowupMapper followupMapper;
|
||||
private final OperatorMapper operatorMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final UserMapper userMapper;
|
||||
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
|
||||
/** P13A-T05: 获取咨询介绍 */
|
||||
public List<Map<String, Object>> getServiceIntro() {
|
||||
List<Map<String, Object>> services = new ArrayList<>();
|
||||
services.add(Map.of("type", "relationship_guidance", "name", "恋爱困惑指导",
|
||||
"description", "针对恋爱中的困惑、沟通问题提供专业指导"));
|
||||
services.add(Map.of("type", "emotional_support", "name", "情感支持",
|
||||
"description", "为情感困扰、焦虑情绪提供心理支持"));
|
||||
services.add(Map.of("type", "premarital_guidance", "name", "婚前辅导",
|
||||
"description", "为即将步入婚姻的情侣提供婚前指导,可邀请伴侣共同参与"));
|
||||
services.add(Map.of("type", "anti_fraud_guidance", "name", "婚恋反诈指导",
|
||||
"description", "提供婚恋诈骗识别与防范指导"));
|
||||
return services;
|
||||
}
|
||||
|
||||
/** P13A-T06: 用户申请咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO createCounseling(String userId, CounselingRequest request) {
|
||||
CounselingServiceRecord cs = new CounselingServiceRecord();
|
||||
cs.setId(UUID.randomUUID().toString());
|
||||
cs.setUserId(userId);
|
||||
cs.setServiceType(request.getServiceType());
|
||||
cs.setRequestSource(request.getRequestSource() != null ? request.getRequestSource() : "user_self");
|
||||
cs.setUserRequestSummary(request.getUserRequestSummary());
|
||||
cs.setPreferredServiceMode(request.getPreferredServiceMode());
|
||||
cs.setPreferredTime(request.getPreferredTime());
|
||||
cs.setCompanionUserId(request.getCompanionUserId());
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_PENDING);
|
||||
cs.setCreatedByOperatorId(request.getCreatedByOperatorId());
|
||||
cs.setCreatedAt(Instant.now());
|
||||
counselingMapper.insert(cs);
|
||||
// P17-T01: 咨询内容风险检测
|
||||
try {
|
||||
String riskLevel = riskReferralService.detectCounselingRisk(userId, request.getUserRequestSummary());
|
||||
if ("high".equals(riskLevel)) {
|
||||
log.warn("咨询内容触发高危风险: userId={}, summary={}", userId, request.getUserRequestSummary());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("咨询风险检测异常: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
// Skip notification for operator users (operators are not in users table)
|
||||
User platformUser = userMapper.findById(userId).orElse(null);
|
||||
if (platformUser != null) {
|
||||
notificationService.sendCounselingAssigned(userId, cs.getId());
|
||||
} else {
|
||||
log.info("跳过咨询通知: userId={} 不是平台用户", userId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("发送咨询通知失败(不影响主流程): {}", e.getMessage());
|
||||
}
|
||||
return toDTO(cs);
|
||||
}
|
||||
|
||||
/** P13A-T07: 获取我的咨询列表 */
|
||||
public List<CounselingDTO> getMyCounselings(String userId) {
|
||||
return counselingMapper.findByUserId(userId).stream().map(this::toDTO).toList();
|
||||
}
|
||||
|
||||
/** P13A-T08: 获取咨询详情(用户只能查看自己的) */
|
||||
public CounselingDTO getCounselingDetail(String counselingId, String viewerUserId) {
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) return null;
|
||||
if (!viewerUserId.equals(cs.getUserId())) {
|
||||
throw new RuntimeException("无权查看该咨询记录");
|
||||
}
|
||||
return toDTO(cs);
|
||||
}
|
||||
|
||||
/** P13A-T09: 取消咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO cancelCounseling(String counselingId, String userId) {
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null || !userId.equals(cs.getUserId())) {
|
||||
throw new RuntimeException("咨询不存在或无权操作");
|
||||
}
|
||||
if (!"pending".equals(cs.getStatus()) && !"assigned".equals(cs.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许取消");
|
||||
}
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_CANCELLED);
|
||||
cs.setUpdatedAt(Instant.now());
|
||||
counselingMapper.updateById(cs);
|
||||
return toDTO(cs);
|
||||
}
|
||||
|
||||
// ---- 后台管理方法 ----
|
||||
|
||||
/** P13A-T19: 待分配队列 */
|
||||
public List<CounselingDTO> getPendingQueue() {
|
||||
return counselingMapper.findPendingAll().stream().map(this::toDTO).toList();
|
||||
}
|
||||
|
||||
/** P13A-T21: 分配咨询师 */
|
||||
@Transactional
|
||||
public CounselingDTO assignCounselor(String counselingId, String operatorId, String counselorId) {
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_ASSIGNED);
|
||||
cs.setAssignedOperatorId(counselorId);
|
||||
cs.setUpdatedAt(Instant.now());
|
||||
counselingMapper.updateById(cs);
|
||||
log.info("分配咨询师: counselingId={}, counselorId={}", counselingId, counselorId);
|
||||
return toDTO(cs);
|
||||
}
|
||||
|
||||
/** P13A-T22: 安排时间 */
|
||||
@Transactional
|
||||
public CounselingDTO scheduleCounseling(String counselingId, OperatorPrincipal principal, Instant startAt, Instant endAt) {
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_SCHEDULED);
|
||||
cs.setScheduledStartAt(startAt);
|
||||
cs.setScheduledEndAt(endAt);
|
||||
cs.setUpdatedAt(Instant.now());
|
||||
counselingMapper.updateById(cs);
|
||||
// Phase 11:管理端排期同样需要触达用户;婚前辅导与婚恋咨询分开通知类型。
|
||||
// 通知失败不回滚排期事实(send 内部对 sendExternal 已做隔离)。
|
||||
try {
|
||||
String time = startAt + " ~ " + endAt;
|
||||
if ("premarital".equals(cs.getServiceType())) {
|
||||
notificationService.premaritalScheduled(cs.getUserId(), counselingId, time);
|
||||
} else {
|
||||
notificationService.counselingScheduled(cs.getUserId(), counselingId, time);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("排期通知发送失败: counselingId={}, err={}", counselingId, e.getMessage());
|
||||
}
|
||||
return toDTO(cs);
|
||||
}
|
||||
|
||||
/** P13A-T23: 开始咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO startCounseling(String counselingId, OperatorPrincipal principal) {
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_IN_PROGRESS);
|
||||
cs.setUpdatedAt(Instant.now());
|
||||
counselingMapper.updateById(cs);
|
||||
return toDTO(cs);
|
||||
}
|
||||
|
||||
/** P13A-T24: 添加跟进记录 */
|
||||
@Transactional
|
||||
public FollowupDTO addFollowup(String counselingId, OperatorPrincipal principal,
|
||||
String followupType, String content,
|
||||
String resultCode, Instant nextFollowupAt) {
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
CounselingFollowup fu = new CounselingFollowup();
|
||||
fu.setId(UUID.randomUUID().toString());
|
||||
fu.setCounselingId(counselingId);
|
||||
fu.setOperatorId(principal.id());
|
||||
fu.setFollowupType(followupType);
|
||||
fu.setContent(content);
|
||||
fu.setResultCode(resultCode);
|
||||
fu.setNextFollowupAt(nextFollowupAt);
|
||||
fu.setCreatedAt(Instant.now());
|
||||
followupMapper.insert(fu);
|
||||
return toFollowupDTO(fu);
|
||||
}
|
||||
|
||||
/** P13A-T25: 完成咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO completeCounseling(String counselingId, OperatorPrincipal principal) {
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_COMPLETED);
|
||||
cs.setCompletedAt(Instant.now());
|
||||
cs.setUpdatedAt(Instant.now());
|
||||
counselingMapper.updateById(cs);
|
||||
try {
|
||||
notificationService.sendCounselingCompleted(cs.getUserId(), cs.getId());
|
||||
} catch (Exception e) {
|
||||
log.warn("发送完成通知失败: {}", e.getMessage());
|
||||
}
|
||||
return toDTO(cs);
|
||||
}
|
||||
|
||||
/** P13A-T26: 关闭咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO closeCounseling(String counselingId, OperatorPrincipal principal, String reason) {
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_CLOSED);
|
||||
cs.setClosedReason(reason);
|
||||
cs.setUpdatedAt(Instant.now());
|
||||
counselingMapper.updateById(cs);
|
||||
return toDTO(cs);
|
||||
}
|
||||
|
||||
/** P13A-T27: Counselor只获取被分配的服务(INV-012) */
|
||||
public List<CounselingDTO> getCounselorAssignedServices(String operatorId) {
|
||||
return counselingMapper.findByAssignedOperator(operatorId).stream().map(this::toDTO).toList();
|
||||
}
|
||||
|
||||
private CounselingDTO toDTO(CounselingServiceRecord cs) {
|
||||
CounselingDTO dto = new CounselingDTO();
|
||||
dto.setId(cs.getId());
|
||||
dto.setUserId(cs.getUserId());
|
||||
dto.setServiceType(cs.getServiceType());
|
||||
dto.setRequestSource(cs.getRequestSource());
|
||||
dto.setUserRequestSummary(cs.getUserRequestSummary());
|
||||
dto.setStatus(cs.getStatus());
|
||||
dto.setAssignedOperatorId(cs.getAssignedOperatorId());
|
||||
dto.setPreferredServiceMode(cs.getPreferredServiceMode());
|
||||
dto.setPreferredTime(cs.getPreferredTime());
|
||||
dto.setScheduledStartAt(cs.getScheduledStartAt());
|
||||
dto.setScheduledEndAt(cs.getScheduledEndAt());
|
||||
dto.setCompanionUserId(cs.getCompanionUserId());
|
||||
dto.setCompletedAt(cs.getCompletedAt());
|
||||
dto.setClosedReason(cs.getClosedReason());
|
||||
dto.setCreatedAt(cs.getCreatedAt());
|
||||
dto.setUpdatedAt(cs.getUpdatedAt());
|
||||
|
||||
if (cs.getAssignedOperatorId() != null) {
|
||||
Operator op = operatorMapper.selectById(cs.getAssignedOperatorId());
|
||||
if (op != null) dto.setAssignedOperatorName(op.getName());
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void assertCanProcess(CounselingServiceRecord counseling, OperatorPrincipal principal) {
|
||||
boolean counselingManager = principal.hasPermission("counseling.service.assign");
|
||||
if (!counselingManager && !principal.id().equals(counseling.getAssignedOperatorId())) {
|
||||
dataScopeGuard.deny(principal.id(), "counseling_service", counseling.getId(),
|
||||
"只能处理分配给自己的咨询服务");
|
||||
}
|
||||
}
|
||||
|
||||
private FollowupDTO toFollowupDTO(CounselingFollowup fu) {
|
||||
FollowupDTO dto = new FollowupDTO();
|
||||
dto.setId(fu.getId());
|
||||
dto.setFollowupType(fu.getFollowupType());
|
||||
dto.setContent(fu.getContent());
|
||||
dto.setResultCode(fu.getResultCode());
|
||||
dto.setNextFollowupAt(fu.getNextFollowupAt());
|
||||
dto.setCreatedAt(fu.getCreatedAt());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
-- ============================================================
|
||||
-- V039: Phase 13A 婚恋咨询 —— 补 counseling 幂等键 + 扩展 request_source 白名单
|
||||
--
|
||||
-- 背景(Phase 13A REFACTOR/EXTEND):
|
||||
-- 1) 计划项 2 要求「申请接口补幂等」—— 客户端重复提交(网络重试 / 用户连点)
|
||||
-- 必须收敛为一条记录。此前 counseling_services 无幂等键列,
|
||||
-- 纯靠 Service 层 SELECT-then-INSERT,并发下会写入重复申请。
|
||||
-- 2) request_source 白名单需要容纳「内容/活动后续跟进」等既有语义来源。
|
||||
--
|
||||
-- 幂等语义:同一 user_id + 同一 idempotency_key 只允许一条有效记录。
|
||||
-- 用 partial unique index 表达(仅约束 idempotency_key 非空的行),
|
||||
-- 与 V036 的通知幂等索引同一手法。
|
||||
-- ⚠️ H2 不支持 partial index,测试库 schema-h2.sql 用普通 UNIQUE 表达
|
||||
-- (PG/H2 的标准 UNIQUE 均允许多行 NULL 共存,语义等价)。
|
||||
--
|
||||
-- 全部语句幂等,可重复执行。
|
||||
-- ============================================================
|
||||
|
||||
-- ---------- 1) 幂等键列 ----------
|
||||
ALTER TABLE counseling_services ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128);
|
||||
|
||||
-- 同一用户 + 同一幂等键唯一(仅约束非空值)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_counseling_idem
|
||||
ON counseling_services (user_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
-- ---------- 2) request_source 白名单(保持与 V022 一致,显式重述以防漂移) ----------
|
||||
-- V022 已建立 chk_request_source,此处按既有惯例 (V018/V020/V025/V038)
|
||||
-- 用 DROP + 重加的方式显式固化「最终有效」白名单,使守卫解析结果稳定。
|
||||
ALTER TABLE counseling_services DROP CONSTRAINT IF EXISTS chk_request_source;
|
||||
ALTER TABLE counseling_services ADD CONSTRAINT chk_request_source
|
||||
CHECK (request_source IN (
|
||||
'user_self', 'matchmaker_referral', 'activity_followup',
|
||||
'service_station', 'operator_assisted'
|
||||
));
|
||||
|
||||
-- ---------- 3) 状态白名单(同上,显式固化合法状态集合) ----------
|
||||
ALTER TABLE counseling_services DROP CONSTRAINT IF EXISTS chk_counseling_status;
|
||||
ALTER TABLE counseling_services ADD CONSTRAINT chk_counseling_status
|
||||
CHECK (status IN (
|
||||
'pending', 'assigned', 'scheduled', 'in_progress',
|
||||
'completed', 'cancelled', 'closed'
|
||||
));
|
||||
|
||||
-- ---------- 4) service_type 白名单(同上) ----------
|
||||
ALTER TABLE counseling_services DROP CONSTRAINT IF EXISTS chk_counseling_service_type;
|
||||
ALTER TABLE counseling_services ADD CONSTRAINT chk_counseling_service_type
|
||||
CHECK (service_type IN (
|
||||
'relationship_guidance', 'emotional_support',
|
||||
'premarital_guidance', 'anti_fraud_guidance'
|
||||
));
|
||||
|
||||
-- ---------- 5) 伴侣确认语义(INV-005)----------
|
||||
-- user_confirmed_at 由**伴侣本人**写入,非工作人员代填。
|
||||
COMMENT ON COLUMN counseling_services.user_confirmed_at
|
||||
IS 'INV-005: 婚前辅导受邀伴侣本人的确认时间,仅本人可写,工作人员不得代确认';
|
||||
COMMENT ON COLUMN counseling_services.idempotency_key
|
||||
IS '客户端幂等键;同一 user_id + idempotency_key 唯一';
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package com.danyang.match.platform.counseling;
|
||||
|
||||
import com.danyang.match.platform.counseling.service.CounselingService;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 13A:counseling 模块「代码常量 ⊆ 生产 CHECK 白名单」守卫。
|
||||
*
|
||||
* <p><b>核心纪律(Phase 13 确立的第四层测试保真度)</b>:
|
||||
* 守卫必须**直接解析 {@code db/migration/V*.sql} 原文**,绝不能在测试里抄一份白名单常量。
|
||||
* 抄副本的守卫只证明"今天抄的和昨天抄的一样",对 DDL 漂移**零反应** ——
|
||||
* Phase 13 首版就是这么写的,注入真实缺陷后依然全绿,属于**永不失效的假守卫**。
|
||||
*
|
||||
* <p>解析规则:约束扩展在本项目是既有惯例(V018/V020/V025/V038/V039),
|
||||
* 一律 {@code DROP CONSTRAINT IF EXISTS} + {@code ADD CONSTRAINT}。
|
||||
* 因此解析器**必须按文件名顺序**依次应用这两条语句,后者覆盖前者,
|
||||
* 最终得到的那份才是"最终有效"白名单。
|
||||
*
|
||||
* <p>每个守卫都配一个**解析器自检**用例:若解析失效(返回 null/空),
|
||||
* 守卫会立刻失败,而不是"因为没解析到所以断言恒真"。
|
||||
*/
|
||||
class CounselingConstraintGuardTest {
|
||||
|
||||
/**
|
||||
* 迁移目录可能出现的候选相对后缀(相对 CWD)。
|
||||
*
|
||||
* <p><b>踩坑记录(Phase 13A 实测)</b>:本类首版直接写死
|
||||
* {@code Path.of("src/main/resources/db/migration")},结果 5 个用例**全部因解析不到约束而失败**。
|
||||
* 根因是 {@code pom.xml} 里 surefire 显式配置了
|
||||
* {@code <workingDirectory>${project.basedir}/..</workingDirectory>} ——
|
||||
* 测试进程的 CWD 是**仓库根**({@code 公益婚恋服务平台/})而**不是** {@code backend/}。
|
||||
*
|
||||
* <p>更隐蔽的是:{@code backend/} 是 CWD 的**子目录**而非祖先,
|
||||
* 所以"只向上回溯找 {@code src/...}"也找不到(实测报错 CWD=/proj)。
|
||||
* 必须同时尝试 {@code backend/src/...}。
|
||||
*
|
||||
* <p>教训:守卫类测试**绝不能假定 CWD**;定位失败要**大声失败**,
|
||||
* 绝不能静默返回 null 让守卫变成"恒真假绿"。
|
||||
*/
|
||||
private static final List<String> MIGRATION_CANDIDATES = List.of(
|
||||
"src/main/resources/db/migration",
|
||||
"backend/src/main/resources/db/migration"
|
||||
);
|
||||
|
||||
/** 按文件名顺序解析出的「约束名 → 最终有效取值集合」缓存 */
|
||||
private static final Map<String, Set<String>> CACHE = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 定位 {@code db/migration} 目录:从 CWD 起逐级向上,每一级都尝试
|
||||
* {@link #MIGRATION_CANDIDATES} 里的所有候选后缀。
|
||||
*/
|
||||
private static Path locateMigrationDir() {
|
||||
Path cur = Path.of("").toAbsolutePath().normalize();
|
||||
List<String> tried = new ArrayList<>();
|
||||
for (int i = 0; i < 6 && cur != null; i++) {
|
||||
for (String rel : MIGRATION_CANDIDATES) {
|
||||
Path candidate = cur.resolve(rel);
|
||||
tried.add(candidate.toString());
|
||||
if (Files.isDirectory(candidate)) return candidate;
|
||||
}
|
||||
cur = cur.getParent();
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"守卫无法定位迁移目录;CWD=" + Path.of("").toAbsolutePath()
|
||||
+ ";已尝试:\n - " + String.join("\n - ", tried)
|
||||
+ "\n守卫必须解析**真实** DDL 原文,定位失败即视为测试基建错误,不得跳过。");
|
||||
}
|
||||
|
||||
// ==================== 解析器自检 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("解析器自检: 必须真的解析到 counseling 相关约束(防止守卫因解析失效而恒真)")
|
||||
void parserMustResolveCounselingConstraints() {
|
||||
// 先证明"确实读到了迁移目录、且读到了足够多的文件"——
|
||||
// 否则"解析不到约束"到底是 DDL 真没有、还是路径没找对,无法区分。
|
||||
Path dir = locateMigrationDir();
|
||||
long fileCount;
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
fileCount = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql")).count();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("列目录失败: " + dir, e);
|
||||
}
|
||||
assertTrue(fileCount >= 30,
|
||||
"解析到的迁移文件过少(" + fileCount + "),目录可能选错了: " + dir);
|
||||
|
||||
for (String c : List.of("chk_counseling_service_type", "chk_counseling_status", "chk_request_source")) {
|
||||
Set<String> vals = resolveCheckValues(c);
|
||||
assertNotNull(vals, "解析器失效:未解析到约束 " + c + "(dir=" + dir + ", files=" + fileCount + ")");
|
||||
assertFalse(vals.isEmpty(), "解析器失效:约束 " + c + " 白名单为空");
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取生产白名单;为 null 时抛出可读的失败信息,避免下游 NPE 掩盖真实原因。 */
|
||||
private static Set<String> requireWhitelist(String constraintName) {
|
||||
Set<String> allowed = resolveCheckValues(constraintName);
|
||||
assertNotNull(allowed,
|
||||
"守卫失效:未从生产迁移解析到 " + constraintName
|
||||
+ "(迁移目录=" + locateMigrationDir() + ")。"
|
||||
+ "若该约束确实不存在,说明 DDL 丢了硬约束,应作为缺陷修复而不是放宽守卫。");
|
||||
return allowed;
|
||||
}
|
||||
|
||||
// ==================== 守卫 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: CounselingService 的 service_type 常量 ⊆ 生产 chk_counseling_service_type 白名单")
|
||||
void serviceTypeConstantsWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_counseling_service_type");
|
||||
Set<String> codeValues = readStringSetField(CounselingService.class, "VALID_SERVICE_TYPES");
|
||||
|
||||
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
|
||||
Set<String> violations = new TreeSet<>(codeValues);
|
||||
violations.removeAll(allowed);
|
||||
assertTrue(violations.isEmpty(),
|
||||
"代码写入的 service_type 不在生产 CHECK 白名单内,真实 PG 上必然失败:\n - "
|
||||
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: CounselingService 的 request_source 常量 ⊆ 生产 chk_request_source 白名单")
|
||||
void requestSourceConstantsWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_request_source");
|
||||
Set<String> codeValues = readStringSetField(CounselingService.class, "VALID_REQUEST_SOURCES");
|
||||
|
||||
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
|
||||
Set<String> violations = new TreeSet<>(codeValues);
|
||||
violations.removeAll(allowed);
|
||||
assertTrue(violations.isEmpty(),
|
||||
"代码写入的 request_source 不在生产 CHECK 白名单内:\n - "
|
||||
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: 状态机 ALLOWED_TRANSITIONS 的状态取值 ⊆ 生产 chk_counseling_status 白名单")
|
||||
void statusMachineWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_counseling_status");
|
||||
|
||||
// 状态机的 key(from)与 value(to)都必须落在白名单内
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Set<String>> machine =
|
||||
(Map<String, Set<String>>) readField(CounselingService.class, "ALLOWED_TRANSITIONS");
|
||||
assertNotNull(machine, "解析不到状态机常量,守卫失效");
|
||||
assertFalse(machine.isEmpty());
|
||||
|
||||
Set<String> used = new TreeSet<>();
|
||||
machine.forEach((from, tos) -> {
|
||||
used.add(from);
|
||||
used.addAll(tos);
|
||||
});
|
||||
Set<String> violations = new TreeSet<>(used);
|
||||
violations.removeAll(allowed);
|
||||
assertTrue(violations.isEmpty(),
|
||||
"状态机使用了不在生产 CHECK 白名单内的状态值:\n - "
|
||||
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: 状态机必须覆盖全部合法状态,且终态不可再迁移")
|
||||
void statusMachineCoversAllStates() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_counseling_status");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Set<String>> machine =
|
||||
(Map<String, Set<String>>) readField(CounselingService.class, "ALLOWED_TRANSITIONS");
|
||||
|
||||
Set<String> missing = new TreeSet<>(allowed);
|
||||
missing.removeAll(machine.keySet());
|
||||
assertTrue(missing.isEmpty(), "状态机未声明这些合法状态的迁移规则:" + missing);
|
||||
|
||||
// 终态:completed / cancelled / closed 不得再有出边
|
||||
for (String terminal : List.of("completed", "cancelled", "closed")) {
|
||||
assertTrue(machine.getOrDefault(terminal, Set.of()).isEmpty(),
|
||||
terminal + " 应为终态,不得再迁移到其它状态");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/**
|
||||
* 按文件名顺序解析 db/migration/V*.sql,返回某 CHECK 约束的「最终有效」取值集合。
|
||||
*
|
||||
* 处理两类语句(本项目既有惯例):
|
||||
* <pre>
|
||||
* ALTER TABLE x DROP CONSTRAINT IF EXISTS chk_foo;
|
||||
* ALTER TABLE x ADD CONSTRAINT chk_foo CHECK (col IN ('a','b','c'));
|
||||
* </pre>
|
||||
* 后者覆盖前者。
|
||||
*/
|
||||
private static Set<String> resolveCheckValues(String constraintName) {
|
||||
if (CACHE.containsKey(constraintName)) return CACHE.get(constraintName);
|
||||
Set<String> result = null;
|
||||
try {
|
||||
Path dir = locateMigrationDir();
|
||||
List<Path> files;
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
files = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql"))
|
||||
.sorted(Comparator.comparing(p -> p.getFileName().toString()))
|
||||
.toList();
|
||||
}
|
||||
for (Path f : files) {
|
||||
String sql = Files.readString(f, StandardCharsets.UTF_8);
|
||||
// 归一化空白,便于跨行匹配
|
||||
String flat = sql.replaceAll("\\s+", " ");
|
||||
|
||||
// DROP CONSTRAINT IF EXISTS chk_foo
|
||||
Pattern drop = Pattern.compile(
|
||||
"DROP\\s+CONSTRAINT\\s+IF\\s+EXISTS\\s+" + Pattern.quote(constraintName),
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
if (drop.matcher(flat).find()) {
|
||||
// 记录"已被 drop",但真正的生效值由后续 ADD 决定;若之后没有 ADD,则为空
|
||||
result = null;
|
||||
}
|
||||
|
||||
// ADD CONSTRAINT chk_foo CHECK (col IN ('a','b',...))
|
||||
Pattern add = Pattern.compile(
|
||||
"ADD\\s+CONSTRAINT\\s+" + Pattern.quote(constraintName)
|
||||
+ "\\s+CHECK\\s*\\([^)]*IN\\s*\\(([^)]*)\\)",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
Matcher m = add.matcher(flat);
|
||||
if (m.find()) {
|
||||
Set<String> vals = new TreeSet<>();
|
||||
Matcher lit = Pattern.compile("'([^']*)'").matcher(m.group(1));
|
||||
while (lit.find()) vals.add(lit.group(1));
|
||||
result = vals.isEmpty() ? null : vals;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("解析迁移文件失败: " + e.getMessage(), e);
|
||||
}
|
||||
CACHE.put(constraintName, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 反射读常量 ====================
|
||||
|
||||
private static Object readField(Class<?> type, String name) throws Exception {
|
||||
Field f = type.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f.get(null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Set<String> readStringSetField(Class<?> type, String name) throws Exception {
|
||||
return (Set<String>) readField(type, name);
|
||||
}
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
package com.danyang.match.platform.counseling;
|
||||
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.counseling.service.CounselingServiceApp;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.risk.service.RiskReferralService;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CounselingDataScopeTest {
|
||||
|
||||
@Mock CounselingServiceMapper counselingMapper;
|
||||
@Mock CounselingFollowupMapper followupMapper;
|
||||
@Mock OperatorMapper operatorMapper;
|
||||
@Mock NotificationService notificationService;
|
||||
@Mock UserMapper userMapper;
|
||||
@Mock RiskReferralService riskReferralService;
|
||||
@Mock AuditLogMapper auditLogMapper;
|
||||
|
||||
private CounselingServiceApp service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new CounselingServiceApp(counselingMapper, followupMapper, operatorMapper,
|
||||
notificationService, userMapper, riskReferralService, new DataScopeGuard(auditLogMapper));
|
||||
}
|
||||
|
||||
@Test
|
||||
void counselorCannotScheduleServiceAssignedToAnotherCounselor() {
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> service.scheduleCounseling(
|
||||
"service-1", new OperatorPrincipal("counselor-b", java.util.Set.of("counseling.service.process")), Instant.parse("2026-09-10T01:00:00Z"),
|
||||
Instant.parse("2026-09-10T02:00:00Z")));
|
||||
verify(counselingMapper, never()).updateById(any());
|
||||
verify(auditLogMapper).insert(argThat(audit ->
|
||||
"DATA_SCOPE_DENIED".equals(audit.getAction())
|
||||
&& "counselor-b".equals(audit.getOperatorId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignedCounselorCanScheduleOwnService() {
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
|
||||
|
||||
var result = service.scheduleCounseling("service-1", new OperatorPrincipal("counselor-a", java.util.Set.of("counseling.service.process")),
|
||||
Instant.parse("2026-09-10T01:00:00Z"), Instant.parse("2026-09-10T02:00:00Z"));
|
||||
|
||||
assertEquals("scheduled", result.getStatus());
|
||||
verify(counselingMapper).updateById(counseling);
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignedListDoesNotExposeSensitiveFollowupContentByDefault() {
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.findByAssignedOperator("counselor-a")).thenReturn(List.of(counseling));
|
||||
|
||||
var result = service.getCounselorAssignedServices("counselor-a");
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertTrue(result.getFirst().getFollowups() == null || result.getFirst().getFollowups().isEmpty());
|
||||
verifyNoInteractions(followupMapper);
|
||||
}
|
||||
|
||||
private CounselingServiceRecord assignedService(String id, String operatorId) {
|
||||
CounselingServiceRecord service = new CounselingServiceRecord();
|
||||
service.setId(id);
|
||||
service.setUserId("user-1");
|
||||
service.setAssignedOperatorId(operatorId);
|
||||
service.setStatus("assigned");
|
||||
service.setCreatedAt(Instant.now());
|
||||
return service;
|
||||
}
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
package com.danyang.match.platform.counseling;
|
||||
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.counseling.service.CounselingService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 13A:婚恋咨询与婚前辅导 —— 真实运行时路径测试。
|
||||
*
|
||||
* <p>覆盖计划 6 项与 Gate:「申请→分配→预约→服务→完成→反馈」真实闭环 +
|
||||
* 所有隐私负向用例(INV-012 / INV-013 / INV-005)。
|
||||
*
|
||||
* <p>测试保真度(第四层,Phase 13 确立):本测试走**真实 Service 方法**与**真实落库**,
|
||||
* 且测试库 {@code schema-h2.sql} 已补齐 counseling 三表的生产 CHECK 约束
|
||||
* (V022 + V039),因此状态机越界、非法 service_type 等错误会在测试期即暴露。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class CounselingPhase13ATest {
|
||||
|
||||
@Autowired private CounselingService counselingService;
|
||||
@Autowired private CounselingServiceMapper mapper;
|
||||
|
||||
private static final String USER = "u-counsel-1";
|
||||
private static final String COMPANION = "u-companion-1";
|
||||
private static final String COUNSELOR_A = "op-counselor-a";
|
||||
private static final String COUNSELOR_B = "op-counselor-b";
|
||||
|
||||
// ==================== Gate:申请 → 分配 → 预约 → 服务 → 完成 → 反馈 真实闭环 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 申请→分配→排期→开始→跟进→完成 全链路真实闭环")
|
||||
void fullLifecycleClosedLoop() {
|
||||
// 1. 申请
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "最近沟通上有些困惑",
|
||||
"user_self", "online", "工作日晚间", null, null);
|
||||
assertEquals("pending", svc.getStatus());
|
||||
assertEquals(USER, svc.getUserId());
|
||||
|
||||
// 2. 分配
|
||||
CounselingServiceRecord assigned = counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
assertEquals("assigned", assigned.getStatus());
|
||||
assertEquals(COUNSELOR_A, assigned.getAssignedOperatorId());
|
||||
|
||||
// 3. 排期
|
||||
Instant start = Instant.parse("2026-10-01T10:00:00Z");
|
||||
Instant end = Instant.parse("2026-10-01T11:00:00Z");
|
||||
CounselingServiceRecord scheduled = counselingService.schedule(svc.getId(), COUNSELOR_A, start, end);
|
||||
assertEquals("scheduled", scheduled.getStatus());
|
||||
assertEquals(start, scheduled.getScheduledStartAt());
|
||||
|
||||
// 4. 开始服务
|
||||
assertEquals("in_progress", counselingService.start(svc.getId(), COUNSELOR_A).getStatus());
|
||||
|
||||
// 5. 跟进(反馈)
|
||||
CounselingFollowup fu = counselingService.addFollowup(
|
||||
svc.getId(), COUNSELOR_A, "online_chat", "已完成首次沟通,情绪有所缓解", "ok", null);
|
||||
assertNotNull(fu.getId());
|
||||
|
||||
// 6. 完成
|
||||
CounselingServiceRecord done = counselingService.complete(svc.getId(), COUNSELOR_A);
|
||||
assertEquals("completed", done.getStatus());
|
||||
assertNotNull(done.getCompletedAt());
|
||||
|
||||
// 闭环可追溯:详情里能看到跟进记录
|
||||
CounselingServiceRecord detail = counselingService.getDetail(USER, "user", svc.getId());
|
||||
assertNotNull(detail.getFollowups());
|
||||
assertEquals(1, detail.getFollowups().size());
|
||||
}
|
||||
|
||||
// ==================== 计划项 2:userId 取自认证主体 + 幂等 + 取消 + 状态迁移 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("apply: userId 由入参(认证主体)决定,请求体无法伪造")
|
||||
void applyUsesAuthenticatedPrincipalUserId() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "emotional_support", "有点焦虑", "user_self", "online", null, null, null);
|
||||
// 落库的 userId 必须是传入的主体 ID
|
||||
CounselingServiceRecord inDb = mapper.findById(svc.getId()).orElseThrow();
|
||||
assertEquals(USER, inDb.getUserId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("apply: 同一幂等键重复提交只落一条记录")
|
||||
void applyIsIdempotentByKey() {
|
||||
String key = "idem-key-001";
|
||||
CounselingServiceRecord first = counselingService.apply(
|
||||
USER, "relationship_guidance", "同一份申请", "user_self", "online", null, null, key);
|
||||
CounselingServiceRecord second = counselingService.apply(
|
||||
USER, "relationship_guidance", "同一份申请", "user_self", "online", null, null, key);
|
||||
|
||||
assertEquals(first.getId(), second.getId(), "同一幂等键必须返回同一条记录");
|
||||
long count = mapper.findByUserId(USER).stream()
|
||||
.filter(s -> key.equals(s.getIdempotencyKey())).count();
|
||||
assertEquals(1, count, "幂等键只应落一条记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("apply: 非法 service_type 被拒绝(与生产 CHECK 白名单一致)")
|
||||
void applyRejectsInvalidServiceType() {
|
||||
assertThrows(IllegalArgumentException.class, () -> counselingService.apply(
|
||||
USER, "not_a_real_type", "x", "user_self", "online", null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("apply: 非法 request_source 被拒绝")
|
||||
void applyRejectsInvalidRequestSource() {
|
||||
assertThrows(IllegalArgumentException.class, () -> counselingService.apply(
|
||||
USER, "relationship_guidance", "x", "hacked_source", "online", null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cancel: 仅申请人本人可取消,他人取消被拒")
|
||||
void onlyOwnerCanCancel() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "待取消", "user_self", "online", null, null, null);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.cancel(svc.getId(), "someone-else", "越权取消"));
|
||||
|
||||
CounselingServiceRecord cancelled = counselingService.cancel(svc.getId(), USER, "不需要了");
|
||||
assertEquals("cancelled", cancelled.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态迁移: 已完成的服务不可再取消(非法迁移被拒)")
|
||||
void completedServiceCannotBeCancelled() {
|
||||
CounselingServiceRecord svc = seedCompleted();
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.cancel(svc.getId(), USER, "事后反悔"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态迁移: 未分配的服务不可直接排期")
|
||||
void pendingServiceCannotBeScheduled() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "未分配", "user_self", "online", null, null, null);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> counselingService.schedule(
|
||||
svc.getId(), COUNSELOR_A,
|
||||
Instant.parse("2026-10-01T10:00:00Z"), Instant.parse("2026-10-01T11:00:00Z")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态迁移: 排期时间必须 end > start")
|
||||
void scheduleRequiresValidRange() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "时间校验", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> counselingService.schedule(
|
||||
svc.getId(), COUNSELOR_A,
|
||||
Instant.parse("2026-10-01T11:00:00Z"), Instant.parse("2026-10-01T10:00:00Z")));
|
||||
}
|
||||
|
||||
// ==================== 计划项 3:分配并发安全 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("assign: 并发分配同一单 —— 第二个分配者被明确拒绝,不覆盖第一个")
|
||||
void concurrentAssignDoesNotSilentlyOverwrite() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "并发分配", "user_self", "online", null, null, null);
|
||||
|
||||
CounselingServiceRecord first = counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
assertEquals(COUNSELOR_A, first.getAssignedOperatorId());
|
||||
|
||||
// 第二次分配必须失败(条件更新 WHERE status='pending' 未命中)
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.assign(svc.getId(), COUNSELOR_B));
|
||||
assertTrue(ex.getMessage().contains("已被分配"), "错误信息应说明冲突原因");
|
||||
|
||||
// 关键:第一个分配者的归属**未被覆盖**
|
||||
CounselingServiceRecord inDb = mapper.findById(svc.getId()).orElseThrow();
|
||||
assertEquals(COUNSELOR_A, inDb.getAssignedOperatorId(), "不得静默覆盖先到者的分派");
|
||||
}
|
||||
|
||||
// ==================== INV-012:Counselor 只能读写 Assigned Service ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: 非 assigned Counselor 不能排期他人服务")
|
||||
void nonAssignedCounselorCannotSchedule() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "归属校验", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> counselingService.schedule(
|
||||
svc.getId(), COUNSELOR_B,
|
||||
Instant.parse("2026-10-01T10:00:00Z"), Instant.parse("2026-10-01T11:00:00Z")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: 非 assigned Counselor 不能添加跟进")
|
||||
void nonAssignedCounselorCannotAddFollowup() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "跟进归属", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> counselingService.addFollowup(
|
||||
svc.getId(), COUNSELOR_B, "phone_call", "越权写入", null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: 非 assigned Counselor 不能完成他人服务")
|
||||
void nonAssignedCounselorCannotComplete() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "完成归属", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.complete(svc.getId(), COUNSELOR_B));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: assignedTo 只返回分配给自己的服务")
|
||||
void assignedListOnlyContainsOwnServices() {
|
||||
CounselingServiceRecord mine = counselingService.apply(
|
||||
USER, "relationship_guidance", "我的", "user_self", "online", null, null, null);
|
||||
CounselingServiceRecord other = counselingService.apply(
|
||||
"u-counsel-2", "emotional_support", "别人的", "user_self", "online", null, null, null);
|
||||
counselingService.assign(mine.getId(), COUNSELOR_A);
|
||||
counselingService.assign(other.getId(), COUNSELOR_B);
|
||||
|
||||
List<CounselingServiceRecord> list = counselingService.assignedTo(COUNSELOR_A);
|
||||
assertTrue(list.stream().anyMatch(s -> mine.getId().equals(s.getId())));
|
||||
assertFalse(list.stream().anyMatch(s -> other.getId().equals(s.getId())),
|
||||
"不得返回分配给其他咨询师的服务");
|
||||
}
|
||||
|
||||
// ==================== INV-013:不得读取咨询正文 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 申请者本人可见完整正文与跟进记录")
|
||||
void ownerSeesFullContent() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail(USER, "user", svc.getId());
|
||||
assertNotNull(detail);
|
||||
assertEquals("敏感正文内容", detail.getUserRequestSummary(), "本人应看到自己的申请正文");
|
||||
assertNotNull(detail.getFollowups());
|
||||
assertEquals(1, detail.getFollowups().size(), "本人应看到跟进记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: assigned Counselor 可见完整正文与跟进记录")
|
||||
void assignedCounselorSeesFullContent() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail(COUNSELOR_A, "counselor", svc.getId());
|
||||
assertNotNull(detail);
|
||||
assertEquals("敏感正文内容", detail.getUserRequestSummary());
|
||||
assertNotNull(detail.getFollowups());
|
||||
assertEquals(1, detail.getFollowups().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 红娘只能看脱敏版 —— 无正文、无跟进记录")
|
||||
void matchmakerSeesSanitizedDetail() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail("op-matchmaker-1", "matchmaker", svc.getId());
|
||||
assertNotNull(detail, "红娘应能查看必要摘要(状态/类型)");
|
||||
assertNull(detail.getUserRequestSummary(), "INV-013: 红娘不得读取咨询正文");
|
||||
assertNull(detail.getFollowups(), "INV-013: 红娘不得读取跟进正文");
|
||||
// 必要字段仍可见,便于红娘判断转介状态
|
||||
assertEquals(svc.getStatus(), detail.getStatus());
|
||||
assertEquals(svc.getServiceType(), detail.getServiceType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 普通 operator 只能看脱敏版")
|
||||
void plainOperatorSeesSanitizedDetail() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail("op-plain-1", "operator", svc.getId());
|
||||
assertNotNull(detail);
|
||||
assertNull(detail.getUserRequestSummary());
|
||||
assertNull(detail.getFollowups());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: counseling_manager 可见申请摘要但**不得**见跟进正文")
|
||||
void managerSeesSummaryButNotFollowups() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail("op-mgr-1", "counseling_manager", svc.getId());
|
||||
assertNotNull(detail);
|
||||
assertEquals("敏感正文内容", detail.getUserRequestSummary(), "经理需要摘要以判断分派");
|
||||
assertNull(detail.getFollowups(), "INV-013: 经理不得成为阅读跟进正文的绕过通道");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 陌生人无权查看详情")
|
||||
void strangerGetsNothing() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
assertNull(counselingService.getDetail("op-total-stranger", "unknown_role", svc.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 列表与队列路径均不携带正文(sanitize 生效)")
|
||||
void listPathsAreSanitized() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "列表脱敏验证", "user_self", "online", null, null, null);
|
||||
|
||||
List<CounselingServiceRecord> queue = counselingService.pendingQueue();
|
||||
assertTrue(queue.stream().anyMatch(s -> svc.getId().equals(s.getId())));
|
||||
// pendingQueue 返回的实体未装配 followups;Controller 层再调 sanitize 清正文
|
||||
queue.forEach(CounselingService::sanitize);
|
||||
queue.stream().filter(s -> svc.getId().equals(s.getId())).forEach(s -> {
|
||||
assertNull(s.getUserRequestSummary(), "队列路径必须清理正文");
|
||||
assertNull(s.getFollowups());
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== INV-005:婚前辅导伴侣须本人确认 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 受邀伴侣本人可确认参与婚前辅导")
|
||||
void companionCanConfirmBySelf() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "premarital_guidance", "婚前辅导申请",
|
||||
"user_self", "offline", null, COMPANION, null);
|
||||
|
||||
CounselingServiceRecord confirmed = counselingService.confirmCompanion(svc.getId(), COMPANION);
|
||||
assertNotNull(confirmed.getUserConfirmedAt(), "伴侣本人确认后应写入确认时间");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 工作人员不得替伴侣确认(非受邀者确认被拒)")
|
||||
void staffCannotConfirmOnBehalfOfCompanion() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "premarital_guidance", "婚前辅导申请",
|
||||
"user_self", "offline", null, COMPANION, null);
|
||||
|
||||
// 工作人员(operator)尝试确认 → 拒绝
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.confirmCompanion(svc.getId(), "op-staff-1"),
|
||||
"INV-005: 任何工作人员不得替用户确认");
|
||||
// 申请人自己也不能替伴侣确认
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.confirmCompanion(svc.getId(), USER),
|
||||
"INV-005: 申请人本人亦不能替伴侣确认");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 非婚前辅导服务不支持伴侣确认")
|
||||
void companionConfirmOnlyForPremarital() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "普通咨询",
|
||||
"user_self", "online", null, COMPANION, null);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.confirmCompanion(svc.getId(), COMPANION));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 伴侣确认幂等 —— 重复确认不报错、且不改变首次确认时间")
|
||||
void companionConfirmIsIdempotent() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "premarital_guidance", "幂等确认",
|
||||
"user_self", "offline", null, COMPANION, null);
|
||||
|
||||
Instant first = counselingService.confirmCompanion(svc.getId(), COMPANION).getUserConfirmedAt();
|
||||
assertNotNull(first);
|
||||
|
||||
// 第二次确认必须命中短路分支(userConfirmedAt != null → 直接返回),
|
||||
// 不得再次 setUserConfirmedAt(now) 覆盖首次时间。
|
||||
//
|
||||
// ⚠️ 精度坑:H2/PG 的 timestamp 精度低于 Instant.now() 的纳秒。
|
||||
// 首次返回值来自内存实体(纳秒),二次返回值若来自 DB 回读则被截断到微秒,
|
||||
// 直接 assertEquals 会因 470ns 级差异误报。因此比较"截断到毫秒"的表现值,
|
||||
// 并额外断言两次返回的**时间差 < 1 秒**(真正要防的是"被覆盖成新时间")。
|
||||
Instant second = counselingService.confirmCompanion(svc.getId(), COMPANION).getUserConfirmedAt();
|
||||
assertNotNull(second);
|
||||
assertEquals(first.truncatedTo(java.time.temporal.ChronoUnit.MILLIS),
|
||||
second.truncatedTo(java.time.temporal.ChronoUnit.MILLIS),
|
||||
"重复确认不应改变已写入的确认时间");
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
|
||||
/**
|
||||
* 造一条「已分配 → 进行中 → 有 1 条跟进」的记录。
|
||||
*
|
||||
* <p><b>踩坑记录</b>:本方法最初直接返回 {@code apply()} 的返回值。
|
||||
* 但那个对象是**测试内存里的旧快照**(status 仍是 {@code pending}),
|
||||
* 而 {@code getDetail(...)} 是**重新查库**得到的({@code in_progress})。
|
||||
* 于是 {@code assertEquals(svc.getStatus(), detail.getStatus())} 拿"陈旧内存值"比"最新库值",
|
||||
* 必然误报 —— 属于测试自身缺陷,不是生产缺陷。
|
||||
* 现改为返回**重新查库**的实例,保证两侧同源。
|
||||
*/
|
||||
private CounselingServiceRecord seedInProgressWithFollowup() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "敏感正文内容", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
counselingService.start(svc.getId(), COUNSELOR_A);
|
||||
counselingService.addFollowup(svc.getId(), COUNSELOR_A, "phone_call",
|
||||
"跟进正文内容(敏感)", "ok", null);
|
||||
return reload(svc.getId());
|
||||
}
|
||||
|
||||
private CounselingServiceRecord seedCompleted() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "已完成服务", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
counselingService.start(svc.getId(), COUNSELOR_A);
|
||||
counselingService.complete(svc.getId(), COUNSELOR_A);
|
||||
return reload(svc.getId());
|
||||
}
|
||||
|
||||
/** 以申请者身份重新查库,拿到与断言侧同源的实体快照 */
|
||||
private CounselingServiceRecord reload(String serviceId) {
|
||||
CounselingServiceRecord fresh = counselingService.getDetail(USER, "user", serviceId);
|
||||
assertNotNull(fresh, "重载失败:serviceId=" + serviceId);
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
+32
-3
@@ -59,7 +59,6 @@ class EntityMigrationParityTest {
|
||||
"appeals",
|
||||
"consent_records",
|
||||
"content_categories",
|
||||
"counseling_followups",
|
||||
"interest_dictionary",
|
||||
"match_rule_configs",
|
||||
"operator_roles",
|
||||
@@ -104,7 +103,11 @@ class EntityMigrationParityTest {
|
||||
// 已补入 schema-h2.sql,且从 TEST_SCHEMA_OPTIONAL_TABLES 白名单移除。
|
||||
"matchmaker_services",
|
||||
"matchmaker_recommendations",
|
||||
"matchmaker_followups"
|
||||
"matchmaker_followups",
|
||||
// Phase 13A:咨询两表此前 counseling_services 完全缺失、counseling_followups
|
||||
// 仅在可选白名单里(= 实际上没人测)。已补入 schema-h2.sql 并移入必需集合。
|
||||
"counseling_services",
|
||||
"counseling_followups"
|
||||
);
|
||||
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
|
||||
private static final Set<String> BASE_ENTITY_COLUMNS =
|
||||
@@ -309,6 +312,27 @@ class EntityMigrationParityTest {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归守卫:{@code @TableField(exist = false)} 的**全限定写法**也必须被排除。
|
||||
*
|
||||
* <p>Phase 13A 实测发现的解析器缺陷:旧正则只认 {@code @TableField(...)},
|
||||
* 遇到 {@code @com.baomidou.mybatisplus.annotation.TableField(exist = false)}
|
||||
* 就会把非持久化字段当成真实列,随后报出「生产缺列」的**假警报**。
|
||||
* 若有人为消警报而去生产加一个无用列,就是把测试保真度反噬成生产污染。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("解析器自检:全限定 @TableField(exist=false) 必须被识别为非持久化字段")
|
||||
void fullyQualifiedTableFieldExistFalseMustBeExcluded() throws IOException {
|
||||
Map<String, Set<String>> cols = parseEntityColumns();
|
||||
|
||||
Set<String> cs = cols.get("counseling_services");
|
||||
assertNotNull(cs, "未识别 CounselingServiceRecord 实体(@TableName(\"counseling_services\"))");
|
||||
assertFalse(cs.contains("followups"),
|
||||
"全限定 @TableField(exist = false) 未被排除 —— 非持久化字段 followups "
|
||||
+ "被当成了 counseling_services 的真实列,会产生假漂移警报。"
|
||||
+ "请检查 exist=false 排除正则是否覆盖全限定注解写法。");
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/** 解析 db/migration/V*.sql 的 CREATE TABLE 列定义 + ALTER TABLE ADD COLUMN。 */
|
||||
@@ -452,8 +476,13 @@ class EntityMigrationParityTest {
|
||||
|
||||
// 所有 private 字段
|
||||
Set<String> excluded = new LinkedHashSet<>();
|
||||
// ⚠️ Phase 13A 加固:注解可能写成**全限定名**
|
||||
// @com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||
// 旧正则只匹配简单形式 @TableField(...),导致全限定写法被漏掉 →
|
||||
// 非持久化字段被误判为"生产缺列"(假警报),或反向掩盖真实漂移。
|
||||
// 这里允许注解名前任意包路径:([\w.]*TableField)。
|
||||
Matcher noPersist = Pattern.compile(
|
||||
"@TableField\\([^)]*exist\\s*=\\s*false[^)]*\\)\\s*"
|
||||
"@[\\w.]*TableField\\([^)]*exist\\s*=\\s*false[^)]*\\)\\s*"
|
||||
+ "private\\s+[^;]*?\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*;",
|
||||
Pattern.DOTALL).matcher(clean);
|
||||
while (noPersist.find()) {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class MigrationValidationTest {
|
||||
|
||||
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
|
||||
private static final int LATEST_MIGRATION = 38;
|
||||
private static final int LATEST_MIGRATION = 39;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+29
-1
@@ -52,7 +52,18 @@ class OperatorEndpointPermissionCoverageTest {
|
||||
for (Class<?> controller : CONTROLLERS) {
|
||||
boolean operatorController = paths(controller).stream().anyMatch(this::isOperatorPath);
|
||||
for (Method method : controller.getDeclaredMethods()) {
|
||||
boolean operatorMethod = paths(method).stream().anyMatch(this::isOperatorPath);
|
||||
// ⚠️ Phase 13A 修正:本测试的语义是「**每一个运营端接口**都要有显式 Permission」,
|
||||
// 因此必须先确认该方法是**真正的 HTTP 端点**(带 @RequestMapping/@GetMapping/
|
||||
// @PostMapping 等 → findMergedAnnotation(RequestMapping) 能取到路径)。
|
||||
// 修复前会扫到**私有辅助方法**:CounselingController.roleOf(OperatorPrincipal)
|
||||
// 只是把 principal 映射成角色字符串的内部工具,不是端点,也不可能加 @PreAuthorize,
|
||||
// 却因"参数里含 OperatorPrincipal"被误判为未受保护的接口(假阳性)。
|
||||
List<String> methodPaths = paths(method);
|
||||
if (methodPaths.isEmpty()) {
|
||||
continue; // 非端点(私有工具方法等)→ 不在本测试职责范围
|
||||
}
|
||||
|
||||
boolean operatorMethod = methodPaths.stream().anyMatch(this::isOperatorPath);
|
||||
boolean operatorPrincipal = Arrays.stream(method.getParameterTypes())
|
||||
.anyMatch(OperatorPrincipal.class::equals);
|
||||
if (!operatorController && !operatorMethod && !operatorPrincipal) {
|
||||
@@ -68,6 +79,23 @@ class OperatorEndpointPermissionCoverageTest {
|
||||
assertEquals(List.of(), missing, "以下运营端接口缺少显式 Permission;不能依赖 /operator/** 粗过滤器");
|
||||
}
|
||||
|
||||
/**
|
||||
* 自检:必须真的扫到了运营端接口,否则本测试可能因"一个方法都没匹配上"而恒真。
|
||||
*/
|
||||
@Test
|
||||
void guardMustActuallyInspectOperatorEndpoints() {
|
||||
long operatorEndpointCount = 0;
|
||||
for (Class<?> controller : CONTROLLERS) {
|
||||
for (Method method : controller.getDeclaredMethods()) {
|
||||
if (paths(method).stream().anyMatch(this::isOperatorPath)) {
|
||||
operatorEndpointCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertTrue(operatorEndpointCount >= 10,
|
||||
"扫描到的运营端接口数量异常偏少(" + operatorEndpointCount + "),守卫可能已失效");
|
||||
}
|
||||
|
||||
private List<String> paths(java.lang.reflect.AnnotatedElement element) {
|
||||
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
|
||||
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
|
||||
|
||||
@@ -113,7 +113,27 @@ CREATE TABLE counseling_services (
|
||||
scheduled_end_at TIMESTAMP WITH TIME ZONE, consent_version VARCHAR(32), consented_at TIMESTAMP WITH TIME ZONE,
|
||||
completed_at TIMESTAMP WITH TIME ZONE, closed_reason VARCHAR(500), companion_user_id VARCHAR(36),
|
||||
created_by_operator_id VARCHAR(36), user_confirmed_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
idempotency_key VARCHAR(128),
|
||||
-- 生产约束(V022 + V039):此前测试库完全没有 CHECK → 约束漂移盲区(Phase 11/13 同族根因)
|
||||
CONSTRAINT chk_counseling_service_type CHECK (service_type IN (
|
||||
'relationship_guidance', 'emotional_support', 'premarital_guidance', 'anti_fraud_guidance'
|
||||
)),
|
||||
CONSTRAINT chk_counseling_status CHECK (status IN (
|
||||
'pending', 'assigned', 'scheduled', 'in_progress', 'completed', 'cancelled', 'closed'
|
||||
)),
|
||||
CONSTRAINT chk_request_source CHECK (request_source IN (
|
||||
'user_self', 'matchmaker_referral', 'activity_followup', 'service_station', 'operator_assisted'
|
||||
)),
|
||||
-- V039 幂等索引:H2 无 partial index,用标准 UNIQUE 表达(PG/H2 均允许多行 NULL 共存,语义等价)
|
||||
CONSTRAINT uk_counseling_idem UNIQUE (user_id, idempotency_key)
|
||||
);
|
||||
-- V022 / V039: counseling_followups 表(跟进正文属 INV-013 敏感数据)
|
||||
CREATE TABLE counseling_followups (
|
||||
id VARCHAR(36) PRIMARY KEY, counseling_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL,
|
||||
followup_type VARCHAR(32), content CLOB NOT NULL, result_code VARCHAR(64),
|
||||
next_followup_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE TABLE service_program_members (
|
||||
id VARCHAR(36) PRIMARY KEY, program_id VARCHAR(36), user_id VARCHAR(36), status VARCHAR(32),
|
||||
|
||||
+60
-25
@@ -5,6 +5,28 @@
|
||||
|
||||
> **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。
|
||||
>
|
||||
> **2026-09-17 Phase 13A 复验关闭:** EPIC-P13A 已完成。复验发现 counseling 模块存在**双实现**——
|
||||
> 线上权威 `CounselingService`(167 行)**零测试**,唯一被测的 `CounselingServiceApp`(280 行)是
|
||||
> **孤儿类**(无注入点、无 HTTP 端点可达,生产永不执行)→ 已合并并删除孤儿类。修复 **9 处缺陷**:
|
||||
> **D1(Critical,生产故障 · INV-013 泄露)** 咨询正文原文被写入 WARN 日志(同 Phase 10 D2 一族);
|
||||
> **D2(High,越权)** 申请 `userId` 取自请求体而非认证主体 → 可替他人申请;
|
||||
> **D3(High,并发)** 分配用「先读后写」→ 并发**静默覆盖**,改 `assignIfPending` 条件更新;
|
||||
> **D4(High,越权读)** manager 经 `getDetail` 可读 followups 正文(INV-013 绕过通道);
|
||||
> **D5(Medium)** 无状态机 → 「已完成」可被再取消;
|
||||
> **D6(Medium,生产故障)** `GET /counseling/services/intro` 后端不存在 → 小程序 intro 页 100% 无数据
|
||||
> (且被 `/services/{id}` 吞掉,需置于变量路径之前);
|
||||
> **D7(Medium,契约)** Admin 调用 5 条不存在的 `/services/operator/*`(404)+ 缺四态 → 后端补 8 端点;
|
||||
> **D8(Medium,契约)** 小程序字段名错 + 无幂等 + 未检查 `success`(403 静默空列表)+ 跳转不存在的页;
|
||||
> **D9(Medium,测试保真度)** 测试库 counseling 两表**零 CHECK 约束**,`counseling_services` 零覆盖。
|
||||
> **另修 4 处测试基建缺陷**:守卫因 surefire `workingDirectory` 指向仓库根而**找不到 DDL**(恒真假绿);
|
||||
> 全限定 `@TableField(exist=false)` 漏解析(假警报);权限守卫扫到私有辅助方法(假阳性);
|
||||
> 两处测试自身逻辑缺陷(陈旧快照 / Instant 精度)。
|
||||
> **关键结论**:负向验证两轮均证明守卫会红——① 从 `V039` 移除 `scheduled` → 常量守卫精准变红
|
||||
> (证明解析的是**真实 DDL** 而非副本);② 注入并发/INV-013/状态机三个行为缺陷 → 三个用例各自精准变红。
|
||||
> 迁移基线 38 → 39(新增 `V039`)。证据:`docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md`。
|
||||
> 后端全量 **420 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(4 分 49 秒)。
|
||||
> **Phase 14 需用户确认后启动。**
|
||||
>
|
||||
> **2026-09-16 Phase 13 复验关闭:** EPIC-P13-V11 已完成。复验中发现并修复 7 处缺陷——
|
||||
> **D1(Critical,生产故障)** `matchmaker_recommendations.status` 代码写入 `liked`/`passed`/`confirmed_mutual`,
|
||||
> 而生产 `V009 chk_rec_status` 只允许 `pending_view/viewed/responded` → **真实 PG 上用户接受/跳过推荐必崩**;
|
||||
@@ -80,6 +102,7 @@
|
||||
| Phase 11 Notification 复验 | 已完成(2026-09-16) | 见 `Phase-11-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处缺陷(10 种通知 type 违反生产 CHECK 约束致全部落库失败、幂等无 DB 兜底、婚前辅导通知未接线),后端 346 tests 全绿 |
|
||||
| Phase 12 Relationship Progress 复验 | 已完成(2026-09-16) | 见 `Phase-12-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处缺陷(**实体↔生产迁移系统性漂移 23 表 50 列**、双路径 episode/NPE、测试库缺 7 表致首页零覆盖、计划项 2 未实现),新增永久守卫 `EntityMigrationParityTest`,删除 2 个假测试类,后端 371 tests 全绿 |
|
||||
| Phase 13 公益红娘转介复验 | 已完成(2026-09-16) | 见 `Phase-13-V11-Recovery-Completion-Report-2026-09-16.md`;修复 7 处缺陷(**2 处生产故障级 CHECK 约束缺口致推荐接受/服务提交必崩**、推荐与转介三处越权、`getServiceDetail` 越权读、Mapper 缺 `@Select` 致必崩、前端 5 处契约缺口),测试库补 3 张 matchmaker 表,迁移基线 V038;新增 `MatchmakerReferralPhase13Test`(17 用例)+ 重写 `MatchmakerServiceTest`(6 用例,解析真实 DDL),删除 3 处假测试,后端 **391 tests 全绿**;**Phase 13A 尚未获准** |
|
||||
| Phase 13A 婚恋咨询与婚前辅导复验 | 已完成(2026-09-17) | 见 `Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md`;发现 counseling **双实现**(权威类零测试 / 孤儿类被单测),已合并删除孤儿类;修复 **9 处缺陷**(**D1 咨询正文写日志=INV-013 泄露**、D2 申请 userId 取请求体、**D3 分配先读后写并发静默覆盖**、D4 manager 可读 followups=INV-013 绕过、D5 无状态机、**D6 `/services/intro` 后端不存在致小程序 intro 页 100% 无数据**、D7 Admin 5 端点 404、D8 小程序字段名/幂等/静默失败、D9 测试库 counseling 零 CHECK 约束)+ **4 处测试基建缺陷**(守卫找不到 DDL 恒真假绿、全限定 `@TableField` 漏解析、权限守卫假阳性、测试自身逻辑缺陷);迁移基线 V039;新增 `CounselingPhase13ATest`(25)+ `CounselingConstraintGuardTest`(5,解析真实 DDL),两轮负向验证证明守卫会红;后端 **420 tests 全绿**;**Phase 14 尚未获准** |
|
||||
| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 |
|
||||
| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 |
|
||||
| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 |
|
||||
@@ -114,7 +137,7 @@
|
||||
| EPIC-P11-V11 | P11 | 公益服务通知扩展 | P0 | ✅
|
||||
| EPIC-P12-V11 | P12 | 关系状态轻量扩展 | P1 | ✅
|
||||
| EPIC-P13-V11 | P13 | 红娘服务转介扩展 | P1 | ✅
|
||||
| EPIC-P13A | P13A | 公益婚恋咨询与婚前辅导 | P1 |
|
||||
| EPIC-P13A | P13A | 公益婚恋咨询与婚前辅导 | P1 | ✅
|
||||
| EPIC-P14-V11 | P14 | 多类型公益活动 | P1 |
|
||||
| EPIC-P15-V11 | P15 | 活动签到回归测试 | P1 |
|
||||
| EPIC-P16-V11 | P16 | 活动互选二阶段同意重构 | P0 |
|
||||
@@ -305,56 +328,68 @@
|
||||
|
||||
---
|
||||
|
||||
## EPIC-P13A: 公益婚恋咨询与婚前辅导 ✅ 核心链路已完成(V1.1 MVP)
|
||||
## EPIC-P13A: 公益婚恋咨询与婚前辅导 ✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验关闭)
|
||||
|
||||
### Story: 数据模型
|
||||
- P13A-T01 counseling_services migration ✅ (V022)
|
||||
- P13A-T02 counseling_followups migration ✅ (V022)
|
||||
- P13A-T03 indexes ✅ (V022)
|
||||
- P13A-T04 migration tests ⏳ 待补全
|
||||
- P13A-T04 migration tests ✅ 已补全(`MigrationValidationTest` 基线 38 → 39;测试库补 3 个 CHECK 约束 + 新建 `counseling_followups` 表)
|
||||
- P13A-T04b **V039 幂等键 + CHECK 白名单显式固化** ✅ 新增(`idempotencyKey` 列 + `uk_counseling_idem` 唯一索引)
|
||||
|
||||
### Story: 用户咨询
|
||||
- P13A-T05 GET counseling service intro ✅
|
||||
- P13A-T06 POST counseling request ✅
|
||||
- P13A-T07 GET my counseling ✅
|
||||
- P13A-T08 GET counseling detail ✅
|
||||
- P13A-T09 cancel counseling ✅
|
||||
- P13A-T05 GET counseling service intro ✅ **修复:后端端点此前不存在**(被 `/services/{id}` 吞掉)→ 已补并置于变量路径之前
|
||||
- P13A-T06 POST counseling request ✅ **修复:userId 改从认证主体取;补幂等键;字段名对齐**
|
||||
- P13A-T07 GET my counseling ✅ **修复:检查 `success`(403 静默空列表);移除跳转不存在的页**
|
||||
- P13A-T08 GET counseling detail ✅ **修复:补 owner 校验 + 读侧分层(INV-013)**
|
||||
- P13A-T09 cancel counseling ✅ **修复:补 owner 校验 + 合法状态迁移**
|
||||
|
||||
### Story: 婚前辅导
|
||||
- P13A-T10 premarital service type ✅ (service_type=premarital_guidance)
|
||||
- P13A-T11 premarital intro ✅ (复用 counseling/intro + U58页面)
|
||||
- P13A-T12 premarital request ✅ (复用 counseling/create,serviceType=premarital_guidance)
|
||||
- P13A-T13 optional companion support ✅ (companionUserId 字段)
|
||||
- P13A-T13 optional companion support ✅ **修复:补 `confirmCompanion`,伴侣须本人确认(INV-005)**
|
||||
|
||||
### Story: Miniapp
|
||||
- P13A-T14 U55 婚恋咨询介绍 ✅
|
||||
- P13A-T15 U56 申请咨询 ✅
|
||||
- P13A-T16 U57 我的咨询 ✅
|
||||
- P13A-T15 U56 申请咨询 ✅ **修复:字段名 `summary`/`preferredMode` + 幂等键**
|
||||
- P13A-T16 U57 我的咨询 ✅ **修复:检查 `success` + Loading/Empty/Error/Permission 四态 + 下拉刷新**
|
||||
- P13A-T17 U58 婚前辅导介绍 ✅
|
||||
- P13A-T18 U59 婚前辅导申请 ✅
|
||||
- P13A-T18 U59 婚前辅导申请 ✅ **修复:字段名对齐 + `companionUserId` 输入**
|
||||
|
||||
### Story: 后台
|
||||
- P13A-T19 Counseling Queue ✅ (getPendingQueue)
|
||||
- P13A-T20 Counseling Detail ✅ (getCounselingDetail)
|
||||
- P13A-T21 Assign Counselor ✅
|
||||
- P13A-T22 Schedule ✅
|
||||
- P13A-T23 Start ✅
|
||||
- P13A-T24 Followup ✅
|
||||
- P13A-T25 Complete ✅
|
||||
- P13A-T19 Counseling Queue ✅ `GET /services/operator/queue`
|
||||
- P13A-T20 Counseling Detail ✅ `GET /services/operator/{id}`
|
||||
- P13A-T21 Assign Counselor ✅ **修复:改 `assignIfPending` 条件更新(并发防静默覆盖)**
|
||||
- P13A-T22 Schedule ✅ **修复:补状态迁移断言 + INV-012 归属校验**
|
||||
- P13A-T23 Start ✅ **修复:同上**
|
||||
- P13A-T24 Followup ✅ **修复:同上**
|
||||
- P13A-T25 Complete ✅ **修复:同上**
|
||||
- P13A-T26 Close ✅
|
||||
- P13A-T26b **Admin Web `CounselingView.vue` 重写 + `/counseling` 路由注册** ✅ 新增(四态 + 二次确认 + 防重复提交)
|
||||
|
||||
### Story: 权限与隐私
|
||||
- P13A-T27 counselor assigned scope ✅ (getCounselorAssignedServices)
|
||||
- P13A-T28 counseling_manager scope ⏳ 待补充(Admin Web Phase 19)
|
||||
- P13A-T29 matchmaker deny counseling notes ✅ (常量隔离,INV-013)
|
||||
- P13A-T30 generic admin deny sensitive note ⏳ 待补充(Admin Web Phase 19)
|
||||
- P13A-T31 SensitiveAccessLog ⏳ 待补充(随访内容写入审计日志)
|
||||
- P13A-T28 counseling_manager scope ✅ **已补全**(可看申请摘要,但**不装配 followups** —— 避免 INV-013 绕过通道)
|
||||
- P13A-T29 matchmaker deny counseling notes ✅ **已由行为测试证明**(INV-013 七用例)
|
||||
- P13A-T30 generic admin deny sensitive note ✅ **已由行为测试证明**(`strangerGetsNothing` / `plainOperatorSeesSanitizedDetail`)
|
||||
- P13A-T31 SensitiveAccessLog ⏳ 待补充(随访内容写入审计日志)—— **不阻塞本 Phase Gate**
|
||||
|
||||
### Story: 风控联动
|
||||
- P13A-T32 counseling risk referral ⏳ 待补充(后续 Phase)
|
||||
- P13A-T32 counseling risk referral ✅ **已接线**(`RiskReferralService.detectCounselingRisk`;**修复:不再把正文写入日志**)
|
||||
- P13A-T33 RiskEvent source=counseling ⏳ 待补充(后续 Phase)
|
||||
|
||||
**备注**:V1.1 MVP 核心链路已实现,Admin Web 前端、高级权限细分、风控联动在后续 Phase 补充。
|
||||
### Story: 复验(2026-09-17 KEEP-VERIFY/EXTEND)
|
||||
- P13A-T34 删除孤儿类 `CounselingServiceApp` 及 3 个仅其使用的 DTO ✅
|
||||
- P13A-T35 删除测死代码的 `CounselingDataScopeTest` ✅
|
||||
- P13A-T36 新增 `CounselingPhase13ATest`(25 用例,真实运行时路径)✅
|
||||
- P13A-T37 新增 `CounselingConstraintGuardTest`(5 用例,**解析真实 `db/migration/V*.sql`**)✅
|
||||
- P13A-T38 修复 4 处测试基建缺陷(守卫找不到 DDL 恒真假绿 / 全限定 `@TableField` 漏解析 / 权限守卫假阳性 / 测试自身逻辑缺陷)✅
|
||||
- P13A-T39 两轮负向验证证明守卫会红 ✅
|
||||
|
||||
**备注**:V1.1 核心链路已实现并完成 KEEP-VERIFY/EXTEND 复验,修复 9 处真实缺陷。
|
||||
仅剩 SensitiveAccessLog 与 RiskEvent source=counseling 两项属后续 Phase 增强,不阻塞本 Phase Gate。
|
||||
后端全量 **420 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# Phase 13A V1.1 Recovery Completion Report — 婚恋咨询与婚前辅导
|
||||
|
||||
> 日期:2026-09-17
|
||||
> 阶段:Phase 13A(Counseling & Premarital Guidance — REFACTOR/EXTEND)
|
||||
> 分支:`main` | Tag:`phase13a-recovery-2026-09-17`
|
||||
> 结论:**CLOSED(Gate 全项通过)**
|
||||
|
||||
---
|
||||
|
||||
## 一、计划目标与 Gate
|
||||
|
||||
计划出处:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 第 308-315 行。
|
||||
|
||||
| 计划项 | 要求 | 结论 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 计划项 1 | 修复 Admin Vue 语法错误并注册 `/counseling` 路由,先使 type-check 失败转为通过 | ✅ 通过 | `router/index.ts:36` 已注册;`vue-tsc --noEmit` EXIT=0 |
|
||||
| 计划项 2 | 用户申请接口从认证主体取 userId;补幂等、取消、详情 owner 校验和合法状态迁移 | ✅ 通过 | D2/D5/D6 + `applyUsesAuthenticatedPrincipalUserId` / `applyIsIdempotentByKey` / `onlyOwnerCanCancel` 等 8 用例 |
|
||||
| 计划项 3 | 分配操作使用条件更新/版本号解决并发;Counselor 只能读取和操作 Assigned Service | ✅ 通过 | D3(`assignIfPending`)+ INV-012 四用例 |
|
||||
| 计划项 4 | Counseling Manager 可分配和查看必要摘要;普通管理员、红娘、内容编辑不得读取咨询正文 | ✅ 通过 | D4 + INV-013 七用例 |
|
||||
| 计划项 5 | 婚前辅导复用 counseling service type;邀请平台内伴侣必须由对方自主确认 | ✅ 通过 | INV-005 四用例 |
|
||||
| 计划项 6 | U55~U59 修复类型错误、API 契约、Loading/Empty/Error/Permission 状态和非医疗化文案 | ✅ 通过 | D7/D8 + 前端双项目 type-check EXIT=0 |
|
||||
|
||||
**Gate:** 申请→分配→预约→服务→完成→反馈真实闭环通过;所有隐私负向用例通过。
|
||||
|
||||
| Gate 断言 | 证据 | 结果 |
|
||||
|---|---|---|
|
||||
| 真实闭环打通 | `fullLifecycleClosedLoop`:apply→assign→schedule→start→addFollowup→complete,全程真实落库,终态 `completed` 且 `completedAt` 非空 | ✅ |
|
||||
| INV-012(Counselor 只能操作自己的) | `nonAssignedCounselorCannotSchedule` / `nonAssignedCounselorCannotAddFollowup` / `nonAssignedCounselorCannotComplete` / `assignedListOnlyContainsOwnServices` | ✅ |
|
||||
| INV-013(红娘/普通运营不可读正文) | `matchmakerSeesSanitizedDetail` / `plainOperatorSeesSanitizedDetail` / `managerSeesSummaryButNotFollowups` / `strangerGetsNothing` / `listPathsAreSanitized` | ✅ |
|
||||
| INV-005(不得替用户确认) | `companionCanConfirmBySelf` / `staffCannotConfirmOnBehalfOfCompanion` / `companionConfirmOnlyForPremarital` / `companionConfirmIsIdempotent` | ✅ |
|
||||
| 并发安全 | `concurrentAssignDoesNotSilentlyOverwrite` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 二、复验发现:counseling 模块存在**双实现**,其中一个是从未被调用的孤儿类
|
||||
|
||||
| 类 | 行数 | 注入点 | HTTP 端点可达 | 测试 |
|
||||
|---|---|---|---|---|
|
||||
| `CounselingService` | 167 | 有(Controller 注入) | 是 | 无直接测试 |
|
||||
| `CounselingServiceApp` | 280 | **零** | **否** | 仅 `CounselingDataScopeTest` 直接 `new` 它 |
|
||||
|
||||
历史 ✅ 声称「counseling 模块已实现并通过测试」,实际情况是:
|
||||
|
||||
- **线上权威路径** `CounselingService` 只有 167 行,**没有任何测试**;
|
||||
- 唯一被测的 `CounselingServiceApp` 是**孤儿类**——无任何注入点、无 HTTP 端点可达。
|
||||
它的 280 行业务逻辑在生产上**永远不会被执行**;
|
||||
- 即「测试覆盖了函数」≠「测试覆盖了运行时路径」——这是同一族陷阱的第七次复现。
|
||||
|
||||
**处理**:将 App 的流程合并入 `CounselingService` 并**删除 App**,连同仅 App 使用的
|
||||
`CounselingDTO` / `CounselingRequest` / `FollowupDTO` 与测死代码的 `CounselingDataScopeTest`。
|
||||
|
||||
---
|
||||
|
||||
## 三、缺陷清单(D1~D9)
|
||||
|
||||
### D1 — Critical(生产故障 · INV-013 泄露):咨询正文被写入日志
|
||||
|
||||
- **位置**:`CounselingServiceApp.java:81`
|
||||
- **代码**:`log.warn("咨询内容触发高危风险: userId={}, summary={}", userId, request.getUserRequestSummary())`
|
||||
- **后果**:用户自述的敏感咨询内容**原文进入应用日志**(落盘、进日志采集、被运维可见),
|
||||
直接违反 INV-013「Matchmaker / 普通 operator 不能读取 Counseling 正文」的数据边界。
|
||||
与 Phase 10 D2 属同一族。
|
||||
- **修复**:合并入 `CounselingService.apply` 后改为只记 `serviceId` + `riskLevel`,
|
||||
**绝不记录 summary 原文**;`followups.content` 亦不落日志。
|
||||
|
||||
### D2 — High(越权):申请接口的 userId 取自请求体而非认证主体
|
||||
|
||||
- **位置**:`CounselingServiceApp.createCounseling`
|
||||
- **后果**:攻击者可伪造 `userId` 为他人代提咨询申请,污染他人服务记录,
|
||||
且可能借他人身份触发风险检测(把风险记到别人头上)。
|
||||
`dataScopeGuard` 虽已注入但**未用于申请路径**。
|
||||
- **修复**:Controller 强制 `principal.id()`,`apply()` 的 `userId` 只能来自认证主体。
|
||||
|
||||
### D3 — High(并发):分配使用「先读后写」,并发下静默覆盖
|
||||
|
||||
- **位置**:`CounselingServiceApp.assignCounselor`
|
||||
- **代码形态**:`selectById` → `setStatus/setAssignedOperatorId` → `updateById`
|
||||
- **后果**:两个管理员**同时**把同一单分配给不同咨询师时,后写者**静默覆盖**先写者;
|
||||
先被分配的咨询师可能已经收到通知并开始服务,造成真实责任错乱。
|
||||
- **修复**:新增 `assignIfPending(serviceId, counselorId)` 条件更新
|
||||
(`WHERE status='pending' AND assigned_operator_id IS NULL`),
|
||||
`affectedRows=0` 即明确报「已被分配或状态已变更」,不做盲目覆盖。
|
||||
|
||||
### D4 — High(越权读):`counseling_manager` 成为 INV-013 的绕过通道
|
||||
|
||||
- **位置**:`getDetail` 的 manager 分支
|
||||
- **后果**:manager 经 `getDetail` 可读取 `followups` 正文(跟进记录含最敏感内容)。
|
||||
计划项 4 明确「Manager 可查看**必要摘要**」,不是全文。
|
||||
- **修复**:manager 分支只加回 `userRequestSummary`(供分派判断),
|
||||
**不装配 followups**。
|
||||
|
||||
### D5 — Medium(状态迁移缺失):无合法状态机
|
||||
|
||||
- **后果**:任意状态可被改成任意状态。例如「已完成」的服务可被再次「取消」写回,
|
||||
历史统计与责任归属被破坏。
|
||||
- **修复**:新增 `ALLOWED_TRANSITIONS` 状态机 + `assertTransition`,
|
||||
终态(`completed`/`cancelled`/`closed`)无出边。
|
||||
|
||||
### D6 — Medium(生产故障 · 契约缺口):`GET /counseling/services/intro` 后端不存在
|
||||
|
||||
- **后果**:两个小程序 intro 页 **100% 拿不到数据**。请求被 `/services/{id}` 吞掉后
|
||||
以 `id="intro"` 查库 → 403/空,**属生产功能完全不可用**。
|
||||
- **修复**:Controller 新增该端点,并**声明在 `/services/{id}` 之前**
|
||||
(Spring MVC 字面量路径必须优先于变量路径,否则被吞)。
|
||||
|
||||
### D7 — Medium(契约缺口):Admin 调用 5 条不存在的运营端端点
|
||||
|
||||
- **位置**:`admin-web/src/modules/counseling/CounselingView.vue`
|
||||
- **后果**:5 条 `/counseling/services/operator/*` 请求全部 404;
|
||||
且缺 Loading/Empty/Error/Permission 四态,权限不足时静默白屏。
|
||||
- **修复**:后端补 8 个运营端端点(`@PreAuthorize` + `OperatorPrincipal`);
|
||||
前端整体重写(补 `el-skeleton`/`el-empty`/`el-alert`/二次确认/防重复提交)。
|
||||
|
||||
### D8 — Medium(契约缺口):小程序申请页字段名与后端不匹配 + 无幂等 + 静默失败
|
||||
|
||||
- **问题**:申请页提交 `userRequestSummary` / `preferredServiceMode`,后端只认
|
||||
`summary` / `preferredMode`;无幂等键(连点产生重复申请);
|
||||
`my-counseling` 列表**未检查 `res.data.success`** → 403 时静默显示空列表;
|
||||
跳转到**不存在**的 `counseling-detail` 页;WXML 内直接调用方法(非法用法)。
|
||||
- **修复**:字段名对齐 + 补 `idempotencyKey` + 检查 `success` +
|
||||
移除无效跳转 + 改为预计算字段(`statusName`/`createdText`/`scheduledText`)。
|
||||
|
||||
### D9 — Medium(测试保真度):测试库 counseling 表零 CHECK 约束
|
||||
|
||||
- **问题**:`counseling_services` / `counseling_followups` 在 `schema-h2.sql` 中
|
||||
**完全缺失 CHECK 约束**(V022 的 3 个 CHECK 未下沉);
|
||||
`counseling_services` 未被列入 `EntityMigrationParityTest` 任何集合(= 零覆盖),
|
||||
`counseling_followups` 仅在「可选白名单」(= 实际无人测)。
|
||||
- **后果**:状态/类型越界在测试期不可见 → 测试全绿但生产 500。
|
||||
- **修复**:`schema-h2.sql` 补 3 个 CHECK + `uk_counseling_idem` + 新建
|
||||
`counseling_followups` 表;两表移入 `REQUIRED_IN_TEST_SCHEMA`。
|
||||
|
||||
---
|
||||
|
||||
## 四、复验中发现的**测试基建缺陷**(守卫自身失效,非生产缺陷)
|
||||
|
||||
Phase 13A 严格执行「守卫必须变红」纪律时,暴露了 3 处**守卫/测试自身**的缺陷。
|
||||
它们不修就会产生「恒真假绿」,危害等同于生产缺陷。
|
||||
|
||||
### T1 — `CounselingConstraintGuardTest` 5 个用例全红:守卫找不到 DDL
|
||||
|
||||
- **现象**:首轮 5 个用例全部报「未解析到约束」。
|
||||
- **根因**:`pom.xml` 的 surefire 配置了
|
||||
`<workingDirectory>${project.basedir}/..</workingDirectory>` ——
|
||||
测试进程 CWD 是**仓库根**而非 `backend/`。
|
||||
更隐蔽的是 `backend/` 是 CWD 的**子目录**而非祖先,
|
||||
所以「只向上回溯找 `src/...`」也找不到(实测报错 `CWD=/proj`)。
|
||||
- **修复**:改为**候选后缀 + 逐级向上**双维度定位
|
||||
(`src/main/resources/db/migration` 与 `backend/src/main/resources/db/migration`),
|
||||
定位失败**大声抛异常**而不是静默返回 null。
|
||||
- **加固**:新增文件数自检(`fileCount >= 30`),
|
||||
避免「目录选错」与「DDL 真没有」无法区分。
|
||||
|
||||
### T2 — `EntityMigrationParityTest` 全限定注解漏解析(假警报)
|
||||
|
||||
- **现象**:报 `counseling_services: 生产缺列 [followups]`。
|
||||
- **根因**:`@TableField(exist = false)` 写成了**全限定形式**
|
||||
`@com.baomidou.mybatisplus.annotation.TableField(exist = false)`,
|
||||
而旧正则只匹配简单形式 `@TableField(...)` → 非持久化字段被误判为真实列。
|
||||
- **危害**:若为消除该假警报而去生产库**加一个无用列**,就是测试保真度反噬成生产污染。
|
||||
- **修复**:正则放宽为 `@[\w.]*TableField\(...\)`;
|
||||
并新增回归用例 `fullyQualifiedTableFieldExistFalseMustBeExcluded`。
|
||||
|
||||
### T3 — `OperatorEndpointPermissionCoverageTest` 扫到私有辅助方法(假阳性)
|
||||
|
||||
- **现象**:报 `CounselingController.roleOf` 缺 Permission。
|
||||
- **根因**:`roleOf(OperatorPrincipal)` 是**私有工具方法**(把 principal 映射成角色字符串),
|
||||
不是 HTTP 端点,也不可能加 `@PreAuthorize`;
|
||||
却因「参数含 `OperatorPrincipal`」被误判为未受保护的接口。
|
||||
- **修复**:先确认方法是**真正的端点**(`findMergedAnnotation(RequestMapping)` 能取到路径)再判定;
|
||||
并新增自检用例 `guardMustActuallyInspectOperatorEndpoints`(≥10 个端点)。
|
||||
|
||||
### T4 — 两处测试自身逻辑缺陷(非生产缺陷)
|
||||
|
||||
| 用例 | 误报原因 | 修复 |
|
||||
|---|---|---|
|
||||
| `matchmakerSeesSanitizedDetail` | 用「测试内存里的旧快照」(status 仍 `pending`)去比「重新查库的最新值」(`in_progress`) | 辅助方法改为返回**重新查库**的同源快照(`reload`) |
|
||||
| `companionConfirmIsIdempotent` | H2 timestamp 精度低于 `Instant.now()` 纳秒,导致 470ns 级差异误报 | 比较**截断到毫秒**的表现值 |
|
||||
|
||||
---
|
||||
|
||||
## 五、负向验证(守卫必须变红 —— 两轮,均已还原)
|
||||
|
||||
### 第一轮:DDL 常量守卫
|
||||
|
||||
注入缺陷:从 `V039` 的 `chk_counseling_status` 白名单中**移除** `scheduled`。
|
||||
|
||||
```
|
||||
[ERROR] CounselingConstraintGuardTest.statusMachineWithinProductionWhitelist:162
|
||||
状态机使用了不在生产 CHECK 白名单内的状态值:
|
||||
- scheduled
|
||||
生产白名单: [assigned, cancelled, closed, completed, in_progress, pending]
|
||||
[ERROR] Tests run: 48, Failures: 1, Errors: 0 → BUILD FAILURE
|
||||
```
|
||||
|
||||
**关键结论**:守卫**正确解析了被篡改的 V039 原文**并识别出漂移 ——
|
||||
证明它确实在解析**真实 DDL**,而不是在测试里抄了一份副本。
|
||||
(这正是 Phase 13 确立的第四层纪律:抄副本的守卫对 DDL 漂移零反应。)
|
||||
|
||||
### 第二轮:行为守卫(3 个注入缺陷,3 个用例精准变红)
|
||||
|
||||
| 注入缺陷 | 变红用例 | 失败信息(节选) |
|
||||
|---|---|---|
|
||||
| **A** `assign` 退回「先读后写」 | `concurrentAssignDoesNotSilentlyOverwrite:182` | `Expected IllegalStateException to be thrown, but nothing was thrown` |
|
||||
| **B** manager 也装配 followups | `managerSeesSummaryButNotFollowups:302` | `INV-013: 经理不得成为阅读跟进正文的绕过通道 ==> expected: <null> but was: <[CounselingFollowup(...content=跟进正文内容(敏感)...)]>` |
|
||||
| **C** `assertTransition` 变空操作 | `completedServiceCannotBeCancelled:143` | `Expected IllegalStateException to be thrown, but nothing was thrown` |
|
||||
|
||||
```
|
||||
[ERROR] Tests run: 48, Failures: 3, Errors: 0 → BUILD FAILURE
|
||||
```
|
||||
|
||||
**结论**:三个行为不变量(并发安全 / INV-013 / 状态机)的守卫均**经负向验证证明会变红**,
|
||||
不是"永不失效的假守卫"。两轮注入的缺陷**已全部还原**(`grep -c "NEG-VAL"` = 0)。
|
||||
|
||||
### 额外发现(诚实记录)
|
||||
|
||||
第一轮同时移除了 `schema-h2.sql` 的 `chk_counseling_status` CHECK,
|
||||
但**没有任何行为用例因此变红**。说明:**当前测试并未依赖测试库 CHECK 来兜底状态合法性**
|
||||
——状态合法性是由 Service 层的 `assertTransition` 保证的。
|
||||
这是一个值得记录的边界:测试库 CHECK 的价值在于「防止绕过 Service 层的直接 SQL 写入」,
|
||||
而非替代 Service 层校验。已在报告中如实标注,未夸大其作用。
|
||||
|
||||
---
|
||||
|
||||
## 六、全量回归实测
|
||||
|
||||
| 项 | 数值 |
|
||||
|---|---|
|
||||
| 后端全量测试 | **420 tests · 0 failures · 0 errors · 3 skipped** |
|
||||
| 构建结果 | `BUILD SUCCESS`(4 分 49 秒) |
|
||||
| Phase 13 基线 | 391 |
|
||||
| 本次净增 | **+29** |
|
||||
| `admin-web` `vue-tsc --noEmit` | EXIT=0 |
|
||||
| `miniapp` `tsc --noEmit` | EXIT=0 |
|
||||
|
||||
新增/重写测试文件:
|
||||
|
||||
| 文件 | 用例数 | 说明 |
|
||||
|---|---|---|
|
||||
| `CounselingPhase13ATest` | 25 | Gate 闭环 + 计划项 2/3/4/5 全项 + INV-012/013/005 |
|
||||
| `CounselingConstraintGuardTest` | 5 | 解析真实 DDL 的常量守卫 + 解析器自检 |
|
||||
| `EntityMigrationParityTest`(+1) | 6 | 新增全限定注解回归守卫 |
|
||||
| `OperatorEndpointPermissionCoverageTest`(+1) | 2 | 新增端点覆盖自检 |
|
||||
|
||||
删除的测试(测死代码 / 名不副实):`CounselingDataScopeTest`。
|
||||
|
||||
---
|
||||
|
||||
## 七、生产迁移
|
||||
|
||||
**新增 `V039__add_counseling_idempotency_and_status_guards.sql`**:
|
||||
|
||||
1. `ALTER TABLE counseling_services ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128)`
|
||||
2. `CREATE UNIQUE INDEX IF NOT EXISTS uk_counseling_idem ON counseling_services(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL`
|
||||
3. 按既有惯例(V018/V020/V025/V038)`DROP CONSTRAINT IF EXISTS` + 重加
|
||||
`chk_request_source` / `chk_counseling_status` / `chk_counseling_service_type`
|
||||
4. `COMMENT ON COLUMN` 说明 INV-005 与幂等语义
|
||||
|
||||
> `LATEST_MIGRATION` 38 → 39。
|
||||
> H2 不支持 partial index,`schema-h2.sql` 用标准 `UNIQUE` 表达
|
||||
> (PG/H2 的标准 UNIQUE 均允许多行 NULL 共存,语义等价)。
|
||||
|
||||
---
|
||||
|
||||
## 八、结论
|
||||
|
||||
Phase 13A **CLOSED**。9 处缺陷(D1~D9)全部修复并验证;
|
||||
4 处测试基建缺陷(T1~T4)一并修复;
|
||||
两轮负向验证证明常量守卫与行为守卫**都会变红**;
|
||||
全量回归 **420 tests 全绿**;前后端 type-check 均通过。
|
||||
|
||||
**Phase 14(多类型公益活动 — REFACTOR/EXTEND)未自动进入,等待用户确认。**
|
||||
+68
-15
@@ -1,10 +1,10 @@
|
||||
# 项目状态跟踪 — STATUS.md
|
||||
|
||||
> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-13 RECOVERY CLOSED / GIT CLEAN`。**
|
||||
> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 13A COMPLETE / GIT CLEAN`。**
|
||||
>
|
||||
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)复验已关闭**,六轮共修复 23 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 13A 需用户确认后启动。**
|
||||
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)复验已关闭**,七轮共修复 32 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 14 需用户确认后启动。**
|
||||
|
||||
最后更新: 2026-09-16 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点)
|
||||
最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点)
|
||||
|
||||
## 当前唯一有效状态
|
||||
|
||||
@@ -21,15 +21,15 @@
|
||||
| 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s |
|
||||
| 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 |
|
||||
| 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md |
|
||||
| Flyway 迁移 | V001~V038 | V028~V038 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列);V038 为 Phase 13 新增(`matchmaker_recommendations.status` + `matchmaker_services.service_type` 的 CHECK 白名单扩展) |
|
||||
| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测:**391 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(4 分 59 秒);含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest` |
|
||||
| Flyway 迁移 | V001~V039 | V028~V038 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列);V038 为 Phase 13 新增(`matchmaker_recommendations.status` + `matchmaker_services.service_type` 的 CHECK 白名单扩展);V039 为 Phase 13A 新增(counseling 幂等键 + 三个 CHECK 白名单显式固化) |
|
||||
| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测:**420 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(4 分 49 秒);含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest` |
|
||||
| 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 |
|
||||
| `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 |
|
||||
| Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA |
|
||||
| Miniapp | **验证通过** | 2026-09-16:tsc ✅;**11 核心页全部 TS-only(11/11)**;首批 11 页于 2026-09-16 通过用户验收;discover 筛选重设计已通过 DevTools 验证 |
|
||||
| UI 契约测试 | **13/13 通过** | 2026-09-16 运行:全部退出码 0;含 capability 独立化适配与文档基线口径 |
|
||||
| Git 工作区 | **已清零** | 阶段 0~4 共 24+ commit 可追溯;`git status --porcelain` 0 行 |
|
||||
| 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(391 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16)、实体↔生产迁移漂移修复(V037/V038,2026-09-16) |
|
||||
| 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(420 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16)、实体↔生产迁移漂移修复(V037/V038/V039,2026-09-16/17) |
|
||||
| UAT / RC / Production | **UI 验收通过,前后端契约已收敛,Phase 08-13 已关闭** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛;Phase 08/09/10/11/12/13 复验通过并关闭,累计修复 23 处真实缺陷,可推进 Phase 13A(**需用户确认后启动**) |
|
||||
|
||||
---
|
||||
@@ -186,17 +186,23 @@
|
||||
| ReviewService 集成本地资格审核通知 | ✅ |
|
||||
| 隐私安全文案(中性化,不暴露敏感信息) | ✅ |
|
||||
|
||||
### Phase 13A — 公益婚恋咨询与婚前辅导(核心链路)✅ 已完成
|
||||
### Phase 13A — 公益婚恋咨询与婚前辅导(核心链路)✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过)
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
| V022 counseling_services + counseling_followups 迁移 | ✅ |
|
||||
| CounselingServiceApp(Service/Controller/DTO) | ✅ |
|
||||
| 用户端 API:intro / create / list / detail / cancel | ✅ |
|
||||
| 后台 API:queue / assign / schedule / start / followup / complete / close | ✅ |
|
||||
| INV-012 Counselor assigned scope 实现 | ✅ |
|
||||
| Miniapp U55-U59 页面(咨询介绍/申请/我的/婚前辅导) | ✅ |
|
||||
| app.json 路由注册 | ✅ |
|
||||
| companionUserId 伴侣支持 | ✅ |
|
||||
| **V039 Phase 13A 迁移**(counseling 幂等键 + 三个 CHECK 白名单显式固化) | ✅ 新增 |
|
||||
| `CounselingService`(权威实现,合并孤儿类 `CounselingServiceApp` 后删除) | ✅ 重写 |
|
||||
| 用户端 API:intro / create / list / detail / cancel / companion-confirm | ✅ |
|
||||
| 后台 API:queue / assigned / detail / assign / schedule / start / followup / complete | ✅ 8 端点 |
|
||||
| INV-012 Counselor assigned scope 实现(`assignIfPending` 条件更新防并发覆盖) | ✅ |
|
||||
| INV-013 正文保护(脱敏分层:user/assigned=全文;matchmaker/operator=脱敏;manager=摘要无 followups) | ✅ |
|
||||
| INV-005 伴侣须本人确认 | ✅ |
|
||||
| 合法状态机 `ALLOWED_TRANSITIONS` + `assertTransition` | ✅ 新增 |
|
||||
| 幂等(`idempotencyKey` + `uk_counseling_idem` 唯一索引) | ✅ 新增 |
|
||||
| Miniapp U55-U59 页面(咨询介绍/申请/我的/婚前辅导),字段名对齐后端 + 检查 `success` | ✅ |
|
||||
| Admin `CounselingView.vue` 重写(四态 + 二次确认)+ `/counseling` 路由注册 | ✅ |
|
||||
| 测试:`CounselingPhase13ATest`(25)+ `CounselingConstraintGuardTest`(5) | ✅ 新增 |
|
||||
| 全量回归 **420 tests 全绿** | ✅ |
|
||||
|
||||
### Phase 13 — 公益红娘服务转介 ✅ 已完成
|
||||
| 任务 | 状态 |
|
||||
@@ -299,6 +305,53 @@
|
||||
> `assertTrue(true)` → 已删除占位、如实改名并注明行为验证位置。
|
||||
> - 报告:`docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md`
|
||||
|
||||
### Phase 13A — 婚恋咨询与婚前辅导 ✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过)
|
||||
|
||||
| 计划项 | 状态 |
|
||||
|------|------|
|
||||
| 计划项 1 Admin Vue 修复 + `/counseling` 路由注册 | ✅ `vue-tsc` EXIT=0 |
|
||||
| 计划项 2 认证主体 userId + 幂等 + 取消 + owner 校验 + 状态迁移 | ✅ 8 用例 |
|
||||
| 计划项 3 条件更新解决并发 + Assigned Service 边界 | ✅ `assignIfPending` + 4 用例 |
|
||||
| 计划项 4 Manager 可摘要不可读正文 | ✅ 7 用例 |
|
||||
| 计划项 5 婚前辅导复用 service type + 伴侣自主确认 | ✅ 4 用例 |
|
||||
| 计划项 6 U55~U59 类型/契约/四态/非医疗化文案 | ✅ 双端 type-check EXIT=0 |
|
||||
|
||||
> **2026-09-17 复验结论(KEEP-VERIFY/EXTEND)**:历史 ✅ 不可信。counseling 模块存在
|
||||
> **双实现**——线上权威 `CounselingService`(167 行)**零测试**,唯一被测的
|
||||
> `CounselingServiceApp`(280 行)是**孤儿类**(无注入点、无端点可达,生产永不执行)。
|
||||
> 已将其流程合并入 `CounselingService` 并删除孤儿类及仅其使用的 3 个 DTO 与测死代码的测试。
|
||||
> 修复 **9 处缺陷**:
|
||||
> - **D1(Critical,生产故障 · INV-013 泄露)** 咨询正文 `userRequestSummary` **原文被写入 WARN 日志**
|
||||
> (`CounselingServiceApp:81`,同 Phase 10 D2 一族)。修复:合并后只记 `serviceId` + `riskLevel`。
|
||||
> - **D2(High,越权)** 申请 `userId` 取自**请求体**而非认证主体 → 可替他人提交申请。
|
||||
> 修复:Controller 强制 `principal.id()`。
|
||||
> - **D3(High,并发)** `assignCounselor` 用「先读后写」→ 两管理员并发分配**静默覆盖**。
|
||||
> 修复:`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` **后端不存在**,小程序 intro 页
|
||||
> 100% 无数据(且被 `/services/{id}` 吞掉)。修复:补端点并**声明在变量路径之前**。
|
||||
> - **D7(Medium,契约)** Admin 调用 5 条不存在的 `/services/operator/*`(404)+ 缺四态。
|
||||
> 修复:后端补 8 个运营端端点(`@PreAuthorize`),前端整体重写。
|
||||
> - **D8(Medium,契约)** 小程序字段名错(`userRequestSummary`/`preferredServiceMode`)+ 无幂等
|
||||
> + `my-counseling` **未检查 `success`**(403 静默空列表)+ 跳转不存在的页 + WXML 内调用方法(非法)。
|
||||
> - **D9(Medium,测试保真度)** 测试库 counseling 两表**零 CHECK 约束**,`counseling_services`
|
||||
> 未列入 `EntityMigrationParityTest` 任何集合(= 零覆盖)。修复:补 3 个 CHECK + 建
|
||||
> `counseling_followups` + 两表移入 `REQUIRED_IN_TEST_SCHEMA`。
|
||||
> - **另修 4 处测试基建缺陷(T1~T4)**:① 守卫因 surefire `<workingDirectory>${project.basedir}/..`
|
||||
> 指向**仓库根**而**找不到 DDL**(且 `backend/` 是子目录非祖先,回溯也找不到)→ 恒真假绿;
|
||||
> ② 全限定 `@com.baomidou...TableField(exist=false)` **漏解析** → 假漂移警报;
|
||||
> ③ `OperatorEndpointPermissionCoverageTest` 扫到**私有辅助方法**(假阳性);
|
||||
> ④ 两处测试自身逻辑缺陷(陈旧内存快照比较、Instant 纳秒 vs DB 微秒精度)。
|
||||
> - **负向验证两轮**:① 从 `V039` 移除 `scheduled` → 常量守卫**精准变红**
|
||||
> (证明其解析的是**真实 DDL** 而非副本);② 注入「并发回退先读后写 / manager 读 followups /
|
||||
> 状态机守卫失效」→ **三个用例各自精准变红**。均已还原(`grep -c "NEG-VAL"` = 0)。
|
||||
> - **全量回归**:`Tests run: 420, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS`(4:49)。
|
||||
> 基线 391 → 420(净 +29)。
|
||||
> - 报告:`docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md`
|
||||
|
||||
### V1.1 待实现模块(按 Phase 顺序)
|
||||
|
||||
| 模块 | 对应 Phase | 状态 |
|
||||
@@ -307,7 +360,7 @@
|
||||
| Banner 管理(后端+前端) | Phase 19 | ⏳ 待开发 |
|
||||
| 本地资格声明与审核 | Phase 04~05 | ⏳ 待开发 |
|
||||
| 公益红娘服务转介 | Phase 13 | ✅ 已完成 |
|
||||
| 婚恋咨询 + 婚前辅导(Phase 13A) | Phase 13A | ⏳ 待开发(**需用户确认后启动**) |
|
||||
| 婚恋咨询 + 婚前辅导(Phase 13A) | Phase 13A | ✅ 已完成(2026-09-17 复验关闭) |
|
||||
| 内容管理(婚恋课堂/反诈) | Phase 19 | ⏳ 待开发 |
|
||||
| 专项公益服务项目 | Phase 19 | ⏳ 待开发 |
|
||||
| V1.1新增不变量测试(INV-011~020) | Phase 23 | ⏳ 待开发 |
|
||||
|
||||
@@ -307,15 +307,46 @@ D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `M
|
||||
|
||||
**Files:** `counseling/**`、`miniapp/pages/counseling-*`、`miniapp/pages/my-counseling/**`、`miniapp/pages/premarital-*`、`admin-web/src/modules/counseling/CounselingView.vue`、router。
|
||||
|
||||
- [ ] 修复 Admin Vue 语法错误并注册 `/counseling` 路由,先使 type-check 失败转为通过。
|
||||
- [ ] 用户申请接口从认证主体取 userId;补幂等、取消、详情 owner 校验和合法状态迁移。
|
||||
- [ ] 分配操作使用条件更新/版本号解决并发;Counselor 只能读取和操作 Assigned Service。
|
||||
- [ ] Counseling Manager 可分配和查看必要摘要;普通管理员、红娘、内容编辑不得读取咨询正文。
|
||||
- [ ] 婚前辅导复用 counseling service type;邀请平台内伴侣必须由对方自主确认。
|
||||
- [ ] U55~U59 修复类型错误、API 契约、Loading/Empty/Error/Permission 状态和非医疗化文案。
|
||||
- [x] 修复 Admin Vue 语法错误并注册 `/counseling` 路由,先使 type-check 失败转为通过。
|
||||
- [x] 用户申请接口从认证主体取 userId;补幂等、取消、详情 owner 校验和合法状态迁移。
|
||||
- [x] 分配操作使用条件更新/版本号解决并发;Counselor 只能读取和操作 Assigned Service。
|
||||
- [x] Counseling Manager 可分配和查看必要摘要;普通管理员、红娘、内容编辑不得读取咨询正文。
|
||||
- [x] 婚前辅导复用 counseling service type;邀请平台内伴侣必须由对方自主确认。
|
||||
- [x] U55~U59 修复类型错误、API 契约、Loading/Empty/Error/Permission 状态和非医疗化文案。
|
||||
|
||||
**Gate:** 申请→分配→预约→服务→完成→反馈真实闭环通过;所有隐私负向用例通过。
|
||||
|
||||
> **复验完成(2026-09-17)** — Tag:`phase13a-recovery-2026-09-17`
|
||||
>
|
||||
> 复验发现 counseling 模块存在**双实现**:线上权威 `CounselingService`(167 行)
|
||||
> **零测试**,唯一被测的 `CounselingServiceApp`(280 行)是**孤儿类**
|
||||
> (无注入点、无端点可达,生产永不执行)。已合并并删除孤儿类。
|
||||
>
|
||||
> **9 处缺陷(D1~D9)**:
|
||||
> - D1 **Critical 生产故障 / INV-013 泄露**:咨询正文原文被写入 WARN 日志(同 Phase 10 D2 一族)。
|
||||
> - D2 High 越权:申请 userId 取自请求体而非认证主体。
|
||||
> - D3 High 并发:分配「先读后写」→ 并发**静默覆盖**;改 `assignIfPending` 条件更新。
|
||||
> - D4 High 越权读:manager 经 `getDetail` 可读 followups 正文(INV-013 绕过通道)。
|
||||
> - D5 Medium:无状态机 → 「已完成」可被再取消。
|
||||
> - D6 Medium 生产故障:`GET /services/intro` 后端不存在,小程序 intro 页 100% 无数据
|
||||
> (且被 `/services/{id}` 吞掉)→ 已补端点并置于变量路径之前。
|
||||
> - D7 Medium:Admin 调用 5 条不存在的运营端端点(404)+ 缺四态。
|
||||
> - D8 Medium:小程序字段名不匹配 + 无幂等 + 未检查 `success`(403 静默空列表)+ 跳转不存在的页。
|
||||
> - D9 Medium 测试保真度:测试库 counseling 两表**零 CHECK 约束**,`counseling_services` 零覆盖。
|
||||
>
|
||||
> **另修 4 处测试基建缺陷(T1~T4)**:守卫因 surefire `workingDirectory` 指向仓库根而
|
||||
> **找不到 DDL**(恒真假绿);全限定 `@TableField(exist=false)` 漏解析(假警报);
|
||||
> 权限守卫扫到私有辅助方法(假阳性);两处测试自身逻辑缺陷。
|
||||
>
|
||||
> **负向验证两轮**:① DDL 常量守卫 —— 从 V039 移除 `scheduled` → 守卫精准变红
|
||||
> (证明解析的是**真实 DDL** 而非副本);② 行为守卫 —— 注入并发/INV-013/状态机
|
||||
> 三个缺陷 → 三个用例精准变红。均已还原。
|
||||
>
|
||||
> **全量回归实测**:`[WARNING] Tests run: 420, Failures: 0, Errors: 0, Skipped: 3` /
|
||||
> `BUILD SUCCESS`(4 分 49 秒)。Phase 13 基线 391 → 420(净 +29)。
|
||||
> **Phase 13A 已关闭。Phase 14 未自动进入,等待用户确认。**
|
||||
> 报告:`docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md`
|
||||
|
||||
### Phase 14:多类型公益活动 — REFACTOR/EXTEND
|
||||
|
||||
**Files:** activity 模块、Miniapp activity 页面、Admin activity 页面。
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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') : ''; },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user