数据库(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 失败
94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
import { defineStore } from 'pinia'
|
||
import { ref, computed } from 'vue'
|
||
import { post } from '@/api/client'
|
||
import { setAccessToken, clearAccessToken } from '@/utils/token'
|
||
|
||
interface OperatorInfo {
|
||
operatorId: string
|
||
name: string
|
||
username: string
|
||
permissions: string[]
|
||
organizationId: string | null
|
||
}
|
||
|
||
const previewRolePermissions: Record<string, string[]> = {
|
||
reviewer: ['review.view', 'user.view'],
|
||
counselor: ['counseling.service.view', 'matchmaker.service'],
|
||
station: ['service_location.view', 'service_appointment.view'],
|
||
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'matchmaker.view', 'matchmaker.service'],
|
||
}
|
||
|
||
export const useAuthStore = defineStore('auth', () => {
|
||
const previewParams = import.meta.env.DEV ? new URLSearchParams(window.location.search) : null
|
||
const previewRole = previewParams?.get('preview') || ''
|
||
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
||
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
|
||
: null
|
||
|
||
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
|
||
const operator = ref<OperatorInfo | null>(previewOperator)
|
||
let sessionInitialized = false
|
||
|
||
const isLoggedIn = computed(() => !!token.value && !!operator.value)
|
||
const permissions = computed(() => operator.value?.permissions || [])
|
||
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
|
||
const hasPermission = (code: string) => permissions.value.includes(code)
|
||
|
||
function applyAuthData(data: any) {
|
||
token.value = data.accessToken || ''
|
||
operator.value = {
|
||
operatorId: data.operatorId,
|
||
name: data.name,
|
||
username: data.username,
|
||
permissions: data.permissions || [],
|
||
organizationId: data.organizationId ?? null,
|
||
}
|
||
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
||
setAccessToken(token.value)
|
||
}
|
||
|
||
async function login(username: string, password: string) {
|
||
const res: any = await post('/auth/operator/login', { username, password })
|
||
if (res.success) {
|
||
applyAuthData(res.data)
|
||
return res.data
|
||
}
|
||
throw new Error(res.message || '登录失败')
|
||
}
|
||
|
||
/**
|
||
* 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
|
||
* 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
|
||
* 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
|
||
*/
|
||
async function initializeSession(): Promise<boolean> {
|
||
if (sessionInitialized) return isLoggedIn.value
|
||
sessionInitialized = true
|
||
if (token.value) return true
|
||
try {
|
||
const res: any = await post('/auth/operator/refresh', {})
|
||
if (res.success) {
|
||
applyAuthData(res.data)
|
||
return true
|
||
}
|
||
} catch {
|
||
// 未登录或 refresh 失效,交由路由守卫跳转登录页
|
||
}
|
||
return false
|
||
}
|
||
|
||
function logout() {
|
||
// 尽力通知后端注销并清除 Cookie(best-effort)
|
||
try {
|
||
post('/auth/operator/logout', {}).catch(() => {})
|
||
} catch {
|
||
// ignore
|
||
}
|
||
token.value = ''
|
||
operator.value = null
|
||
clearAccessToken()
|
||
}
|
||
|
||
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
|
||
})
|