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
@@ -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 { serviceTypeLabel } = require('../../constants/profileLabels');
Page({
data: {
@@ -13,7 +19,8 @@ Page({
inProgressCount: 0,
todayCount: 0,
completedCount: 0,
pendingServices: [],
myAssignments: [],
assignmentCount: 0,
loading: true,
currentDate: '',
},
@@ -23,12 +30,12 @@ Page({
// 获取操作员信息
const operatorName = wx.getStorageSync('operatorName') || '红娘';
this.setData({ operatorName });
// 设置当前日期
const now = new Date();
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`;
this.setData({ currentDate: dateStr });
this.loadDashboard();
},
@@ -46,10 +53,10 @@ Page({
return;
}
// 加载工作台数据
const [dashRes, pendingRes] = await Promise.all([
// 加载工作台数据 + 本人服务对象分配(ZC-MM-002:不再调用 /pending,普通红娘无该权限)
const [dashRes, assignmentRes] = await Promise.all([
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) {
@@ -61,9 +68,11 @@ Page({
});
}
if (pendingRes.success) {
if (assignmentRes.success) {
const assignments = assignmentRes.data || [];
this.setData({
pendingServices: pendingRes.data || [],
myAssignments: assignments,
assignmentCount: assignments.length,
});
}
} catch (err) {
@@ -75,30 +84,29 @@ Page({
onViewService(e) {
const serviceId = e.currentTarget.dataset.id;
if (!serviceId) {
// 纯用户级分配(无关联服务单)无处可跳,不拼空参数
return;
}
wx.navigateTo({ url: `/pages/matchmaker-service-detail/matchmaker-service-detail?id=${serviceId}` });
},
onClaimService(e) {
const serviceId = e.currentTarget.dataset.id;
wx.showLoading({ title: '接单中...' });
const token = wx.getStorageSync('operatorToken');
// 注意:api.post 签名为 (url, data, headers, idempotencyKey),Authorization 必须放第 3 参(headers),
// 此前误放到第 4 参(idempotencyKey),导致实际请求不携带任何鉴权头、接单必 401。
api.post(`/operator/matchmaker/services/${serviceId}/claim`, {}, { Authorization: `Bearer ${token}` })
.then(res => {
wx.hideLoading();
if (res.success) {
wx.showToast({ title: '接单成功', icon: 'success' });
this.loadDashboard();
} else {
wx.showToast({ title: res.message || '接单失败', icon: 'none' });
}
})
.catch(err => {
wx.hideLoading();
wx.showToast({ title: '网络错误', icon: 'none' });
});
// 分配状态 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
assignmentStatusLabel(code) {
const labels = {
active: '生效中',
ended: '已结束',
revoked: '已撤销',
transferred: '已转分配',
expired: '已过期',
};
return labels[code] || code || '';
},
// 分配范围 → 中文
assignmentScopeLabel(code) {
const labels = { primary: '主责', collaborating: '协同' };
return labels[code] || code || '';
},
formatTime(timeStr) {
@@ -115,11 +123,6 @@ Page({
return m + '月' + d + '日';
},
// 红娘服务类型 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
serviceTypeLabel(code) {
return serviceTypeLabel(code);
},
onLogout() {
wx.showModal({
title: '退出登录',
@@ -1,4 +1,4 @@
<!-- 红娘工作台 -->
<!-- 红娘工作台(ZC-MM-002:普通红娘仅见本人有效分配,无接单) -->
<view class="page">
<!-- 欢迎区 -->
<view class="header">
@@ -17,7 +17,7 @@
<view class="stat-card" hover-class="stat-hover">
<view class="stat-icon pending">📋</view>
<view class="stat-value">{{pendingCount}}</view>
<view class="stat-label">待处理</view>
<view class="stat-label">待分配</view>
</view>
<view class="stat-card" hover-class="stat-hover">
<view class="stat-icon progress">⏳</view>
@@ -36,39 +36,39 @@
</view>
</view>
<!-- 待处理列表 -->
<!-- 我的服务对象分配 -->
<view class="section" wx:if="{{!loading}}">
<view class="section-header">
<view class="section-title-wrap">
<text class="section-dot"></text>
<text class="section-title">待处理服务</text>
<text class="section-title">我的服务对象分配</text>
</view>
<text class="section-count" wx:if="{{pendingCount > 0}}">共{{pendingCount}}件</text>
<text class="section-count" wx:if="{{assignmentCount > 0}}">共{{assignmentCount}}人</text>
</view>
<view class="service-list" wx:if="{{pendingServices.length > 0}}">
<view
wx:for="{{pendingServices}}"
wx:key="serviceId"
<view class="service-list" wx:if="{{myAssignments.length > 0}}">
<view
wx:for="{{myAssignments}}"
wx:key="id"
class="service-card"
data-id="{{item.serviceId}}"
bindtap="onViewService"
>
<view class="service-left">
<view class="service-header">
<view class="service-tag">{{serviceTypeLabel(item.serviceType)}}</view>
<view class="service-time">{{formatTime(item.createdAt)}}</view>
<view class="service-tag">{{assignmentScopeLabel(item.assignmentScope)}}</view>
<view class="service-tag">{{assignmentStatusLabel(item.status)}}</view>
<view class="service-time">{{formatTime(item.assignedAt)}}</view>
</view>
<view class="service-desc">{{item.description}}</view>
<view class="service-desc">{{item.assignmentReason}}</view>
</view>
<button class="btn-claim" catchtap="onClaimService" data-id="{{item.serviceId}}" hover-class="btn-hover">接单</button>
</view>
</view>
<view class="empty-state" wx:else>
<view class="empty-illustration">📭</view>
<view class="empty-text">暂无待处理服务</view>
<view class="empty-hint">用户申请的服务将在这里展示</view>
<view class="empty-text">暂无服务对象分配</view>
<view class="empty-hint">分配给你的服务对象将在这里展示</view>
</view>
</view>