fix(matchmaker,referral): close Phase 13 KEEP-VERIFY/EXTEND — 7 real defects

KEEP-VERIFY/EXTEND 复验(历史 ✅ 不可信;测试覆盖函数 ≠ 覆盖运行时路径)。
逐条核对运行时路径与生产约束后修复 7 处缺陷,其中 3 处「只在真实 PostgreSQL 上炸」。

D1 (Critical, 生产故障)
  matchmaker_recommendations.status 代码写 liked/passed/confirmed_mutual,
  而 V009 chk_rec_status 只允许 pending_view/viewed/responded 且从未扩展
  → 真实 PG 上用户接受/跳过推荐必崩。测试库未复刻该 CHECK,故长期静默。

D2 (Critical, 生产故障)
  控制器缺省 serviceType="recommendation" 不在 chk_matchmaker_service_type
  白名单 → 提交红娘服务必崩。
  D1+D2 同修于新增 V038__align_matchmaker_status_constraints.sql
  (沿用 V018/V020/V025 的 DROP CONSTRAINT IF EXISTS + 重加白名单惯例)。

D3 (High, 越权写)
  MatchmakerServiceService.recommendPartner 无 assignedOperatorId 归属校验,
  且可把用户推荐给自己 → 补校验 + 禁止自我推荐。

D4 (High, 越权读)
  getServiceDetail 无归属校验且返回完整用户资料(userId/description/
  userProfile/followups)→ 签名加 requesterId,按调用者返回完整/脱敏视图。

D5 (High, 越权写)
  ReferralService 的 userId 取自请求体且零授权校验 → 新增
  assertMatchmakerServesUser,在 referToServiceStation/referToActivity 入口
  强制校验存在进行中的服务关系。

D6 (High, 生产故障)
  MatchmakerFollowupMapper.findByServiceId 只有声明无 @Select
  → Invalid bound statement (not found),getServiceDetail 必崩。
  该缺陷由本轮新增测试首次暴露(历史测试从未调用该路径)→ 补 @Select。

D7 (Medium, 契约缺口)
  admin-web 5 处 API 缺口(4 条 404 + C 端端点被运营端误用)+ 小程序缺转介服务层
  → 后端补 GET /operator/matchmaker/recommendations 与
  GET /referrals/operator/history 两个运营端端点;前端改路径、
  assign 改 POST + body operatorId;新增 miniapp/services/referralService.js。

测试保真度
  - 测试库补 matchmaker_services/recommendations/followups 三表(此前该模块零真实覆盖),
    约束与 V038 对齐;EntityMigrationParityTest 三表移入 REQUIRED_IN_TEST_SCHEMA;
    MigrationValidationTest LATEST_MIGRATION 37 → 38。
  - 新增 MatchmakerReferralPhase13Test(17 用例,@SpringBootTest 走真实方法路径 + 真实落库)。
  - 重写 MatchmakerServiceTest(6 用例):直接解析 db/migration/V*.sql,按文件名顺序应用
    DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT 取"最终有效"白名单,并内置解析器自检。
    【关键】首版守卫硬编码白名单副本,注入 D1 后仍全绿(对漂移零反应)→ 必须解析真实 DDL。
  - 删除 3 处假测试占位(MatchmakerServiceTest.testMatchmakerCannotLike 的 assertTrue(true);
    CoreInvariantsTest INV-007 断言的是状态常量、INV-010 亦为 assertTrue(true)),
    已如实改名并注明行为验证位置。

负向验证(两轮)
  注入 D1(回退 chk_rec_status 白名单)→ 行为守卫 DataIntegrityViolation
  + 常量守卫 Multiple Failures (3 failures) → Tests run: 23, Failures: 1, Errors: 2
  / BUILD FAILURE,守卫有效性得证。缺陷已还原。

全量回归(离线容器,实测)
  [WARNING] Tests run: 391, Failures: 0, Errors: 0, Skipped: 3
  [INFO] BUILD SUCCESS / Total time: 04:59 min
  Phase 12 基线 371 → 391(净 +20)。

报告:docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md
Phase 13A 未自动进入,等待用户确认。
This commit is contained in:
WorkBuddy
2026-09-17 00:01:53 +08:00
parent c607e87b45
commit 5b3bad326e
21 changed files with 1336 additions and 71 deletions
@@ -72,7 +72,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
const activeTab = ref('services')
@@ -113,11 +113,15 @@ function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-C
async function loadServices() {
loading.value = true
try { const res: any = await get('/matchmaker/services/operator/queue'); services.value = res?.data || [] } finally { loading.value = false }
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
}
async function loadRecommendations() {
loading.value = true
try { const res: any = await get('/matchmaker/recommendations/operator'); recommendations.value = res?.data || [] } finally { loading.value = false }
// Phase 13 契约修复:原 `/matchmaker/recommendations/operator` 不存在(404)。
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
}
async function refresh() {
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
@@ -125,8 +129,9 @@ async function refresh() {
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
async function doAssign() {
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
try {
await put(`/matchmaker/services/${currentServiceId.value}/assign`, { assignedOperatorId: assignForm.value.operatorId })
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
@@ -138,8 +143,9 @@ function openRecommendation(row: any) {
}
async function doCreateRecommendation() {
if (!recForm.value.targetUserId || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
try {
await post(`/matchmaker/services/${currentServiceId.value}/recommendation`, {
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
targetUserId: recForm.value.targetUserId, reason: recForm.value.reason
})
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
@@ -30,7 +30,9 @@ function typeLabel(t: string) { return t === 'service_station' ? '服务站' : t
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function load() {
loading.value = true
try { const res: any = await get('/referrals/my-history'); referrals.value = res.data || [] } finally { loading.value = false }
// Phase 13 契约修复:原 `/referrals/my-history` 是 C 端用户端点(UserPrincipal),
// 运营端无"我的用户身份"→ 取不到数据。改用运营端端点(按发起红娘过滤)。
try { const res: any = await get('/referrals/operator/history'); referrals.value = res.data || [] } finally { loading.value = false }
}
onMounted(load)
</script>