feat(matchmaker): ZC-MM-002 A1 服务对象分配建模(DEC-MATCHMAKER-002 LOCKED 落地)

数据库(V053/V054):
- 新表 matchmaker_assignments:独立分配关系(assigned_by/reason/scope/
  expires_at/ended_at/end_reason),状态机 active/ended/revoked/transferred/
  expired(CHECK 白名单),部分唯一索引防同用户多 ACTIVE 主责分配,
  存量进行中服务单幂等回填
- 新角色 matchmaker_leader(014) + 新权限 matchmaker.team.assign(10000000-…-023);
  组长授 view/service/team.assign(不给平台级 matchmaker.assign)

后端:
- ServiceAssignmentPolicy 有效分配判定器(读前惰性过期,失效分配即时拒绝)
- 分配/转分配/撤销/结束四操作:状态机非法转换拒绝、理由必填、
  同用户主责冲突拒绝;组织范围校验(team.assign 同组织,NULL=NULL 视为
  平台直属,技术默认安全值待治理登记);transfer 同时校验原持有人与新目标范围
- 全操作审计 MATCHMAKER_ASSIGNMENT_CREATE/TRANSFER/REVOKE/END,
  operatorId=当前操作者,afterData 仅记摘要(无敏感内容)
- 删除认领端点 POST /services/{id}/claim(A1 禁止普通红娘认领,MM-AUTH-004=404)
- GET /pending 收敛为分配权持有者(assign or team.assign);
  dashboard today/completed 改本人口径,inProgress 口径修正 'processing'→'in_service'
- recommend/followup/complete/detail 归属判定接入判定器,拒绝走
  DataScopeGuard(自动 DATA_SCOPE_DENIED 审计),不再裸 IllegalStateException;
  assignService 与创建分配同事务联动;删除死查询 findAssignedTo

前端:
- admin-web 分配弹窗去硬编码假数据(operator_wang/chen)→ assignable-matchmakers
  真实接口(仅 id/name/organizationId,无联系方式)+ 理由必填;
  队列 tab 按 assign/team.assign 显隐;新增「服务对象分配」tab(转分配/撤销/结束)
- 小程序红娘工作台:删除接单入口,数据源切 GET /operator/matchmaker/assignments/my,
  枚举码不上屏(中文映射),空 serviceId 不跳转

测试:
- MatchmakerAssignmentA1Test 14 用例(MM-AUTH-001~004、MM-AUDIT-001、
  MM-LIFECYCLE-001、组长跨组织拒绝、NULL=NULL 平台直属边界、transfer 原持有人
  范围负向、/pending 403+审计),负向注入自证有效
- MatchmakerAssignmentPermissionTest 3 用例(端点权限契约 + claim 残留扫描)
- MatchmakerServiceTest 补 assignment 状态/scope 白名单守卫;迁移基线 LATEST=54
- 验证:定向 38/38 全绿;UI 契约 24/24;小程序 wcc/wcsc 真编译 0 失败
This commit is contained in:
WorkBuddy
2026-10-09 22:46:32 +08:00
parent 6187ec69df
commit 861c78efb5
21 changed files with 1868 additions and 136 deletions
@@ -3,7 +3,8 @@
<el-card> <el-card>
<template #header><div class="card-header"><span>公益红娘管理</span></div></template> <template #header><div class="card-header"><span>公益红娘管理</span></div></template>
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="服务请求队列" name="services"> <!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
<el-table :data="services" stripe v-loading="loading"> <el-table :data="services" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" /> <el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="serviceType" label="服务类型" width="160"> <el-table-column prop="serviceType" label="服务类型" width="160">
@@ -19,7 +20,7 @@
<el-table-column label="操作" width="200"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button> <el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
<el-button v-if="row.status==='assigned' || row.status==='processing'" size="small" @click="openRecommendation(row)">创建推荐</el-button> <el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -38,16 +39,47 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
<el-tab-pane label="服务对象分配" name="assignments">
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="assignmentScope" label="范围" width="90">
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
</el-table-column>
<el-table-column prop="assignmentReason" label="分配理由" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }"><el-tag :type="assignmentStatusTag(row.status)">{{ assignmentStatusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="assignedAt" label="分配时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.assignedAt) }}</template>
</el-table-column>
<el-table-column prop="expiresAt" label="有效期至" width="170">
<template #default="{ row }">{{ row.expiresAt ? formatDateTime(row.expiresAt) : '长期' }}</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
<!-- 分配弹窗 --> <!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px"> <el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
<el-form label-width="80px"> <el-form label-width="90px">
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘"> <el-form-item label="红娘">
<el-option label="王红娘" value="operator_wang" /> <el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
<el-option label="陈红娘" value="operator_chen" /> <el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select></el-form-item> </el-select>
</el-form-item>
<el-form-item label="分配理由" required>
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="assignDialogVisible=false">取消</el-button> <el-button @click="assignDialogVisible=false">取消</el-button>
@@ -55,6 +87,37 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
<el-form label-width="90px">
<el-form-item label="新红娘">
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select>
</el-form-item>
<el-form-item label="转分配理由" required>
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
</template>
</el-dialog>
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
<el-form label-width="90px">
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="terminateDialogVisible=false">取消</el-button>
<el-button type="danger" @click="doTerminate">确认</el-button>
</template>
</el-dialog>
<!-- 创建推荐弹窗 --> <!-- 创建推荐弹窗 -->
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px"> <el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
<el-form label-width="100px"> <el-form label-width="100px">
@@ -71,18 +134,35 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const activeTab = ref('services') const authStore = useAuthStore()
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
const canAssign = computed(() => {
const perms: string[] = authStore.permissions || []
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
})
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
const loading = ref(false) const loading = ref(false)
const services = ref<any[]>([]) const services = ref<any[]>([])
const recommendations = ref<any[]>([]) const recommendations = ref<any[]>([])
const assignments = ref<any[]>([])
const assignmentsLoading = ref(false)
const assignableMatchmakers = ref<any[]>([])
const assignDialogVisible = ref(false) const assignDialogVisible = ref(false)
const recDialogVisible = ref(false) const transferDialogVisible = ref(false)
const terminateDialogVisible = ref(false)
const currentServiceId = ref('') const currentServiceId = ref('')
const assignForm = ref({ operatorId: '' }) const currentUserId = ref('')
const currentAssignmentId = ref('')
const terminateAction = ref<'revoke' | 'end'>('revoke')
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
const terminateForm = ref({ reason: '' })
const recForm = ref({ userId: '', targetUserId: '', reason: '' }) const recForm = ref({ userId: '', targetUserId: '', reason: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = { const SERVICE_TYPE_LABEL: Record<string, string> = {
@@ -90,10 +170,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
referral_activity: '转介活动', referral_station: '转介服务站' referral_activity: '转介活动', referral_station: '转介服务站'
} }
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消' submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
processing: '处理中', completed: '已完成', cancelled: '已取消'
} }
const STATUS_TAG: Record<string, string> = { const STATUS_TAG: Record<string, string> = {
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger' submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
processing: '', completed: 'success', cancelled: 'danger'
} }
const REC_STATUS_LABEL: Record<string, string> = { const REC_STATUS_LABEL: Record<string, string> = {
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢', pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
@@ -103,18 +185,27 @@ const REC_STATUS_TAG: Record<string, string> = {
pending_view: 'warning', viewed: 'info', liked: 'primary', pending_view: 'warning', viewed: 'info', liked: 'primary',
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info' passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
} }
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
}
const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
}
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t } function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
function statusLabel(s: string) { return STATUS_LABEL[s] || s } function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' } function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s } function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' } function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || s }
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' } function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function loadServices() { async function loadServices() {
loading.value = true loading.value = true
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。 // Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。 // 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false } try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
} }
async function loadRecommendations() { async function loadRecommendations() {
@@ -123,19 +214,80 @@ async function loadRecommendations() {
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。 // 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false } try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
} }
async function loadAssignments() {
assignmentsLoading.value = true
// ZC-MM-002:本人名下生效中的服务对象分配
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
}
async function loadAssignableMatchmakers() {
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
}
async function refresh() { async function refresh() {
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations() if (activeTab.value === 'services') await loadServices()
else if (activeTab.value === 'assignments') await loadAssignments()
else await loadRecommendations()
} }
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true } async function openAssign(row: any) {
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
currentServiceId.value = row.serviceId || row.id
currentUserId.value = row.userId
assignForm.value = { matchmakerOperatorId: '', reason: '' }
await loadAssignableMatchmakers()
assignDialogVisible.value = true
}
async function doAssign() { async function doAssign() {
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
try { try {
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId }) await post('/operator/matchmaker/assignments', {
userId: currentUserId.value,
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
serviceId: currentServiceId.value,
reason: assignForm.value.reason.trim()
})
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices() ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') } } catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
} }
async function openTransfer(row: any) {
currentAssignmentId.value = row.id
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
await loadAssignableMatchmakers()
transferDialogVisible.value = true
}
async function doTransfer() {
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
try {
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
reason: transferForm.value.reason.trim()
})
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openTerminate(row: any, action: 'revoke' | 'end') {
currentAssignmentId.value = row.id
terminateAction.value = action
terminateForm.value = { reason: '' }
terminateDialogVisible.value = true
}
async function doTerminate() {
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
}
try {
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
reason: terminateForm.value.reason.trim()
})
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openRecommendation(row: any) { function openRecommendation(row: any) {
currentServiceId.value = row.id currentServiceId.value = row.id
recForm.value = { userId: row.userId, targetUserId: '', reason: '' } recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
+3 -1
View File
@@ -31,6 +31,8 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!token.value && !!operator.value) const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || []) const permissions = computed(() => operator.value?.permissions || [])
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
const hasPermission = (code: string) => permissions.value.includes(code)
function applyAuthData(data: any) { function applyAuthData(data: any) {
token.value = data.accessToken || '' token.value = data.accessToken || ''
@@ -87,5 +89,5 @@ export const useAuthStore = defineStore('auth', () => {
clearAccessToken() clearAccessToken()
} }
return { token, operator, isLoggedIn, permissions, login, logout, initializeSession } return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
}) })
@@ -0,0 +1,136 @@
package com.danyang.match.platform.matchmaker.controller;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
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;
/**
* 服务对象分配接口(运营端,ZC-MM-002 / A1)。
*
* <p>权限模型(D2):
* <ul>
* <li>写操作(创建/转分配/撤销/结束/可分配列表):
* {@code matchmaker.assign}(平台授权人员,不限组织)
* 或 {@code matchmaker.team.assign}(红娘组长,同组织范围);</li>
* <li>读自己名下分配(/my):{@code matchmaker.service}(普通红娘即可)。</li>
* </ul>
* operatorId 一律取当前认证主体(principal),不信任请求参数。
*/
@RestController
@RequestMapping("/operator/matchmaker/assignments")
@RequiredArgsConstructor
public class MatchmakerAssignmentController {
private final MatchmakerAssignmentService assignmentService;
/**
* 创建分配。
* Body: userId / matchmakerOperatorId / serviceId? / reason(必填)/ expiresAt?(ISO-8601)
*/
@PostMapping
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> create(@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, String> body) {
MatchmakerAssignment created = assignmentService.create(
principal.id(),
principal.hasPermission("matchmaker.assign"),
body.get("userId"),
body.get("matchmakerOperatorId"),
body.getOrDefault("serviceId", null),
body.get("reason"),
parseInstant(body.get("expiresAt")));
return ApiResponse.ok(assignmentView(created));
}
/** 转分配:旧记录置 transferred,同事务新建 active。reason 必填。 */
@PostMapping("/{id}/transfer")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> transfer(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
MatchmakerAssignment created = assignmentService.transfer(
id,
principal.id(),
principal.hasPermission("matchmaker.assign"),
body.get("newMatchmakerOperatorId"),
body.get("reason"));
return ApiResponse.ok(assignmentView(created));
}
/** 撤销分配。reason 必填。 */
@PostMapping("/{id}/revoke")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> revoke(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(assignmentView(assignmentService.revoke(
id, principal.id(), principal.hasPermission("matchmaker.assign"),
body.get("reason"))));
}
/** 结束分配。reason 必填。 */
@PostMapping("/{id}/end")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> end(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(assignmentView(assignmentService.end(
id, principal.id(), principal.hasPermission("matchmaker.assign"),
body.get("reason"))));
}
/** 我名下生效中的服务对象分配(普通红娘工作台)。 */
@GetMapping("/my")
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<List<Map<String, Object>>> my(
@AuthenticationPrincipal OperatorPrincipal principal) {
List<Map<String, Object>> list = assignmentService.listMy(principal.id()).stream()
.map(this::assignmentView)
.toList();
return ApiResponse.ok(list);
}
/**
* 可分配红娘列表(创建/转分配弹窗数据源)。
* 返回 id/name/organizationId,不含联系方式(字段级隐私,ZC-MM-005 范围)。
*/
@GetMapping("/assignable-matchmakers")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> assignable(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(assignmentService.listAssignableMatchmakers(
principal.id(), principal.hasPermission("matchmaker.assign")));
}
private Instant parseInstant(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
return Instant.parse(raw);
}
private Map<String, Object> assignmentView(MatchmakerAssignment a) {
return Map.ofEntries(
Map.entry("id", a.getId()),
Map.entry("userId", a.getUserId()),
Map.entry("matchmakerOperatorId", a.getMatchmakerOperatorId()),
Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()),
Map.entry("assignmentScope", a.getAssignmentScope()),
Map.entry("status", a.getStatus()),
Map.entry("assignmentReason", a.getAssignmentReason()),
Map.entry("assignedAt", a.getAssignedAt() == null ? "" : a.getAssignedAt().toString()),
Map.entry("expiresAt", a.getExpiresAt() == null ? "" : a.getExpiresAt().toString()),
Map.entry("endedAt", a.getEndedAt() == null ? "" : a.getEndedAt().toString()),
Map.entry("endReason", a.getEndReason() == null ? "" : a.getEndReason())
);
}
}
@@ -13,6 +13,11 @@ import java.util.Map;
/** /**
* 红娘工作台控制器(Operator调用) * 红娘工作台控制器(Operator调用)
*
* <p>ZC-MM-002(A1)状态机变更:原 {@code POST /services/{id}/claim}(红娘接单)端点已删除。
* 服务对象归属统一由 matchmaker_assignments 建模(D3 决策:不保留组长认领),
* 分配入口收敛为 {@code MatchmakerAssignmentController}(POST /operator/matchmaker/assignments),
* 服务单状态由分配动作同事务联动(submitted → assigned)。
*/ */
@RestController @RestController
@RequestMapping("/operator/matchmaker") @RequestMapping("/operator/matchmaker")
@@ -29,30 +34,33 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId)); return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId));
} }
/**
* 待分配队列。ZC-MM-002(D4):方法级权限收敛 —— 仅分配权持有者
* (matchmaker.assign 平台授权 / matchmaker.team.assign 组长)可看全局待分配队列,
* 普通红娘(仅 matchmaker.view/service)403。
*/
@GetMapping("/pending") @GetMapping("/pending")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> getPending( public ApiResponse<List<Map<String, Object>>> getPending(
@AuthenticationPrincipal OperatorPrincipal principal) { @AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(matchmakerServiceService.getPendingServices()); return ApiResponse.ok(matchmakerServiceService.getPendingServices());
} }
/**
* 分配服务给红娘(兼容入口)。ZC-MM-002(D4):
* 同事务联动「创建 matchmaker_assignments 分配 + 服务单置 assigned」。
*/
@PostMapping("/services/{id}/assign") @PostMapping("/services/{id}/assign")
@PreAuthorize("hasAuthority('matchmaker.assign')") @PreAuthorize("hasAuthority('matchmaker.assign')")
public ApiResponse<?> assignService( public ApiResponse<?> assignService(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id, @PathVariable String id,
@RequestBody Map<String, String> body) { @RequestBody Map<String, String> body) {
String operatorId = principal.id(); String assignerOperatorId = principal.id();
String assignOperatorId = body.get("operatorId"); String targetOperatorId = body.get("operatorId");
return ApiResponse.ok(matchmakerServiceService.assignService(id, assignOperatorId, null)); String reason = body.getOrDefault("reason", "红娘服务单分配");
} return ApiResponse.ok(matchmakerServiceService.assignService(
id, assignerOperatorId, targetOperatorId, reason));
@PostMapping("/services/{id}/claim")
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<?> claimService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
String operatorId = principal.id();
return ApiResponse.ok(matchmakerServiceService.claimService(operatorId, id));
} }
@GetMapping("/my-services") @GetMapping("/my-services")
@@ -114,7 +122,8 @@ public class MatchmakerOperatorController {
public ApiResponse<?> completeService( public ApiResponse<?> completeService(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) { @PathVariable String id) {
return ApiResponse.ok(matchmakerServiceService.completeService(id)); // ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy(服务层完成)
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
} }
} }
@@ -0,0 +1,62 @@
package com.danyang.match.platform.matchmaker.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 服务对象分配(ZC-MM-002 / A1)
* 对应 DB: matchmaker_assignments(V053)
*
* <p>建模说明:
* <ul>
* <li>{@code matchmakerOperatorId}:持有该用户服务对象的红娘(operators 主体,
* 沿用 matchmaker_services.assigned_operator_id 惯例)。</li>
* <li>{@code assignedBy}:做出本次分配的操作者(组长 / 平台授权人员)。</li>
* <li>状态机(D5):active → transferred / revoked / ended;
* expires_at 届满由 {@code ServiceAssignmentPolicy} 惰性置 expired(无定时任务);
* 终态一律拒绝再转换。</li>
* <li>scope:primary(主责,同用户仅一条生效中,DB 部分唯一索引兜底)/
* collaborating(协同)。</li>
* </ul>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("matchmaker_assignments")
public class MatchmakerAssignment extends BaseEntity {
/** 被服务用户 */
private String userId;
/** 持有服务对象的红娘(operators.id) */
private String matchmakerOperatorId;
/** 关联的服务单(可空:允许脱离服务单的用户级分配) */
private String serviceId;
/** 做出本次分配的操作者(operators.id) */
private String assignedBy;
/** 分配范围:primary / collaborating(与 V053 chk_mm_assignment_scope 白名单一致) */
private String assignmentScope;
/** 分配理由(必填) */
private String assignmentReason;
/** 状态:active/ended/revoked/transferred/expired(与 V053 chk_mm_assignment_status 白名单一致) */
private String status;
private Instant assignedAt;
/** 有效期届满时间;null 表示长期有效 */
private Instant expiresAt;
/** 终态时间(ended/revoked/transferred/expired 时落库) */
private Instant endedAt;
/** 终态理由(revoked/ended 必填;transferred 记录转分配理由;expired 为固定文案) */
private String endReason;
}
@@ -0,0 +1,69 @@
package com.danyang.match.platform.matchmaker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
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;
/**
* 服务对象分配 Mapper(ZC-MM-002 / A1)
*
* <p>「生效中」的统一口径:status = 'active' 且(expires_at IS NULL OR expires_at &gt; NOW())。
* 惰性过期由 {@link #expireOverdue} 负责(读前触发,无定时任务),
* 这里的 expires 条件是双保险:即使惰性过期尚未执行,已届满的行也不会被当作生效中。
*/
@Mapper
public interface MatchmakerAssignmentMapper extends BaseMapper<MatchmakerAssignment> {
@Select("SELECT * FROM matchmaker_assignments WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerAssignment> findById(@Param("id") String id);
/** 某红娘名下全部生效中的分配(GET /my、GET /assignable 的工作台口径) */
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
List<MatchmakerAssignment> findActiveByOperator(@Param("operatorId") String operatorId);
/** 某红娘对某用户是否持有生效中的分配(数据范围判定核心查询) */
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
"AND user_id = #{userId} AND status = 'active' " +
"AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
Optional<MatchmakerAssignment> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
@Param("userId") String userId);
/** 某用户当前生效中的主责分配(创建/转分配前的唯一性预检) */
@Select("SELECT * FROM matchmaker_assignments WHERE user_id = #{userId} " +
"AND status = 'active' AND assignment_scope = 'primary' " +
"AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerAssignment> findActivePrimaryByUser(@Param("userId") String userId);
/** 某服务单上生效中的分配(服务单维度的归属判定) */
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
Optional<MatchmakerAssignment> findActiveByService(@Param("serviceId") String serviceId);
/** 某服务单的全部分配历史(追溯用) */
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
List<MatchmakerAssignment> findByService(@Param("serviceId") String serviceId);
/**
* 惰性过期:把 expires_at 已届满的 active 行置为 expired 并落库。
* 由 {@code ServiceAssignmentPolicy} 在读取路径前触发,无定时任务。
*
* @return 本次被置为 expired 的行数
*/
@Update("UPDATE matchmaker_assignments SET status = 'expired', " +
"ended_at = NOW(), end_reason = '有效期届满自动失效', updated_at = NOW() " +
"WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() " +
"AND deleted_at IS NULL")
int expireOverdue();
}
@@ -26,11 +26,25 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL") @Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
long countSubmitted(); long countSubmitted();
/** 某红娘进行中的服务数(工作台统计) */ /**
* 某红娘进行中的服务数(工作台统计)。
* ZC-MM-002(D4):状态口径修正 'processing' → 'in_service'
* ('processing' 不在 V009 chk_matchmaker_status 白名单,原查询恒为 0)。
*/
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " + @Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND status = 'processing' AND deleted_at IS NULL") "AND status = 'in_service' AND deleted_at IS NULL")
long countInProgressByOperator(@Param("operatorId") String operatorId); long countInProgressByOperator(@Param("operatorId") String operatorId);
/** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-002 / D4) */
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND CAST(assigned_at AS DATE) = CURRENT_DATE AND deleted_at IS NULL")
long countTodayByOperator(@Param("operatorId") String operatorId);
/** 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-002 / D4) */
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND status = 'completed' AND deleted_at IS NULL")
long countCompletedByOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}") @Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit); List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
@@ -39,9 +53,6 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
"AND deleted_at IS NULL ORDER BY created_at DESC") "AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId); List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
/** /**
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。 * Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。 * 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
@@ -0,0 +1,339 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
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.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 服务对象分配服务(ZC-MM-002 / A1,架构师设计 D5)。
*
* <p>状态机矩阵(status ∈ active/ended/revoked/transferred/expired):
* <pre>
* active → transferred(transfer:旧记录置 transferred + end_reason,同事务新建 active)
* active → revoked (撤销,end_reason 必填)
* active → ended (结束,end_reason 必填)
* 终态(ended/revoked/transferred/expired)一律拒绝再转换 → CONFLICT
* </pre>
*
* <p>权限模型(D2):
* <ul>
* <li>{@code matchmaker.assign}:平台授权人员,不限组织;</li>
* <li>{@code matchmaker.team.assign}:红娘组长,团队边界 = 同 organization_id。</li>
* </ul>
* 组织边界规则(技术默认,治理登记后续补充):
* <b>NULL=NULL 视为平台直属</b> —— 平台直属组长只能分配平台直属红娘;
* 有组织组长不能分配 org=NULL 红娘;org=NULL 普通红娘只能由 matchmaker.assign 持有者分配。
* 范围违规统一走 {@link DataScopeGuard#deny}(自动落 DATA_SCOPE_DENIED 审计,403)。
*
* <p>唯一性:同 user_id + primary scope 同时最多一条 active(DB 部分唯一索引兜底),
* 业务层预检 + 捕获 DuplicateKeyException 双保险,均映射 CONFLICT。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MatchmakerAssignmentService {
private static final String RESOURCE_TYPE = "matchmaker_assignment";
private final MatchmakerAssignmentMapper assignmentMapper;
private final MatchmakerServiceMapper serviceMapper;
private final OperatorMapper operatorMapper;
private final DataScopeGuard dataScopeGuard;
private final AuditLogMapper auditLogMapper;
/** 读路径复用判定器(含惰性过期);Policy 不反向依赖本 Service,无环。 */
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
/**
* 创建分配(primary)。含可选服务单联动:serviceId 非空时同事务把服务单置为 assigned。
*
* @param assignerOperatorId 当前操作者(分配人)
* @param hasGlobalAssign 操作者是否持有 matchmaker.assign(true=不限组织)
* @param userId 被服务用户
* @param matchmakerOperatorId 目标红娘
* @param serviceId 关联服务单(可空)
* @param reason 分配理由(必填)
* @param expiresAt 有效期(可空=长期)
*/
@Transactional
public MatchmakerAssignment create(String assignerOperatorId, boolean hasGlobalAssign,
String userId, String matchmakerOperatorId,
String serviceId, String reason, Instant expiresAt) {
requireReason(reason, "分配理由必填");
Operator target = requireActiveOperator(matchmakerOperatorId, "目标红娘不存在");
assertAssignScope(assignerOperatorId, hasGlobalAssign, matchmakerOperatorId,
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId);
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配,请先转分配或撤销");
});
if (serviceId != null && !serviceId.isBlank()) {
linkServiceRow(serviceId, matchmakerOperatorId);
}
MatchmakerAssignment assignment = new MatchmakerAssignment();
assignment.setUserId(userId);
assignment.setMatchmakerOperatorId(matchmakerOperatorId);
assignment.setServiceId(serviceId);
assignment.setAssignedBy(assignerOperatorId);
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
assignment.setAssignmentReason(reason);
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
assignment.setAssignedAt(Instant.now());
assignment.setExpiresAt(expiresAt);
try {
assignmentMapper.insert(assignment);
} catch (DuplicateKeyException e) {
// 并发竞态兜底:uq_mm_assignment_active_primary 部分唯一索引拒绝第二条 active+primary
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配(并发创建被唯一索引拒绝)");
}
log.info("服务对象分配创建: assignmentId={}, userId={}, matchmakerOperatorId={}, by={}",
assignment.getId(), userId, matchmakerOperatorId, assignerOperatorId);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, assignment.getId(),
auditSummary(matchmakerOperatorId, assignment.getAssignmentScope(), reason));
return assignment;
}
/**
* 转分配(active → transferred + 同事务新建 active)。
* 旧记录置 transferred + end_reason,新记录 assigned_by = 当前操作者。
*/
@Transactional
public MatchmakerAssignment transfer(String assignmentId, String assignerOperatorId,
boolean hasGlobalAssign, String newMatchmakerOperatorId,
String reason) {
requireReason(reason, "转分配理由必填");
MatchmakerAssignment old = requireAssignment(assignmentId);
assertActiveForTransition(old, "转分配");
// 防御纵深(QA P2-1):不仅新目标红娘要在操作者范围内,
// 被转记录的**原持有人**也必须在范围内(与 terminate 路径同口径;
// 否则 org=X 组长可把 org=Y 红娘持有的分配"顺手"转走)。
assertAssignScope(assignerOperatorId, hasGlobalAssign, old.getMatchmakerOperatorId(),
"组长只能处置同组织红娘持有的分配(NULL=NULL 平台直属)", assignmentId);
requireActiveOperator(newMatchmakerOperatorId, "目标红娘不存在");
assertAssignScope(assignerOperatorId, hasGlobalAssign, newMatchmakerOperatorId,
"组长只能转分配给同组织的红娘(NULL=NULL 平台直属)", assignmentId);
// 1) 旧记录置 transferred
old.setStatus(Constants.ASSIGNMENT_STATUS_TRANSFERRED);
old.setEndReason(reason);
old.setEndedAt(Instant.now());
assignmentMapper.updateById(old);
// 2) 同事务新建 active 分配
MatchmakerAssignment created = new MatchmakerAssignment();
created.setUserId(old.getUserId());
created.setMatchmakerOperatorId(newMatchmakerOperatorId);
created.setServiceId(old.getServiceId());
created.setAssignedBy(assignerOperatorId);
created.setAssignmentScope(old.getAssignmentScope());
created.setAssignmentReason(reason);
created.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
created.setAssignedAt(Instant.now());
created.setExpiresAt(old.getExpiresAt());
try {
assignmentMapper.insert(created);
} catch (DuplicateKeyException e) {
// 事务内旧记录已非 active,正常不应触发;仍兜底映射 CONFLICT(整个事务回滚)
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配(并发转分配被唯一索引拒绝)");
}
log.info("服务对象转分配: oldId={}, newId={}, fromUser={}, toOperator={}, by={}",
old.getId(), created.getId(), old.getUserId(), newMatchmakerOperatorId, assignerOperatorId);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER, assignmentId,
auditSummary(newMatchmakerOperatorId, created.getAssignmentScope(), reason));
return created;
}
/** 撤销分配(active → revoked,end_reason 必填)。 */
@Transactional
public MatchmakerAssignment revoke(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason) {
return terminate(assignmentId, operatorId, hasGlobalAssign, reason,
Constants.ASSIGNMENT_STATUS_REVOKED, Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE, "撤销");
}
/** 结束分配(active → ended,end_reason 必填)。 */
@Transactional
public MatchmakerAssignment end(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason) {
return terminate(assignmentId, operatorId, hasGlobalAssign, reason,
Constants.ASSIGNMENT_STATUS_ENDED, Constants.AUDIT_ACTION_ASSIGNMENT_END, "结束");
}
/** 我(当前红娘)名下生效中的服务对象分配。读前惰性过期。 */
public List<MatchmakerAssignment> listMy(String operatorId) {
return serviceAssignmentPolicy.findActiveByOperator(operatorId);
}
/**
* 可分配红娘列表(创建分配弹窗数据源)。
* operators.status='active';组长按组织过滤、平台授权人员(matchmaker.assign)返回全部。
* 返回 id/name/organizationId,<b>不含联系方式</b>(字段级隐私,ZC-MM-005 范围)。
*/
public List<Map<String, Object>> listAssignableMatchmakers(String assignerOperatorId,
boolean hasGlobalAssign) {
String assignerOrg = null;
if (!hasGlobalAssign) {
Operator assigner = operatorMapper.findById(assignerOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
assignerOrg = assigner.getOrganizationId();
}
String orgFilter = assignerOrg;
return operatorMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Operator>()
.eq(Operator::getStatus, "active")
.isNull(Operator::getDeletedAt))
.stream()
.filter(op -> hasGlobalAssign || Objects.equals(orgFilter, op.getOrganizationId()))
.map(op -> {
Map<String, Object> item = new HashMap<>();
item.put("id", op.getId());
item.put("name", op.getName());
item.put("organizationId", op.getOrganizationId());
return item;
})
.collect(Collectors.toList());
}
// ==================== 内部实现 ====================
/** revoke / end 共用的终态迁移(仅允许 active → 终态)。 */
private MatchmakerAssignment terminate(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason,
String targetStatus, String auditAction,
String actionLabel) {
requireReason(reason, actionLabel + "理由必填");
MatchmakerAssignment assignment = requireAssignment(assignmentId);
assertActiveForTransition(assignment, actionLabel);
// 范围校验:操作者必须有权处置该红娘的分配(assign 不限组织 / team.assign 同组织)
assertAssignScope(operatorId, hasGlobalAssign, assignment.getMatchmakerOperatorId(),
"组长只能处置同组织红娘的分配(NULL=NULL 平台直属)", assignmentId);
assignment.setStatus(targetStatus);
assignment.setEndReason(reason);
assignment.setEndedAt(Instant.now());
assignmentMapper.updateById(assignment);
log.info("服务对象分配{}: assignmentId={}, operatorId={}, by={}",
actionLabel, assignmentId, assignment.getMatchmakerOperatorId(), operatorId);
saveAudit(operatorId, auditAction, assignmentId,
auditSummary(assignment.getMatchmakerOperatorId(), assignment.getAssignmentScope(), reason));
return assignment;
}
/**
* 组织范围判定(D2):hasGlobalAssign(matchmaker.assign)不限组织;
* team.assign 时校验操作者与目标红娘 organization_id 一致。
* <b>NULL=NULL 视为平台直属</b>:平台直属组长只能分配平台直属红娘;
* 有组织组长不能分配 org=NULL 红娘。违规走 DataScopeGuard.deny(审计 + 403)。
*/
private void assertAssignScope(String assignerOperatorId, boolean hasGlobalAssign,
String targetOperatorId, String denyMessage,
String auditResourceId) {
if (hasGlobalAssign) {
return; // 平台授权人员(matchmaker.assign),不限组织
}
Operator assigner = operatorMapper.findById(assignerOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
Operator target = operatorMapper.findById(targetOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "目标红娘不存在"));
// Objects.equals 的 NULL=NULL → true 语义即「平台直属对平台直属」规则
if (Objects.equals(assigner.getOrganizationId(), target.getOrganizationId())) {
return;
}
dataScopeGuard.deny(assignerOperatorId, RESOURCE_TYPE, auditResourceId, denyMessage);
}
/** 服务单联动(同事务):仅 submitted 状态允许关联分配并置为 assigned。 */
private void linkServiceRow(String serviceId, String matchmakerOperatorId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联服务单不存在");
}
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "服务单当前状态不允许关联分配");
}
service.setAssignedOperatorId(matchmakerOperatorId);
service.setStatus(Constants.MATCHMAKER_STATUS_ASSIGNED);
service.setAssignedAt(Instant.now());
serviceMapper.updateById(service);
}
private MatchmakerAssignment requireAssignment(String assignmentId) {
return assignmentMapper.findById(assignmentId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "分配记录不存在"));
}
private Operator requireActiveOperator(String operatorId, String notFoundMessage) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, notFoundMessage));
if (!"active".equals(operator.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "目标红娘账号非 active 状态");
}
return operator;
}
/** 终态一律拒绝再转换(D5 状态机矩阵)。 */
private void assertActiveForTransition(MatchmakerAssignment assignment, String actionLabel) {
if (!Constants.ASSIGNMENT_STATUS_ACTIVE.equals(assignment.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"分配已处于终态(" + assignment.getStatus() + "),不允许" + actionLabel);
}
}
private void requireReason(String reason, String message) {
if (reason == null || reason.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
}
}
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
String summary = "matchmakerOperatorId=" + matchmakerOperatorId
+ ";scope=" + scope
+ ";reason=" + (reason == null ? "" : reason);
return summary.length() > 500 ? summary.substring(0, 500) : summary;
}
private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(RESOURCE_TYPE);
auditLog.setResourceId(resourceId);
auditLog.setAfterData(afterData);
auditLogMapper.insert(auditLog);
}
}
@@ -1,6 +1,5 @@
package com.danyang.match.platform.matchmaker.service; package com.danyang.match.platform.matchmaker.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog; import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO; import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO;
@@ -14,6 +13,7 @@ import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -27,6 +27,19 @@ import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 红娘服务服务层。
*
* <p>ZC-MM-002(A1)变更说明:
* <ul>
* <li>claimService(红娘接单)已删除(D3:不保留组长认领),服务对象归属统一由
* matchmaker_assignments 建模;</li>
* <li>写接口(recommend/followup/complete)归属判定切换到
* {@link ServiceAssignmentPolicy}(D4),拒绝统一走 DataScopeGuard(审计 + 403);
* 存量兼容:assignment 表上线前的服务单仍按 assigned_operator_id 判定(005 阶段收敛);</li>
* <li>assignService 同事务联动「创建分配 + 服务单置 assigned」。</li>
* </ul>
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MatchmakerServiceService { public class MatchmakerServiceService {
@@ -39,6 +52,9 @@ public class MatchmakerServiceService {
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final NotificationService notificationService; private final NotificationService notificationService;
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
private final DataScopeGuard dataScopeGuard;
private final MatchmakerAssignmentService matchmakerAssignmentService;
/** /**
* 用户申请红娘服务 * 用户申请红娘服务
@@ -82,43 +98,44 @@ public class MatchmakerServiceService {
} }
/** /**
* 分配服务给红娘 * 分配服务给红娘(ZC-MM-002 / D4 联动版)。
*
* <p>同事务完成:① 创建 matchmaker_assignments 主责分配(含组织范围校验、
* 唯一性约束、CREATE 审计,由 {@link MatchmakerAssignmentService#create} 承担);
* ② 服务单 submitted → assigned、写回 assigned_operator_id。
* 任何一步失败整体回滚。
*
* @param serviceId 服务单 ID
* @param assignerOperatorId 当前操作者(分配人,principal)
* @param targetOperatorId 目标红娘
* @param reason 分配理由(默认文案由 Controller 传入)
*/ */
@Transactional @Transactional
public MatchmakerServiceRecord assignService(String serviceId, String operatorId, String organizationId) { public MatchmakerServiceRecord assignService(String serviceId, String assignerOperatorId,
String targetOperatorId, String reason) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) { if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配"); throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
} }
if (targetOperatorId == null || targetOperatorId.isBlank()) {
service.setAssignedOperatorId(operatorId); throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "目标红娘必填");
service.setOrganizationId(organizationId);
service.setStatus(Constants.MATCHMAKER_STATUS_ASSIGNED);
service.setAssignedAt(Instant.now());
serviceMapper.updateById(service);
log.info("分配红娘服务: serviceId={}, operatorId={}", serviceId, operatorId);
saveAuditLog(operatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null);
return service;
}
/**
* 红娘接单
*/
@Transactional
public MatchmakerServiceRecord claimService(String operatorId, String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许接单");
} }
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE); // ① 创建分配(matchmaker.assign 入口为平台授权,hasGlobalAssign=true)
serviceMapper.updateById(service); matchmakerAssignmentService.create(assignerOperatorId, true,
service.getUserId(), targetOperatorId, serviceId, reason, null);
log.info("红娘接单: operatorId={}, serviceId={}", operatorId, serviceId); // ② 服务单联动(create 内已对 submitted 服务单置 assigned;此处再校验一次确保联动生效)
return service; MatchmakerServiceRecord after = serviceMapper.selectById(serviceId);
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(after.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "服务单分配联动失败");
}
log.info("分配红娘服务: serviceId={}, targetOperatorId={}, by={}",
serviceId, targetOperatorId, assignerOperatorId);
saveAuditLog(assignerOperatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null);
return after;
} }
/** /**
@@ -132,10 +149,11 @@ public class MatchmakerServiceService {
/** /**
* 红娘推荐候选人给用户 * 红娘推荐候选人给用户
* *
* Phase 13 复验修复(D3):补 assigned_operator 归属校验。 * Phase 13 复验修复(D3):补归属校验。
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator * ZC-MM-002(D4):归属判定切换到 {@link ServiceAssignmentPolicy}(matchmaker_assignments
* 都能对**任意** serviceId 创建推荐(越权)。与 * 是服务对象归属的唯一事实源);存量兼容:assignment 表上线前的服务单仍按
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。 * assigned_operator_id 判定。拒绝统一走 DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403),
* 不再抛裸 IllegalStateException。
* *
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。 * AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
*/ */
@@ -145,10 +163,8 @@ public class MatchmakerServiceService {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐 // D4:归属校验 —— 只有该服务对象的负责人(分配持有人)才能推荐
if (!operatorId.equals(service.getAssignedOperatorId())) { assertServiceAccess(operatorId, service);
throw new IllegalStateException("您不是此服务的负责人");
}
// 不能推荐给自己(服务申请人) // 不能推荐给自己(服务申请人)
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) { if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
throw new IllegalArgumentException("不能推荐用户给自己"); throw new IllegalArgumentException("不能推荐用户给自己");
@@ -182,11 +198,18 @@ public class MatchmakerServiceService {
/** /**
* 红娘添加跟进记录 * 红娘添加跟进记录
*
* ZC-MM-002(D4):补归属校验(此前无任何校验,任何持 matchmaker.service 权限的
* operator 可对任意 serviceId 写跟进 → 越权写)。判定口径同 recommendPartner。
*/ */
@Transactional @Transactional
public MatchmakerFollowup addFollowup(String serviceId, String operatorId, public MatchmakerFollowup addFollowup(String serviceId, String operatorId,
String followupType, String content, String followupType, String content,
String nextFollowupAt) { String nextFollowupAt) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
assertServiceAccess(operatorId, service);
MatchmakerFollowup followup = new MatchmakerFollowup(); MatchmakerFollowup followup = new MatchmakerFollowup();
followup.setServiceId(serviceId); followup.setServiceId(serviceId);
followup.setOperatorId(operatorId); followup.setOperatorId(operatorId);
@@ -204,11 +227,15 @@ public class MatchmakerServiceService {
/** /**
* 完成服务 * 完成服务
*
* ZC-MM-002(D4):补归属校验(判定口径同 recommendPartner);
* operatorId 由 Controller 传入 principal,不信任请求参数。
*/ */
@Transactional @Transactional
public MatchmakerServiceRecord completeService(String serviceId) { public MatchmakerServiceRecord completeService(String serviceId, String operatorId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
assertServiceAccess(operatorId, service);
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) && if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) { !Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成"); throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
@@ -257,6 +284,14 @@ public class MatchmakerServiceService {
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
} }
/**
* 获取红娘工作台数据。
*
* ZC-MM-002(D4):today / completed 改为<b>本人口径</b>(该红娘名下的服务),
* pendingCount 保留全局口径(待分配队列是分配权持有者的全局视图);
* inProgress 统计的状态口径由错误的 'processing' 修正为 'in_service'
* ('processing' 不在 V009 chk_matchmaker_status 白名单,恒为 0)。
*/
public Map<String, Object> getDashboard(String operatorId) { public Map<String, Object> getDashboard(String operatorId) {
long pendingCount = serviceMapper.countSubmitted(); long pendingCount = serviceMapper.countSubmitted();
long inProgressCount = serviceMapper.countInProgressByOperator(operatorId); long inProgressCount = serviceMapper.countInProgressByOperator(operatorId);
@@ -264,13 +299,11 @@ public class MatchmakerServiceService {
Map<String, Object> dashboard = new HashMap<>(); Map<String, Object> dashboard = new HashMap<>();
dashboard.put("pendingCount", pendingCount); dashboard.put("pendingCount", pendingCount);
dashboard.put("inProgressCount", inProgressCount); dashboard.put("inProgressCount", inProgressCount);
long todayCount = serviceMapper.selectCount( // 本人口径:今天新流转到该红娘名下(按 assigned_at)的服务数
new LambdaQueryWrapper<MatchmakerServiceRecord>() long todayCount = serviceMapper.countTodayByOperator(operatorId);
.eq(MatchmakerServiceRecord::getStatus, "submitted"));
dashboard.put("todayCount", todayCount); dashboard.put("todayCount", todayCount);
long completedCount = serviceMapper.selectCount( // 本人口径:该红娘名下已完成的服务数
new LambdaQueryWrapper<MatchmakerServiceRecord>() long completedCount = serviceMapper.countCompletedByOperator(operatorId);
.eq(MatchmakerServiceRecord::getStatus, "completed"));
dashboard.put("completedCount", completedCount); dashboard.put("completedCount", completedCount);
return dashboard; return dashboard;
} }
@@ -302,7 +335,12 @@ public class MatchmakerServiceService {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) return null; if (service == null) return null;
boolean isOwner = requesterId != null && requesterId.equals(service.getAssignedOperatorId()); // ZC-MM-002(D4):负责人判定 = assignment 判定器优先,存量服务单回退 assigned_operator_id
boolean isOwner = requesterId != null &&
(service.getUserId() == null
? requesterId.equals(service.getAssignedOperatorId())
: serviceAssignmentPolicy.canAccessUser(requesterId, service.getUserId())
|| requesterId.equals(service.getAssignedOperatorId()));
Map<String, Object> detail = new HashMap<>(); Map<String, Object> detail = new HashMap<>();
detail.put("serviceId", service.getId()); detail.put("serviceId", service.getId());
@@ -355,6 +393,28 @@ public class MatchmakerServiceService {
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears(); return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
} }
/**
* 服务归属校验(ZC-MM-002 / D4)。
*
* 判定口径:
* 1. assignment 判定器优先 —— 操作者对该服务申请人持有生效中的 matchmaker_assignments
* 分配(含惰性过期);判定无效时由 Policy 走 DataScopeGuard.deny
* (落 DATA_SCOPE_DENIED 审计 + 抛 AccessDeniedException → 403)。
* 2. 存量兼容:assignment 表上线前的服务单仍按 assigned_operator_id 判定
* (与 V053 存量回填语义一致;005 阶段读口径统一时收敛)。
*/
private void assertServiceAccess(String operatorId, MatchmakerServiceRecord service) {
if (operatorId != null && service.getUserId() != null
&& serviceAssignmentPolicy.canAccessUser(operatorId, service.getUserId())) {
return;
}
if (operatorId != null && operatorId.equals(service.getAssignedOperatorId())) {
return;
}
dataScopeGuard.deny(operatorId, "matchmaker_service", service.getId(),
"您不是此服务对象的负责人,禁止操作");
}
/** /**
* 获取红娘为该用户推荐的人员列表 * 获取红娘为该用户推荐的人员列表
@@ -0,0 +1,71 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
/**
* 服务对象分配判定器(ZC-MM-002 / A1,架构师设计 D6)。
*
* <p>红娘对某个用户能否读/写,唯一事实源是 matchmaker_assignments 里
* 「生效中的分配」(active 且未过有效期)。所有读路径前先 {@link #expireOverdue}
* 惰性过期(无定时任务,D5 决策),保证过期分配立即失效。
*
* <p>判定无效时统一走 {@link DataScopeGuard#deny}:先落 DATA_SCOPE_DENIED 审计,
* 再抛 AccessDeniedException(全局处理器映射为 403 + FORBIDDEN)。
*/
@Component
@RequiredArgsConstructor
public class ServiceAssignmentPolicy {
private final MatchmakerAssignmentMapper assignmentMapper;
private final DataScopeGuard dataScopeGuard;
/**
* 惰性过期:把 expires_at 已届满的 active 分配置为 expired 并落库。
* 读路径入口统一调用;无定时任务(D5 决策)。
*/
public int expireOverdue() {
return assignmentMapper.expireOverdue();
}
/** 查询某红娘对某用户的生效中分配;不存在返回 empty。 */
public Optional<MatchmakerAssignment> findActiveAssignment(String operatorId, String userId) {
expireOverdue();
return assignmentMapper.findActiveByOperatorAndUser(operatorId, userId);
}
/** 某红娘当前是否可访问某用户(存在生效中的分配)。 */
public boolean canAccessUser(String operatorId, String userId) {
return findActiveAssignment(operatorId, userId).isPresent();
}
/**
* 断言某红娘对某用户持有生效中的分配;无效时:
* 落 DATA_SCOPE_DENIED 审计 + 抛 AccessDeniedException(403)。
*
* @param resourceType 审计 resourceType(如 matchmaker_service / matchmaker_user)
* @return 生效中的分配记录(永不返回 null)
*/
public MatchmakerAssignment assertActiveAssignment(String operatorId, String userId,
String resourceType) {
Optional<MatchmakerAssignment> found = findActiveAssignment(operatorId, userId);
if (found.isPresent()) {
return found.get();
}
dataScopeGuard.deny(operatorId, resourceType, userId,
"未持有该服务对象的分配关系,禁止访问");
return null; // deny 必然抛出,此行不可达
}
/** 某红娘名下全部生效中的分配(GET /my 口径),读前惰性过期。 */
public List<MatchmakerAssignment> findActiveByOperator(String operatorId) {
expireOverdue();
return assignmentMapper.findActiveByOperator(operatorId);
}
}
@@ -330,6 +330,25 @@ public final class Constants {
public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker"; public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker";
public static final String PROFILE_STATUS_PENDING_REVIEW = "pending_review"; public static final String PROFILE_STATUS_PENDING_REVIEW = "pending_review";
// ========== 服务对象分配(ZC-MM-002 / A1,与 V053 数据库 CHECK 白名单一致)==========
// 状态机:active → transferred / revoked / ended;expires_at 届满由判定器惰性置 expired。
// 终态(ended/revoked/transferred/expired)一律拒绝再转换。
public static final String ASSIGNMENT_STATUS_ACTIVE = "active";
public static final String ASSIGNMENT_STATUS_ENDED = "ended";
public static final String ASSIGNMENT_STATUS_REVOKED = "revoked";
public static final String ASSIGNMENT_STATUS_TRANSFERRED = "transferred";
public static final String ASSIGNMENT_STATUS_EXPIRED = "expired";
// 分配范围:primary(主责,同用户仅一条生效中)/ collaborating(协同)
public static final String ASSIGNMENT_SCOPE_PRIMARY = "primary";
public static final String ASSIGNMENT_SCOPE_COLLABORATING = "collaborating";
// 服务对象分配审计 action(resourceType = matchmaker_assignment)
public static final String AUDIT_ACTION_ASSIGNMENT_CREATE = "MATCHMAKER_ASSIGNMENT_CREATE";
public static final String AUDIT_ACTION_ASSIGNMENT_TRANSFER = "MATCHMAKER_ASSIGNMENT_TRANSFER";
public static final String AUDIT_ACTION_ASSIGNMENT_REVOKE = "MATCHMAKER_ASSIGNMENT_REVOKE";
public static final String AUDIT_ACTION_ASSIGNMENT_END = "MATCHMAKER_ASSIGNMENT_END";
// ========== V1.1 本地资格审核 ========== // ========== V1.1 本地资格审核 ==========
/** 本地资格审核任务类型 */ /** 本地资格审核任务类型 */
public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility"; public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility";
@@ -0,0 +1,79 @@
-- ============================================================
-- V053: 服务对象分配表(ZC-MM-002 / A1 服务对象分配建模)
--
-- 建模决策(架构师设计 D1/D5):
-- * 决策文档 5.1 的 matchmaker_user_id 对应 operators 主体,
-- 定名 matchmaker_operator_id(沿用 matchmaker_services.assigned_operator_id 惯例)。
-- * status 状态机:active → transferred / revoked / ended;
-- expires_at < now 的 active 行由 ServiceAssignmentPolicy 惰性置 expired(无定时任务);
-- 终态(transferred/revoked/ended/expired)一律不允许再次转换。
-- * assignment_scope:primary(主责)/ collaborating(协同)。
-- * 同一用户同时最多一条 active + primary 分配 → 部分唯一索引兜底
-- (业务层 SELECT-then-INSERT 有竞态,只有 DB 唯一索引能兜住)。
--
-- 存量回填说明:
-- matchmaker_services.assigned_operator_id IS NOT NULL 且服务仍处于非终态
-- (assigned / in_service / waiting_user,与 V009 chk_matchmaker_status 白名单一致)的行,
-- 回填为 active + primary 分配。assigned_by 无历史记录可考,取技术回填值
-- = assigned_operator_id(仅表示"分配关系成立",不代表当年实际操作者)。
-- 幂等:NOT EXISTS 防重复回填(按 service_id 关联)。
-- ============================================================
CREATE TABLE IF NOT EXISTS matchmaker_assignments (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
matchmaker_operator_id UUID NOT NULL REFERENCES operators(id),
service_id UUID REFERENCES matchmaker_services(id),
assigned_by UUID NOT NULL REFERENCES operators(id),
assignment_scope VARCHAR(32) NOT NULL DEFAULT 'primary',
assignment_reason VARCHAR(500) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
assigned_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
end_reason VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
ALTER TABLE matchmaker_assignments ADD CONSTRAINT chk_mm_assignment_status CHECK (status IN (
'active', 'ended', 'revoked', 'transferred', 'expired'
));
ALTER TABLE matchmaker_assignments ADD CONSTRAINT chk_mm_assignment_scope CHECK (assignment_scope IN (
'primary', 'collaborating'
));
-- 部分唯一索引:同一用户最多一条「生效中的主责分配」。
-- 终态行(ended/revoked/transferred/expired)不占位,允许历史多条。
CREATE UNIQUE INDEX IF NOT EXISTS uq_mm_assignment_active_primary
ON matchmaker_assignments(user_id)
WHERE status = 'active' AND assignment_scope = 'primary';
CREATE INDEX IF NOT EXISTS idx_mm_assignment_operator ON matchmaker_assignments(matchmaker_operator_id, status);
CREATE INDEX IF NOT EXISTS idx_mm_assignment_user ON matchmaker_assignments(user_id, status);
CREATE INDEX IF NOT EXISTS idx_mm_assignment_service ON matchmaker_assignments(service_id);
-- 存量回填(幂等)
INSERT INTO matchmaker_assignments (
id, user_id, matchmaker_operator_id, service_id, assigned_by,
assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at
)
SELECT
gen_random_uuid(),
ms.user_id,
ms.assigned_operator_id,
ms.id,
ms.assigned_operator_id, -- assigned_by 为技术回填值:历史分配操作者未记录
'primary',
'V053 存量回填:原始分配人未记录',
'active',
COALESCE(ms.assigned_at, NOW()),
NOW(),
NOW()
FROM matchmaker_services ms
WHERE ms.assigned_operator_id IS NOT NULL
AND ms.status IN ('assigned', 'in_service', 'waiting_user')
AND NOT EXISTS (
SELECT 1 FROM matchmaker_assignments ma WHERE ma.service_id = ms.id
);
@@ -0,0 +1,38 @@
-- ============================================================
-- V054: 新增红娘组长角色与团队分配权限(ZC-MM-002 / A1,架构师设计 D2)
--
-- 角色权限边界:
-- * matchmaker_leader(014)授 view(020) + service(022) + team.assign(023),
-- **不给** matchmaker.assign(021) —— 组长只能在同组织范围内分配;
-- 组织边界判定:组长团队边界 = 同 organization_id,NULL=NULL 视为平台直属
-- (平台直属组长只能分配平台直属红娘;有组织组长不能分配 org=NULL 红娘;
-- org=NULL 普通红娘只能由 matchmaker.assign 持有者分配 —— 该技术默认
-- 写在 ServiceAssignmentPolicy / MatchmakerAssignmentService 代码注释,
-- 治理登记后续补充)。
-- * 普通红娘(005)权限现状不变(view/service),由 V052 授予。
--
-- 幂等:全部 ON CONFLICT DO NOTHING(对照 V052 风格)。
-- ============================================================
INSERT INTO roles (id, code, name, description, system_role) VALUES (
'00000000-0000-0000-0000-000000000014',
'matchmaker_leader',
'红娘组长',
'公益红娘团队组长,可在同组织范围内分配/转分配服务对象',
TRUE
) ON CONFLICT (id) DO NOTHING;
INSERT INTO permissions (id, code, name, module, description) VALUES (
'10000000-0000-0000-0000-000000000023',
'matchmaker.team.assign',
'红娘团队分配',
'matchmaker',
'在组长同组织范围内分配/转分配/撤销/结束服务对象分配'
) ON CONFLICT (id) DO NOTHING;
-- 红娘组长:view + service + team.assign(不含 matchmaker.assign)
INSERT INTO role_permissions (role_id, permission_id)
SELECT '00000000-0000-0000-0000-000000000014', id
FROM permissions
WHERE code IN ('matchmaker.view', 'matchmaker.service', 'matchmaker.team.assign')
ON CONFLICT (role_id, permission_id) DO NOTHING;
@@ -32,7 +32,9 @@ class MigrationValidationTest {
// V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一 // V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一
// —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled), // —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled),
// 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭) // 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭)
private static final int LATEST_MIGRATION = 51; // ZC-MM-002(A1 服务对象分配建模):V052 补齐 matchmaker 角色权限(V052 其实早于本轮,漏计);
// V053 matchmaker_assignments 建表 + 存量回填;V054 matchmaker_leader 角色与团队分配权限
private static final int LATEST_MIGRATION = 54;
private static final String MIGRATION_DIR = private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/"; "backend/src/main/resources/db/migration/";
@@ -0,0 +1,504 @@
package com.danyang.match.platform.matchmaker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy;
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.principal.OperatorPrincipal;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
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;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ZC-MM-002(A1 服务对象分配建模)验收测试 —— 真实 H2 数据构造。
*
* <p>覆盖:
* <ul>
* <li>MM-AUTH-001:红娘对本人有效分配对象 canAccessUser=true,GET /assignments/my 含该对象;</li>
* <li>MM-AUTH-002(负向):访问未分配用户 → DataScopeGuard 拒绝 + DATA_SCOPE_DENIED 审计;</li>
* <li>MM-AUTH-003(负向):访问他人(红娘D)分配对象 → 拒绝 + 审计;</li>
* <li>MM-AUTH-004(负向):POST /services/{id}/claim → 404(D3:claim 端点已删除);</li>
* <li>MM-AUDIT-001:create/transfer/revoke/end 各产生对应 action 审计且 operatorId=操作者;</li>
* <li>MM-LIFECYCLE-001:end/revoke 后 canAccessUser=false;expires_at 届满惰性 expired;</li>
* <li>负向:组长跨组织分配 → 403 + 审计;普通红娘调 /pending → 403 + 审计。</li>
* </ul>
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MatchmakerAssignmentA1Test {
@Autowired private MatchmakerAssignmentService assignmentService;
@Autowired private ServiceAssignmentPolicy assignmentPolicy;
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
@Autowired private OperatorMapper operatorMapper;
@Autowired private UserMapper userMapper;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private MatchmakerAssignmentController assignmentController;
@Autowired private MatchmakerOperatorController operatorController;
private String leaderA; // 组长,org X
private String mmB; // 红娘,org X
private String mmC; // 红娘,org Y
private String mmD; // 红娘,org X
private String user1; // B 的服务对象
private String user2; // 无人分配
private String user3; // D 的服务对象
private static final String ORG_X = "11111111-1111-1111-1111-111111111111";
private static final String ORG_Y = "22222222-2222-2222-2222-222222222222";
@BeforeEach
void setUp() {
leaderA = createOperator("组长A", ORG_X);
mmB = createOperator("红娘B", ORG_X);
mmC = createOperator("红娘C", ORG_Y);
mmD = createOperator("红娘D", ORG_X);
user1 = createUser("a1-user1");
user2 = createUser("a1-user2");
user3 = createUser("a1-user3");
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
// ==================== 数据构造 ====================
private String createOperator(String name, String organizationId) {
Operator op = new Operator();
op.setName(name);
op.setOrganizationId(organizationId);
op.setUsername("a1_" + name + "_" + System.nanoTime());
op.setPasswordHash("test-hash");
op.setStatus("active");
operatorMapper.insert(op);
return op.getId();
}
private String createUser(String tag) {
User u = new User();
u.setWechatOpenid("a1_" + tag + "_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.insert(u);
return u.getId();
}
/** 以指定操作者身份 + 权限集设置 SecurityContext(方法级 @PreAuthorize 据此判定)。 */
private void authenticate(String operatorId, String... permissions) {
Set<String> permSet = Set.of(permissions);
var authorities = Stream.concat(
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new OperatorPrincipal(operatorId, permSet), null, authorities));
}
private long countAudits(String action, String operatorId) {
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, action)
.eq(operatorId != null, AuditLog::getOperatorId, operatorId));
}
// ==================== MM-AUTH-001 ====================
@Test
@DisplayName("MM-AUTH-001: 红娘B 对本人有效分配对象 canAccessUser=true,GET /assignments/my 含该对象")
void mmAuth001_matchmakerAccessesOwnAssignedUser() {
// 组长A(同组织 X,team.assign 路径)创建 B → user1 分配
MatchmakerAssignment created = assignmentService.create(
leaderA, false, user1, mmB, null, "同一社区,就近服务", null);
assertNotNull(created.getId());
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, created.getStatus());
assertEquals(Constants.ASSIGNMENT_SCOPE_PRIMARY, created.getAssignmentScope());
assertTrue(assignmentPolicy.canAccessUser(mmB, user1),
"红娘B 必须能访问其名下生效分配的服务对象");
// GET /assignments/my(matchmaker.service 权限)包含该对象
authenticate(mmB, "matchmaker.view", "matchmaker.service");
var result = assignmentController.my(new OperatorPrincipal(mmB,
Set.of("matchmaker.view", "matchmaker.service")));
assertTrue(result.isSuccess());
List<Map<String, Object>> data = result.getData();
assertNotNull(data);
assertEquals(1, data.size(), "GET /my 应恰好返回红娘B 名下的生效分配");
assertEquals(user1, data.get(0).get("userId"));
}
// ==================== MM-AUTH-002 / 003 ====================
@Test
@DisplayName("MM-AUTH-002(负向): B 访问未分配用户 → DataScopeGuard 拒绝 + DATA_SCOPE_DENIED 审计")
void mmAuth002_accessUnassignedUserDeniedAndAudited() {
assertFalse(assignmentPolicy.canAccessUser(mmB, user2));
long before = countAudits("DATA_SCOPE_DENIED", mmB);
assertThrows(AccessDeniedException.class,
() -> assignmentPolicy.assertActiveAssignment(mmB, user2, "matchmaker_user"),
"未持有分配的红娘访问用户必须被拒绝");
long after = countAudits("DATA_SCOPE_DENIED", mmB);
assertEquals(before + 1, after, "拒绝必须落一条 DATA_SCOPE_DENIED 审计行");
}
@Test
@DisplayName("MM-AUTH-003(负向): B 访问他人(红娘D)分配对象 → 拒绝 + 审计")
void mmAuth003_accessOthersAssignedUserDeniedAndAudited() {
// D 名下分配(组长A 同组织创建)
assignmentService.create(leaderA, false, user3, mmD, null, "D 负责该用户", null);
assertTrue(assignmentPolicy.canAccessUser(mmD, user3));
assertFalse(assignmentPolicy.canAccessUser(mmB, user3), "他人的分配对象对 B 不可见");
long before = countAudits("DATA_SCOPE_DENIED", mmB);
assertThrows(AccessDeniedException.class,
() -> assignmentPolicy.assertActiveAssignment(mmB, user3, "matchmaker_user"),
"B 访问红娘D 的分配对象必须被拒绝");
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", mmB),
"拒绝必须落 DATA_SCOPE_DENIED 审计");
}
// ==================== MM-AUTH-004 ====================
@Test
@DisplayName("MM-AUTH-004(负向): POST /services/{id}/claim → 404(claim 端点已删除)")
void mmAuth004_claimEndpointRemoved() throws Exception {
// standalone MockMvc:仅注册真实控制器路由,验证 /claim 已不在映射中(D3 决策)
MatchmakerServiceService stubService = Mockito.mock(MatchmakerServiceService.class);
MockMvc mockMvc = MockMvcBuilders
.standaloneSetup(new MatchmakerOperatorController(stubService))
.build();
mockMvc.perform(post("/operator/matchmaker/services/some-id/claim"))
.andExpect(status().isNotFound());
}
// ==================== MM-AUDIT-001 ====================
@Test
@DisplayName("MM-AUDIT-001: create/transfer/revoke/end 各产生对应 action 审计且 operatorId=操作者")
void mmAudit001_eachOperationWritesAuditWithActor() {
// 1) create: A 分配 B → user1
MatchmakerAssignment a1 = assignmentService.create(
leaderA, false, user1, mmB, null, "初始分配", null);
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, leaderA) > 0,
"create 必须落 MATCHMAKER_ASSIGNMENT_CREATE 审计");
// 2) transfer: B → D
MatchmakerAssignment a2 = assignmentService.transfer(
a1.getId(), leaderA, false, mmD, "B 休假,转给 D");
assertEquals(mmD, a2.getMatchmakerOperatorId());
assertEquals(Constants.ASSIGNMENT_STATUS_TRANSFERRED,
assignmentMapper.findById(a1.getId()).orElseThrow().getStatus());
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER, leaderA) > 0,
"transfer 必须落 MATCHMAKER_ASSIGNMENT_TRANSFER 审计");
// 3) revoke: 撤销 D 名下的新分配
assignmentService.revoke(a2.getId(), leaderA, false, "用户主动申请更换");
assertEquals(Constants.ASSIGNMENT_STATUS_REVOKED,
assignmentMapper.findById(a2.getId()).orElseThrow().getStatus());
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE, leaderA) > 0,
"revoke 必须落 MATCHMAKER_ASSIGNMENT_REVOKE 审计");
// 4) end: 新建再结束
MatchmakerAssignment a3 = assignmentService.create(
leaderA, false, user2, mmB, null, "第二次分配", null);
assignmentService.end(a3.getId(), leaderA, false, "服务完结");
assertEquals(Constants.ASSIGNMENT_STATUS_ENDED,
assignmentMapper.findById(a3.getId()).orElseThrow().getStatus());
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_END, leaderA) > 0,
"end 必须落 MATCHMAKER_ASSIGNMENT_END 审计");
// 审计行 operatorId 必须是操作者(组长A),且 resourceType 正确
// 注:本用例共 5 次操作(create×2 + transfer + revoke + end),审计 1:1 对应
List<AuditLog> audits = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getOperatorId, leaderA)
.in(AuditLog::getAction, List.of(
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE,
Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER,
Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE,
Constants.AUDIT_ACTION_ASSIGNMENT_END)));
assertEquals(5, audits.size(), "五次操作各产生一条审计(create×2/transfer/revoke/end)");
assertEquals(2, audits.stream().filter(a ->
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count());
assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())),
"resourceType 必须是 matchmaker_assignment");
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason
assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null
&& a.getAfterData().contains("matchmakerOperatorId=")
&& a.getAfterData().contains("scope=")
&& a.getAfterData().contains("reason=")),
"afterData 必须记录 operatorId/scope/reason 摘要");
}
// ==================== MM-LIFECYCLE-001 ====================
@Test
@DisplayName("MM-LIFECYCLE-001: end/revoke 后 canAccessUser=false;expires_at 届满惰性置 expired")
void mmLifecycle001_terminalStatesRevokeAccessAndLazyExpiry() {
// end 之后不可访问
MatchmakerAssignment a1 = assignmentService.create(
leaderA, false, user1, mmB, null, "待结束分配", null);
assertTrue(assignmentPolicy.canAccessUser(mmB, user1));
assignmentService.end(a1.getId(), leaderA, false, "服务完结");
assertFalse(assignmentPolicy.canAccessUser(mmB, user1), "end 后必须立即失去访问权");
// revoke 之后不可访问
MatchmakerAssignment a2 = assignmentService.create(
leaderA, false, user2, mmB, null, "待撤销分配", null);
assignmentService.revoke(a2.getId(), leaderA, false, "误分配");
assertFalse(assignmentPolicy.canAccessUser(mmB, user2), "revoke 后必须立即失去访问权");
// expires_at 届满(固定时间注入:写入已过期的 expires_at)→ 读路径惰性置 expired
MatchmakerAssignment a3 = assignmentService.create(
leaderA, false, user3, mmB, null, "短期分配",
Instant.now().minusSeconds(3600));
assertTrue(assignmentPolicy.canAccessUser(mmB, user3) == false,
"已过有效期的分配必须被视为失效");
MatchmakerAssignment after = assignmentMapper.findById(a3.getId()).orElseThrow();
assertEquals(Constants.ASSIGNMENT_STATUS_EXPIRED, after.getStatus(),
"惰性过期必须落库(无定时任务,读路径触发)");
assertNotNull(after.getEndedAt());
assertNotNull(after.getEndReason());
// 终态一律拒绝再转换(D5 状态机矩阵)
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.revoke(a3.getId(), leaderA, false, "对已过期分配撤销"),
"expired 是终态,不允许再转换");
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.end(a1.getId(), leaderA, false, "对已结束分配再结束"),
"ended 是终态,不允许再转换");
}
// ==================== 组长组织边界(负向) ====================
@Test
@DisplayName("负向: 组长A 跨组织分配红娘C(org=Y) → 403 + DATA_SCOPE_DENIED 审计")
void leaderCannotAssignCrossOrganization() {
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
assertThrows(AccessDeniedException.class,
() -> assignmentService.create(leaderA, false, user2, mmC, null, "跨组织分配", null),
"组长(org=X)不得分配 org=Y 的红娘");
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
"跨组织分配拒绝必须落 DATA_SCOPE_DENIED 审计");
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
.eq(MatchmakerAssignment::getUserId, user2)).isEmpty(),
"被拒绝的分配不得留下任何分配记录");
}
@Test
@DisplayName("负向: 平台授权人员(matchmaker.assign)可跨组织分配;组长不可分配 org=NULL 红娘")
void globalAssignScopeAndNullOrgBoundary() {
// matchmaker.assign 持有者不受组织限制(leaderA 只是被用作操作者身份)
assertDoesNotThrow(() ->
assignmentService.create(leaderA, true, user1, mmC, null, "平台授权分配", null),
"matchmaker.assign 持有者不限组织");
// org=NULL 的红娘:平台直属组长只能分配平台直属红娘(NULL=NULL 规则)
String platformLeader = createOperator("平台直属组长", null);
String platformMm = createOperator("平台直属红娘", null);
assertDoesNotThrow(() ->
assignmentService.create(platformLeader, false, user2, platformMm, null, "平台直属互配", null),
"NULL=NULL(平台直属 → 平台直属)必须放行");
assertThrows(AccessDeniedException.class,
() -> assignmentService.create(platformLeader, false, user3, mmB, null, "平台直属分配有组织红娘", null),
"平台直属组长不得分配有组织的红娘(NULL≠org X)");
}
// ==================== /pending 方法级权限(负向) ====================
@Test
@DisplayName("负向: 普通红娘(无分配权限)调 /pending → 403 + AUTHORIZATION_DENIED 审计")
void normalMatchmakerCannotListPendingQueue() {
authenticate(mmB, "matchmaker.view", "matchmaker.service");
long before = countAudits("AUTHORIZATION_DENIED", mmB);
assertThrows(AccessDeniedException.class,
() -> operatorController.getPending(
new OperatorPrincipal(mmB, Set.of("matchmaker.view", "matchmaker.service"))),
"/pending 方法级权限收敛后,普通红娘必须 403");
assertEquals(before + 1, countAudits("AUTHORIZATION_DENIED", mmB),
"方法级授权拒绝必须落 AUTHORIZATION_DENIED 审计");
}
// ==================== QA 验收补充(Edward):边界与异常路径 ====================
@Test
@DisplayName("QA补充(负向): 理由为 null/空串/纯空格 → 400 VALIDATION_ERROR,且不落任何分配记录或 CREATE 审计")
void blankReasonMustBeRejectedWithValidationError() {
// null 理由
var ex1 = assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.create(leaderA, false, user1, mmB, null, null, null),
"reason=null 必须被拒绝");
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex1.getCode(),
"理由缺失必须是 400 VALIDATION_ERROR,而非 500/403");
// 空串理由
var ex2 = assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.create(leaderA, false, user1, mmB, null, "", null));
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex2.getCode());
// 纯空格理由(isBlank 语义,防止" "绕过)
var ex3 = assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.create(leaderA, false, user1, mmB, null, " ", null));
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex3.getCode());
// revoke/end/transfer 同口径
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR,
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.transfer("any-id", leaderA, false, mmB, " ")).getCode(),
"转分配理由空白同样必须 VALIDATION_ERROR");
// 三次被拒的 create 不得留下分配记录,也不得落 CREATE 审计
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
.eq(MatchmakerAssignment::getUserId, user1)).isEmpty(),
"被拒绝的分配不得留下任何分配记录");
assertEquals(0, countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, leaderA),
"被拒绝的分配不得产生 CREATE 审计");
}
@Test
@DisplayName("QA补充: collaborating 分配与 active+primary 共存,不触发主责部分唯一索引冲突")
void collaboratingAssignmentMustNotConflictWithActivePrimary() {
// 先建立主责分配(组长A → 红娘B)
assignmentService.create(leaderA, false, user1, mmB, null, "主责分配", null);
// 直接落一条同用户、同红娘的 collaborating 生效中分配(协同注记不占主责唯一位)
MatchmakerAssignment collab = new MatchmakerAssignment();
collab.setUserId(user1);
collab.setMatchmakerOperatorId(mmB);
collab.setAssignedBy(leaderA);
collab.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_COLLABORATING);
collab.setAssignmentReason("协同服务(组长指派协同)");
collab.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
collab.setAssignedAt(Instant.now());
assertDoesNotThrow(() -> assignmentMapper.insert(collab),
"collaborating 生效中分配不得触发 uq_mm_assignment_active_primary 冲突");
// 主责仍在、协同也在,两条均为生效中
assertEquals(2, assignmentMapper.selectCount(new LambdaQueryWrapper<MatchmakerAssignment>()
.eq(MatchmakerAssignment::getUserId, user1)
.eq(MatchmakerAssignment::getStatus, Constants.ASSIGNMENT_STATUS_ACTIVE)),
"同一用户应同时存在 1 条 active 主责 + 1 条 active 协同");
// 主责唯一性仍然生效:同用户第二条 primary 必须被拒(对照组)
assertThrows(Exception.class,
() -> assignmentService.create(leaderA, false, user1, mmD, null, "第二条主责", null),
"同用户第二条 active primary 必须被唯一性约束拒绝");
// 预检路径的 CONFLICT
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.create(leaderA, false, user1, mmD, null, "第二条主责", null)).getCode());
}
@Test
@DisplayName("QA补充(负向): 有组织组长分配 org=NULL(平台直属)红娘 → 403(Objects.equals 非对称方向的显式覆盖)")
void orgLeaderMustNotAssignPlatformDirectMatchmaker() {
// org=NULL 红娘(平台直属)
String platformMm = createOperator("平台直属红娘-供拒配", null);
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
// leaderA(org X)→ org=NULL 红娘:Objects.equals(orgX, null)=false,必须拒绝
assertThrows(AccessDeniedException.class,
() -> assignmentService.create(leaderA, false, user2, platformMm, null, "有组织组长碰平台直属红娘", null),
"有组织组长不得分配 org=NULL(平台直属)红娘");
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
"拒绝必须落 DATA_SCOPE_DENIED 审计");
// 同理 transfer 的范围校验也覆盖该方向
MatchmakerAssignment a = assignmentService.create(leaderA, false, user3, mmB, null, "主责", null);
assertThrows(AccessDeniedException.class,
() -> assignmentService.transfer(a.getId(), leaderA, false, platformMm, "转给平台直属红娘"),
"有组织组长不得把分配转给 org=NULL 红娘");
}
@Test
@DisplayName("P2-1补充(负向): org=X 组长转分配 org=Y 红娘持有的分配 → 403 + DATA_SCOPE_DENIED 审计(原持有人范围校验)")
void leaderCannotTransferAssignmentHeldByCrossOrgMatchmaker() {
// org=Y 红娘C 持有一条分配(由平台授权路径创建,绕过 create 的范围校验以构造前置状态)
MatchmakerAssignment held = assignmentService.create(
leaderA, true, user1, mmC, null, "平台授权分配给C", null);
assertEquals(mmC, held.getMatchmakerOperatorId());
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
// org=X 组长A 把 C(org=Y)持有的分配转给同组织成员 B:原持有人范围违规,必须拒绝
assertThrows(AccessDeniedException.class,
() -> assignmentService.transfer(held.getId(), leaderA, false, mmB, "转回本组织"),
"组长不得处置跨组织红娘持有的分配(原持有人范围校验)");
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
"拒绝必须落 DATA_SCOPE_DENIED 审计");
// 被转记录必须原样保留:仍 active 且持有人仍是 C,未产生新分配
MatchmakerAssignment after = assignmentMapper.findById(held.getId()).orElseThrow();
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, after.getStatus(),
"被拒绝的转分配不得改变原分配状态");
assertEquals(mmC, after.getMatchmakerOperatorId(),
"被拒绝的转分配不得改变原持有人");
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
.eq(MatchmakerAssignment::getUserId, user1)
.eq(MatchmakerAssignment::getMatchmakerOperatorId, mmB))
.isEmpty(),
"被拒绝的转分配不得给目标红娘留下任何分配记录");
}
// ==================== 分配同事务联动服务单 ====================
@Autowired
private com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper realServiceMapper;
@Test
@DisplayName("联动: 带 serviceId 的分配同事务把服务单置 assigned 并写回 assigned_operator_id")
void createWithServiceIdLinksServiceRow() {
// 真实落库一条 submitted 服务单
var record = new com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord();
record.setUserId(user1);
record.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
record.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
realServiceMapper.insert(record);
assignmentService.create(leaderA, false, user1, mmB, record.getId(), "带服务单分配", null);
var updated = realServiceMapper.selectById(record.getId());
assertEquals(Constants.MATCHMAKER_STATUS_ASSIGNED, updated.getStatus(),
"分配联动必须把服务单置为 assigned");
assertEquals(mmB, updated.getAssignedOperatorId(),
"服务单 assigned_operator_id 必须写为目标红娘");
}
}
@@ -0,0 +1,94 @@
package com.danyang.match.platform.matchmaker;
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-002(A1)权限契约守卫(standalone 反射扫描,无需 Spring 上下文)。
*
* <p>锁定两条不变量:
* <ol>
* <li>{@code MatchmakerAssignmentController} 每个端点都有显式 @PreAuthorize;
* 写操作(create/transfer/revoke/end/assignable)必须同时包含
* {@code matchmaker.assign} 或 {@code matchmaker.team.assign};
* 读自己名下(/my)必须是 {@code matchmaker.service}。</li>
* <li>{@code MatchmakerOperatorController} 无 claim 残留(D3:claim 端点/方法已删除)。</li>
* </ol>
*/
class MatchmakerAssignmentPermissionTest {
@Test
@DisplayName("AssignmentController:全部端点有 @PreAuthorize,写操作限定分配权限")
void assignmentControllerPermissionContract() {
List<String> violations = new ArrayList<>();
for (Method method : MatchmakerAssignmentController.class.getDeclaredMethods()) {
if (paths(method).isEmpty()) {
continue; // 非端点方法
}
PreAuthorize preAuthorize = AnnotatedElementUtils.findMergedAnnotation(method, PreAuthorize.class);
if (preAuthorize == null) {
violations.add(method.getName() + ": 缺少 @PreAuthorize");
continue;
}
String expr = preAuthorize.value();
boolean isReadMy = method.getName().equals("my");
if (isReadMy) {
if (!expr.contains("matchmaker.service")) {
violations.add("my: 读接口必须要求 matchmaker.service,实际=" + expr);
}
} else {
boolean hasAssignPermission = expr.contains("matchmaker.assign")
&& expr.contains("matchmaker.team.assign");
if (!hasAssignPermission) {
violations.add(method.getName()
+ ": 写操作必须同时限定 matchmaker.assign / matchmaker.team.assign,实际=" + expr);
}
}
}
assertEquals(List.of(), violations, "AssignmentController 权限契约被破坏");
}
@Test
@DisplayName("守卫自检:必须真的扫到 AssignmentController 端点(防守卫失效)")
void guardMustActuallySeeEndpoints() {
long endpointCount = Arrays.stream(MatchmakerAssignmentController.class.getDeclaredMethods())
.filter(m -> !paths(m).isEmpty())
.count();
assertTrue(endpointCount >= 5,
"扫描到的端点数异常偏少(" + endpointCount + "),守卫可能失效");
}
@Test
@DisplayName("OperatorController:无 claim 残留(端点路径与方法名均不得含 claim)")
void operatorControllerMustNotHaveClaimResidue() {
List<String> residue = new ArrayList<>();
for (Method method : MatchmakerOperatorController.class.getDeclaredMethods()) {
if (method.getName().toLowerCase().contains("claim")) {
residue.add("方法名残留: " + method.getName());
}
if (paths(method).stream().anyMatch(p -> p.toLowerCase().contains("claim"))) {
residue.add("路径残留: " + method.getName());
}
}
assertEquals(List.of(), residue,
"D3 决策:claim(红娘接单)端点必须彻底删除,归属由 matchmaker_assignments 建模");
}
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
? mapping.path() : mapping.value());
}
}
@@ -303,7 +303,9 @@ class MatchmakerReferralPhase13Test {
@Test @Test
@DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)") @DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)")
void otherMatchmakerCannotRecommend() { void otherMatchmakerCannotRecommend() {
assertThrows(IllegalStateException.class, // ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy 后,
// 拒绝方式从裸 IllegalStateException 改为 DataScopeGuard(审计 + AccessDeniedException)
assertThrows(org.springframework.security.access.AccessDeniedException.class,
() -> matchmakerServiceService.recommendPartner( () -> matchmakerServiceService.recommendPartner(
serviceId, MATCHMAKER_OTHER, target, "越权推荐"), serviceId, MATCHMAKER_OTHER, target, "越权推荐"),
"非服务负责人不得添加推荐"); "非服务负责人不得添加推荐");
@@ -185,4 +185,29 @@ class MatchmakerServiceTest {
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_OPERATOR_ASSISTED), whitelist.toString()) () -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_OPERATOR_ASSISTED), whitelist.toString())
); );
} }
@Test
@DisplayName("ZC-MM-002 守卫:assignment 状态常量必须落在 chk_mm_assignment_status 白名单内")
void assignmentStatusConstantsAreWithinProductionWhitelist() {
Set<String> whitelist = resolveCheckValues("chk_mm_assignment_status");
assertAll(
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_ACTIVE), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_ENDED), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_REVOKED), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_TRANSFERRED), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_EXPIRED), whitelist.toString())
);
}
@Test
@DisplayName("ZC-MM-002 守卫:assignment scope 常量必须落在 chk_mm_assignment_scope 白名单内")
void assignmentScopeConstantsAreWithinProductionWhitelist() {
Set<String> whitelist = resolveCheckValues("chk_mm_assignment_scope");
assertAll(
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_SCOPE_PRIMARY), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_SCOPE_COLLABORATING), whitelist.toString())
);
}
} }
+55
View File
@@ -492,6 +492,61 @@ CREATE TABLE matchmaker_followups (
deleted_at TIMESTAMP WITH TIME ZONE deleted_at TIMESTAMP WITH TIME ZONE
); );
-- V053: matchmaker_assignments 服务对象分配表(ZC-MM-002 / A1)
-- 生产为部分唯一索引 uq_mm_assignment_active_primary WHERE status='active' AND scope='primary';
-- H2 不支持部分索引,用「生成列 + 唯一索引」等价替代:
-- active_primary_key 仅在 (active, primary) 时取 user_id,否则为 NULL,
-- 标准唯一索引对多行 NULL 互不冲突 → 终态历史行可共存,语义与生产一致。
-- 注:H2 无 operators 表(见 Phase 17 注),去掉生产 DDL 的外键;CHECK 白名单保留。
CREATE TABLE matchmaker_assignments (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
matchmaker_operator_id VARCHAR(36) NOT NULL,
service_id VARCHAR(36),
assigned_by VARCHAR(36) NOT NULL,
assignment_scope VARCHAR(32) NOT NULL DEFAULT 'primary',
assignment_reason VARCHAR(500) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
assigned_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE,
ended_at TIMESTAMP WITH TIME ZONE,
end_reason VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT chk_mm_assignment_status CHECK (status IN (
'active', 'ended', 'revoked', 'transferred', 'expired'
)),
CONSTRAINT chk_mm_assignment_scope CHECK (assignment_scope IN ('primary', 'collaborating')),
active_primary_key VARCHAR(36) GENERATED ALWAYS AS (
CASE WHEN status = 'active' AND assignment_scope = 'primary' THEN user_id END
)
);
CREATE UNIQUE INDEX uq_mm_assignment_active_primary_h2 ON matchmaker_assignments(active_primary_key);
CREATE INDEX idx_mm_assignment_operator_h2 ON matchmaker_assignments(matchmaker_operator_id, status);
CREATE INDEX idx_mm_assignment_user_h2 ON matchmaker_assignments(user_id, status);
CREATE INDEX idx_mm_assignment_service_h2 ON matchmaker_assignments(service_id);
-- V054 配套:operators 运营人员表(ZC-MM-002 A1 测试需要:组长/红娘按 organization_id
-- 划定团队边界;此前 operators 一直在 EntityMigrationParityTest 可选白名单里 = 零覆盖)。
-- 注意:与生产 V001 对齐但去掉 REFERENCES organizations(id) 外键(organizations 本表已存在,
-- 保留外键亦可;为与既有「去外键」惯例一致,不建外键)。
CREATE TABLE operators (
id VARCHAR(36) PRIMARY KEY,
organization_id VARCHAR(36),
name VARCHAR(100) NOT NULL,
phone_enc CLOB,
phone_hash VARCHAR(128),
username VARCHAR(100) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
last_login_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE UNIQUE INDEX uk_operator_username_h2 ON operators(username);
-- Phase 17:举报与风控三表(与 V012__add_risk_and_report_tables.sql 生产 DDL 对齐)。 -- Phase 17:举报与风控三表(与 V012__add_risk_and_report_tables.sql 生产 DDL 对齐)。
-- 此前完全缺失 → ReportService / RiskReferralService 整条真实 DB 路径零覆盖, -- 此前完全缺失 → ReportService / RiskReferralService 整条真实 DB 路径零覆盖,
-- 且 ReportServiceTest 只能写 assertTrue(true) 假绿。已补入并移出 TEST_SCHEMA_OPTIONAL_TABLES 白名单。 -- 且 ReportServiceTest 只能写 assertTrue(true) 假绿。已补入并移出 TEST_SCHEMA_OPTIONAL_TABLES 白名单。
@@ -1,10 +1,16 @@
/** /**
* 红娘工作台主页 * 红娘工作台主页
* *
* ZC-MM-002(A1 / DEC-MATCHMAKER-002 锁定决策):
* - 普通红娘禁止认领(原 POST /services/{id}/claim 端点已删除),本页不再有任何接单逻辑;
* - 列表数据源从 GET /operator/matchmaker/pending(现仅分配权持有者可调,普通红娘 403)
* 改为 GET /operator/matchmaker/assignments/my(仅返回本人生效中的服务对象分配)。
* 响应为 assignment 字段(userId/assignmentReason/status/assignedAt/serviceId 等),
* 无服务类型/诉求描述字段,故按实际字段精简展示,不前端拼空值。
*
* 展示待处理服务、工作台数据概览 * 展示待处理服务、工作台数据概览
*/ */
const api = require('../../utils/api'); const api = require('../../utils/api');
const { serviceTypeLabel } = require('../../constants/profileLabels');
Page({ Page({
data: { data: {
@@ -13,7 +19,8 @@ Page({
inProgressCount: 0, inProgressCount: 0,
todayCount: 0, todayCount: 0,
completedCount: 0, completedCount: 0,
pendingServices: [], myAssignments: [],
assignmentCount: 0,
loading: true, loading: true,
currentDate: '', currentDate: '',
}, },
@@ -23,12 +30,12 @@ Page({
// 获取操作员信息 // 获取操作员信息
const operatorName = wx.getStorageSync('operatorName') || '红娘'; const operatorName = wx.getStorageSync('operatorName') || '红娘';
this.setData({ operatorName }); this.setData({ operatorName });
// 设置当前日期 // 设置当前日期
const now = new Date(); const now = new Date();
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`; const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`;
this.setData({ currentDate: dateStr }); this.setData({ currentDate: dateStr });
this.loadDashboard(); this.loadDashboard();
}, },
@@ -46,10 +53,10 @@ Page({
return; return;
} }
// 加载工作台数据 // 加载工作台数据 + 本人服务对象分配(ZC-MM-002:不再调用 /pending,普通红娘无该权限)
const [dashRes, pendingRes] = await Promise.all([ const [dashRes, assignmentRes] = await Promise.all([
api.get('/operator/matchmaker/dashboard', {}, { Authorization: `Bearer ${token}` }), api.get('/operator/matchmaker/dashboard', {}, { Authorization: `Bearer ${token}` }),
api.get('/operator/matchmaker/pending', {}, { Authorization: `Bearer ${token}` }), api.get('/operator/matchmaker/assignments/my', {}, { Authorization: `Bearer ${token}` }),
]); ]);
if (dashRes.success) { if (dashRes.success) {
@@ -61,9 +68,11 @@ Page({
}); });
} }
if (pendingRes.success) { if (assignmentRes.success) {
const assignments = assignmentRes.data || [];
this.setData({ this.setData({
pendingServices: pendingRes.data || [], myAssignments: assignments,
assignmentCount: assignments.length,
}); });
} }
} catch (err) { } catch (err) {
@@ -75,30 +84,29 @@ Page({
onViewService(e) { onViewService(e) {
const serviceId = e.currentTarget.dataset.id; const serviceId = e.currentTarget.dataset.id;
if (!serviceId) {
// 纯用户级分配(无关联服务单)无处可跳,不拼空参数
return;
}
wx.navigateTo({ url: `/pages/matchmaker-service-detail/matchmaker-service-detail?id=${serviceId}` }); wx.navigateTo({ url: `/pages/matchmaker-service-detail/matchmaker-service-detail?id=${serviceId}` });
}, },
onClaimService(e) { // 分配状态 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
const serviceId = e.currentTarget.dataset.id; assignmentStatusLabel(code) {
wx.showLoading({ title: '接单中...' }); const labels = {
active: '生效中',
const token = wx.getStorageSync('operatorToken'); ended: '已结束',
// 注意:api.post 签名为 (url, data, headers, idempotencyKey),Authorization 必须放第 3 参(headers), revoked: '已撤销',
// 此前误放到第 4 参(idempotencyKey),导致实际请求不携带任何鉴权头、接单必 401。 transferred: '已转分配',
api.post(`/operator/matchmaker/services/${serviceId}/claim`, {}, { Authorization: `Bearer ${token}` }) expired: '已过期',
.then(res => { };
wx.hideLoading(); return labels[code] || code || '';
if (res.success) { },
wx.showToast({ title: '接单成功', icon: 'success' });
this.loadDashboard(); // 分配范围 → 中文
} else { assignmentScopeLabel(code) {
wx.showToast({ title: res.message || '接单失败', icon: 'none' }); const labels = { primary: '主责', collaborating: '协同' };
} return labels[code] || code || '';
})
.catch(err => {
wx.hideLoading();
wx.showToast({ title: '网络错误', icon: 'none' });
});
}, },
formatTime(timeStr) { formatTime(timeStr) {
@@ -115,11 +123,6 @@ Page({
return m + '月' + d + '日'; return m + '月' + d + '日';
}, },
// 红娘服务类型 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
serviceTypeLabel(code) {
return serviceTypeLabel(code);
},
onLogout() { onLogout() {
wx.showModal({ wx.showModal({
title: '退出登录', title: '退出登录',
@@ -1,4 +1,4 @@
<!-- 红娘工作台 --> <!-- 红娘工作台(ZC-MM-002:普通红娘仅见本人有效分配,无接单) -->
<view class="page"> <view class="page">
<!-- 欢迎区 --> <!-- 欢迎区 -->
<view class="header"> <view class="header">
@@ -17,7 +17,7 @@
<view class="stat-card" hover-class="stat-hover"> <view class="stat-card" hover-class="stat-hover">
<view class="stat-icon pending">📋</view> <view class="stat-icon pending">📋</view>
<view class="stat-value">{{pendingCount}}</view> <view class="stat-value">{{pendingCount}}</view>
<view class="stat-label">待处理</view> <view class="stat-label">待分配</view>
</view> </view>
<view class="stat-card" hover-class="stat-hover"> <view class="stat-card" hover-class="stat-hover">
<view class="stat-icon progress">⏳</view> <view class="stat-icon progress">⏳</view>
@@ -36,39 +36,39 @@
</view> </view>
</view> </view>
<!-- 待处理列表 --> <!-- 我的服务对象分配 -->
<view class="section" wx:if="{{!loading}}"> <view class="section" wx:if="{{!loading}}">
<view class="section-header"> <view class="section-header">
<view class="section-title-wrap"> <view class="section-title-wrap">
<text class="section-dot"></text> <text class="section-dot"></text>
<text class="section-title">待处理服务</text> <text class="section-title">我的服务对象分配</text>
</view> </view>
<text class="section-count" wx:if="{{pendingCount > 0}}">共{{pendingCount}}件</text> <text class="section-count" wx:if="{{assignmentCount > 0}}">共{{assignmentCount}}人</text>
</view> </view>
<view class="service-list" wx:if="{{pendingServices.length > 0}}"> <view class="service-list" wx:if="{{myAssignments.length > 0}}">
<view <view
wx:for="{{pendingServices}}" wx:for="{{myAssignments}}"
wx:key="serviceId" wx:key="id"
class="service-card" class="service-card"
data-id="{{item.serviceId}}" data-id="{{item.serviceId}}"
bindtap="onViewService" bindtap="onViewService"
> >
<view class="service-left"> <view class="service-left">
<view class="service-header"> <view class="service-header">
<view class="service-tag">{{serviceTypeLabel(item.serviceType)}}</view> <view class="service-tag">{{assignmentScopeLabel(item.assignmentScope)}}</view>
<view class="service-time">{{formatTime(item.createdAt)}}</view> <view class="service-tag">{{assignmentStatusLabel(item.status)}}</view>
<view class="service-time">{{formatTime(item.assignedAt)}}</view>
</view> </view>
<view class="service-desc">{{item.description}}</view> <view class="service-desc">{{item.assignmentReason}}</view>
</view> </view>
<button class="btn-claim" catchtap="onClaimService" data-id="{{item.serviceId}}" hover-class="btn-hover">接单</button>
</view> </view>
</view> </view>
<view class="empty-state" wx:else> <view class="empty-state" wx:else>
<view class="empty-illustration">📭</view> <view class="empty-illustration">📭</view>
<view class="empty-text">暂无待处理服务</view> <view class="empty-text">暂无服务对象分配</view>
<view class="empty-hint">用户申请的服务将在这里展示</view> <view class="empty-hint">分配给你的服务对象将在这里展示</view>
</view> </view>
</view> </view>