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:
@@ -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;
|
||||
Reference in New Issue
Block a user