Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377429995c | ||
|
|
f6744d3f91 | ||
|
|
692d71696a | ||
|
|
ec9252bd3d | ||
|
|
dd291868fb | ||
|
|
b38056f1d0 | ||
|
|
34b9ea1169 | ||
|
|
88f4176e60 | ||
|
|
a3bd1e8a95 | ||
|
|
d51c4c6a76 | ||
|
|
69ebf33e18 | ||
|
|
8b505d9133 | ||
|
|
3c2b967274 | ||
|
|
827c23db1d | ||
|
|
ef61d51f61 | ||
|
|
02bc01f9c9 | ||
|
|
16c82739db | ||
|
|
f73a116a10 | ||
|
|
21275ef770 | ||
|
|
d3735e3afa | ||
|
|
4248e9574e | ||
|
|
6c2601950b | ||
|
|
1be0d98ebd | ||
|
|
5e484d0e74 | ||
|
|
3caf5f8816 | ||
|
|
861c78efb5 | ||
|
|
6187ec69df | ||
|
|
2d60f7899f | ||
|
|
f76bda1efa | ||
|
|
4edb7619bc | ||
|
|
dfb74f3386 |
@@ -61,6 +61,9 @@ tests/fixtures/*.json
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
.zcode/
|
.zcode/
|
||||||
.workbuddy/
|
.workbuddy/
|
||||||
|
.mimosa/
|
||||||
|
.trae/
|
||||||
|
.wb_tmp_bcrypt/
|
||||||
project.private.config.json
|
project.private.config.json
|
||||||
/nul
|
/nul
|
||||||
/infrastructure/nul
|
/infrastructure/nul
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Task 1 完成报告:角色权限与范围基线
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
- 新增 `docs/v12/V1.2-角色权限与前端入口矩阵.md`。
|
||||||
|
- 冻结 14 个正式角色的数据范围、工作台入口、允许操作和禁止操作。
|
||||||
|
- 明确普通红娘为 SELF、红娘组长为 TEAM、平台/超级管理员为 ALL。
|
||||||
|
- 明确五类工作台业务卡片的查看及审核、待办分配、确认、完成权限,同时保留红娘不得代用户 Like/Pass、不得查看身份证原始佐证和咨询敏感备注等边界。
|
||||||
|
- 说明 V070/V071 是历史收敛迁移,不能覆盖 2026-10-25 最新裁决,后续通过幂等迁移恢复正式最小权限。
|
||||||
|
- 修改 `docs/governance/decision-register.md`,登记 V070/V071 冲突裁决。
|
||||||
|
- 新增 `backend/src/test/java/com/danyang/match/platform/deployment/PermissionBaselineContractTest.java`,锁定角色、范围、操作、敏感权限和历史迁移裁决。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=PermissionBaselineContractTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## 证据边界与风险
|
||||||
|
|
||||||
|
- 本任务未修改 V070/V071 历史迁移,也未直接修改数据库;活跃 PostgreSQL 角色权限/版本查询留给活栈验收任务。
|
||||||
|
- 矩阵锁定的是产品权限与数据范围契约,实际恢复权限需要后续 Task 5 的幂等迁移并通过干净数据库和活栈验证。
|
||||||
|
- 未运行全量 Maven、前端构建或 E2E;这些属于后续任务门禁。
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Task 2 完成报告
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
- 工作台六项指标先按各自 `detailPermission` 判断;无权限仅返回 `available=false` 与权限标识,不执行统计查询或返回真实值。
|
||||||
|
- 授权指标继续返回真实统计值,空数据保持 `0`。
|
||||||
|
- `matchmaker_leader` 未绑定组织时返回 `CROSS_ORGANIZATION_DENIED`。
|
||||||
|
- 团队范围使用严格 `organization_id = ?`,不再把 NULL 组织视为同一团队。
|
||||||
|
- 统计统一排除没有已发布资料 revision 的用户。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=MatchmakerWorkbenchAuthorizationTest,MatchmakerWorkbenchScopeTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:3 个测试通过,0 失败,0 错误。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
本次仅涉及工作台服务及新增授权/范围单元测试;未修改迁移、控制器或其他任务文件。
|
||||||
@@ -32,8 +32,12 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# API proxy to backend
|
# API proxy to backend
|
||||||
|
# 用 resolver + 变量 proxy_pass:避免 nginx 启动时 backend DNS 还没就绪就 [emerg] 退出
|
||||||
|
# Docker Compose 默认网络的内置 DNS resolver 是 127.0.0.11
|
||||||
|
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||||
|
set $backend_upstream "http://backend:8080";
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass $backend_upstream;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -42,7 +46,7 @@ server {
|
|||||||
|
|
||||||
# WebSocket proxy for chat
|
# WebSocket proxy for chat
|
||||||
location /ws/ {
|
location /ws/ {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass $backend_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
packages:
|
packages:
|
||||||
- '.'
|
- '.'
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: set this to true or false
|
esbuild: true
|
||||||
vue-demi: set this to true or false
|
vue-demi: true
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- esbuild
|
- esbuild
|
||||||
- vue-demi
|
- vue-demi
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 672 KiB |
@@ -1,26 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
|
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
|
||||||
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
|
<header class="scene-header" aria-label="爱在丹阳公益服务品牌信息">
|
||||||
|
<!-- 背景装饰层:粉渐变兜底 + 丹阳剪影底图 + 爱心点缀 -->
|
||||||
|
<div class="header-bg" aria-hidden="true"></div>
|
||||||
|
<div class="header-hearts" aria-hidden="true">
|
||||||
|
<span>♡</span><span>♡</span><span>♡</span><span>♡</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="scene-brand">
|
<div class="scene-brand">
|
||||||
<img :src="BRAND_LOGO" alt="善缘丹阳" />
|
<div class="brand-logo-wrap">
|
||||||
|
<img :src="BRAND_LOGO" alt="爱在丹阳" />
|
||||||
|
<span class="brand-dot" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
<div class="scene-brand-copy">
|
<div class="scene-brand-copy">
|
||||||
<strong>善缘丹阳</strong>
|
<div class="brand-name-row">
|
||||||
<span>公益婚恋服务平台</span>
|
<strong>爱在丹阳</strong>
|
||||||
|
<em class="brand-badge">公益运营</em>
|
||||||
|
</div>
|
||||||
|
<span class="brand-tagline">公益婚恋服务平台</span>
|
||||||
|
<span class="brand-ornament" aria-hidden="true">❖ ● ❖ ● ❖</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="scene-title">
|
<div class="scene-title">
|
||||||
<h1>管理端(运营后台)</h1>
|
<h1>管理端(运营后台)</h1>
|
||||||
<p>用专业的服务,帮助更多人遇见对的人</p>
|
<p class="title-sub">
|
||||||
|
<span>公益婚恋服务平台</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>用爱连接美好姻缘</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>让更多人在丹阳遇见幸福</span>
|
||||||
|
</p>
|
||||||
|
<p class="title-meta">
|
||||||
|
<span class="meta-chip" title="当前权限范围内今日新增、分配、跟进或完成的红娘服务单,按北京时间去重">今日服务 {{ todayStats?.total ?? '—' }} 单</span>
|
||||||
|
<span class="meta-chip" title="当前权限范围内尚未完成的红娘服务单">待处理 {{ todayStats?.pending ?? '—' }} 件</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="scene-slogan" aria-hidden="true">
|
<div class="scene-slogan" aria-hidden="true">
|
||||||
<span>善缘相牵</span>
|
<span class="slogan-top">爱在丹阳</span>
|
||||||
<strong>爱在丹阳</strong>
|
<strong class="slogan-main">原来是你</strong>
|
||||||
|
<em class="slogan-en">Love In Danyang</em>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="scene-benefits" aria-label="平台服务原则">
|
<div class="scene-benefits" aria-label="平台服务原则">
|
||||||
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span>
|
<span><i class="benefit-icon"><el-icon><StarFilled /></el-icon></i><b>公益为本</b><small>不以营利为目的</small></span>
|
||||||
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span>
|
<span><i class="benefit-icon"><el-icon><Lock /></el-icon></i><b>真实可靠</b><small>严格审核·多重保障</small></span>
|
||||||
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span>
|
<span><i class="benefit-icon"><el-icon><LocationFilled /></el-icon></i><b>本地服务</b><small>深耕丹阳·服务家乡</small></span>
|
||||||
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
<span><i class="benefit-icon"><el-icon><Opportunity /></el-icon></i><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -29,7 +55,7 @@
|
|||||||
<div class="brand" :class="{ compact: isCollapse }">
|
<div class="brand" :class="{ compact: isCollapse }">
|
||||||
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
|
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
|
||||||
<div v-if="!isCollapse" class="brand-copy">
|
<div v-if="!isCollapse" class="brand-copy">
|
||||||
<strong>善缘丹阳</strong>
|
<strong>爱在丹阳</strong>
|
||||||
<span>公益婚恋服务平台</span>
|
<span>公益婚恋服务平台</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,14 +147,15 @@
|
|||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
<footer class="scene-footer">
|
<footer class="scene-footer">
|
||||||
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span>
|
<strong>爱在丹阳</strong><span>公益婚恋服务平台</span>
|
||||||
<small>真实 · 真诚 · 公益 · 幸福</small>
|
<small>真实 · 真诚 · 公益 · 幸福</small>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { get } from '@/api/client'
|
||||||
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
|
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -174,8 +201,8 @@ const navigation: NavigationItem[] = [
|
|||||||
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
|
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
|
||||||
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
|
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
|
||||||
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
|
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
|
||||||
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
|
{ label: '公益红娘', path: '/referral', permissions: ['matchmaker.service'], icon: Connection },
|
||||||
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection },
|
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.view'], icon: Connection },
|
||||||
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
|
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
|
||||||
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
|
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
|
||||||
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
|
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
|
||||||
@@ -190,6 +217,7 @@ const navigation: NavigationItem[] = [
|
|||||||
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
|
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
|
||||||
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
|
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
|
||||||
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
|
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
|
||||||
|
{ label: '红娘团队管理', path: '/matchmaker-team', permissions: ['matchmaker.role.manage'], icon: Connection },
|
||||||
]
|
]
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -199,6 +227,23 @@ const isCollapse = ref(false)
|
|||||||
const globalSearch = ref('')
|
const globalSearch = ref('')
|
||||||
const notificationCount = ref(0)
|
const notificationCount = ref(0)
|
||||||
|
|
||||||
|
const todayStats = ref<{ total: number; pending: number } | null>(null)
|
||||||
|
|
||||||
|
watch(() => route.fullPath, async () => {
|
||||||
|
todayStats.value = null
|
||||||
|
const perms = authStore.permissions
|
||||||
|
if (authStore.token === 'ui-preview-only') return
|
||||||
|
try {
|
||||||
|
if (perms.includes('workbench.view')) {
|
||||||
|
const response: any = await get('/operator/workbench/dashboard')
|
||||||
|
const value = response.data?.metrics?.matchmakerTodos?.value
|
||||||
|
if (response.success && typeof value === 'number') todayStats.value = { total: value, pending: value }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
todayStats.value = null
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const activeMenu = computed(() => {
|
const activeMenu = computed(() => {
|
||||||
if (route.path === '/users/detail') return '/users'
|
if (route.path === '/users/detail') return '/users'
|
||||||
return route.path
|
return route.path
|
||||||
@@ -209,6 +254,13 @@ const permissions = computed(() => authStore.permissions || [])
|
|||||||
const currentRoleName = computed(() => {
|
const currentRoleName = computed(() => {
|
||||||
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
|
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
|
||||||
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
|
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
|
||||||
|
const role = operator.value?.roles?.[0]
|
||||||
|
const roleNames: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员', platform_admin: '平台管理员', matchmaker_leader: '红娘组长', matchmaker: '公益红娘',
|
||||||
|
reviewer: '资料审核人员', counselor: '公益咨询人员', counseling_manager: '咨询管理员', service_station_operator: '服务站运营人员',
|
||||||
|
content_editor: '内容编辑员', risk_operator: '风控运营人员',
|
||||||
|
}
|
||||||
|
if (role && roleNames[role]) return roleNames[role]
|
||||||
const perms = permissions.value
|
const perms = permissions.value
|
||||||
if (perms.includes('permission.manage')) return '平台管理员'
|
if (perms.includes('permission.manage')) return '平台管理员'
|
||||||
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
|
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
|
||||||
@@ -217,10 +269,15 @@ const currentRoleName = computed(() => {
|
|||||||
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
||||||
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
||||||
if (perms.includes('review.view')) return '资料审核人员'
|
if (perms.includes('review.view')) return '资料审核人员'
|
||||||
if (perms.includes('matchmaker.service.view')) return '公益红娘'
|
if (perms.includes('matchmaker.team.assign')) return '红娘组长'
|
||||||
|
if (perms.includes('matchmaker.view')) return '公益红娘'
|
||||||
return '运营人员'
|
return '运营人员'
|
||||||
})
|
})
|
||||||
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
|
const visibleNavigation = computed(() => navigation.filter((item) => {
|
||||||
|
const adminRole = operator.value?.roles?.some((role) => ['super_admin', 'platform_admin'].includes(role))
|
||||||
|
if ((item.path === '/referral' || item.path === '/matchmaker') && (permissions.value.includes('permission.manage') || adminRole)) return false
|
||||||
|
return item.permissions.every(hasPerm)
|
||||||
|
}))
|
||||||
|
|
||||||
function hasPerm(permission: string) {
|
function hasPerm(permission: string) {
|
||||||
return permissions.value.includes(permission)
|
return permissions.value.includes(permission)
|
||||||
@@ -258,137 +315,307 @@ function handleLogout() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-scene {
|
.admin-scene {
|
||||||
min-width: 1100px;
|
min-width: 1100px;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 18px 34px 14px;
|
padding: 18px 34px 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
|
/* 外层:纯色粉渐变兜底,再叠加背景图。
|
||||||
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
|
原实现把 background 和 background-image 拆两行在某些浏览器会覆盖 —— 这里统一写一条,
|
||||||
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
|
并显式声明 position/size/repeat/attachment,确保 cover 全区域。 */
|
||||||
/* 先给纯色兜底,避免图未加载完成时闪白 */
|
background:
|
||||||
background: var(--admin-coral-lighter) center / cover fixed no-repeat;
|
linear-gradient(180deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 40%, var(--admin-scene-bg-end) 100%),
|
||||||
background-image: url(v-bind('BRAND_BACKDROP'));
|
url(v-bind('BRAND_BACKDROP')) center / cover fixed no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-header {
|
.scene-header {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
max-width: var(--admin-shell-max-width);
|
/* 100vw 撑满浏览器:用负 margin 抵消 admin-scene 的 padding */
|
||||||
height: 188px;
|
width: 100vw;
|
||||||
margin: 0 auto;
|
max-width: none;
|
||||||
grid-template-columns: minmax(640px, 1fr) auto;
|
height: 105px;
|
||||||
grid-template-rows: 112px 76px;
|
margin: -18px -34px 12px;
|
||||||
|
padding: 0 34px;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
grid-template-rows: 1fr 36px;
|
||||||
|
grid-template-areas:
|
||||||
|
"brand title slogan"
|
||||||
|
"benefits benefits benefits";
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: var(--space-8);
|
column-gap: 20px;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 背景装饰层 ===== */
|
||||||
|
.header-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 55%, var(--admin-scene-bg-end) 100%),
|
||||||
|
url(v-bind('BRAND_BACKDROP')) center / cover no-repeat;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
/* 左上角柔光晕 */
|
||||||
|
.header-bg::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -60px;
|
||||||
|
left: -40px;
|
||||||
|
width: 300px;
|
||||||
|
height: 180px;
|
||||||
|
background: radial-gradient(ellipse at center, var(--admin-scene-glow) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* 右下角柔光晕 */
|
||||||
|
.header-bg::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 40px;
|
||||||
|
bottom: -30px;
|
||||||
|
width: 280px;
|
||||||
|
height: 140px;
|
||||||
|
background: radial-gradient(ellipse at center, var(--admin-scene-glow-soft) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 爱心点缀 */
|
||||||
|
.header-hearts {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 34px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--admin-coral-border);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.header-hearts span {
|
||||||
|
animation: heartbeat 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.header-hearts span:nth-child(2) { animation-delay: .4s; }
|
||||||
|
.header-hearts span:nth-child(3) { animation-delay: .8s; }
|
||||||
|
.header-hearts span:nth-child(4) { animation-delay: 1.2s; }
|
||||||
|
@keyframes heartbeat {
|
||||||
|
0%, 100% { transform: scale(1); opacity: .5; }
|
||||||
|
50% { transform: scale(1.25); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 所有 header 内容浮在背景层之上 ===== */
|
||||||
|
.scene-brand,
|
||||||
|
.scene-slogan,
|
||||||
|
.scene-benefits {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 品牌区 ===== */
|
||||||
.scene-brand,
|
.scene-brand,
|
||||||
.scene-benefits,
|
|
||||||
.scene-benefits span,
|
|
||||||
.scene-brand-copy {
|
.scene-brand-copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-brand > img {
|
.scene-brand {
|
||||||
width: 64px;
|
grid-area: brand;
|
||||||
height: 64px;
|
gap: 10px;
|
||||||
object-fit: contain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-slogan {
|
/* Logo + 装饰圆点 */
|
||||||
|
.brand-logo-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.brand-logo-wrap img {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 50%;
|
||||||
|
clip-path: circle(50%);
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(244,95,104,0.25));
|
||||||
|
}
|
||||||
|
.brand-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--gradient-brand-solid);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 4px rgba(200, 74, 83, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌名 + 徽章 — tagline/ornament 在 105px 高度里塞不下,隐藏 */
|
||||||
|
.scene-brand-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--admin-coral-strong);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.brand-name-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-right: var(--space-6);
|
align-items: center;
|
||||||
align-items: flex-end;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.brand-name-row strong {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: var(--gradient-brand-text);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.brand-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
background: var(--gradient-brand-solid);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 6px rgba(200, 74, 83, 0.3);
|
||||||
|
}
|
||||||
|
.brand-tagline,
|
||||||
|
.brand-ornament {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题区 */
|
||||||
|
.scene-title {
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 20px;
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 2px solid var(--admin-scene-divider);
|
||||||
|
}
|
||||||
|
.scene-title h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.title-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
.title-sub .sep {
|
||||||
|
margin: 0 5px;
|
||||||
|
color: var(--admin-coral-solid);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.title-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.meta-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--admin-coral-strong);
|
||||||
|
background: var(--admin-scene-surface);
|
||||||
|
border: 1px solid var(--admin-coral-border);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 书法 slogan ===== */
|
||||||
|
.scene-slogan {
|
||||||
|
grid-area: slogan;
|
||||||
|
display: flex;
|
||||||
|
padding-right: 0;
|
||||||
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
color: var(--admin-coral-strong);
|
color: var(--admin-coral-strong);
|
||||||
font-family: var(--font-family-brand);
|
font-family: var(--font-family-brand);
|
||||||
line-height: 1.05;
|
line-height: 1;
|
||||||
transform: rotate(-7deg);
|
text-align: center;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
.slogan-top {
|
||||||
.scene-slogan span {
|
font-size: 12px;
|
||||||
font-size: var(--font-size-lg);
|
letter-spacing: 6px;
|
||||||
}
|
|
||||||
|
|
||||||
.scene-slogan strong {
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
font-size: var(--font-size-3xl);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-brand-copy {
|
|
||||||
min-width: 172px;
|
|
||||||
margin-left: 10px;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
color: var(--admin-coral-strong);
|
color: var(--admin-coral-strong);
|
||||||
|
opacity: .8;
|
||||||
}
|
}
|
||||||
|
.slogan-main {
|
||||||
.scene-brand-copy strong {
|
font-size: 26px;
|
||||||
font-size: var(--font-size-4xl);
|
font-weight: 500;
|
||||||
line-height: 1.15;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-brand-copy span {
|
|
||||||
margin-top: 5px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
letter-spacing: 4px;
|
letter-spacing: 4px;
|
||||||
|
background: var(--gradient-brand-text);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.slogan-en {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-title {
|
/* ===== 公益原则行 ===== */
|
||||||
min-width: 370px;
|
|
||||||
margin-left: 28px;
|
|
||||||
padding-left: 28px;
|
|
||||||
border-left: 1px solid var(--admin-coral-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-title h1 {
|
|
||||||
font-size: clamp(var(--font-size-3xl), 2.2vw, var(--font-size-display));
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-title p {
|
|
||||||
margin-top: 9px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-benefits {
|
.scene-benefits {
|
||||||
grid-column: 1 / -1;
|
grid-area: benefits;
|
||||||
align-self: start;
|
align-self: center;
|
||||||
justify-content: flex-start;
|
justify-content: center;
|
||||||
gap: clamp(38px, 5vw, 92px);
|
flex-wrap: nowrap;
|
||||||
padding: var(--space-1) 0 0 50px;
|
gap: clamp(10px, 1.6vw, 28px);
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-benefits span {
|
.scene-benefits span {
|
||||||
display: grid;
|
flex: none;
|
||||||
grid-template-columns: 36px auto;
|
display: inline-flex;
|
||||||
column-gap: var(--space-2);
|
align-items: center;
|
||||||
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
|
gap: 8px;
|
||||||
color: var(--admin-coral-strong);
|
padding: 4px 14px 4px 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.scene-benefits .el-icon {
|
|
||||||
grid-row: 1 / 3;
|
|
||||||
font-size: var(--font-size-4xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-benefits b {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-benefits small {
|
|
||||||
margin-top: 2px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
font-size: var(--font-size-micro);
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
background: var(--admin-scene-surface);
|
||||||
|
border: 1px solid var(--admin-coral-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
color: var(--admin-coral-strong);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
box-shadow: 0 1px 4px rgba(200, 74, 83, 0.06);
|
||||||
|
}
|
||||||
|
.scene-benefits b {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.scene-benefits small {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
width: min(var(--admin-shell-max-width), 100%);
|
width: min(var(--admin-shell-max-width), 100%);
|
||||||
height: calc(100vh - 238px);
|
flex: 1;
|
||||||
min-height: 560px;
|
min-height: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--admin-shell-bg);
|
background: var(--admin-shell-bg);
|
||||||
@@ -427,6 +654,8 @@ function handleLogout() {
|
|||||||
height: 42px;
|
height: 42px;
|
||||||
flex: none;
|
flex: none;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
border-radius: 50%;
|
||||||
|
clip-path: circle(50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy {
|
.brand-copy {
|
||||||
@@ -629,10 +858,15 @@ function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--color-bg-page);
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge 旧版 */
|
||||||
|
/* 暖色调背景,与整体品牌粉调一致;冷灰蓝 --color-bg-page 仅用于通用组件 */
|
||||||
|
background: var(--admin-scene-bg-end);
|
||||||
}
|
}
|
||||||
|
.admin-content::-webkit-scrollbar { display: none; } /* Chrome/Safari/Edge */
|
||||||
|
|
||||||
.scene-footer {
|
.scene-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -656,62 +890,49 @@ function handleLogout() {
|
|||||||
letter-spacing: 4px;
|
letter-spacing: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1450px), (max-height: 820px) {
|
@media (max-width: 1280px), (max-height: 780px) {
|
||||||
.admin-scene {
|
.admin-scene {
|
||||||
padding: 12px 24px 10px;
|
padding: 12px 24px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-header {
|
.scene-header {
|
||||||
display: grid;
|
height: 160px;
|
||||||
height: 120px;
|
grid-template-columns: minmax(480px, 1fr) auto;
|
||||||
grid-template-columns: minmax(520px, 1fr) auto;
|
grid-template-rows: 92px 64px;
|
||||||
grid-template-rows: 66px 54px;
|
padding-top: 14px;
|
||||||
column-gap: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-slogan {
|
.brand-logo-wrap { width: 64px; height: 64px; }
|
||||||
padding-right: var(--space-3);
|
.brand-logo-wrap img { width: 56px; height: 56px; }
|
||||||
}
|
.brand-dot { display: none; }
|
||||||
|
|
||||||
.scene-slogan span {
|
.brand-name-row strong { font-size: 30px; letter-spacing: 3px; }
|
||||||
font-size: var(--font-size-base);
|
.brand-badge { display: none; }
|
||||||
}
|
.brand-tagline { font-size: 12px; letter-spacing: 2px; }
|
||||||
|
.brand-ornament { display: none; }
|
||||||
.scene-slogan strong {
|
|
||||||
font-size: var(--font-size-2xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-brand > img {
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-brand-copy strong {
|
|
||||||
font-size: var(--font-size-2xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-title {
|
.scene-title {
|
||||||
min-width: 310px;
|
min-width: 280px;
|
||||||
margin-left: 18px;
|
margin-left: 20px;
|
||||||
padding-left: 18px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
.scene-title h1 { font-size: 22px; letter-spacing: 1px; }
|
||||||
|
.title-sub { font-size: 12px; }
|
||||||
|
.title-meta { display: none; }
|
||||||
|
|
||||||
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
|
.slogan-top { font-size: 13px; letter-spacing: 6px; }
|
||||||
.scene-title h1 {
|
.slogan-main { font-size: 34px; letter-spacing: 4px; }
|
||||||
font-size: var(--font-size-3xl);
|
.slogan-en { display: none; }
|
||||||
}
|
|
||||||
|
|
||||||
.scene-benefits {
|
.benefit-icon { width: 34px; height: 34px; }
|
||||||
padding: 0 0 0 28px;
|
.benefit-icon .el-icon { font-size: 16px; }
|
||||||
gap: clamp(22px, 4vw, 54px);
|
|
||||||
}
|
.scene-benefits { gap: clamp(10px, 2vw, 28px); }
|
||||||
|
.scene-benefits b { font-size: 12px; }
|
||||||
|
.scene-benefits small { display: none; font-size: 12px; }
|
||||||
|
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
height: calc(100vh - 170px);
|
height: calc(100vh - 200px);
|
||||||
}
|
|
||||||
|
|
||||||
.scene-footer {
|
|
||||||
height: 28px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,9 @@
|
|||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="never" class="inner-card">
|
<el-card shadow="never" class="inner-card">
|
||||||
<template #header><span>手工签到(特殊情形)</span></template>
|
<template #header><span>手工签到(特殊情形)</span></template>
|
||||||
<el-input v-model="manualUserId" placeholder="输入用户 ID" />
|
<el-input v-model="manualUserNumber" placeholder="输入13位用户编号" />
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<el-button type="primary" :loading="saving" :disabled="!manualUserId"
|
<el-button type="primary" :loading="saving" :disabled="!manualUserNumber"
|
||||||
@click="manual">手工签到</el-button>
|
@click="manual">手工签到</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-hint">
|
<div class="form-hint">
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
|
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
|
||||||
<template #header><span>最近一次签到</span></template>
|
<template #header><span>最近一次签到</span></template>
|
||||||
<el-descriptions :column="2" border size="small">
|
<el-descriptions :column="2" border size="small">
|
||||||
<el-descriptions-item label="用户 ID">{{ lastResult.userId }}</el-descriptions-item>
|
<el-descriptions-item label="用户编号">{{ lastResult.userDisplayNumber }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</el-descriptions-item>
|
<el-descriptions-item label="活动编号">{{ lastResult.activityDisplayId }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="本次是否为重复签到">
|
<el-descriptions-item label="本次是否为重复签到">
|
||||||
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
|
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
|
||||||
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
|
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
|
||||||
@@ -96,7 +96,7 @@ import { get, post } from '@/api/client'
|
|||||||
const activities = ref<any[]>([])
|
const activities = ref<any[]>([])
|
||||||
const activityId = ref('')
|
const activityId = ref('')
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
const manualUserId = ref('')
|
const manualUserNumber = ref('')
|
||||||
const stats = ref<any>(null)
|
const stats = ref<any>(null)
|
||||||
const lastResult = ref<any>(null)
|
const lastResult = ref<any>(null)
|
||||||
const scanning = ref(false)
|
const scanning = ref(false)
|
||||||
@@ -151,15 +151,15 @@ async function scan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function manual() {
|
async function manual() {
|
||||||
if (!activityId.value || !manualUserId.value.trim()) return
|
if (!activityId.value || !manualUserNumber.value.trim()) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await post(
|
const res: any = await post(
|
||||||
`/activities/operator/${activityId.value}/checkin/manual`,
|
`/activities/operator/${activityId.value}/checkin/manual`,
|
||||||
{ userId: manualUserId.value.trim() },
|
{ userId: manualUserNumber.value.trim() },
|
||||||
authHeader())
|
authHeader())
|
||||||
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value }
|
lastResult.value = { ...res.data, userDisplayNumber: res.data?.userDisplayNumber || manualUserNumber.value.trim(), activityDisplayId: activities.value.find((item: any) => item.id === activityId.value)?.displayId }
|
||||||
manualUserId.value = ''
|
manualUserNumber.value = ''
|
||||||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
|
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
|
||||||
await loadStats()
|
await loadStats()
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>活动互选管理</span>
|
<span>活动互选管理</span>
|
||||||
<el-input v-model="searchActivityId" placeholder="活动ID搜索" clearable style="width:260px" />
|
<el-input v-model="searchActivityNumber" placeholder="活动编号搜索" clearable style="width:260px" />
|
||||||
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
|
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||||
<el-table-column prop="activityId" label="活动ID" width="160" />
|
<el-table-column prop="activityDisplayId" label="活动编号" width="160" />
|
||||||
<el-table-column prop="activityTitle" label="活动名称" min-width="180" />
|
<el-table-column prop="activityTitle" label="活动名称" min-width="180" />
|
||||||
<el-table-column label="双向互选对数" width="140">
|
<el-table-column label="双向互选对数" width="140">
|
||||||
<template #default="{row}">{{ row.mutualPairs }} 对</template>
|
<template #default="{row}">{{ row.mutualPairs }} 对</template>
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
<div v-if="currentActivity">
|
<div v-if="currentActivity">
|
||||||
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
|
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
|
||||||
<el-table :data="mutualList" border size="small" style="margin-top:16px">
|
<el-table :data="mutualList" border size="small" style="margin-top:16px">
|
||||||
<el-table-column prop="sourceUserId" label="表达者ID" min-width="180" />
|
<el-table-column prop="sourceUserDisplayNumber" label="表达者编号" min-width="180" />
|
||||||
<el-table-column prop="targetUserId" label="被表达者ID" min-width="180" />
|
<el-table-column prop="targetUserDisplayNumber" label="被表达者编号" min-width="180" />
|
||||||
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
|
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
|
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
|
||||||
@@ -46,7 +46,7 @@ import { getAccessToken } from '@/utils/token'
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get } from '@/api/client'
|
import { get } from '@/api/client'
|
||||||
|
|
||||||
const searchActivityId = ref('')
|
const searchActivityNumber = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tableData = ref<any[]>([])
|
const tableData = ref<any[]>([])
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
@@ -59,8 +59,8 @@ async function loadData() {
|
|||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
|
||||||
const rows = res.data || []
|
const rows = res.data || []
|
||||||
tableData.value = searchActivityId.value
|
tableData.value = searchActivityNumber.value
|
||||||
? rows.filter((row: any) => row.activityId.includes(searchActivityId.value.trim()))
|
? rows.filter((row: any) => (row.activityDisplayId || '').includes(searchActivityNumber.value.trim()))
|
||||||
: rows
|
: rows
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||||
|
<el-table-column prop="displayId" label="活动编号" min-width="180" />
|
||||||
<el-table-column prop="title" label="活动名称" min-width="180" />
|
<el-table-column prop="title" label="活动名称" min-width="180" />
|
||||||
<el-table-column prop="activityType" label="活动类型" width="130">
|
<el-table-column prop="activityType" label="活动类型" width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -128,7 +129,6 @@
|
|||||||
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
|
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
|
||||||
<h4>组织关系</h4>
|
<h4>组织关系</h4>
|
||||||
<el-table :data="orgs" border size="small">
|
<el-table :data="orgs" border size="small">
|
||||||
<el-table-column prop="organizationId" label="组织ID" min-width="160" />
|
|
||||||
<el-table-column prop="name" label="名称" min-width="140" />
|
<el-table-column prop="name" label="名称" min-width="140" />
|
||||||
<el-table-column prop="role" label="角色" width="120">
|
<el-table-column prop="role" label="角色" width="120">
|
||||||
<template #default="{ row }">{{ roleText(row.role) }}</template>
|
<template #default="{ row }">{{ roleText(row.role) }}</template>
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||||
<el-table-column prop="operatorId" label="操作人ID" width="140" />
|
<el-table-column prop="operatorDisplayId" label="操作人编号" min-width="180" />
|
||||||
<el-table-column prop="action" label="操作" width="160">
|
<el-table-column prop="action" label="操作" width="160">
|
||||||
<template #default="{row}">{{ actionText(row.action) }}</template>
|
<template #default="{row}">{{ actionText(row.action) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="resourceType" label="资源类型" width="120">
|
<el-table-column prop="resourceType" label="资源类型" width="120">
|
||||||
<template #default="{row}">{{ row.resourceType }}</template>
|
<template #default="{row}">{{ row.resourceType }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="resourceId" label="资源ID" width="140" show-overflow-tooltip />
|
<el-table-column prop="resourceDisplayId" label="业务编号" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.resourceDisplayId || '未公开编号(已脱敏)' }}</template></el-table-column>
|
||||||
<el-table-column prop="ip" label="IP地址" width="140" />
|
<el-table-column prop="ip" label="IP地址" width="140" />
|
||||||
<el-table-column prop="createdAt" label="时间" width="180">
|
<el-table-column prop="createdAt" label="时间" width="180">
|
||||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ function isExternalLink(value: string) {
|
|||||||
function previewTargetText(banner: any) {
|
function previewTargetText(banner: any) {
|
||||||
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
|
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
|
||||||
if (!banner.targetValue) return dest
|
if (!banner.targetValue) return dest
|
||||||
return `${dest} · ${banner.targetValue}`
|
return `${dest} · 已配置站内目标`
|
||||||
}
|
}
|
||||||
function assignForm(source: any) {
|
function assignForm(source: any) {
|
||||||
form.value = {
|
form.value = {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
|
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="咨询师">
|
<el-form-item label="咨询师">
|
||||||
<el-input v-model="assignForm.counselorId" placeholder="请输入咨询师工号 / ID" />
|
<el-input v-model="assignForm.counselorNumber" placeholder="请输入13位工作人员编号" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -143,7 +143,7 @@ const assignedServices = ref<any[]>([])
|
|||||||
const assignDialogVisible = ref(false)
|
const assignDialogVisible = ref(false)
|
||||||
const followupDialogVisible = ref(false)
|
const followupDialogVisible = ref(false)
|
||||||
const currentId = ref('')
|
const currentId = ref('')
|
||||||
const assignForm = ref({ counselorId: '' })
|
const assignForm = ref({ counselorNumber: '' })
|
||||||
const followupForm = ref({ type: 'phone_call', content: '' })
|
const followupForm = ref({ type: 'phone_call', content: '' })
|
||||||
|
|
||||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||||
@@ -211,19 +211,19 @@ function onTabChange(name: string | number) {
|
|||||||
|
|
||||||
function openAssign(row: any) {
|
function openAssign(row: any) {
|
||||||
currentId.value = row.id
|
currentId.value = row.id
|
||||||
assignForm.value.counselorId = ''
|
assignForm.value.counselorNumber = ''
|
||||||
assignDialogVisible.value = true
|
assignDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doAssign() {
|
async function doAssign() {
|
||||||
if (!assignForm.value.counselorId) {
|
if (!assignForm.value.counselorNumber) {
|
||||||
ElMessage.warning('请填写咨询师 ID')
|
ElMessage.warning('请填写咨询师编号')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await post(`/counseling/services/operator/${currentId.value}/assign`,
|
await post(`/counseling/services/operator/${currentId.value}/assign`,
|
||||||
{ counselorId: assignForm.value.counselorId })
|
{ counselorId: assignForm.value.counselorNumber })
|
||||||
ElMessage.success('分配成功')
|
ElMessage.success('分配成功')
|
||||||
assignDialogVisible.value = false
|
assignDialogVisible.value = false
|
||||||
loadQueue()
|
loadQueue()
|
||||||
|
|||||||
@@ -2,10 +2,24 @@
|
|||||||
<div class="matchmaker-management">
|
<div class="matchmaker-management">
|
||||||
<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" @tab-change="refresh">
|
||||||
<el-tab-pane label="服务请求队列" name="services">
|
<el-tab-pane v-if="canAssign" label="服务进展与跟进" name="management">
|
||||||
|
<el-table :data="managedServices" stripe v-loading="loading">
|
||||||
|
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="150" />
|
||||||
|
<el-table-column prop="operatorDisplayId" label="红娘编号" min-width="150" />
|
||||||
|
<el-table-column label="服务状态" width="120"><template #default="{ row }">{{ statusLabel(row.status) }}</template></el-table-column>
|
||||||
|
<el-table-column prop="latestFollowupContent" label="最新跟进" min-width="230" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="110"><template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openServiceDetail(row.serviceId, true)">查看记录</el-button>
|
||||||
|
</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
<!-- 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="userDisplayNumber" label="用户编号" width="190">
|
||||||
|
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="serviceType" label="服务类型" width="160">
|
<el-table-column prop="serviceType" label="服务类型" width="160">
|
||||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -19,7 +33,8 @@
|
|||||||
<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>
|
||||||
|
<el-button v-if="row.status!=='submitted'" size="small" @click="openServiceDetail(row.serviceId || row.id)">查看服务</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -27,8 +42,8 @@
|
|||||||
|
|
||||||
<el-tab-pane label="推荐列表" name="recommendations">
|
<el-tab-pane label="推荐列表" name="recommendations">
|
||||||
<el-table :data="recommendations" stripe v-loading="loading">
|
<el-table :data="recommendations" stripe v-loading="loading">
|
||||||
<el-table-column prop="userId" label="用户" width="110" />
|
<el-table-column prop="userDisplayNumber" label="用户" width="110" />
|
||||||
<el-table-column prop="targetUserId" label="推荐对象" width="110" />
|
<el-table-column prop="targetUserDisplayNumber" label="推荐对象" width="110" />
|
||||||
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
|
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
|
||||||
<el-table-column prop="status" label="状态" width="130">
|
<el-table-column prop="status" label="状态" width="130">
|
||||||
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
|
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
|
||||||
@@ -38,16 +53,80 @@
|
|||||||
</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="userDisplayNumber" label="用户编号" width="190">
|
||||||
|
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<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 v-if="row.serviceId" size="small" @click="openServiceDetail(row.serviceId)">查看服务</el-button>
|
||||||
|
<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>
|
||||||
|
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
|
||||||
|
<el-tab-pane label="联系授权审批" name="contactAuths">
|
||||||
|
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
|
||||||
|
<el-table-column prop="operatorDisplayId" label="红娘编号" width="120" />
|
||||||
|
<el-table-column prop="userDisplayNumber" label="用户编号" width="120" />
|
||||||
|
<el-table-column prop="channel" label="渠道" width="110">
|
||||||
|
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="purpose" label="授权目的" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="status" label="状态" width="110">
|
||||||
|
<template #default="{ row }"><el-tag :type="contactStatusTag(row.status)">{{ contactStatusLabel(row.status) }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="申请时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="expiresAt" label="窗口/有效期至" width="170">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.status === 'pending_approval' && canApprove">
|
||||||
|
<el-button size="small" type="primary" @click="doContactApprove(row)">批准</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="doContactReject(row)">拒绝</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-if="['active','pending_approval','pending_consent'].includes(row.status)"
|
||||||
|
size="small" @click="doContactRevoke(row)">撤销</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,11 +134,42 @@
|
|||||||
</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">
|
||||||
<el-form-item label="推荐给用户"><el-input v-model="recForm.userId" disabled /></el-form-item>
|
<el-form-item label="推荐给用户"><el-input v-model="recForm.userDisplayNumber" disabled /></el-form-item>
|
||||||
<el-form-item label="推荐对象ID"><el-input v-model="recForm.targetUserId" placeholder="输入被推荐对象的用户ID" /></el-form-item>
|
<el-form-item label="推荐对象编号"><el-input v-model="recForm.targetUserDisplayNumber" placeholder="输入13位用户编号" /></el-form-item>
|
||||||
<el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item>
|
<el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -67,33 +177,108 @@
|
|||||||
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
|
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="serviceDetailVisible" title="服务详情" width="620px">
|
||||||
|
<el-descriptions v-if="serviceDetail" :column="2" border>
|
||||||
|
<el-descriptions-item label="服务编号">{{ serviceDetail.serviceDisplayId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">{{ statusLabel(serviceDetail.status) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务类型">{{ serviceTypeLabel(serviceDetail.serviceType) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDateTime(serviceDetail.createdAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务需求" :span="2">{{ serviceDetail.description || '未填写' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-divider content-position="left">服务历史</el-divider>
|
||||||
|
<el-empty v-if="!serviceDetail?.followups?.length" description="暂无服务记录" />
|
||||||
|
<el-timeline v-else>
|
||||||
|
<el-timeline-item v-for="item in serviceDetail.followups" :key="item.id" :timestamp="formatDateTime(item.createdAt)">
|
||||||
|
{{ item.content }}
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
<template v-if="serviceDetail && !managementDetail">
|
||||||
|
<el-divider />
|
||||||
|
<el-button type="primary" @click="openFollowup">添加跟进记录</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="['completed', 'cancelled'].indexOf(serviceDetail.status) < 0"
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
@click="completeCurrentService"
|
||||||
|
>完成服务</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="520px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="跟进方式">
|
||||||
|
<el-select v-model="followupForm.followupType" style="width: 100%">
|
||||||
|
<el-option label="电话沟通" value="phone_call" />
|
||||||
|
<el-option label="微信沟通" value="wechat" />
|
||||||
|
<el-option label="面对面" value="face_to_face" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="跟进内容" required>
|
||||||
|
<el-input v-model="followupForm.content" type="textarea" :rows="4" placeholder="记录本次服务进展" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="followupDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitFollowup">保存记录</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onActivated } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { get, post } from '@/api/client'
|
import { get, post } from '@/api/client'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const activeTab = ref('services')
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const managementDetail = ref(false)
|
||||||
|
const managedServices = ref<any[]>([])
|
||||||
|
// 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 transferDialogVisible = ref(false)
|
||||||
|
const terminateDialogVisible = ref(false)
|
||||||
const recDialogVisible = ref(false)
|
const recDialogVisible = ref(false)
|
||||||
const currentServiceId = ref('')
|
const currentServiceId = ref('')
|
||||||
const assignForm = ref({ operatorId: '' })
|
const currentUserId = ref('')
|
||||||
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
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({ userDisplayNumber: '', targetUserDisplayNumber: '', reason: '' })
|
||||||
|
const serviceDetailVisible = ref(false)
|
||||||
|
const serviceDetail = ref<any>(null)
|
||||||
|
const followupDialogVisible = ref(false)
|
||||||
|
const followupForm = ref({ followupType: 'phone_call', content: '' })
|
||||||
|
|
||||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||||
recommendation: '推荐对象', referral_counseling: '转介咨询',
|
recommendation: '推荐对象', referral_counseling: '转介咨询',
|
||||||
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 +288,128 @@ 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ZC-MM-003 / B1:受控联系授权(枚举码不上屏,中文映射) =====
|
||||||
|
const CONTACT_CHANNEL_LABEL: Record<string, string> = {
|
||||||
|
phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式'
|
||||||
|
}
|
||||||
|
const CONTACT_STATUS_LABEL: Record<string, string> = {
|
||||||
|
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
|
||||||
|
rejected: '已拒绝', expired: '已过期', revoked: '已撤销'
|
||||||
|
}
|
||||||
|
const CONTACT_STATUS_TAG: Record<string, string> = {
|
||||||
|
pending_approval: 'warning', pending_consent: 'warning', active: 'success',
|
||||||
|
rejected: 'danger', expired: 'info', revoked: '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') : '-' }
|
||||||
|
function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
|
||||||
|
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
|
||||||
|
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' }
|
||||||
|
|
||||||
|
async function openServiceDetail(serviceId: string, management = false) {
|
||||||
|
managementDetail.value = management
|
||||||
|
if (!serviceId) return
|
||||||
|
try {
|
||||||
|
const res: any = await get(`/operator/matchmaker/${management ? 'management/' : ''}services/${serviceId}`)
|
||||||
|
serviceDetail.value = res?.data || null
|
||||||
|
serviceDetailVisible.value = true
|
||||||
|
} catch { serviceDetail.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFollowup() {
|
||||||
|
followupForm.value = { followupType: 'phone_call', content: '' }
|
||||||
|
followupDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFollowup() {
|
||||||
|
if (!followupForm.value.content.trim() || !serviceDetail.value?.serviceId) {
|
||||||
|
ElMessage.warning('请填写跟进内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/followup`, followupForm.value)
|
||||||
|
ElMessage.success('跟进记录已保存')
|
||||||
|
followupDialogVisible.value = false
|
||||||
|
await openServiceDetail(serviceDetail.value.serviceId)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || '保存跟进记录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeCurrentService() {
|
||||||
|
if (!serviceDetail.value?.serviceId) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认将该红娘服务标记为已完成吗?', '完成服务', { type: 'warning' })
|
||||||
|
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/complete`, {})
|
||||||
|
ElMessage.success('服务已完成')
|
||||||
|
await openServiceDetail(serviceDetail.value.serviceId)
|
||||||
|
await loadAssignments()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.response?.data?.message || '完成服务失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ZC-MM-003 / B1:受控联系授权 =====
|
||||||
|
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
|
||||||
|
const canApprove = computed(() => {
|
||||||
|
const perms: string[] = authStore.permissions || []
|
||||||
|
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||||
|
})
|
||||||
|
const contactAuths = ref<any[]>([])
|
||||||
|
const contactAuthsLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadContactAuths() {
|
||||||
|
contactAuthsLoading.value = true
|
||||||
|
try {
|
||||||
|
const endpoint = canApprove.value
|
||||||
|
? '/operator/matchmaker/contact-authorizations/pending'
|
||||||
|
: '/operator/matchmaker/contact-authorizations/my'
|
||||||
|
const res: any = await get(endpoint)
|
||||||
|
contactAuths.value = res?.data?.list || []
|
||||||
|
} finally { contactAuthsLoading.value = false }
|
||||||
|
}
|
||||||
|
async function doContactApprove(row: any) {
|
||||||
|
try {
|
||||||
|
await post(`/operator/matchmaker/contact-authorizations/${row.id}/approve`, {})
|
||||||
|
ElMessage.success('已批准,等待用户单独确认'); loadContactAuths()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
async function doContactReject(row: any) {
|
||||||
|
try {
|
||||||
|
await post(`/operator/matchmaker/contact-authorizations/${row.id}/reject`, {})
|
||||||
|
ElMessage.success('已拒绝'); loadContactAuths()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
async function doContactRevoke(row: any) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('撤销理由(必填)', '撤销联系授权', {
|
||||||
|
confirmButtonText: '确认撤销', cancelButtonText: '取消',
|
||||||
|
inputValidator: (v: string) => (v && v.trim() ? true : '撤销理由必填'),
|
||||||
|
})
|
||||||
|
await post(`/operator/matchmaker/contact-authorizations/${row.id}/revoke`, { reason: value.trim() })
|
||||||
|
ElMessage.success('已撤销'); loadContactAuths()
|
||||||
|
} catch { /* 用户取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
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,36 +418,109 @@ 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 === 'management') {
|
||||||
|
loading.value = true
|
||||||
|
try { const res: any = await get('/operator/matchmaker/management/services'); managedServices.value = res?.data || [] }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
else if (activeTab.value === 'services') await loadServices()
|
||||||
|
else if (activeTab.value === 'assignments') await loadAssignments()
|
||||||
|
else if (activeTab.value === 'contactAuths') await loadContactAuths()
|
||||||
|
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 = { userDisplayNumber: row.userDisplayNumber || '实名后生成', targetUserDisplayNumber: '', reason: '' }
|
||||||
recDialogVisible.value = true
|
recDialogVisible.value = true
|
||||||
}
|
}
|
||||||
async function doCreateRecommendation() {
|
async function doCreateRecommendation() {
|
||||||
if (!recForm.value.targetUserId || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
|
if (!recForm.value.targetUserDisplayNumber || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
|
||||||
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
|
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
|
||||||
try {
|
try {
|
||||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
|
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
|
||||||
targetUserId: recForm.value.targetUserId, reason: recForm.value.reason
|
targetUserId: recForm.value.targetUserDisplayNumber, reason: recForm.value.reason
|
||||||
})
|
})
|
||||||
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
|
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
|
||||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refresh)
|
onActivated(async () => {
|
||||||
|
if (canAssign.value && typeof route.query.serviceId === 'string') {
|
||||||
|
activeTab.value = 'management'
|
||||||
|
await openServiceDetail(route.query.serviceId, true)
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="header-row">
|
||||||
|
<span>红娘团队管理</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-select v-model="status" clearable placeholder="全部状态" style="width:120px" @change="load">
|
||||||
|
<el-option label="正常" value="normal" /><el-option label="停用" value="suspended" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="keyword" clearable placeholder="姓名或用户名" style="width:180px" @keyup.enter="load" />
|
||||||
|
<el-button type="primary" @click="openCreate">新增红娘</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-alert v-if="error" type="error" :closable="false" :title="error" />
|
||||||
|
<el-table v-loading="loading" :data="items" border stripe empty-text="暂无红娘团队成员">
|
||||||
|
<el-table-column prop="username" label="用户名" width="150" />
|
||||||
|
<el-table-column prop="name" label="姓名" width="120" />
|
||||||
|
<el-table-column label="角色" width="130"><template #default="{ row }">{{ roleName(row.roles?.[0]) }}</template></el-table-column>
|
||||||
|
<el-table-column label="数据范围" width="110"><template #default="{ row }">{{ scopeName(row.scope) }}</template></el-table-column>
|
||||||
|
<el-table-column label="所属组织" min-width="180"><template #default="{ row }">{{ orgMap[row.organizationId] || '未绑定' }}</template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'normal' ? 'success' : 'danger'">{{ row.status === 'normal' ? '正常' : '停用' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="300" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" @click="resetPassword(row)">重置密码</el-button>
|
||||||
|
<el-button size="small" :type="row.status === 'normal' ? 'warning' : 'success'" @click="toggleStatus(row)">{{ row.status === 'normal' ? '停用' : '启用' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-if="total" v-model:current-page="page" style="margin-top:16px;text-align:right" layout="total, prev, pager, next" :page-size="pageSize" :total="total" @current-change="load" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editing ? '编辑红娘团队成员' : '新增红娘团队成员'" width="520px">
|
||||||
|
<el-form label-width="92px">
|
||||||
|
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||||
|
<el-form-item label="角色"><el-radio-group v-model="form.roleCode"><el-radio value="matchmaker">普通红娘</el-radio><el-radio value="matchmaker_leader">红娘组长</el-radio></el-radio-group></el-form-item>
|
||||||
|
<el-form-item label="所属组织" required><el-select v-model="form.organizationId" clearable style="width:100%"><el-option v-for="org in organizations" :key="org.id" :label="org.name" :value="org.id" /></el-select></el-form-item>
|
||||||
|
<el-form-item v-if="!editing" label="初始密码"><el-input v-model="form.password" type="password" show-password /></el-form-item>
|
||||||
|
<el-alert type="info" :closable="false" :title="`数据范围:${scopeName(form.roleCode === 'matchmaker_leader' ? 'TEAM' : 'SELF')}`" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { get, post, put } from '@/api/client'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const organizations = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const keyword = ref('')
|
||||||
|
const status = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editing = ref<any | null>(null)
|
||||||
|
const form = ref({ name: '', roleCode: 'matchmaker', organizationId: '', password: '' })
|
||||||
|
const orgMap = computed(() => Object.fromEntries(organizations.value.map((org) => [org.id, org.name])))
|
||||||
|
|
||||||
|
function roleName(role: string) { return role === 'matchmaker_leader' ? '红娘组长' : '普通红娘' }
|
||||||
|
function scopeName(scope: string) { return scope === 'TEAM' ? '团队范围' : scope === 'ALL' ? '全部范围' : '本人范围' }
|
||||||
|
async function load() {
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const res: any = await get('/operator/matchmaker-team/members', { page: page.value - 1, pageSize, keyword: keyword.value || undefined, status: status.value || undefined })
|
||||||
|
items.value = res.data?.items || []; total.value = res.data?.total || 0
|
||||||
|
} catch (e: any) { error.value = e?.response?.data?.message || '红娘团队数据加载失败' } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
async function loadOrganizations() {
|
||||||
|
const res: any = await get('/operator/operators/organizations')
|
||||||
|
organizations.value = res.data || []
|
||||||
|
}
|
||||||
|
function openCreate() { editing.value = null; form.value = { name: '', roleCode: 'matchmaker', organizationId: '', password: '' }; dialogVisible.value = true }
|
||||||
|
function openEdit(row: any) { editing.value = row; form.value = { name: row.name, roleCode: row.roles?.[0] || 'matchmaker', organizationId: row.organizationId || '', password: '' }; dialogVisible.value = true }
|
||||||
|
async function save() {
|
||||||
|
if (!form.value.name.trim()) return ElMessage.warning('请填写红娘姓名')
|
||||||
|
if (form.value.roleCode === 'matchmaker_leader' && !form.value.organizationId) return ElMessage.warning('红娘组长必须选择所属组织')
|
||||||
|
if (!editing.value && form.value.password.length < 8) return ElMessage.warning('初始密码至少8位')
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload: any = { name: form.value.name, roleCode: form.value.roleCode, organizationId: form.value.organizationId || null }
|
||||||
|
if (!editing.value) Object.assign(payload, { password: form.value.password })
|
||||||
|
const res: any = editing.value ? await put(`/operator/matchmaker-team/members/${editing.value.id}`, payload) : await post('/operator/matchmaker-team/members', payload)
|
||||||
|
if (!res.success) throw new Error(res.message || '保存失败')
|
||||||
|
dialogVisible.value = false; ElMessage.success('保存成功'); await load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.response?.data?.message || e.message || '保存失败') } finally { saving.value = false }
|
||||||
|
}
|
||||||
|
async function toggleStatus(row: any) {
|
||||||
|
await put(`/operator/matchmaker-team/members/${row.id}/${row.status === 'normal' ? 'disable' : 'enable'}`)
|
||||||
|
ElMessage.success('状态已更新'); await load()
|
||||||
|
}
|
||||||
|
async function resetPassword(row: any) {
|
||||||
|
const value = await ElMessageBox.prompt('请输入新密码(至少8位)', '重置密码', { inputType: 'password', inputValidator: (v) => v.length >= 8 || '密码至少8位' })
|
||||||
|
await put(`/operator/matchmaker-team/members/${row.id}/reset-password`, { newPassword: value.value })
|
||||||
|
ElMessage.success('密码已重置')
|
||||||
|
}
|
||||||
|
onMounted(async () => { await loadOrganizations(); await load() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-row,.header-actions{display:flex;align-items:center}.header-row{justify-content:space-between}.header-actions{gap:8px}
|
||||||
|
</style>
|
||||||
@@ -10,7 +10,16 @@
|
|||||||
|
|
||||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||||
<el-table-column prop="username" label="用户名" width="140" />
|
<el-table-column prop="username" label="用户名" width="140" />
|
||||||
|
<el-table-column prop="displayId" label="人员编号" width="150" />
|
||||||
<el-table-column prop="name" label="姓名" width="120" />
|
<el-table-column prop="name" label="姓名" width="120" />
|
||||||
|
<el-table-column label="角色" min-width="190">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag v-for="role in row.roles || []" :key="role" size="small" style="margin-right:4px">{{ roleName(role) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数据范围" width="110">
|
||||||
|
<template #default="{row}">{{ scopeName(row.scope) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="organizationId" label="所属机构" width="160">
|
<el-table-column prop="organizationId" label="所属机构" width="160">
|
||||||
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -25,8 +34,9 @@
|
|||||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200">
|
<el-table-column label="操作" width="270">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
|
<el-button size="small" @click="openEdit(row)">角色权限</el-button>
|
||||||
<el-button size="small" @click="resetPwd(row.id)">重置密码</el-button>
|
<el-button size="small" @click="resetPwd(row.id)">重置密码</el-button>
|
||||||
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
|
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
|
||||||
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
|
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
|
||||||
@@ -40,15 +50,23 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 新增对话框 -->
|
<!-- 新增对话框 -->
|
||||||
<el-dialog v-model="formVisible" title="新增Operator" width="480px">
|
<el-dialog v-model="formVisible" title="新增工作人员" width="560px">
|
||||||
<el-form :model="form" label-width="100px">
|
<el-form :model="form" label-width="100px">
|
||||||
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||||
<el-form-item label="所属机构">
|
<el-form-item label="所属机构">
|
||||||
<el-select v-model="form.organizationId" style="width:100%">
|
<el-select v-model="form.organizationId" clearable style="width:100%">
|
||||||
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
|
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-checkbox-group v-model="form.roleCodes">
|
||||||
|
<el-checkbox v-for="role in roleTemplates" :key="role.code" :label="role.code">{{ role.name }}</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert v-if="selectedPermissions.length" type="info" :closable="false" title="将继承以下权限">
|
||||||
|
{{ selectedPermissions.join('、') }}
|
||||||
|
</el-alert>
|
||||||
<el-form-item label="密码">
|
<el-form-item label="密码">
|
||||||
<el-input v-model="form.password" type="password" show-password />
|
<el-input v-model="form.password" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -58,12 +76,35 @@
|
|||||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="editVisible" title="调整角色与数据范围" width="560px">
|
||||||
|
<el-form :model="editForm" label-width="100px">
|
||||||
|
<el-form-item label="账号">{{ editForm.name }}({{ editForm.username }})</el-form-item>
|
||||||
|
<el-form-item label="所属机构">
|
||||||
|
<el-select v-model="editForm.organizationId" clearable style="width:100%">
|
||||||
|
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-checkbox-group v-model="editForm.roleCodes">
|
||||||
|
<el-checkbox v-for="role in roleTemplates" :key="role.code" :label="role.code">{{ role.name }}</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert v-if="editPermissions.length" type="info" :closable="false" title="将继承以下权限">
|
||||||
|
{{ editPermissions.join('、') }}
|
||||||
|
</el-alert>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible=false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getAccessToken } from '@/utils/token'
|
import { getAccessToken } from '@/utils/token'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { get, post, put } from '@/api/client'
|
import { get, post, put } from '@/api/client'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -73,7 +114,10 @@ const page = ref(0)
|
|||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const formVisible = ref(false)
|
const formVisible = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const form = ref({ username: '', name: '', organizationId: '', password: '' })
|
const form = ref({ username: '', name: '', organizationId: '', password: '', roleCodes: [] as string[] })
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const editForm = ref({ id: '', username: '', name: '', organizationId: '', roleCodes: [] as string[] })
|
||||||
|
const roleTemplates = ref<any[]>([])
|
||||||
const orgList = ref<any[]>([])
|
const orgList = ref<any[]>([])
|
||||||
const orgMap = ref<Record<string, string>>({})
|
const orgMap = ref<Record<string, string>>({})
|
||||||
|
|
||||||
@@ -82,12 +126,20 @@ async function loadData() {
|
|||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
||||||
tableData.value = res.data?.list || []
|
tableData.value = res.data?.items || res.data?.list || []
|
||||||
total.value = res.data?.total || 0
|
total.value = res.data?.total || 0
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
try {
|
||||||
|
const token = getAccessToken()
|
||||||
|
const res: any = await get('/operator/operators/roles', {}, { Authorization: `Bearer ${token}` })
|
||||||
|
roleTemplates.value = res.data || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOrgs() {
|
async function loadOrgs() {
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
@@ -98,7 +150,7 @@ async function loadOrgs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
form.value = { username: '', name: '', organizationId: '', password: '' }
|
form.value = { username: '', name: '', organizationId: '', password: '', roleCodes: ['matchmaker'] }
|
||||||
formVisible.value = true
|
formVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +159,8 @@ async function save() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
|
if (!form.value.roleCodes.length) return
|
||||||
|
if (form.value.roleCodes.includes('matchmaker_leader') && !form.value.organizationId) return
|
||||||
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
||||||
formVisible.value = false
|
formVisible.value = false
|
||||||
loadData()
|
loadData()
|
||||||
@@ -114,6 +168,29 @@ async function save() {
|
|||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit(row: any) {
|
||||||
|
editForm.value = { id: row.id, username: row.username, name: row.name, organizationId: row.organizationId || '', roleCodes: [...(row.roles || [])] }
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editForm.value.roleCodes.length) return
|
||||||
|
if (editForm.value.roleCodes.includes('matchmaker_leader') && !editForm.value.organizationId) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const token = getAccessToken()
|
||||||
|
await put(`/operator/operators/${editForm.value.id}/roles`, { roleCodes: editForm.value.roleCodes, organizationId: editForm.value.organizationId || null }, { Authorization: `Bearer ${token}` })
|
||||||
|
editVisible.value = false
|
||||||
|
await loadData()
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleName(code: string) { return roleTemplates.value.find(r => r.code === code)?.name || code }
|
||||||
|
function scopeName(scope: string) { return scope === 'ALL' ? '全平台' : scope === 'TEAM' ? '本组织' : '本人' }
|
||||||
|
const selectedPermissions = computed(() => [...new Set(form.value.roleCodes.flatMap(code => roleTemplates.value.find(r => r.code === code)?.permissions || []))])
|
||||||
|
const editPermissions = computed(() => [...new Set(editForm.value.roleCodes.flatMap(code => roleTemplates.value.find(r => r.code === code)?.permissions || []))])
|
||||||
|
|
||||||
async function toggleStatus(row: any) {
|
async function toggleStatus(row: any) {
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
@@ -135,7 +212,7 @@ async function resetPwd(id: string) {
|
|||||||
function handlePageChange(p: number) { page.value = p - 1; loadData() }
|
function handlePageChange(p: number) { page.value = p - 1; loadData() }
|
||||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||||
|
|
||||||
onMounted(() => { loadData(); loadOrgs() })
|
onMounted(() => { loadData(); loadOrgs(); loadRoles() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<el-table-column prop="contactName" label="联系人" width="120" />
|
<el-table-column prop="contactName" label="联系人" width="120" />
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-tag :type="row.status==='normal'?'success':'danger'">{{ row.status==='normal'?'正常':'已停用' }}</el-tag>
|
<el-tag :type="row.status==='active'?'success':'danger'">{{ row.status==='active'?'正常':'已停用' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<el-table-column label="操作" width="180">
|
<el-table-column label="操作" width="180">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
|
<el-button size="small" :type="row.status==='active'?'warning':'success'"
|
||||||
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
|
@click="toggleStatus(row)">{{ row.status==='active'?'停用':'启用' }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -40,10 +40,11 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="机构类型">
|
<el-form-item label="机构类型">
|
||||||
<el-select v-model="form.type" style="width:100%">
|
<el-select v-model="form.type" style="width:100%">
|
||||||
<el-option label="妇联" value="fulian" />
|
<el-option label="妇联" value="women_federation" />
|
||||||
<el-option label="工会" value="gonghui" />
|
<el-option label="工会" value="trade_union" />
|
||||||
<el-option label="公益服务中心" value="public_service" />
|
<el-option label="公益服务中心" value="charity_center" />
|
||||||
<el-option label="志愿者团队" value="volunteer" />
|
<el-option label="志愿者团队" value="volunteer_team" />
|
||||||
|
<el-option label="民政合作单位" value="civil_affairs_partner" />
|
||||||
<el-option label="其他合作机构" value="other" />
|
<el-option label="其他合作机构" value="other" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -72,8 +73,8 @@ const form = ref({ name: '', type: '', contactName: '' })
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
const ORG_TYPE_TEXT: Record<string, string> = {
|
const ORG_TYPE_TEXT: Record<string, string> = {
|
||||||
fulian: '妇联', gonghui: '工会', public_service: '公益服务中心',
|
women_federation: '妇联', trade_union: '工会', charity_center: '公益服务中心',
|
||||||
volunteer: '志愿者团队', other: '其他合作机构'
|
volunteer_team: '志愿者团队', civil_affairs_partner: '民政合作单位', other: '其他合作机构'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@@ -88,7 +89,7 @@ async function loadData() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editId.value = ''
|
editId.value = ''
|
||||||
form.value = { name: '', type: 'fulian', contactName: '' }
|
form.value = { name: '', type: 'women_federation', contactName: '' }
|
||||||
formVisible.value = true
|
formVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ async function save() {
|
|||||||
async function toggleStatus(row: any) {
|
async function toggleStatus(row: any) {
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
const action = row.status === 'active' ? 'disable' : 'enable'
|
||||||
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<el-table-column prop="type" label="类型" width="120">
|
<el-table-column prop="type" label="类型" width="120">
|
||||||
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
|
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="id" label="关联ID" width="120" />
|
<el-table-column prop="referralDisplayId" label="转介编号" min-width="180" />
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
|
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
|
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
|
||||||
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
|
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
|
||||||
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
|
<el-table-column prop="displayId" label="任务编号" min-width="180" />
|
||||||
|
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -42,7 +43,8 @@
|
|||||||
|
|
||||||
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
|
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
|
||||||
<el-table :data="myTasks" v-loading="loading" row-key="taskId">
|
<el-table :data="myTasks" v-loading="loading" row-key="taskId">
|
||||||
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
|
<el-table-column prop="displayId" label="任务编号" min-width="180" />
|
||||||
|
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -65,7 +67,7 @@
|
|||||||
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
|
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
|
||||||
<template v-else-if="currentTask">
|
<template v-else-if="currentTask">
|
||||||
<el-descriptions :column="1" border>
|
<el-descriptions :column="1" border>
|
||||||
<el-descriptions-item label="用户编号">{{ currentTask.userId }}</el-descriptions-item>
|
<el-descriptions-item label="用户编号">{{ currentTask.userDisplayNumber }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
|
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
|
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="当前状态">
|
<el-descriptions-item label="当前状态">
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
|
|
||||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||||
<el-table-column prop="reasonText" label="举报原因" width="140" />
|
<el-table-column prop="reasonText" label="举报原因" width="140" />
|
||||||
<el-table-column prop="reportedUserId" label="被举报人ID" width="140" />
|
<el-table-column prop="displayId" label="举报编号" min-width="180" />
|
||||||
|
<el-table-column prop="reportedUserDisplayNumber" label="被举报人编号" width="140" />
|
||||||
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div></template>
|
</div></template>
|
||||||
<el-table :data="appointments" stripe v-loading="loading">
|
<el-table :data="appointments" stripe v-loading="loading">
|
||||||
<el-table-column prop="id" label="ID" width="100" />
|
<el-table-column prop="displayId" label="预约编号" min-width="180" />
|
||||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="180" />
|
||||||
<el-table-column prop="serviceType" label="服务类型" width="150" />
|
<el-table-column prop="serviceType" label="服务类型" width="150" />
|
||||||
<el-table-column prop="source" label="来源" width="140">
|
<el-table-column prop="source" label="来源" width="140">
|
||||||
<template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template>
|
<template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="preferredDate" label="预约日期" width="110" />
|
<el-table-column prop="preferredDate" label="预约日期" width="110" />
|
||||||
<el-table-column prop="assignedOperatorId" label="分配人员" width="100" />
|
<el-table-column prop="assignedOperatorDisplayId" label="分配人员" width="100" />
|
||||||
<el-table-column prop="createdAt" label="创建时间" width="160">
|
<el-table-column prop="createdAt" label="创建时间" width="160">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
|
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<el-table :data="members" stripe v-loading="memberLoading">
|
<el-table :data="members" stripe v-loading="memberLoading">
|
||||||
<el-table-column prop="userId" label="用户ID" width="200" show-overflow-tooltip />
|
<el-table-column prop="userDisplayNumber" label="用户编号" width="200" show-overflow-tooltip />
|
||||||
<el-table-column prop="status" label="状态" width="110">
|
<el-table-column prop="status" label="状态" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
|
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container page-shell">
|
||||||
|
<header class="page-heading"><div><h1>后台建档</h1><p>建立草稿档案,手机号核验和资料审核仍需独立完成。</p></div></header>
|
||||||
|
<section class="surface-card form-card">
|
||||||
|
<el-form label-position="top" @submit.prevent="submit">
|
||||||
|
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||||
|
<el-form-item label="所属组织"><el-input v-model="form.organization" /></el-form-item>
|
||||||
|
<el-form-item label="组织联系电话"><el-input v-model="form.organizationPhone" /></el-form-item>
|
||||||
|
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="submit">创建草稿</el-button>
|
||||||
|
</el-form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { post } from '@/api/client'
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({ name: '', organization: '', organizationPhone: '', phone: '' })
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
try { await post('/operator/users', form); ElMessage.success('草稿档案已创建'); Object.assign(form, { name: '', organization: '', organizationPhone: '', phone: '' }) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>.page-container{padding:28px}.form-card{max-width:620px;padding:24px}</style>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<section v-else-if="user" class="surface-card detail-card">
|
<section v-else-if="user" class="surface-card detail-card">
|
||||||
<div class="identity-summary">
|
<div class="identity-summary">
|
||||||
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
|
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
|
||||||
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
|
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.displayNumber || '实名后生成' }}</span></div>
|
||||||
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
|
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
|
||||||
</div>
|
</div>
|
||||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container page-shell">
|
||||||
|
<header class="page-heading"><div><h1>CSV 批量建档</h1><p>仅支持 UTF-8 CSV;预览不会写入用户表。</p></div></header>
|
||||||
|
<section class="surface-card form-card">
|
||||||
|
<input type="file" accept=".csv,text/csv" @change="onFile" />
|
||||||
|
<el-button type="primary" :loading="loading" :disabled="!fileText" @click="preview">预览</el-button>
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||||
|
<el-table v-if="result" :data="result.rows" class="preview-table"><el-table-column prop="rowNumber" label="行号" width="80" /><el-table-column prop="status" label="状态" /><el-table-column prop="error" label="错误" /></el-table>
|
||||||
|
<el-button v-if="result && result.validRows" type="success" :loading="committing" @click="commit">提交 {{ result.validRows }} 行</el-button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { post } from '@/api/client'
|
||||||
|
const fileText = ref(''); const result = ref<any>(null); const error = ref(''); const loading = ref(false); const committing = ref(false)
|
||||||
|
function onFile(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { fileText.value = String(reader.result || '') }; reader.readAsText(file, 'UTF-8') }
|
||||||
|
async function preview() { loading.value = true; error.value = ''; try { const response: any = await post('/operator/users/import/preview', fileText.value, { 'Content-Type': 'text/csv' }); result.value = response.data } catch (e: any) { error.value = e.response?.data?.message || '预览失败' } finally { loading.value = false } }
|
||||||
|
async function commit() { if (!result.value?.batchId) return; committing.value = true; try { await post('/operator/users/import/commit', { batchId: result.value.batchId, idempotencyKey: crypto.randomUUID() }); ElMessage.success('导入提交成功'); result.value = null } finally { committing.value = false } }
|
||||||
|
</script>
|
||||||
|
<style scoped>.page-container{padding:28px}.form-card{padding:24px;display:grid;gap:16px}.preview-table{margin-top:12px}</style>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<h1>用户管理</h1>
|
<h1>用户管理</h1>
|
||||||
<p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
|
<p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-tag type="info" effect="plain">共 {{ total }} 人</el-tag>
|
<div class="heading-actions"><el-button v-if="canCreate" type="primary" @click="router.push('/users/create')">后台建档</el-button><el-button v-if="canCreate" @click="router.push('/users/import')">CSV导入</el-button><el-tag type="info" effect="plain">共 {{ total }} 人</el-tag></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="surface-card filter-panel" aria-label="用户筛选">
|
<section class="surface-card filter-panel" aria-label="用户筛选">
|
||||||
@@ -42,7 +42,9 @@
|
|||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<el-table v-else :data="tableData" v-loading="loading" row-key="id">
|
<el-table v-else :data="tableData" v-loading="loading" row-key="id">
|
||||||
<el-table-column prop="id" label="用户编号" min-width="170" show-overflow-tooltip />
|
<el-table-column prop="displayNumber" label="用户编号" min-width="190" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.displayNumber || '实名后生成' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="displayName" label="显示名称" min-width="110">
|
<el-table-column prop="displayName" label="显示名称" min-width="110">
|
||||||
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
|
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -99,12 +101,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { get } from '@/api/client'
|
import { get } from '@/api/client'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const canCreate = computed(() => authStore.permissions.includes('user.create'))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
|
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
|
||||||
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
|
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
|
||||||
{ path: 'users', name: 'UserManagement', component: () => import('@/modules/user/UserManagementView.vue'), meta: { title: '用户管理', permissions: ['user.view'] } },
|
{ path: 'users', name: 'UserManagement', component: () => import('@/modules/user/UserManagementView.vue'), meta: { title: '用户管理', permissions: ['user.view'] } },
|
||||||
|
{ path: 'users/create', name: 'UserCreate', component: () => import('@/modules/user/UserCreateView.vue'), meta: { title: '后台建档', permissions: ['user.create'] } },
|
||||||
|
{ path: 'users/import', name: 'UserImport', component: () => import('@/modules/user/UserImportView.vue'), meta: { title: '批量建档', permissions: ['user.create'] } },
|
||||||
{ path: 'users/detail', name: 'UserDetail', component: () => import('@/modules/user/UserDetailView.vue'), meta: { title: '用户详情', permissions: ['user.view', 'user.sensitive.view'] } },
|
{ path: 'users/detail', name: 'UserDetail', component: () => import('@/modules/user/UserDetailView.vue'), meta: { title: '用户详情', permissions: ['user.view', 'user.sensitive.view'] } },
|
||||||
{ path: 'organizations', name: 'OrganizationManagement', component: () => import('@/modules/organization/OrganizationView.vue'), meta: { title: '组织管理', permissions: ['organization.manage'] } },
|
{ path: 'organizations', name: 'OrganizationManagement', component: () => import('@/modules/organization/OrganizationView.vue'), meta: { title: '组织管理', permissions: ['organization.manage'] } },
|
||||||
{ path: 'operators', name: 'OperatorManagement', component: () => import('@/modules/operator/OperatorManagementView.vue'), meta: { title: '工作人员管理', permissions: ['permission.manage'] } },
|
{ path: 'operators', name: 'OperatorManagement', component: () => import('@/modules/operator/OperatorManagementView.vue'), meta: { title: '工作人员管理', permissions: ['permission.manage'] } },
|
||||||
|
{ path: 'matchmaker-team', name: 'MatchmakerTeamManagement', component: () => import('@/modules/operator/MatchmakerTeamManagementView.vue'), meta: { title: '红娘团队管理', permissions: ['matchmaker.role.manage'] } },
|
||||||
{ path: 'analytics', name: 'Analytics', component: () => import('@/modules/analytics/AnalyticsView.vue'), meta: { title: '数据统计', permissions: ['analytics.view'] } },
|
{ path: 'analytics', name: 'Analytics', component: () => import('@/modules/analytics/AnalyticsView.vue'), meta: { title: '数据统计', permissions: ['analytics.view'] } },
|
||||||
{ path: 'audit', name: 'AuditLog', component: () => import('@/modules/audit/AuditLogView.vue'), meta: { title: '操作日志', permissions: ['user.view'] } },
|
{ path: 'audit', name: 'AuditLog', component: () => import('@/modules/audit/AuditLogView.vue'), meta: { title: '操作日志', permissions: ['user.view'] } },
|
||||||
// V1.1 新增模块
|
// V1.1 新增模块
|
||||||
@@ -37,8 +40,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
|
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
|
||||||
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
|
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
|
||||||
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
|
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
|
||||||
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
|
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['matchmaker.service'] } },
|
||||||
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } },
|
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.view'] } },
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -61,6 +64,10 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 权限检查
|
// 2. 权限检查
|
||||||
|
if ((to.path === '/referral' || to.path === '/matchmaker') && authStore.permissions.includes('permission.manage')) {
|
||||||
|
ElMessage.info('管理员请在工作台查看红娘业务统计')
|
||||||
|
return { path: '/dashboard' }
|
||||||
|
}
|
||||||
if (requiredPerms.length > 0 && !hasPermission(requiredPerms)) {
|
if (requiredPerms.length > 0 && !hasPermission(requiredPerms)) {
|
||||||
ElMessage.warning('您没有访问该页面的权限')
|
ElMessage.warning('您没有访问该页面的权限')
|
||||||
return { path: '/dashboard' }
|
return { path: '/dashboard' }
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ interface ApiEnvelope<T> {
|
|||||||
|
|
||||||
const REQUEST_TIMEOUT = 8000
|
const REQUEST_TIMEOUT = 8000
|
||||||
|
|
||||||
|
export function summarizeMatchmakerServices(rows: JsonRecord[]) {
|
||||||
|
const day = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||||
|
const today = day.format(new Date())
|
||||||
|
return {
|
||||||
|
pending: rows.filter(row => ['submitted', 'assigned', 'in_service', 'waiting_user'].includes(String(row.status))).length,
|
||||||
|
completed: rows.filter(row => row.status === 'completed').length,
|
||||||
|
total: rows.filter(row => ['createdAt', 'assignedAt', 'completedAt', 'latestFollowupAt'].some(key => {
|
||||||
|
if (!row[key]) return false
|
||||||
|
const date = new Date(String(row[key]))
|
||||||
|
return !Number.isNaN(date.getTime()) && day.format(date) === today
|
||||||
|
})).length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const numberOf = (source: JsonRecord | undefined, key: string) => {
|
const numberOf = (source: JsonRecord | undefined, key: string) => {
|
||||||
const value = source?.[key]
|
const value = source?.[key]
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||||
@@ -40,18 +54,18 @@ const emptyMetric = (
|
|||||||
key: string,
|
key: string,
|
||||||
label: string,
|
label: string,
|
||||||
tone: DashboardMetric['tone'],
|
tone: DashboardMetric['tone'],
|
||||||
route: string,
|
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, status: 'forbidden', route: '' })
|
||||||
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, route })
|
|
||||||
|
|
||||||
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
|
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
|
||||||
return {
|
return {
|
||||||
|
followups: [],
|
||||||
metrics: [
|
metrics: [
|
||||||
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
|
emptyMetric('pending_reviews', '待审核资料', 'rose'),
|
||||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'),
|
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber'),
|
||||||
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
|
emptyMetric('counseling_todos', '咨询服务待办', 'blue'),
|
||||||
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
|
emptyMetric('station_appointments', '服务站预约', 'green'),
|
||||||
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
|
emptyMetric('effective_matches', '有效匹配', 'purple'),
|
||||||
emptyMetric('activity_registrations', '活动报名', 'red', '/activities/interests'),
|
emptyMetric('activity_registrations', '活动报名', 'red'),
|
||||||
],
|
],
|
||||||
todos: [],
|
todos: [],
|
||||||
activities: [],
|
activities: [],
|
||||||
@@ -65,8 +79,12 @@ export function createEmptyDashboard(notice = '当前账号暂无可展示的工
|
|||||||
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
|
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
|
||||||
const canViewAnalytics = permissions.includes('analytics.view')
|
const canViewAnalytics = permissions.includes('analytics.view')
|
||||||
const canViewActivities = permissions.includes('activity.view')
|
const canViewActivities = permissions.includes('activity.view')
|
||||||
|
const canViewMatchmaker = permissions.includes('matchmaker.view')
|
||||||
|
const canManageMatchmaker = permissions.includes('matchmaker.assign') || permissions.includes('matchmaker.team.assign')
|
||||||
|
const canViewReviews = permissions.includes('review.view')
|
||||||
|
|
||||||
const [coreResult, serviceResult, activityResult] = await Promise.allSettled([
|
const [workbenchResult, coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
|
||||||
|
client.get('/operator/workbench/dashboard', { timeout: REQUEST_TIMEOUT }),
|
||||||
canViewAnalytics
|
canViewAnalytics
|
||||||
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
|
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
@@ -76,53 +94,83 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
|||||||
canViewActivities
|
canViewActivities
|
||||||
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
|
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
|
canViewMatchmaker
|
||||||
|
? client.get('/operator/matchmaker/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||||
|
: Promise.resolve(null),
|
||||||
|
canViewReviews ? client.get('/operator/reviews/stats', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||||
|
canManageMatchmaker ? client.get('/operator/matchmaker/management/services', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const workbench = fulfilledData<JsonRecord>(workbenchResult)
|
||||||
|
const workbenchMetrics = recordOf(workbench || undefined, 'metrics')
|
||||||
|
const workbenchValue = (key: string) => numberOf(recordOf(workbenchMetrics, key), 'value')
|
||||||
|
const hasWorkbenchMetric = (key: string) => Object.prototype.hasOwnProperty.call(workbenchMetrics, key)
|
||||||
|
const workbenchAvailable = (key: string) => Boolean(recordOf(workbenchMetrics, key).available)
|
||||||
|
const metricStatus = (key: string): DashboardMetric['status'] => {
|
||||||
|
if (hasWorkbenchMetric(key)) return workbenchAvailable(key) ? 'ready' : 'forbidden'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
const metricAvailable = (key: string) => metricStatus(key) === 'ready'
|
||||||
|
const metricRoute = (status: DashboardMetric['status'], route: string) => status === 'ready' ? route : ''
|
||||||
const core = fulfilledData<JsonRecord>(coreResult)
|
const core = fulfilledData<JsonRecord>(coreResult)
|
||||||
const service = fulfilledData<JsonRecord>(serviceResult)
|
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||||
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
|
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
|
||||||
|
const matchmaker = fulfilledData<JsonRecord>(matchmakerResult)
|
||||||
|
const reviews = fulfilledData<JsonRecord>(reviewResult)
|
||||||
|
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
|
||||||
|
const managedStats = summarizeMatchmakerServices(managedServices || [])
|
||||||
|
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
|
||||||
|
const matchmakerCompleted = canManageMatchmaker
|
||||||
|
? managedStats.completed
|
||||||
|
: numberOf(matchmaker || undefined, 'completedCount')
|
||||||
|
const followups = (managedServices || []).filter(row => row.latestFollowupContent)
|
||||||
|
.sort((a, b) => String(b.latestFollowupAt).localeCompare(String(a.latestFollowupAt)))
|
||||||
|
.slice(0, 5).map(row => ({ serviceId: String(row.serviceId), userDisplayNumber: String(row.userDisplayNumber || '未生成编号'),
|
||||||
|
content: String(row.latestFollowupContent), createdAt: String(row.latestFollowupAt || '') }))
|
||||||
const analyticsAvailable = Boolean(core || service)
|
const analyticsAvailable = Boolean(core || service)
|
||||||
|
|
||||||
const eligibility = recordOf(service || undefined, 'localEligibility')
|
|
||||||
const counseling = recordOf(service || undefined, 'counseling')
|
const counseling = recordOf(service || undefined, 'counseling')
|
||||||
const appointments = recordOf(service || undefined, 'serviceAppointments')
|
|
||||||
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
||||||
const registrationSources = recordOf(service || undefined, 'registrationSources')
|
const registrationSources = recordOf(service || undefined, 'registrationSources')
|
||||||
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
|
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
|
||||||
|
|
||||||
const metrics: DashboardMetric[] = [
|
const metrics: DashboardMetric[] = [
|
||||||
{
|
{
|
||||||
key: 'pending_reviews', label: '待审核资料', value: numberOf(eligibility, 'pending'),
|
key: 'pending_reviews', label: '待审核资料', value: workbenchValue('pendingReviews'),
|
||||||
trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review',
|
trendText: workbenchAvailable('pendingReviews') ? '当前数据范围内待审核资料' : '暂无授权数据', tone: 'rose', available: metricAvailable('pendingReviews'), status: metricStatus('pendingReviews'), route: metricRoute(metricStatus('pendingReviews'), '/review'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'matchmaker_todos', label: '红娘服务待办', value: 0,
|
key: 'matchmaker_todos', label: '红娘服务待办',
|
||||||
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral',
|
value: workbenchValue('matchmakerTodos'),
|
||||||
|
trendText: workbenchAvailable('matchmakerTodos') ? `${workbench?.scopeLabel || '当前范围'}待完成服务` : '暂无授权数据', tone: 'amber', available: metricAvailable('matchmakerTodos'), status: metricStatus('matchmakerTodos'), route: metricRoute(metricStatus('matchmakerTodos'), permissions.includes('permission.manage') ? '' : '/matchmaker'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'counseling_todos', label: '咨询服务待办',
|
key: 'counseling_todos', label: '咨询服务待办',
|
||||||
value: numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'),
|
value: workbenchValue('counselingTodos'),
|
||||||
trendText: service ? '待受理与已分配' : '暂无授权数据', tone: 'blue', available: Boolean(service), route: '/counseling',
|
trendText: workbenchAvailable('counselingTodos') ? `${workbench?.scopeLabel || '当前范围'}咨询待办` : '暂无授权数据', tone: 'blue', available: metricAvailable('counselingTodos'), status: metricStatus('counselingTodos'), route: metricRoute(metricStatus('counselingTodos'), '/counseling'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'station_appointments', label: '服务站预约',
|
key: 'station_appointments', label: '服务站预约',
|
||||||
value: numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'),
|
value: workbenchValue('serviceStationAppointments'),
|
||||||
trendText: service ? '待确认与已确认' : '暂无授权数据', tone: 'green', available: Boolean(service), route: '/service-appointment',
|
trendText: workbenchAvailable('serviceStationAppointments') ? `${workbench?.scopeLabel || '当前范围'}服务站预约` : '暂无授权数据', tone: 'green', available: metricAvailable('serviceStationAppointments'), status: metricStatus('serviceStationAppointments'), route: metricRoute(metricStatus('serviceStationAppointments'), '/service-appointment'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'effective_matches', label: '有效匹配', value: numberOf(effectiveConnections, 'activeMatches'),
|
key: 'effective_matches', label: '有效匹配', value: workbenchValue('effectiveMatches'),
|
||||||
trendText: service ? '当前活跃匹配' : '暂无授权数据', tone: 'purple', available: Boolean(service), route: '/analytics',
|
trendText: workbenchAvailable('effectiveMatches') ? `${workbench?.scopeLabel || '当前范围'}有效匹配` : '暂无授权数据', tone: 'purple', available: metricAvailable('effectiveMatches'), status: metricStatus('effectiveMatches'), route: metricRoute(metricStatus('effectiveMatches'), '/analytics'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'activity_registrations', label: '活动报名', value: numberOf(registrationSources, 'totalRegistrations'),
|
key: 'activity_registrations', label: '活动报名', value: workbenchValue('activityRegistrations'),
|
||||||
trendText: service ? '累计有效报名' : '暂无授权数据', tone: 'red', available: Boolean(service), route: '/activities/interests',
|
trendText: workbenchAvailable('activityRegistrations') ? `${workbench?.scopeLabel || '当前范围'}活动报名` : '暂无授权数据', tone: 'red', available: metricAvailable('activityRegistrations'), status: metricStatus('activityRegistrations'), route: metricRoute(metricStatus('activityRegistrations'), '/activities/interests'),
|
||||||
},
|
},
|
||||||
|
// 保留 forbidden 卡片以明确展示“无权限”;其数值只在 available 时渲染,route 由 metricRoute 置空。
|
||||||
]
|
]
|
||||||
|
const metricByKey = (key: string) => metrics.find(metric => metric.key === key)
|
||||||
|
|
||||||
const todos = ([
|
const todos = ([
|
||||||
{ key: 'review', title: '资料审核', description: '本地资格材料等待核验', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
|
{ key: 'review', title: '资料审核', description: '待领取与审核中资料', count: metricByKey('pending_reviews')?.value || 0, actionText: '立即审核', route: '/review', tone: 'rose', available: metricByKey('pending_reviews')?.available || false },
|
||||||
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available },
|
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metricByKey('counseling_todos')?.value || 0, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metricByKey('counseling_todos')?.available || false },
|
||||||
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available },
|
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metricByKey('station_appointments')?.value || 0, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metricByKey('station_appointments')?.available || false },
|
||||||
|
{ key: 'matchmaker', title: '红娘服务', description: metricByKey('matchmaker_todos')?.trendText || '暂无授权数据', count: metricByKey('matchmaker_todos')?.value || 0, actionText: permissions.includes('permission.manage') ? '统计' : '查看服务', route: permissions.includes('permission.manage') ? '' : '/matchmaker', tone: 'amber', available: metricByKey('matchmaker_todos')?.available || false },
|
||||||
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
|
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
|
||||||
] as DashboardTodo[]).filter((item) => item.available)
|
] as DashboardTodo[]).filter((item) => item.available)
|
||||||
|
|
||||||
@@ -142,6 +190,7 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
|||||||
{ key: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) },
|
{ key: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) },
|
||||||
{ key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) },
|
{ key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) },
|
||||||
{ key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) },
|
{ key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) },
|
||||||
|
{ key: 'matchmaker_completed', label: '红娘服务完成', value: matchmakerCompleted, compareText: canManageMatchmaker ? '管理范围内已完成服务' : '本人已完成服务', tone: 'amber', available: matchmakerAvailable },
|
||||||
] as DashboardOverviewItem[]).filter((item) => item.available)
|
] as DashboardOverviewItem[]).filter((item) => item.available)
|
||||||
|
|
||||||
const effectiveness = ([
|
const effectiveness = ([
|
||||||
@@ -152,12 +201,14 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
|||||||
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
|
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
|
||||||
] as DashboardEffectivenessItem[]).filter((item) => item.available)
|
] as DashboardEffectivenessItem[]).filter((item) => item.available)
|
||||||
|
|
||||||
const rejectedCount = [coreResult, serviceResult, activityResult].filter((item) => item.status === 'rejected').length
|
const rejectedCount = [workbenchResult, coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
|
||||||
const notice = !analyticsAvailable && !activityRows
|
const anyData = Boolean(workbench) || analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
|
||||||
|
const notice = !anyData
|
||||||
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
||||||
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
followups,
|
||||||
metrics,
|
metrics,
|
||||||
todos,
|
todos,
|
||||||
activities,
|
activities,
|
||||||
|
|||||||
@@ -9,20 +9,21 @@ interface OperatorInfo {
|
|||||||
username: string
|
username: string
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
organizationId: string | null
|
organizationId: string | null
|
||||||
|
roles: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewRolePermissions: Record<string, string[]> = {
|
const previewRolePermissions: Record<string, string[]> = {
|
||||||
reviewer: ['review.view', 'user.view'],
|
reviewer: ['review.view', 'user.view'],
|
||||||
counselor: ['counseling.service.view', 'referral.manage'],
|
counselor: ['counseling.service.view', 'matchmaker.service'],
|
||||||
station: ['service_location.view', 'service_appointment.view'],
|
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', 'referral.manage'],
|
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', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const previewParams = import.meta.env.DEV ? new URLSearchParams(window.location.search) : null
|
const previewParams = import.meta.env.DEV ? new URLSearchParams(window.location.search) : null
|
||||||
const previewRole = previewParams?.get('preview') || ''
|
const previewRole = previewParams?.get('preview') || ''
|
||||||
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
||||||
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
|
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null, roles: [previewRole] }
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
|
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
|
||||||
@@ -31,6 +32,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 || ''
|
||||||
@@ -40,6 +43,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
username: data.username,
|
username: data.username,
|
||||||
permissions: data.permissions || [],
|
permissions: data.permissions || [],
|
||||||
organizationId: data.organizationId ?? null,
|
organizationId: data.organizationId ?? null,
|
||||||
|
roles: Array.isArray(data.roles) ? data.roles : (Array.isArray(data.roleCodes) ? data.roleCodes : [data.roleCode || data.role].filter(Boolean)),
|
||||||
}
|
}
|
||||||
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
||||||
setAccessToken(token.value)
|
setAccessToken(token.value)
|
||||||
@@ -87,5 +91,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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,8 +37,18 @@
|
|||||||
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
|
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
|
||||||
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
|
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
|
||||||
--admin-coral-border: rgba(244, 95, 104, 0.22);
|
--admin-coral-border: rgba(244, 95, 104, 0.22);
|
||||||
|
--admin-scene-bg-start: #FFEDEE;
|
||||||
|
--admin-scene-bg-mid: #FFF6F4;
|
||||||
|
--admin-scene-bg-end: #FBF5F4;
|
||||||
|
--admin-scene-glow: rgba(244, 95, 104, 0.28);
|
||||||
|
--admin-scene-glow-soft: rgba(255, 150, 170, 0.22);
|
||||||
|
--admin-scene-surface: rgba(255, 255, 255, 0.65);
|
||||||
|
--admin-scene-border: rgba(244, 95, 104, 0.18);
|
||||||
|
--admin-scene-divider: rgba(244, 95, 104, 0.30);
|
||||||
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
|
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
|
||||||
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
|
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
|
||||||
|
/* 文字用渐变(background-clip:text):比 solid 更鲜亮,用于「善缘丹阳」等大字号品牌标题 */
|
||||||
|
--gradient-brand-text: linear-gradient(135deg, #FF7D6E 0%, #E8475D 60%, #B8434A 100%);
|
||||||
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
|
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
|
||||||
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
|
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
|
||||||
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
|
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red'
|
export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red'
|
||||||
|
export type DashboardMetricStatus = 'ready' | 'forbidden' | 'error'
|
||||||
|
|
||||||
export interface DashboardMetric {
|
export interface DashboardMetric {
|
||||||
key: string
|
key: string
|
||||||
@@ -7,6 +8,7 @@ export interface DashboardMetric {
|
|||||||
trendText: string
|
trendText: string
|
||||||
tone: DashboardTone
|
tone: DashboardTone
|
||||||
available: boolean
|
available: boolean
|
||||||
|
status: DashboardMetricStatus
|
||||||
route: string
|
route: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ export interface DashboardEffectivenessItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardModel {
|
export interface DashboardModel {
|
||||||
|
followups: { serviceId: string; userDisplayNumber: string; content: string; createdAt: string }[]
|
||||||
metrics: DashboardMetric[]
|
metrics: DashboardMetric[]
|
||||||
todos: DashboardTodo[]
|
todos: DashboardTodo[]
|
||||||
activities: DashboardActivity[]
|
activities: DashboardActivity[]
|
||||||
|
|||||||
@@ -106,6 +106,17 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<article v-if="dashboard.followups.length" class="dashboard-panel">
|
||||||
|
<header class="panel-header"><div><h2>最新红娘跟进记录</h2></div></header>
|
||||||
|
<div class="todo-list">
|
||||||
|
<button v-for="item in dashboard.followups" :key="item.serviceId" type="button" class="todo-row"
|
||||||
|
@click="goTo(`/matchmaker?serviceId=${encodeURIComponent(item.serviceId)}`)">
|
||||||
|
<span class="todo-copy"><strong>{{ item.userDisplayNumber }}</strong><small>{{ item.content }}</small></span>
|
||||||
|
<em>{{ formatDateTime(item.createdAt) }} · 查看服务</em>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<section class="dashboard-grid dashboard-grid-bottom">
|
<section class="dashboard-grid dashboard-grid-bottom">
|
||||||
<article class="dashboard-panel service-overview-panel">
|
<article class="dashboard-panel service-overview-panel">
|
||||||
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
|
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
|
||||||
@@ -136,7 +147,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onActivated, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
@@ -277,15 +288,19 @@ async function retryLoad() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(retryLoad)
|
onActivated(retryLoad)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-page {
|
.dashboard-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
max-width: var(--admin-content-max-width);
|
max-width: var(--admin-content-max-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-5) 26px 28px;
|
padding: var(--space-5) 26px 28px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-welcome {
|
.dashboard-welcome {
|
||||||
@@ -478,6 +493,19 @@ onMounted(retryLoad)
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-grid-top {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid-bottom {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid-bottom .dashboard-panel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-panel {
|
.dashboard-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 14px var(--space-4);
|
padding: 14px var(--space-4);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="login-shell">
|
<main class="login-shell">
|
||||||
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
|
<section class="brand-panel" aria-label="爱在丹阳公益婚恋服务平台">
|
||||||
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
|
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
|
||||||
<div class="brand-overlay">
|
<div class="brand-overlay">
|
||||||
<div class="brand-title">
|
<div class="brand-title">
|
||||||
<img :src="BRAND_LOGO" alt="" />
|
<img :src="BRAND_LOGO" alt="" />
|
||||||
<div>
|
<div>
|
||||||
<strong>善缘丹阳</strong>
|
<strong>爱在丹阳</strong>
|
||||||
<span>公益婚恋服务平台</span>
|
<span>公益婚恋服务平台</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
|
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer>善缘丹阳 · 公益婚恋服务平台</footer>
|
<footer>爱在丹阳 · 公益婚恋服务平台</footer>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -135,6 +135,8 @@ async function handleLogin() {
|
|||||||
.brand-title img {
|
.brand-title img {
|
||||||
width: 54px;
|
width: 54px;
|
||||||
height: 54px;
|
height: 54px;
|
||||||
|
border-radius: 50%;
|
||||||
|
clip-path: circle(50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title div {
|
.brand-title div {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default defineConfig(({ command }) => ({
|
|||||||
server: {
|
server: {
|
||||||
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
|
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
|
||||||
port: 13520,
|
port: 13520,
|
||||||
|
// 允许同一局域网的浏览器访问开发页面。
|
||||||
|
host: '0.0.0.0',
|
||||||
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
|
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
|
||||||
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
|
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<groupId>com.danyang.match</groupId>
|
<groupId>com.danyang.match</groupId>
|
||||||
<artifactId>danyang-match-platform</artifactId>
|
<artifactId>danyang-match-platform</artifactId>
|
||||||
<version>1.1.0</version>
|
<version>1.2.0</version>
|
||||||
<name>Danyang Match Platform</name>
|
<name>Danyang Match Platform</name>
|
||||||
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
|
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
|
||||||
|
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.danyang.match.platform.account.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/account/service-consent")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ServiceConsentController {
|
||||||
|
private final ServiceConsentService service;
|
||||||
|
@GetMapping public ApiResponse<?> get(@AuthenticationPrincipal UserPrincipal p) { return ApiResponse.ok(service.getConsentState(p.id())); }
|
||||||
|
@PostMapping public ApiResponse<?> agree(@AuthenticationPrincipal UserPrincipal p, @RequestBody Map<String, Object> body, jakarta.servlet.http.HttpServletRequest req) {
|
||||||
|
@SuppressWarnings("unchecked") Map<String, String> versions = (Map<String, String>) body.get("documentVersions");
|
||||||
|
return ApiResponse.ok(service.agree(p.id(), versions, req.getRemoteAddr(), req.getHeader("User-Agent")));
|
||||||
|
}
|
||||||
|
@PostMapping("/withdraw") public ApiResponse<?> withdraw(@AuthenticationPrincipal UserPrincipal p, @RequestBody Map<String, String> body, jakarta.servlet.http.HttpServletRequest req) {
|
||||||
|
return ApiResponse.ok(service.withdraw(p.id(), body.get("consentType"), req.getRemoteAddr(), req.getHeader("User-Agent")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.danyang.match.platform.account.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("users")
|
||||||
|
public class UserAccountLifecycle extends BaseEntity {
|
||||||
|
@TableField("phone_verification_status")
|
||||||
|
private String phoneVerificationStatus;
|
||||||
|
@TableField("account_creation_status")
|
||||||
|
private String accountCreationStatus;
|
||||||
|
@TableField("credential_delivery_status")
|
||||||
|
private String credentialDeliveryStatus;
|
||||||
|
@TableField("service_eligibility_status")
|
||||||
|
private String serviceEligibilityStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.danyang.match.platform.account.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;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("user_consent_states")
|
||||||
|
public class UserConsentState extends BaseEntity {
|
||||||
|
private String userId;
|
||||||
|
private String consentType;
|
||||||
|
private String documentVersion;
|
||||||
|
private String status;
|
||||||
|
private Instant agreedAt;
|
||||||
|
private Instant withdrawnAt;
|
||||||
|
private String actorUserId;
|
||||||
|
private String ipAddress;
|
||||||
|
private String userAgent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.danyang.match.platform.account.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;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("user_credentials")
|
||||||
|
public class UserCredential extends BaseEntity {
|
||||||
|
private String userId;
|
||||||
|
private String passwordHash;
|
||||||
|
private Integer credentialVersion;
|
||||||
|
private Boolean mustChangePassword;
|
||||||
|
private Instant issuedAt;
|
||||||
|
private Instant firstUsedAt;
|
||||||
|
private Instant lastChangedAt;
|
||||||
|
private Instant revokedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.danyang.match.platform.account.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("user_service_states")
|
||||||
|
public class UserServiceState {
|
||||||
|
@TableId private String userId;
|
||||||
|
private Boolean serviceActive;
|
||||||
|
private String serviceBlockReason;
|
||||||
|
private Instant evaluatedAt;
|
||||||
|
private Instant createdAt;
|
||||||
|
private Instant updatedAt;
|
||||||
|
private Instant deletedAt;
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.danyang.match.platform.account.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.account.entity.UserConsentState;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserConsentStateMapper extends BaseMapper<UserConsentState> {
|
||||||
|
@Select("SELECT COUNT(*) FROM user_consent_states WHERE user_id = #{userId} AND status = 'agreed' AND deleted_at IS NULL")
|
||||||
|
int countAgreed(String userId);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM user_consent_states WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY consent_type")
|
||||||
|
List<UserConsentState> findByUserId(String userId);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM user_consent_states WHERE user_id = #{userId} AND consent_type = #{consentType} AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<UserConsentState> findByUserAndType(String userId, String consentType);
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.danyang.match.platform.account.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.account.entity.UserServiceState;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserServiceStateMapper extends BaseMapper<UserServiceState> {
|
||||||
|
@Select("SELECT * FROM user_service_states WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<UserServiceState> findByUserId(String userId);
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.danyang.match.platform.account.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.account.entity.UserConsentState;
|
||||||
|
import com.danyang.match.platform.account.entity.UserServiceState;
|
||||||
|
import com.danyang.match.platform.account.mapper.UserConsentStateMapper;
|
||||||
|
import com.danyang.match.platform.account.mapper.UserServiceStateMapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
|
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.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ServiceConsentService {
|
||||||
|
public static final String SERVICE_TERMS = "service_terms";
|
||||||
|
public static final String PRIVACY_SAFETY = "privacy_safety";
|
||||||
|
private final UserConsentStateMapper consentMapper;
|
||||||
|
private final UserServiceStateMapper serviceStateMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final UserProfileMapper profileMapper;
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
@Value("${account.service-consent.service-terms-version:v1}") private String serviceTermsVersion = "v1";
|
||||||
|
@Value("${account.service-consent.privacy-safety-version:v1}") private String privacySafetyVersion = "v1";
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getConsentState(String userId) {
|
||||||
|
Map<String, UserConsentState> current = new HashMap<>();
|
||||||
|
consentMapper.findByUserId(userId).forEach(c -> current.put(c.getConsentType(), c));
|
||||||
|
return List.of(document(SERVICE_TERMS, serviceTermsVersion, current.get(SERVICE_TERMS)),
|
||||||
|
document(PRIVACY_SAFETY, privacySafetyVersion, current.get(PRIVACY_SAFETY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> agree(String userId, Map<String, String> versions, String ip, String ua) {
|
||||||
|
if (versions == null || !versions.containsKey(SERVICE_TERMS) || !versions.containsKey(PRIVACY_SAFETY))
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "两份服务授权必须分别确认");
|
||||||
|
agreeOne(userId, SERVICE_TERMS, serviceTermsVersion, versions.get(SERVICE_TERMS), ip, ua);
|
||||||
|
agreeOne(userId, PRIVACY_SAFETY, privacySafetyVersion, versions.get(PRIVACY_SAFETY), ip, ua);
|
||||||
|
boolean active = evaluate(userId);
|
||||||
|
audit(userId, "SERVICE_CONSENT_AGREE", "service_consent", ip, ua);
|
||||||
|
return Map.of("serviceActive", active, "consents", getConsentState(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> withdraw(String userId, String type, String ip, String ua) {
|
||||||
|
if (!required().contains(type)) throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的授权类型");
|
||||||
|
UserConsentState state = consentMapper.findByUserAndType(userId, type)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该授权记录"));
|
||||||
|
state.setStatus("withdrawn"); state.setWithdrawnAt(Instant.now()); state.setActorUserId(userId);
|
||||||
|
state.setIpAddress(ip); state.setUserAgent(ua); consentMapper.updateById(state); evaluate(userId);
|
||||||
|
audit(userId, "SERVICE_CONSENT_WITHDRAW", type, ip, ua);
|
||||||
|
return Map.of("withdrawn", true, "consentType", type, "serviceActive", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireActive(String userId) {
|
||||||
|
User user = userMapper.findById(userId).orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
UserProfile profile = profileMapper.findByUserId(userId).orElse(null);
|
||||||
|
if (!Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus()) || !"verified".equals(user.getPhoneVerificationStatus()))
|
||||||
|
throw blocked("账号或手机号尚未满足服务条件");
|
||||||
|
if (profile == null || !Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||||
|
|| profile.getPublishedProfileRevision() == null
|
||||||
|
|| !Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision()))
|
||||||
|
throw blocked("资料尚未审核通过");
|
||||||
|
if (consentMapper.countAgreed(userId) != required().size()) throw blocked("请先确认两份服务授权");
|
||||||
|
if (!"eligible".equals(user.getServiceEligibilityStatus())) throw blocked("当前账号不具备服务资格");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive(String userId) { try { requireActive(userId); return true; } catch (RuntimeException e) { return false; } }
|
||||||
|
|
||||||
|
private void agreeOne(String userId, String type, String expected, String actual, String ip, String ua) {
|
||||||
|
if (!Objects.equals(expected, actual)) throw new BusinessException(ApiResponseCode.CONFLICT, "授权文档版本已更新,请刷新后重新确认");
|
||||||
|
UserConsentState s = consentMapper.findByUserAndType(userId, type).orElseGet(UserConsentState::new);
|
||||||
|
s.setUserId(userId); s.setConsentType(type); s.setDocumentVersion(expected); s.setStatus("agreed"); s.setAgreedAt(Instant.now());
|
||||||
|
s.setWithdrawnAt(null); s.setActorUserId(userId); s.setIpAddress(ip); s.setUserAgent(ua);
|
||||||
|
if (s.getId() == null) { s.setId(UUID.randomUUID().toString()); consentMapper.insert(s); } else consentMapper.updateById(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean evaluate(String userId) {
|
||||||
|
UserServiceState s = serviceStateMapper.findByUserId(userId).orElseGet(UserServiceState::new);
|
||||||
|
s.setUserId(userId); boolean active = consentMapper.countAgreed(userId) == required().size();
|
||||||
|
s.setServiceActive(active); s.setServiceBlockReason(active ? null : "consent_required"); s.setEvaluatedAt(Instant.now());
|
||||||
|
if (s.getCreatedAt() == null) serviceStateMapper.insert(s); else serviceStateMapper.updateById(s);
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void audit(String userId, String action, String resourceId, String ip, String ua) {
|
||||||
|
AuditLog log = new AuditLog(); log.setSubjectUserId(userId); log.setAction(action); log.setResourceType("service_consent");
|
||||||
|
log.setResourceId(resourceId); log.setIp(ip); log.setUserAgent(ua); auditLogMapper.insert(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> required() { return Set.of(SERVICE_TERMS, PRIVACY_SAFETY); }
|
||||||
|
private BusinessException blocked(String message) { return new BusinessException(ApiResponseCode.FORBIDDEN, message); }
|
||||||
|
private Map<String, Object> document(String type, String version, UserConsentState state) {
|
||||||
|
return Map.of("consentType", type, "documentVersion", version, "status", state == null ? "required" : state.getStatus(), "agreed", state != null && "agreed".equals(state.getStatus()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 活动反馈与互选控制器
|
* 活动反馈与互选控制器
|
||||||
@@ -33,6 +34,8 @@ public class ActivityInterestController {
|
|||||||
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
|
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
|
||||||
private final ActivityMutualConsentService consentService;
|
private final ActivityMutualConsentService consentService;
|
||||||
private final ActivityMapper activityMapper;
|
private final ActivityMapper activityMapper;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@GetMapping("/operator/activity-interest-summary")
|
@GetMapping("/operator/activity-interest-summary")
|
||||||
@PreAuthorize("hasAuthority('activity.view')")
|
@PreAuthorize("hasAuthority('activity.view')")
|
||||||
@@ -63,6 +66,7 @@ public class ActivityInterestController {
|
|||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@RequestBody ActivityFeedbackDTO body) {
|
@RequestBody ActivityFeedbackDTO body) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||||
Map<String, Object> result = interestService.submitFeedback(
|
Map<String, Object> result = interestService.submitFeedback(
|
||||||
userId,
|
userId,
|
||||||
id,
|
id,
|
||||||
@@ -83,6 +87,7 @@ public class ActivityInterestController {
|
|||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||||
return ApiResponse.ok(interestService.getMutualMatches(userId, id));
|
return ApiResponse.ok(interestService.getMutualMatches(userId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +101,7 @@ public class ActivityInterestController {
|
|||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@PathVariable String partnerId) {
|
@PathVariable String partnerId) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||||
boolean mutual = interestService.hasMutualInterest(userId, partnerId, id);
|
boolean mutual = interestService.hasMutualInterest(userId, partnerId, id);
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("hasMutual", mutual);
|
result.put("hasMutual", mutual);
|
||||||
@@ -120,6 +126,7 @@ public class ActivityInterestController {
|
|||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||||
String partnerId = body.get("partnerId");
|
String partnerId = body.get("partnerId");
|
||||||
if (partnerId == null || partnerId.isBlank()) {
|
if (partnerId == null || partnerId.isBlank()) {
|
||||||
return ApiResponse.error(
|
return ApiResponse.error(
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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.risk.entity.RiskAction;
|
import com.danyang.match.platform.risk.entity.RiskAction;
|
||||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -46,6 +48,7 @@ public class AppealService {
|
|||||||
|
|
||||||
private final AppealMapper appealMapper;
|
private final AppealMapper appealMapper;
|
||||||
private final RiskActionMapper riskActionMapper;
|
private final RiskActionMapper riskActionMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
private final AuditLogMapper auditLogMapper;
|
private final AuditLogMapper auditLogMapper;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
@@ -281,6 +284,11 @@ public class AppealService {
|
|||||||
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
|
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。
|
||||||
|
// 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
|
||||||
|
// users.account_status=banned/suspended 无人回滚。
|
||||||
|
restoreAccountStatusIfNoActiveRestriction(appeal.getUserId());
|
||||||
|
|
||||||
// 通知用户
|
// 通知用户
|
||||||
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
|
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||||
"申诉审核通过",
|
"申诉审核通过",
|
||||||
@@ -371,6 +379,38 @@ public class AppealService {
|
|||||||
return "limited";
|
return "limited";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。
|
||||||
|
*
|
||||||
|
* <p>否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
|
||||||
|
* users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
|
||||||
|
*
|
||||||
|
* <p>仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。
|
||||||
|
*/
|
||||||
|
private void restoreAccountStatusIfNoActiveRestriction(String userId) {
|
||||||
|
boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream()
|
||||||
|
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())
|
||||||
|
|| Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
|
||||||
|
if (stillRestricted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String oldStatus = user.getAccountStatus();
|
||||||
|
if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus)
|
||||||
|
&& !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
userMapper.updateById(user);
|
||||||
|
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。
|
||||||
|
saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId,
|
||||||
|
"{\"accountStatus\":\"" + oldStatus + "\"}",
|
||||||
|
"{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}");
|
||||||
|
}
|
||||||
|
|
||||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||||
String resourceId, String beforeData, String afterData) {
|
String resourceId, String beforeData, String afterData) {
|
||||||
AuditLog auditLog = new AuditLog();
|
AuditLog auditLog = new AuditLog();
|
||||||
@@ -378,6 +418,10 @@ public class AppealService {
|
|||||||
auditLog.setAction(action);
|
auditLog.setAction(action);
|
||||||
auditLog.setResourceType(resourceType);
|
auditLog.setResourceType(resourceType);
|
||||||
auditLog.setResourceId(resourceId);
|
auditLog.setResourceId(resourceId);
|
||||||
|
// 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意
|
||||||
|
// audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。
|
||||||
|
auditLog.setBeforeData(beforeData);
|
||||||
|
auditLog.setAfterData(afterData);
|
||||||
auditLogMapper.insert(auditLog);
|
auditLogMapper.insert(auditLog);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,20 @@ public class AuditLog extends BaseEntity {
|
|||||||
private String afterData;
|
private String afterData;
|
||||||
private String ip;
|
private String ip;
|
||||||
private String userAgent;
|
private String userAgent;
|
||||||
|
|
||||||
|
// ==================== ZC-MM-006 / T01 扩展审计字段(V055,全可空) ====================
|
||||||
|
|
||||||
|
/** 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view。 */
|
||||||
|
private String purposeCode;
|
||||||
|
/** 关联的 matchmaker_assignments.id(非分配场景为 null)。 */
|
||||||
|
private String serviceAssignmentId;
|
||||||
|
/** 落库时操作者权限快照(permissions 逗号拼接,截断 128)。 */
|
||||||
|
private String actorRoleSnapshot;
|
||||||
|
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
|
||||||
|
private String requestTraceId;
|
||||||
|
|
||||||
|
// ==================== V057 / 运行时实测修复:C 端用户主体维度 ====================
|
||||||
|
|
||||||
|
/** 用户主体 ID(C 端触发的审计写此列且 operator_id=null;两者恰有一者非空)。 */
|
||||||
|
private String subjectUserId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.danyang.match.platform.audit.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒绝类审计的独立事务写入器(运行时实测修复)。
|
||||||
|
*
|
||||||
|
* <p>背景:拒绝审计(DATA_SCOPE_DENIED / AUTHORIZATION_DENIED)原先与业务方法同一事务——
|
||||||
|
* 拒绝时抛异常必然触发业务事务回滚,审计 insert(已执行 Updates:1)随之被回滚吞掉:
|
||||||
|
* 生产全量 deny 审计零落库。H2 集成测试因测试类 @Transactional(测试事务内可见未提交行)
|
||||||
|
* 结构性假绿。运行时实测(outsider consent → deny 行 0 条)抓到。
|
||||||
|
*
|
||||||
|
* <p>语义:REQUIRES_NEW 独立事务提交——外层业务事务回滚与否均不影响拒绝审计落库;
|
||||||
|
* 内层失败仍由调用方 catch 记日志后照常拒绝(审计失败不阻断拒绝响应)。
|
||||||
|
* 成功路径审计(SENSITIVE_ACCESS / 业务动作)保持与业务同事务(业务回滚审计一并消失,语义正确),
|
||||||
|
* 不走本写入器。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditDenyWriter {
|
||||||
|
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void insertIndependent(AuditLog auditLog) {
|
||||||
|
auditLogMapper.insert(auditLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@ public class AuditLogService {
|
|||||||
m.put("ip", log.getIp());
|
m.put("ip", log.getIp());
|
||||||
m.put("userAgent", log.getUserAgent());
|
m.put("userAgent", log.getUserAgent());
|
||||||
m.put("createdAt", log.getCreatedAt());
|
m.put("createdAt", log.getCreatedAt());
|
||||||
|
// ZC-MM-006 / T01:扩展审计字段输出(可空键,存量行输出 null,读取兼容)
|
||||||
|
m.put("purposeCode", log.getPurposeCode());
|
||||||
|
m.put("serviceAssignmentId", log.getServiceAssignmentId());
|
||||||
|
m.put("actorRoleSnapshot", log.getActorRoleSnapshot());
|
||||||
|
m.put("requestTraceId", log.getRequestTraceId());
|
||||||
return m;
|
return m;
|
||||||
}).collect(java.util.stream.Collectors.toList());
|
}).collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
@@ -73,11 +78,48 @@ public class AuditLogService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
|
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
|
||||||
|
recordSensitiveAccess(operatorId, resourceType, resourceId, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T01 扩展版敏感访问记录:附加目的码与关联分配 ID。
|
||||||
|
* 旧 3 参签名委托本方法(purposeCode/serviceAssignmentId 为 null),存量调用兼容。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId,
|
||||||
|
String purposeCode, String serviceAssignmentId) {
|
||||||
AuditLog auditLog = new AuditLog();
|
AuditLog auditLog = new AuditLog();
|
||||||
auditLog.setOperatorId(operatorId);
|
auditLog.setOperatorId(operatorId);
|
||||||
auditLog.setAction("SENSITIVE_ACCESS");
|
auditLog.setAction("SENSITIVE_ACCESS");
|
||||||
auditLog.setResourceType(resourceType);
|
auditLog.setResourceType(resourceType);
|
||||||
auditLog.setResourceId(resourceId);
|
auditLog.setResourceId(resourceId);
|
||||||
|
auditLog.setPurposeCode(purposeCode);
|
||||||
|
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||||
|
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||||
|
HttpServletRequest req = attrs.getRequest();
|
||||||
|
auditLog.setIp(req.getRemoteAddr());
|
||||||
|
String ua = req.getHeader("User-Agent");
|
||||||
|
auditLog.setUserAgent(ua != null ? (ua.length() > 512 ? ua.substring(0, 512) : ua) : null);
|
||||||
|
}
|
||||||
|
auditLogMapper.insert(auditLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V057 / 运行时实测修复:C 端用户主体的审计入口。
|
||||||
|
* operator 主体照旧走 recordSensitiveAccess/saveAuditLog(operator_id 维度);
|
||||||
|
* user 主体(consent/decline 等用户动作)写 subject_user_id 且 operator_id=null——
|
||||||
|
* 否则 userId 撞 audit_logs_operator_id_fkey(→operators) 必 FK violation(H2 无 FK 假绿)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void recordUserAction(String userId, String action, String resourceType, String resourceId,
|
||||||
|
String purposeCode, String afterData) {
|
||||||
|
AuditLog auditLog = new AuditLog();
|
||||||
|
auditLog.setSubjectUserId(userId);
|
||||||
|
auditLog.setAction(action);
|
||||||
|
auditLog.setResourceType(resourceType);
|
||||||
|
auditLog.setResourceId(resourceId);
|
||||||
|
auditLog.setAfterData(afterData);
|
||||||
|
auditLog.setPurposeCode(purposeCode);
|
||||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||||
HttpServletRequest req = attrs.getRequest();
|
HttpServletRequest req = attrs.getRequest();
|
||||||
auditLog.setIp(req.getRemoteAddr());
|
auditLog.setIp(req.getRemoteAddr());
|
||||||
|
|||||||
+48
-2
@@ -9,6 +9,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import com.danyang.match.platform.user.dto.PhoneVerificationRequest;
|
||||||
|
import com.danyang.match.platform.user.dto.UserRegistrationRequest;
|
||||||
|
import com.danyang.match.platform.user.service.PhoneVerificationService;
|
||||||
|
import com.danyang.match.platform.user.service.UserRegistrationService;
|
||||||
|
import com.danyang.match.platform.auth.service.PasswordAuthService;
|
||||||
|
import com.danyang.match.platform.user.dto.PasswordLoginRequest;
|
||||||
|
import com.danyang.match.platform.user.dto.InitialPasswordChangeRequest;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -18,6 +26,43 @@ import java.util.Map;
|
|||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
|
private final UserRegistrationService userRegistrationService;
|
||||||
|
private final PasswordAuthService passwordAuthService;
|
||||||
|
|
||||||
|
@PostMapping("/password/login")
|
||||||
|
public ApiResponse<Map<String, Object>> passwordLogin(@Valid @RequestBody PasswordLoginRequest request) {
|
||||||
|
return ApiResponse.ok(passwordAuthService.login(request.phone(), request.password()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/password/change-initial")
|
||||||
|
@PreAuthorize("hasRole('USER')")
|
||||||
|
public ApiResponse<Void> changeInitialPassword(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody InitialPasswordChangeRequest request) {
|
||||||
|
passwordAuthService.changeInitialPassword(principal.id(), request.oldPassword(), request.newPassword());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ApiResponse<Map<String, Object>> register(@Valid @RequestBody UserRegistrationRequest request) {
|
||||||
|
return ApiResponse.ok(userRegistrationService.register(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/phone/send-code")
|
||||||
|
public ApiResponse<Void> sendPhoneCode(@RequestBody Map<String, String> body) {
|
||||||
|
String phone = body.get("phone");
|
||||||
|
if (phone == null || !phone.matches("^1\\d{10}$")) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号格式不正确");
|
||||||
|
}
|
||||||
|
phoneVerificationService.sendCode(phone);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/phone/verify")
|
||||||
|
public ApiResponse<Map<String, String>> verifyPhone(@Valid @RequestBody PhoneVerificationRequest request) {
|
||||||
|
return ApiResponse.ok(Map.of("verificationTransaction",
|
||||||
|
phoneVerificationService.verify(request.phone(), request.code())));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信登录
|
* 微信登录
|
||||||
@@ -44,10 +89,11 @@ public class AuthController {
|
|||||||
public ApiResponse<Void> bindPhone(@AuthenticationPrincipal UserPrincipal principal,
|
public ApiResponse<Void> bindPhone(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
String phone = body.get("phone");
|
String phone = body.get("phone");
|
||||||
if (phone == null || phone.isBlank()) {
|
String transaction = body.get("verificationTransaction");
|
||||||
|
if (phone == null || phone.isBlank() || transaction == null || transaction.isBlank()) {
|
||||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
|
||||||
}
|
}
|
||||||
authService.bindPhone(principal.id(), phone);
|
authService.bindPhone(principal.id(), phone, transaction);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.danyang.match.platform.auth.provider;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile("!test & !demo")
|
||||||
|
public class ConfiguredSmsVerificationProvider implements SmsVerificationProvider {
|
||||||
|
private final String provider;
|
||||||
|
|
||||||
|
public ConfiguredSmsVerificationProvider(
|
||||||
|
@Value("${sms.provider:}") String provider) {
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendCode(String phone, String code) {
|
||||||
|
if (provider == null || provider.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "短信服务未配置");
|
||||||
|
}
|
||||||
|
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "短信服务适配器尚未配置完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.danyang.match.platform.auth.provider;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile({"test", "demo"})
|
||||||
|
public class MockSmsVerificationProvider implements SmsVerificationProvider {
|
||||||
|
private final Map<String, String> sentCodes = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendCode(String phone, String code) {
|
||||||
|
sentCodes.put(phone, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String lastCode(String phone) {
|
||||||
|
return sentCodes.get(phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-1
@@ -29,6 +29,10 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
|||||||
@Value("${wechat.app-id:dev-app-id}")
|
@Value("${wechat.app-id:dev-app-id}")
|
||||||
private String appId;
|
private String appId;
|
||||||
|
|
||||||
|
/** 演示环境可固定到预置用户;生产真实微信登录不使用此 Provider。 */
|
||||||
|
@Value("${wechat.mock.openid:}")
|
||||||
|
private String configuredOpenid;
|
||||||
|
|
||||||
// 模拟 openid 映射
|
// 模拟 openid 映射
|
||||||
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
|
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -36,8 +40,21 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
|||||||
public Map<String, String> getCodeSession(String code) {
|
public Map<String, String> getCodeSession(String code) {
|
||||||
log.debug("[Mock] 微信登录 code: {}", code);
|
log.debug("[Mock] 微信登录 code: {}", code);
|
||||||
|
|
||||||
|
// 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。
|
||||||
|
// 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。
|
||||||
|
if (code != null && code.startsWith("demo_openid_")) {
|
||||||
|
return Map.of(
|
||||||
|
"openid", code,
|
||||||
|
"unionid", "mock_unionid_" + code,
|
||||||
|
"session_key", "mock_session_key",
|
||||||
|
"appid", appId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 模拟:相同 code 返回相同 openid
|
// 模拟:相同 code 返回相同 openid
|
||||||
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
|
String openid = configuredOpenid != null && !configuredOpenid.isBlank()
|
||||||
|
? configuredOpenid
|
||||||
|
: codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"openid", openid,
|
"openid", openid,
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package com.danyang.match.platform.auth.provider;
|
||||||
|
|
||||||
|
public interface SmsVerificationProvider {
|
||||||
|
void sendCode(String phone, String code);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import com.danyang.match.platform.review.entity.ReviewTask;
|
|||||||
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
|
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
|
||||||
import com.danyang.match.platform.user.entity.User;
|
import com.danyang.match.platform.user.entity.User;
|
||||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.user.service.PhoneVerificationService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -47,6 +48,7 @@ public class AuthService {
|
|||||||
private final CapabilityService capabilityService;
|
private final CapabilityService capabilityService;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
private final ReviewTaskMapper reviewTaskMapper;
|
private final ReviewTaskMapper reviewTaskMapper;
|
||||||
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
|
|
||||||
@Value("${jwt.access-token-expiration-ms:7200000}")
|
@Value("${jwt.access-token-expiration-ms:7200000}")
|
||||||
private long accessTokenExpireMs;
|
private long accessTokenExpireMs;
|
||||||
@@ -111,8 +113,8 @@ public class AuthService {
|
|||||||
* 绑定手机号
|
* 绑定手机号
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void bindPhone(String userId, String phoneCode) {
|
public void bindPhone(String userId, String phoneCode, String verificationTransaction) {
|
||||||
// TODO: Phase 03 对接真实短信验证码
|
phoneVerificationService.consumeTransaction(verificationTransaction, phoneCode);
|
||||||
User user = userMapper.findById(userId)
|
User user = userMapper.findById(userId)
|
||||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
|
||||||
@@ -124,6 +126,7 @@ public class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
user.setPhone(phoneCode);
|
user.setPhone(phoneCode);
|
||||||
|
user.setPhoneVerificationStatus("verified");
|
||||||
userMapper.updateById(user);
|
userMapper.updateById(user);
|
||||||
|
|
||||||
saveAuditLog(userId, null, "PHONE_BIND", "user", userId, null, null);
|
saveAuditLog(userId, null, "PHONE_BIND", "user", userId, null, null);
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ public class OperatorAuthService {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// 签发 Token
|
// 签发 Token
|
||||||
|
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
|
||||||
|
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
|
||||||
|
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
|
||||||
Map<String, Object> claims = new HashMap<>();
|
Map<String, Object> claims = new HashMap<>();
|
||||||
claims.put("operatorId", operator.getId());
|
claims.put("operatorId", operator.getId());
|
||||||
claims.put("organizationId", operator.getOrganizationId());
|
claims.put("organizationId", operator.getOrganizationId());
|
||||||
@@ -121,6 +124,7 @@ public class OperatorAuthService {
|
|||||||
result.put("accessToken", accessToken);
|
result.put("accessToken", accessToken);
|
||||||
result.put("refreshToken", refreshToken);
|
result.put("refreshToken", refreshToken);
|
||||||
result.put("operatorId", operator.getId());
|
result.put("operatorId", operator.getId());
|
||||||
|
result.put("operatorDisplayId", operator.getDisplayId());
|
||||||
result.put("name", operator.getName());
|
result.put("name", operator.getName());
|
||||||
result.put("username", operator.getUsername());
|
result.put("username", operator.getUsername());
|
||||||
result.put("organizationId", operator.getOrganizationId());
|
result.put("organizationId", operator.getOrganizationId());
|
||||||
@@ -154,6 +158,7 @@ public class OperatorAuthService {
|
|||||||
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("operatorId", operator.getId());
|
result.put("operatorId", operator.getId());
|
||||||
|
result.put("operatorDisplayId", operator.getDisplayId());
|
||||||
result.put("name", operator.getName());
|
result.put("name", operator.getName());
|
||||||
result.put("username", operator.getUsername());
|
result.put("username", operator.getUsername());
|
||||||
result.put("username", operator.getUsername());
|
result.put("username", operator.getUsername());
|
||||||
@@ -201,6 +206,7 @@ public class OperatorAuthService {
|
|||||||
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||||
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
|
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
|
||||||
result.put("operatorId", operatorInfo.get("operatorId"));
|
result.put("operatorId", operatorInfo.get("operatorId"));
|
||||||
|
result.put("operatorDisplayId", operatorInfo.get("operatorDisplayId"));
|
||||||
result.put("name", operatorInfo.get("name"));
|
result.put("name", operatorInfo.get("name"));
|
||||||
result.put("username", operatorInfo.get("username"));
|
result.put("username", operatorInfo.get("username"));
|
||||||
result.put("organizationId", operatorInfo.get("organizationId"));
|
result.put("organizationId", operatorInfo.get("organizationId"));
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package com.danyang.match.platform.auth.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||||
|
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.exception.GlobalExceptionHandler.UnauthorizedException;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserCredentialMapper;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.account.entity.UserCredential;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PasswordAuthService {
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final UserCredentialMapper credentialMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenService jwtTokenService;
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> login(String phone, String password) {
|
||||||
|
User user = userMapper.findByPhone(phone)
|
||||||
|
.orElseThrow(() -> new UnauthorizedException("手机号或密码错误"));
|
||||||
|
if (!"verified".equals(user.getPhoneVerificationStatus())
|
||||||
|
|| !Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())) {
|
||||||
|
throw new UnauthorizedException("账号尚未完成手机号核验或已停用");
|
||||||
|
}
|
||||||
|
UserCredential credential = credentialMapper.findByUserId(user.getId())
|
||||||
|
.orElseThrow(() -> new UnauthorizedException("手机号或密码错误"));
|
||||||
|
if (credential.getRevokedAt() != null || !passwordEncoder.matches(password, credential.getPasswordHash())) {
|
||||||
|
throw new UnauthorizedException("手机号或密码错误");
|
||||||
|
}
|
||||||
|
credential.setFirstUsedAt(credential.getFirstUsedAt() == null ? Instant.now() : credential.getFirstUsedAt());
|
||||||
|
credentialMapper.updateById(credential);
|
||||||
|
String access = jwtTokenService.generateAccessToken(user.getId(), null);
|
||||||
|
String refresh = jwtTokenService.generateRefreshToken(user.getId());
|
||||||
|
redisTemplate.opsForValue().set("auth:refresh_token:" + user.getId(), refresh,
|
||||||
|
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("accessToken", access);
|
||||||
|
result.put("refreshToken", refresh);
|
||||||
|
result.put("userId", user.getId());
|
||||||
|
result.put("mustChangePassword", Boolean.TRUE.equals(credential.getMustChangePassword()));
|
||||||
|
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void changeInitialPassword(String userId, String oldPassword, String newPassword) {
|
||||||
|
if (newPassword == null || newPassword.length() < 8) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "新密码至少 8 位");
|
||||||
|
}
|
||||||
|
UserCredential credential = credentialMapper.findByUserId(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "凭据不存在"));
|
||||||
|
if (!Boolean.TRUE.equals(credential.getMustChangePassword())
|
||||||
|
|| !passwordEncoder.matches(oldPassword, credential.getPasswordHash())) {
|
||||||
|
throw new UnauthorizedException("初始密码不正确或已完成修改");
|
||||||
|
}
|
||||||
|
credential.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
|
credential.setCredentialVersion((credential.getCredentialVersion() == null ? 0 : credential.getCredentialVersion()) + 1);
|
||||||
|
credential.setMustChangePassword(false);
|
||||||
|
credential.setLastChangedAt(Instant.now());
|
||||||
|
credentialMapper.updateById(credential);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-5
@@ -1,6 +1,7 @@
|
|||||||
package com.danyang.match.platform.capability.service;
|
package com.danyang.match.platform.capability.service;
|
||||||
|
|
||||||
import com.danyang.match.platform.home.dto.HomeDTO;
|
import com.danyang.match.platform.home.dto.HomeDTO;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||||
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;
|
||||||
@@ -32,6 +33,7 @@ public class CapabilityService {
|
|||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
private final UserIdentityMapper userIdentityMapper;
|
private final UserIdentityMapper userIdentityMapper;
|
||||||
private final LocalEligibilityMapper localEligibilityMapper;
|
private final LocalEligibilityMapper localEligibilityMapper;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户的能力开关集合
|
* 获取用户的能力开关集合
|
||||||
@@ -45,7 +47,8 @@ public class CapabilityService {
|
|||||||
|
|
||||||
boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())
|
boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())
|
||||||
&& isIdentityVerified(userId)
|
&& isIdentityVerified(userId)
|
||||||
&& isProfileApproved(user);
|
&& isProfileApproved(user)
|
||||||
|
&& serviceConsentService.isActive(userId);
|
||||||
|
|
||||||
HomeDTO.Capabilities caps = new HomeDTO.Capabilities();
|
HomeDTO.Capabilities caps = new HomeDTO.Capabilities();
|
||||||
caps.setCanReceiveRecommendations(baseEligible
|
caps.setCanReceiveRecommendations(baseEligible
|
||||||
@@ -77,10 +80,11 @@ public class CapabilityService {
|
|||||||
/** 资料是否审核通过 */
|
/** 资料是否审核通过 */
|
||||||
public boolean isProfileApproved(User user) {
|
public boolean isProfileApproved(User user) {
|
||||||
if (user == null) return false;
|
if (user == null) return false;
|
||||||
String status = userProfileMapper.findByUserId(user.getId())
|
return userProfileMapper.findByUserId(user.getId()).map(profile ->
|
||||||
.map(UserProfile::getProfileStatus).orElse(null);
|
Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||||
return Constants.PROFILE_STATUS_APPROVED.equals(status)
|
&& (profile.getPublishedProfileRevision() == null
|
||||||
|| Constants.PROFILE_STATUS_COMPLETED.equals(status);
|
|| java.util.Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision()))
|
||||||
|
).orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否在认真寻找中 */
|
/** 是否在认真寻找中 */
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.danyang.match.platform.chat.config;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品当前不提供用户之间的即时聊天。
|
||||||
|
*
|
||||||
|
* <p>在 HTTP 和 WebSocket 握手进入 Controller/Handler 前统一拒绝,
|
||||||
|
* 防止只隐藏小程序入口却留下可调用的通信通道。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class ChatDisabledFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
String context = request.getContextPath();
|
||||||
|
if (context != null && !context.isEmpty() && path.startsWith(context)) {
|
||||||
|
path = path.substring(context.length());
|
||||||
|
}
|
||||||
|
if (path.equals("/conversations") || path.startsWith("/conversations/")
|
||||||
|
|| path.equals("/ws/chat") || path.startsWith("/ws/chat/")) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_GONE);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write(
|
||||||
|
"{\"success\":false,\"code\":\"CHAT_DISABLED\","
|
||||||
|
+ "\"message\":\"平台未提供用户之间的即时聊天功能\",\"data\":null}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conversation 端点
|
* Conversation 端点
|
||||||
@@ -35,6 +36,8 @@ public class ConversationController {
|
|||||||
|
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
private final MatchMapper matchMapper;
|
private final MatchMapper matchMapper;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 Match 创建/获取 Conversation
|
* 从 Match 创建/获取 Conversation
|
||||||
@@ -47,6 +50,7 @@ public class ConversationController {
|
|||||||
public ApiResponse<ConversationDTO> fromMatch(@PathVariable String matchId,
|
public ApiResponse<ConversationDTO> fromMatch(@PathVariable String matchId,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
Match match = matchMapper.findById(matchId).orElse(null);
|
Match match = matchMapper.findById(matchId).orElse(null);
|
||||||
if (match == null) return ApiResponse.notFound();
|
if (match == null) return ApiResponse.notFound();
|
||||||
// 权限:必须是 Match 参与者
|
// 权限:必须是 Match 参与者
|
||||||
@@ -68,6 +72,7 @@ public class ConversationController {
|
|||||||
@RequestParam(defaultValue = "20") int limit,
|
@RequestParam(defaultValue = "20") int limit,
|
||||||
@RequestParam(defaultValue = "0") int offset) {
|
@RequestParam(defaultValue = "0") int offset) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 游标契约(小程序):cursor + pageSize;偏移契约:offset + limit。两者兼容。
|
// 游标契约(小程序):cursor + pageSize;偏移契约:offset + limit。两者兼容。
|
||||||
int off = cursor != null && !cursor.isBlank() ? parseIntOrDefault(cursor, 0) : Math.max(offset, 0);
|
int off = cursor != null && !cursor.isBlank() ? parseIntOrDefault(cursor, 0) : Math.max(offset, 0);
|
||||||
int size = Math.min(Math.max(pageSize != null ? pageSize : limit, 1), MAX_PAGE_SIZE);
|
int size = Math.min(Math.max(pageSize != null ? pageSize : limit, 1), MAX_PAGE_SIZE);
|
||||||
@@ -92,6 +97,7 @@ public class ConversationController {
|
|||||||
public ApiResponse<ConversationDTO> detail(@PathVariable String id,
|
public ApiResponse<ConversationDTO> detail(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 对象级鉴权:非会话成员一律 404 —— 否则可凭 conversationId 读到对方昵称、最后一条消息
|
// 对象级鉴权:非会话成员一律 404 —— 否则可凭 conversationId 读到对方昵称、最后一条消息
|
||||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||||
ConversationDTO dto = chatService.getConversation(principal.id(), id);
|
ConversationDTO dto = chatService.getConversation(principal.id(), id);
|
||||||
@@ -107,6 +113,7 @@ public class ConversationController {
|
|||||||
public ApiResponse<Map<String, Object>> unread(@PathVariable String id,
|
public ApiResponse<Map<String, Object>> unread(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 对象级鉴权:非成员不得读取他人会话未读
|
// 对象级鉴权:非成员不得读取他人会话未读
|
||||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||||
return ApiResponse.ok(Map.of("unreadCount", chatService.getUnreadCount(principal.id(), id)));
|
return ApiResponse.ok(Map.of("unreadCount", chatService.getUnreadCount(principal.id(), id)));
|
||||||
|
|||||||
+6
@@ -14,6 +14,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message 端点(HTTP REST + 幂等)
|
* Message 端点(HTTP REST + 幂等)
|
||||||
@@ -36,12 +37,15 @@ public class MessageController {
|
|||||||
private static final int MAX_PAGE_SIZE = 100;
|
private static final int MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@PostMapping("/{id}/messages")
|
@PostMapping("/{id}/messages")
|
||||||
public ApiResponse<MessageResponse> send(@PathVariable String id,
|
public ApiResponse<MessageResponse> send(@PathVariable String id,
|
||||||
@RequestBody SendRequest req,
|
@RequestBody SendRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 幂等键:优先 clientMessageId,兼容小程序早期约定的 messageId
|
// 幂等键:优先 clientMessageId,兼容小程序早期约定的 messageId
|
||||||
String idempotentKey = req.getClientMessageId() != null ? req.getClientMessageId() : req.getMessageId();
|
String idempotentKey = req.getClientMessageId() != null ? req.getClientMessageId() : req.getMessageId();
|
||||||
Message msg = chatService.sendText(id, principal.id(), req.getContent(), idempotentKey);
|
Message msg = chatService.sendText(id, principal.id(), req.getContent(), idempotentKey);
|
||||||
@@ -59,6 +63,7 @@ public class MessageController {
|
|||||||
@RequestParam(defaultValue = "0") int offset,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 对象级鉴权:非会话成员一律 404(不区分"不存在"与"无权",避免探测他人会话)
|
// 对象级鉴权:非会话成员一律 404(不区分"不存在"与"无权",避免探测他人会话)
|
||||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||||
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||||
@@ -84,6 +89,7 @@ public class MessageController {
|
|||||||
@RequestBody Map<String, String> body,
|
@RequestBody Map<String, String> body,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 对象级鉴权:非成员不得改写他人会话的已读状态
|
// 对象级鉴权:非成员不得改写他人会话的已读状态
|
||||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||||
String lastReadId = body.get("lastReadMessageId");
|
String lastReadId = body.get("lastReadMessageId");
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.danyang.match.platform.contactauth.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||||
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受控联系授权控制器(C 端用户,ZC-MM-003 / B1 立项)。
|
||||||
|
*
|
||||||
|
* <p>端点(基路径 {@code /api/v1/contact-authorizations},USER 角色兜底):
|
||||||
|
* <ul>
|
||||||
|
* <li>GET {@code /my} —— 我的授权列表 + 待同意队列;</li>
|
||||||
|
* <li>POST {@code /{id}/consent} —— 用户<b>单独明示同意</b>(独立端点,
|
||||||
|
* 非任何其他操作的捆绑步骤、无预勾选/预同意参数);</li>
|
||||||
|
* <li>POST {@code /{id}/decline} —— 用户拒绝。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>归属校验(userId.equals)在 Service 层 {@code requireOwnedAuthorization} 承担;
|
||||||
|
* 不碰 SecurityConfig(路径落入 {@code /** → hasRole("USER")} 兜底规则)。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/contact-authorizations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContactAuthorizationUserController {
|
||||||
|
|
||||||
|
private final ContactAuthorizationService contactAuthorizationService;
|
||||||
|
|
||||||
|
/** 我的授权列表 + 待同意队列。 */
|
||||||
|
@GetMapping("/my")
|
||||||
|
public ApiResponse<Map<String, Object>> my(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.myAuthorizationsForUser(principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单独明示同意(独立端点,无预同意参数)。 */
|
||||||
|
@PostMapping("/{id}/consent")
|
||||||
|
public ApiResponse<Object> consent(@PathVariable String id,
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.consent(id, principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拒绝授权请求。 */
|
||||||
|
@PostMapping("/{id}/decline")
|
||||||
|
public ApiResponse<Object> decline(@PathVariable String id,
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.decline(id, principal.id()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -18,6 +18,7 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 婚恋咨询 / 婚前辅导 端点(Phase 13A)
|
* 婚恋咨询 / 婚前辅导 端点(Phase 13A)
|
||||||
@@ -56,6 +57,8 @@ public class CounselingController {
|
|||||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
|
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
|
||||||
|
|
||||||
private final CounselingService counselingService;
|
private final CounselingService counselingService;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
// ==================== 用户端 ====================
|
// ==================== 用户端 ====================
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ public class CounselingController {
|
|||||||
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
|
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(counselingService.apply(
|
return ApiResponse.ok(counselingService.apply(
|
||||||
principal.id(), // 修复:userId 来自认证主体,不取请求体
|
principal.id(), // 修复:userId 来自认证主体,不取请求体
|
||||||
req.serviceType,
|
req.serviceType,
|
||||||
@@ -79,6 +83,7 @@ public class CounselingController {
|
|||||||
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
List<CounselingServiceRecord> list = counselingService.myServices(principal.id(), limit);
|
List<CounselingServiceRecord> list = counselingService.myServices(principal.id(), limit);
|
||||||
// 列表场景剥离正文与 followups(INV-013)
|
// 列表场景剥离正文与 followups(INV-013)
|
||||||
list.forEach(CounselingService::sanitize);
|
list.forEach(CounselingService::sanitize);
|
||||||
@@ -98,6 +103,7 @@ public class CounselingController {
|
|||||||
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
|
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 非法 UUID 直接 400。否则参数会打到数据库,PostgreSQL 抛
|
// 非法 UUID 直接 400。否则参数会打到数据库,PostgreSQL 抛
|
||||||
// `invalid input syntax for type uuid` → 500 且泄漏数据库异常。
|
// `invalid input syntax for type uuid` → 500 且泄漏数据库异常。
|
||||||
if (!UUID_PATTERN.matcher(id).matches()) {
|
if (!UUID_PATTERN.matcher(id).matches()) {
|
||||||
@@ -114,6 +120,7 @@ public class CounselingController {
|
|||||||
@RequestBody(required = false) CancelRequest req,
|
@RequestBody(required = false) CancelRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(counselingService.cancel(id, principal.id(),
|
return ApiResponse.ok(counselingService.cancel(id, principal.id(),
|
||||||
req != null ? req.reason : null));
|
req != null ? req.reason : null));
|
||||||
}
|
}
|
||||||
@@ -124,6 +131,7 @@ public class CounselingController {
|
|||||||
public ApiResponse<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
|
public ApiResponse<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(counselingService.confirmCompanion(id, principal.id()));
|
return ApiResponse.ok(counselingService.confirmCompanion(id, principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
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.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 找对象控制器
|
* 找对象控制器
|
||||||
@@ -21,6 +22,7 @@ public class DiscoverController {
|
|||||||
|
|
||||||
private final DiscoverService discoverService;
|
private final DiscoverService discoverService;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 搜索用户(临时筛选)
|
* 搜索用户(临时筛选)
|
||||||
@@ -38,6 +40,7 @@ public class DiscoverController {
|
|||||||
@RequestParam(defaultValue = "20") int pageSize) {
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
Map<String, Object> myProfile = getMyProfile(userId);
|
Map<String, Object> myProfile = getMyProfile(userId);
|
||||||
String myGender = (String) myProfile.get("gender");
|
String myGender = (String) myProfile.get("gender");
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ public class DiscoverController {
|
|||||||
public ApiResponse<Map<String, Object>> getProfile(
|
public ApiResponse<Map<String, Object>> getProfile(
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String userId) {
|
@PathVariable String userId) {
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
Map<String, Object> profile = discoverService.getPublicProfile(principal.id(), userId);
|
Map<String, Object> profile = discoverService.getPublicProfile(principal.id(), userId);
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在");
|
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在");
|
||||||
|
|||||||
+8
-1
@@ -40,6 +40,8 @@ public class DiscoverService {
|
|||||||
LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
|
||||||
wrapper.ne(UserProfile::getGender, myGender)
|
wrapper.ne(UserProfile::getGender, myGender)
|
||||||
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
|
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
|
||||||
|
.isNotNull(UserProfile::getPublishedProfileRevision)
|
||||||
|
.apply("profile_revision = published_profile_revision")
|
||||||
.isNull(UserProfile::getDeletedAt);
|
.isNull(UserProfile::getDeletedAt);
|
||||||
|
|
||||||
// INV-004 搜索不可见:排除自己 + 双向拉黑者。
|
// INV-004 搜索不可见:排除自己 + 双向拉黑者。
|
||||||
@@ -117,6 +119,11 @@ public class DiscoverService {
|
|||||||
}
|
}
|
||||||
return userProfileMapper.findByUserId(userId)
|
return userProfileMapper.findByUserId(userId)
|
||||||
.map(profile -> {
|
.map(profile -> {
|
||||||
|
if (!Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||||
|
|| profile.getPublishedProfileRevision() == null
|
||||||
|
|| !Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Map<String, Object> map = new HashMap<>();
|
Map<String, Object> map = new HashMap<>();
|
||||||
map.put("userId", profile.getUserId());
|
map.put("userId", profile.getUserId());
|
||||||
map.put("displayName", profile.getDisplayName());
|
map.put("displayName", profile.getDisplayName());
|
||||||
@@ -135,7 +142,7 @@ public class DiscoverService {
|
|||||||
map.put("longTermDanyang", profile.getLongTermDanyang());
|
map.put("longTermDanyang", profile.getLongTermDanyang());
|
||||||
map.put("futureDanyang", profile.getFutureDanyang());
|
map.put("futureDanyang", profile.getFutureDanyang());
|
||||||
map.put("verified", true);
|
map.put("verified", true);
|
||||||
map.put("reviewed", Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus()));
|
map.put("reviewed", true);
|
||||||
return map;
|
return map;
|
||||||
})
|
})
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|||||||
+8
-23
@@ -1,7 +1,5 @@
|
|||||||
package com.danyang.match.platform.match.controller;
|
package com.danyang.match.platform.match.controller;
|
||||||
|
|
||||||
import com.danyang.match.platform.chat.entity.Conversation;
|
|
||||||
import com.danyang.match.platform.chat.service.ChatService;
|
|
||||||
import com.danyang.match.platform.match.service.RelationService;
|
import com.danyang.match.platform.match.service.RelationService;
|
||||||
import com.danyang.match.platform.match.service.RelationService.LikeResult;
|
import com.danyang.match.platform.match.service.RelationService.LikeResult;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
@@ -11,9 +9,9 @@ import com.danyang.match.platform.shared.idempotency.Idempotent;
|
|||||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like 端点(canonical 路径,2026-09-14 版)
|
* Like 端点(canonical 路径,2026-09-14 版)
|
||||||
@@ -22,29 +20,29 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
|
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
|
||||||
*
|
*
|
||||||
* 核心规则:INV-005 未 Mutual 不可聊天
|
* 核心规则:INV-005 未 Mutual 不可聊天
|
||||||
* 响应契约对齐 miniapp/types/index.d.ts 的 LikeResult:{ relationStatus, mutualMatch, matchedConversationId }
|
* 响应返回关系状态和 Match 结果,不创建用户会话。
|
||||||
*
|
*
|
||||||
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
|
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/likes")
|
@RequestMapping("/likes")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class LikeController {
|
public class LikeController {
|
||||||
|
|
||||||
private final RelationService relationService;
|
private final RelationService relationService;
|
||||||
private final ChatService chatService;
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@Idempotent
|
@Idempotent
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ApiResponse<LikeResponse> like(@RequestBody LikeRequest req,
|
public ApiResponse<LikeResponse> like(@RequestBody LikeRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
||||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
||||||
}
|
}
|
||||||
LikeResult result = relationService.like(principal.id(), req.targetUserId);
|
LikeResult result = relationService.like(principal.id(), req.targetUserId);
|
||||||
return ApiResponse.ok(LikeResponse.from(result, ensureConversation(result, principal.id(), req.targetUserId)));
|
return ApiResponse.ok(LikeResponse.from(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +53,7 @@ public class LikeController {
|
|||||||
public ApiResponse<Void> unlike(@PathVariable String targetUserId,
|
public ApiResponse<Void> unlike(@PathVariable String targetUserId,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
relationService.unlike(principal.id(), targetUserId);
|
relationService.unlike(principal.id(), targetUserId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
@@ -62,20 +61,8 @@ public class LikeController {
|
|||||||
/**
|
/**
|
||||||
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
|
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
|
||||||
*
|
*
|
||||||
* - getOrCreateConversation 幂等,不会重复建会话
|
|
||||||
* - 建会话失败不应让 Like 整体失败(Like 已落库),故降级为 null,前端仅提示不跳转
|
|
||||||
*/
|
*/
|
||||||
private String ensureConversation(LikeResult result, String userId, String targetUserId) {
|
// 当前产品不提供用户间会话;Match 成功后仅展示结果和公益服务入口。
|
||||||
if (!result.mutual || result.match == null) return null;
|
|
||||||
try {
|
|
||||||
Conversation conv = chatService.getOrCreateConversation(result.match.getId(), userId, targetUserId);
|
|
||||||
return conv.getId();
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn("Mutual 达成但会话创建失败,已降级:user={}, target={}, matchId={}, reason={}",
|
|
||||||
userId, targetUserId, result.match.getId(), e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== DTO ====================
|
// ==================== DTO ====================
|
||||||
|
|
||||||
@@ -89,16 +76,14 @@ public class LikeController {
|
|||||||
public static class LikeResponse {
|
public static class LikeResponse {
|
||||||
private String relationStatus;
|
private String relationStatus;
|
||||||
private boolean mutualMatch;
|
private boolean mutualMatch;
|
||||||
private String matchedConversationId;
|
|
||||||
private String matchId;
|
private String matchId;
|
||||||
|
|
||||||
public static LikeResponse from(LikeResult r, String matchedConversationId) {
|
public static LikeResponse from(LikeResult r) {
|
||||||
LikeResponse resp = new LikeResponse();
|
LikeResponse resp = new LikeResponse();
|
||||||
resp.relationStatus = r.mutual
|
resp.relationStatus = r.mutual
|
||||||
? Constants.RELATION_STATUS_MUTUAL_MATCH
|
? Constants.RELATION_STATUS_MUTUAL_MATCH
|
||||||
: Constants.RELATION_STATUS_LIKED;
|
: Constants.RELATION_STATUS_LIKED;
|
||||||
resp.mutualMatch = r.mutual;
|
resp.mutualMatch = r.mutual;
|
||||||
resp.matchedConversationId = matchedConversationId;
|
|
||||||
resp.matchId = r.match != null ? r.match.getId() : null;
|
resp.matchId = r.match != null ? r.match.getId() : null;
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -22,6 +22,7 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match 端点(canonical 路径,2026-09-14 版)
|
* Match 端点(canonical 路径,2026-09-14 版)
|
||||||
@@ -46,12 +47,16 @@ public class MatchController {
|
|||||||
private final RelationService relationService;
|
private final RelationService relationService;
|
||||||
private final RelationshipStatusService relationshipStatusService;
|
private final RelationshipStatusService relationshipStatusService;
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
List<Match> matches = matchMapper.findActiveByUser(principal.id());
|
List<Match> matches = matchMapper.findActiveByUser(principal.id());
|
||||||
List<MatchResponse> items = matches.stream().map(MatchResponse::from).collect(Collectors.toList());
|
List<MatchResponse> items = matches.stream()
|
||||||
|
.map(match -> MatchResponse.from(match, principal.id()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
// 与全平台列表接口统一信封 { items, nextCursor };当前一次性返回全部,nextCursor 恒为 null
|
// 与全平台列表接口统一信封 { items, nextCursor };当前一次性返回全部,nextCursor 恒为 null
|
||||||
return ApiResponse.ok(CursorPage.all(items));
|
return ApiResponse.ok(CursorPage.all(items));
|
||||||
}
|
}
|
||||||
@@ -60,6 +65,7 @@ public class MatchController {
|
|||||||
public ApiResponse<MatchResponse> get(@PathVariable String id,
|
public ApiResponse<MatchResponse> get(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
Optional<Match> opt = matchMapper.findById(id);
|
Optional<Match> opt = matchMapper.findById(id);
|
||||||
if (opt.isEmpty()) return ApiResponse.notFound();
|
if (opt.isEmpty()) return ApiResponse.notFound();
|
||||||
Match m = opt.get();
|
Match m = opt.get();
|
||||||
@@ -67,7 +73,7 @@ public class MatchController {
|
|||||||
if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) {
|
if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) {
|
||||||
return ApiResponse.forbidden();
|
return ApiResponse.forbidden();
|
||||||
}
|
}
|
||||||
return ApiResponse.ok(MatchResponse.from(m));
|
return ApiResponse.ok(MatchResponse.from(m, principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Idempotent
|
@Idempotent
|
||||||
@@ -75,6 +81,7 @@ public class MatchController {
|
|||||||
public ApiResponse<Void> unmatch(@PathVariable String id,
|
public ApiResponse<Void> unmatch(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
Optional<Match> opt = matchMapper.findById(id);
|
Optional<Match> opt = matchMapper.findById(id);
|
||||||
if (opt.isEmpty()) return ApiResponse.notFound();
|
if (opt.isEmpty()) return ApiResponse.notFound();
|
||||||
Match m = opt.get();
|
Match m = opt.get();
|
||||||
@@ -166,13 +173,13 @@ public class MatchController {
|
|||||||
private java.time.Instant lastActivityAt;
|
private java.time.Instant lastActivityAt;
|
||||||
private Integer episode;
|
private Integer episode;
|
||||||
|
|
||||||
public static MatchResponse from(Match m) {
|
public static MatchResponse from(Match m, String currentUserId) {
|
||||||
MatchResponse r = new MatchResponse();
|
MatchResponse r = new MatchResponse();
|
||||||
r.id = m.getId();
|
r.id = m.getId();
|
||||||
r.source = m.getSource();
|
r.source = m.getSource();
|
||||||
r.sourceId = m.getSourceId();
|
r.sourceId = m.getSourceId();
|
||||||
r.status = m.getStatus();
|
r.status = m.getStatus();
|
||||||
r.peerUserId = m.getUserAId(); // 客户端需要自己判断对方
|
r.peerUserId = currentUserId.equals(m.getUserAId()) ? m.getUserBId() : m.getUserAId();
|
||||||
r.matchedAt = m.getMatchedAt();
|
r.matchedAt = m.getMatchedAt();
|
||||||
r.lastActivityAt = m.getLastActivityAt();
|
r.lastActivityAt = m.getLastActivityAt();
|
||||||
r.episode = m.getEpisode();
|
r.episode = m.getEpisode();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import lombok.Data;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pass 端点(canonical 路径,补齐 2026-09-14 Like/Block/Match 模块)
|
* Pass 端点(canonical 路径,补齐 2026-09-14 Like/Block/Match 模块)
|
||||||
@@ -28,12 +29,14 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class PassController {
|
public class PassController {
|
||||||
|
|
||||||
private final RelationService relationService;
|
private final RelationService relationService;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@Idempotent
|
@Idempotent
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ApiResponse<Void> pass(@RequestBody PassRequest req,
|
public ApiResponse<Void> pass(@RequestBody PassRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
||||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 推荐控制器
|
* 推荐控制器
|
||||||
@@ -21,6 +22,7 @@ import java.util.Map;
|
|||||||
public class RecommendationController {
|
public class RecommendationController {
|
||||||
|
|
||||||
private final RecommendationEngineService engineService;
|
private final RecommendationEngineService engineService;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取今日推荐
|
* 获取今日推荐
|
||||||
@@ -30,6 +32,7 @@ public class RecommendationController {
|
|||||||
public ApiResponse<List<RecommendationCandidateDTO>> getRecommendations(
|
public ApiResponse<List<RecommendationCandidateDTO>> getRecommendations(
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
return ApiResponse.ok(engineService.getDailyRecommendations(userId));
|
return ApiResponse.ok(engineService.getDailyRecommendations(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ public class RecommendationController {
|
|||||||
@PathVariable String targetUserId,
|
@PathVariable String targetUserId,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
String action = body.get("action");
|
String action = body.get("action");
|
||||||
if (action == null) {
|
if (action == null) {
|
||||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "action不能为空");
|
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "action不能为空");
|
||||||
@@ -62,6 +66,7 @@ public class RecommendationController {
|
|||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String targetUserId) {
|
@PathVariable String targetUserId) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
boolean eligible = engineService.isStillEligible(userId, targetUserId);
|
boolean eligible = engineService.isStillEligible(userId, targetUserId);
|
||||||
return ApiResponse.ok(Map.of("eligible", eligible));
|
return ApiResponse.ok(Map.of("eligible", eligible));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -159,6 +159,9 @@ public class MatchEligibilityService {
|
|||||||
if (targetProfile == null) return false;
|
if (targetProfile == null) return false;
|
||||||
String ps = targetProfile.getProfileStatus();
|
String ps = targetProfile.getProfileStatus();
|
||||||
if (!Constants.PROFILE_STATUS_APPROVED.equals(ps) && !Constants.PROFILE_STATUS_COMPLETED.equals(ps)) return false;
|
if (!Constants.PROFILE_STATUS_APPROVED.equals(ps) && !Constants.PROFILE_STATUS_COMPLETED.equals(ps)) return false;
|
||||||
|
if (Constants.PROFILE_STATUS_APPROVED.equals(ps)
|
||||||
|
&& targetProfile.getPublishedProfileRevision() != null
|
||||||
|
&& !java.util.Objects.equals(targetProfile.getProfileRevision(), targetProfile.getPublishedProfileRevision())) return false;
|
||||||
|
|
||||||
// V1.1: 本地资格核验(可配置是否强制)
|
// V1.1: 本地资格核验(可配置是否强制)
|
||||||
if (matchConfig.isLocalEligibilityRequired()) {
|
if (matchConfig.isLocalEligibilityRequired()) {
|
||||||
|
|||||||
+17
-1
@@ -127,6 +127,8 @@ public class RecommendationEngineService {
|
|||||||
|
|
||||||
// 检查审核状态
|
// 检查审核状态
|
||||||
if (!Constants.PROFILE_STATUS_APPROVED.equals(target.getProfileStatus())) return false;
|
if (!Constants.PROFILE_STATUS_APPROVED.equals(target.getProfileStatus())) return false;
|
||||||
|
if (target.getPublishedProfileRevision() != null
|
||||||
|
&& !java.util.Objects.equals(target.getProfileRevision(), target.getPublishedProfileRevision())) return false;
|
||||||
|
|
||||||
// 检查是否已被当前用户Block
|
// 检查是否已被当前用户Block
|
||||||
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) {
|
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) {
|
||||||
@@ -310,7 +312,11 @@ public class RecommendationEngineService {
|
|||||||
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
|
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
|
||||||
record.setScore(java.math.BigDecimal.valueOf(score));
|
record.setScore(java.math.BigDecimal.valueOf(score));
|
||||||
record.setLevel(toLevel(score));
|
record.setLevel(toLevel(score));
|
||||||
record.setReasons(String.join(";", reasons));
|
// recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。
|
||||||
|
// 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会
|
||||||
|
// 触发 PostgreSQL「invalid input syntax for type json」,导致整个
|
||||||
|
// /home 聚合接口 500。改为序列化为 JSON 数组。
|
||||||
|
record.setReasons(writeReasonsJson(reasons));
|
||||||
record.setShownAt(Instant.now());
|
record.setShownAt(Instant.now());
|
||||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||||
@@ -342,6 +348,16 @@ public class RecommendationEngineService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
|
||||||
|
private String writeReasonsJson(List<String> reasons) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("序列化推荐理由失败", e);
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内部评分结果
|
* 内部评分结果
|
||||||
*/
|
*/
|
||||||
|
|||||||
+15
-1
@@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
|||||||
import com.danyang.match.platform.profile.service.UserProfileService;
|
import com.danyang.match.platform.profile.service.UserProfileService;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
|
|||||||
public class RecommendationService {
|
public class RecommendationService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
|
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
private final MatchEligibilityService eligibilityService;
|
private final MatchEligibilityService eligibilityService;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
@@ -61,7 +63,9 @@ public class RecommendationService {
|
|||||||
record.setTargetUserId(targetUserId);
|
record.setTargetUserId(targetUserId);
|
||||||
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
|
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
|
||||||
record.setLevel(level);
|
record.setLevel(level);
|
||||||
record.setReasons(new java.util.ArrayList<>(reasons).toString());
|
// recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON
|
||||||
|
// (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。
|
||||||
|
record.setReasons(writeReasonsJson(reasons));
|
||||||
record.setShownAt(Instant.now());
|
record.setShownAt(Instant.now());
|
||||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||||
@@ -70,6 +74,16 @@ public class RecommendationService {
|
|||||||
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
|
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
|
||||||
|
private String writeReasonsJson(List<String> reasons) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("序列化推荐理由失败", e);
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记录用户对推荐的响应(Like/Pass/Block)
|
* 记录用户对推荐的响应(Like/Pass/Block)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受控联系授权配置(ZC-MM-003 / B1 立项,对照 {@code ProfileProperties} 范式)。
|
||||||
|
*
|
||||||
|
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):
|
||||||
|
* 五个 canonical key 统一在此管理,禁止散落在 Controller/Service/前端各自写死;
|
||||||
|
* 治理登记见 T06 config-register(OQ-MATCHMAKER-003)。
|
||||||
|
*
|
||||||
|
* <p><b>fail-safe 语义</b>:{@code enabled} 的 Java 字段缺省为 <b>false</b> ——
|
||||||
|
* 若配置键完全缺失(如新环境漏配),机制整体关闭(fail-safe 关闭优于意外开放)。
|
||||||
|
* 生产 {@code application.yml} 显式声明 {@code matchmaker.contact.enabled: true};
|
||||||
|
* "默认开启" 指的是配置注册表中的生效默认值,而非代码缺省。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "matchmaker.contact")
|
||||||
|
public class ContactAuthorizationConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机制总开关。
|
||||||
|
* Java 缺省 false(fail-safe:配置缺失 → 机制关闭);生产 yml 显式置 true。
|
||||||
|
* disclose(H4 唯一披露出口)前强校验,关闭时任何披露请求 404 化拒绝。
|
||||||
|
*/
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批岗权限码(逗号分隔,<b>分层语义</b>,用户裁决①:组长主审 + 管理员兜底)。
|
||||||
|
* 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。
|
||||||
|
*
|
||||||
|
* <p>分层规则(ContactAuthorizationService.assertApproverAuthority):
|
||||||
|
* <ol>
|
||||||
|
* <li>列表<b>首段 = 主审岗</b>(默认 {@code matchmaker.team.assign},组长)——
|
||||||
|
* 仅可审批本组织红娘的申请(NULL=NULL 平台直属,assertTeamScope 复用);</li>
|
||||||
|
* <li><b>兜底岗键固定 {@code matchmaker.assign}</b>(平台管理员)——
|
||||||
|
* 可审批全部申请且不做组织校验;兜底键不受本列表调整影响(权限面不放大);</li>
|
||||||
|
* <li>两者皆无 → 403 拒绝。</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>审批权限在 Service 层动态判定(principal 权限 ∈ 分层规则),不用 @PreAuthorize
|
||||||
|
* 静态注解 —— 主审岗名单可经 yml 调整而无需改代码。
|
||||||
|
*/
|
||||||
|
private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户同意窗口(小时):pending_consent 超窗未同意 → 惰性 expired。
|
||||||
|
* 默认 72h(治理登记 OQ-MATCHMAKER-003 技术默认,待产品确认)。
|
||||||
|
*/
|
||||||
|
private int consentDeadlineHours = 72;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权默认有效期(天):用户 consent 起算,expires_at = consent + N 天。
|
||||||
|
* 默认 7 天;到期由双态惰性收敛置 expired。
|
||||||
|
*/
|
||||||
|
private int defaultValidityDays = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权审计数据保留期(天):受控联系相关审计/敏感访问记录的保留下限口径。
|
||||||
|
* 默认 365 天;当前以 /my 响应元数据形式下发(retentionDays 字段)供前端展示,
|
||||||
|
* 治理登记 T06 open-questions 同步说明。
|
||||||
|
*/
|
||||||
|
private int retentionDays = 365;
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
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 com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
|
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;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final UserProfileMapper userProfileMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分配。
|
||||||
|
* 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")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组长团队统计(ZC-MM-005 / E4 新增)。仅 matchmaker.team.assign 持有者可调;
|
||||||
|
* 以组长本人 organization_id 为边界(NULL 组长统计平台直属)。
|
||||||
|
* 返回纯脱敏计数:activeAssignmentCount / perMatchmakerCounts / activeMatchmakerCount,
|
||||||
|
* 不含任何用户身份或联系方式。
|
||||||
|
*/
|
||||||
|
@GetMapping("/team/statistics")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<Map<String, Object>> teamStatistics(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(assignmentService.teamStatistics(principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant parseInstant(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Instant.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> assignmentView(MatchmakerAssignment a) {
|
||||||
|
var user = userMapper.findById(a.getUserId()).orElse(null);
|
||||||
|
var profile = userProfileMapper.findByUserId(a.getUserId()).orElse(null);
|
||||||
|
return Map.ofEntries(
|
||||||
|
Map.entry("id", a.getId()),
|
||||||
|
Map.entry("userId", a.getUserId()),
|
||||||
|
Map.entry("userDisplayNumber", user == null || user.getDisplayNumber() == null ? "" : user.getDisplayNumber()),
|
||||||
|
Map.entry("userDisplayName", profile == null || profile.getDisplayName() == null ? "" : profile.getDisplayName()),
|
||||||
|
Map.entry("userGender", profile == null || profile.getGender() == null ? "" : profile.getGender()),
|
||||||
|
Map.entry("userAge", profile == null || profile.getBirthDate() == null ? "" : String.valueOf(java.time.Period.between(profile.getBirthDate(), java.time.LocalDate.now()).getYears())),
|
||||||
|
Map.entry("userRegionName", profile == null || profile.getRegionName() == null ? "" : profile.getRegionName()),
|
||||||
|
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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||||
|
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.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受控联系授权控制器(运营端,ZC-MM-003 / B1 立项)。
|
||||||
|
*
|
||||||
|
* <p>端点(基路径 {@code /api/v1/operator/matchmaker/contact-authorizations}):
|
||||||
|
* <ul>
|
||||||
|
* <li>POST {@code ""} —— 红娘发起授权申请(matchmaker.service,purpose 必填);</li>
|
||||||
|
* <li>POST {@code /{id}/approve} / {@code /{id}/reject} —— 审批岗批准/拒绝
|
||||||
|
* (审批岗权限码在 Service 层动态判定 ∈ approver-authorities,组长另有组织范围校验);</li>
|
||||||
|
* <li>GET {@code /my} —— 红娘本人授权列表;</li>
|
||||||
|
* <li>POST {@code /{id}/revoke} —— 申请人或审批人撤销(reason 必填);</li>
|
||||||
|
* <li>GET {@code /disclose} —— H4 唯一披露出口(userId/purpose 必填,
|
||||||
|
* 每次落 SensitiveAccessLog;phone 不落任何应用日志)。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>类级 {@code matchmaker.view} 覆盖列表/详情读;写动作方法级收敛;
|
||||||
|
* 审批岗动态权限与 G4 404 化拒绝、approve 403 边界均由
|
||||||
|
* {@code ContactAuthorizationService} 承担(T06 open-questions OQ-MATCHMAKER-009)。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/operator/matchmaker/contact-authorizations")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.view')")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerContactAuthorizationController {
|
||||||
|
|
||||||
|
private final ContactAuthorizationService contactAuthorizationService;
|
||||||
|
|
||||||
|
/** 红娘发起授权申请(须持分配;purpose 必填)。 */
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||||
|
public ApiResponse<MatchmakerContactAuthorization> request(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.request(
|
||||||
|
principal.id(),
|
||||||
|
body.get("userId"),
|
||||||
|
body.get("channel"),
|
||||||
|
body.get("purpose")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批岗批准(权限动态判定 + 组长组织范围校验)。 */
|
||||||
|
@PostMapping("/{id}/approve")
|
||||||
|
public ApiResponse<MatchmakerContactAuthorization> approve(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.approve(
|
||||||
|
id, principal.id(), principal.permissions()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批岗拒绝。 */
|
||||||
|
@PostMapping("/{id}/reject")
|
||||||
|
public ApiResponse<MatchmakerContactAuthorization> reject(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.reject(
|
||||||
|
id, principal.id(), principal.permissions()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 红娘本人授权列表(含保留期元数据)。 */
|
||||||
|
@GetMapping("/my")
|
||||||
|
public ApiResponse<Map<String, Object>> my(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.myAuthorizations(principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批岗待处理队列;服务层按平台管理员/组长组织范围过滤。 */
|
||||||
|
@GetMapping("/pending")
|
||||||
|
public ApiResponse<Map<String, Object>> pending(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.pendingApprovals(
|
||||||
|
principal.id(), principal.permissions()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 申请人或审批人撤销(reason 必填)。 */
|
||||||
|
@PostMapping("/{id}/revoke")
|
||||||
|
public ApiResponse<MatchmakerContactAuthorization> revoke(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.revoke(
|
||||||
|
id, principal.id(), body.get("reason")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** H4 唯一披露出口:userId/purpose 必填,channel 缺省 phone。 */
|
||||||
|
@GetMapping("/disclose")
|
||||||
|
public ApiResponse<Map<String, Object>> disclose(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@RequestParam String userId,
|
||||||
|
@RequestParam String purpose,
|
||||||
|
@RequestParam(required = false) String channel) {
|
||||||
|
return ApiResponse.ok(contactAuthorizationService.disclose(
|
||||||
|
principal.id(), userId, channel, purpose));
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-1
@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 红娘服务端点(用户端)
|
* 红娘服务端点(用户端)
|
||||||
@@ -42,6 +43,8 @@ import java.util.Map;
|
|||||||
public class MatchmakerController {
|
public class MatchmakerController {
|
||||||
|
|
||||||
private final MatchmakerService matchmakerService;
|
private final MatchmakerService matchmakerService;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
// ==================== 用户端 ====================
|
// ==================== 用户端 ====================
|
||||||
|
|
||||||
@@ -50,23 +53,46 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<MatchmakerServiceRecord> submit(@RequestBody SubmitRequest req,
|
public ApiResponse<MatchmakerServiceRecord> submit(@RequestBody SubmitRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 契约兼容:小程序传 requirement,后端契约为 description;缺省服务类型为 recommendation
|
// 契约兼容:小程序传 requirement,后端契约为 description;缺省服务类型为 recommendation
|
||||||
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
|
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
|
||||||
? "recommendation" : req.serviceType;
|
? "recommendation" : req.serviceType;
|
||||||
String description = req.description != null ? req.description : req.requirement;
|
String description = req.description != null ? req.description : req.requirement;
|
||||||
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description));
|
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description,
|
||||||
|
req.targetUserId, req.authorizationAccepted));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/services")
|
@GetMapping("/services")
|
||||||
public ApiResponse<List<MatchmakerServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
public ApiResponse<List<MatchmakerServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
|
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Idempotent
|
||||||
|
@PostMapping("/services/{id}/confirm")
|
||||||
|
public ApiResponse<MatchmakerServiceRecord> confirmService(@PathVariable String id,
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
|
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Idempotent
|
||||||
|
@PostMapping("/services/{id}/withdraw")
|
||||||
|
public ApiResponse<Void> withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
|
matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/recommendations")
|
@GetMapping("/recommendations")
|
||||||
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
|
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(matchmakerService.pendingRecommendations(principal.id()));
|
return ApiResponse.ok(matchmakerService.pendingRecommendations(principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +100,7 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<Void> markViewed(@PathVariable String id,
|
public ApiResponse<Void> markViewed(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
matchmakerService.markRecommendationViewed(id, principal.id());
|
matchmakerService.markRecommendationViewed(id, principal.id());
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
@@ -83,6 +110,7 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<MatchmakerService.LikeRecommendationResult> accept(@PathVariable String id,
|
public ApiResponse<MatchmakerService.LikeRecommendationResult> accept(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(matchmakerService.acceptRecommendation(id, principal.id()));
|
return ApiResponse.ok(matchmakerService.acceptRecommendation(id, principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,5 +131,10 @@ public class MatchmakerController {
|
|||||||
private String description;
|
private String description;
|
||||||
/** 兼容字段:小程序约定的需求描述(requirement -> description) */
|
/** 兼容字段:小程序约定的需求描述(requirement -> description) */
|
||||||
private String requirement;
|
private String requirement;
|
||||||
|
private String targetUserId;
|
||||||
|
private Boolean authorizationAccepted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class WithdrawRequest { private String reason; }
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-26
@@ -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")
|
||||||
@@ -21,6 +26,20 @@ import java.util.Map;
|
|||||||
public class MatchmakerOperatorController {
|
public class MatchmakerOperatorController {
|
||||||
|
|
||||||
private final MatchmakerServiceService matchmakerServiceService;
|
private final MatchmakerServiceService matchmakerServiceService;
|
||||||
|
private final com.danyang.match.platform.matchmaker.service.MatchmakerManagementService managementService;
|
||||||
|
|
||||||
|
@GetMapping("/management/services")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> managementServices(@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(managementService.list(principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/management/services/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<Map<String, Object>> managementDetail(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return ApiResponse.ok(managementService.detail(principal, id));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
public ApiResponse<Map<String, Object>> getDashboard(
|
public ApiResponse<Map<String, Object>> getDashboard(
|
||||||
@@ -29,42 +48,57 @@ 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。
|
||||||
|
*
|
||||||
|
* <p>组长视角说明(ZC-MM-005 / E4,治理登记 007):matchmaker_services 表无组织归属字段
|
||||||
|
* (organization_id 恒 null 的已知限制),因此待分配队列无法按组长组织过滤,
|
||||||
|
* 权衡后对分配权持有者全局可读 —— 待分配服务单尚无负责人,队列不含敏感分配关系。
|
||||||
|
* 组织归属字段的补齐登记至 ZC-MM-007。
|
||||||
|
*/
|
||||||
@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")
|
// GET /my-services 已删除(ZC-MM-005 / E4 死端点清理):
|
||||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
// 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空),
|
||||||
public ApiResponse<?> claimService(
|
// 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径:
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
// GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。
|
||||||
@PathVariable String id) {
|
// getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。
|
||||||
String operatorId = principal.id();
|
|
||||||
return ApiResponse.ok(matchmakerServiceService.claimService(operatorId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/my-services")
|
|
||||||
public ApiResponse<?> getMyServices(
|
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
|
||||||
String operatorId = principal.id();
|
|
||||||
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
|
* 红娘名下服务的全部推荐列表(运营端工作台用)。
|
||||||
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
|
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
|
||||||
|
*
|
||||||
|
* <p>口径核查(ZC-MM-005 收口 / P1-1):范围事实源是 {@code matchmaker_assignments}
|
||||||
|
* 中本人<b>生效中的分配</b>(active 且未过有效期)→ 该分配对象(user)名下
|
||||||
|
* 服务单上的推荐,经 matchmaker_services 桥接;既不按
|
||||||
|
* matchmaker_services.assigned_operator_id 残留列过滤,也<b>不</b>按
|
||||||
|
* recommendation.operator_id 过滤(推荐条目只含 targetUserId/reason/status
|
||||||
|
* 摘要,不涉及服务对象字段级隐私)。分配 revoke/end 后不再可见
|
||||||
|
* (DEC-002 规则7:撤销即失权)。
|
||||||
*/
|
*/
|
||||||
@GetMapping("/recommendations")
|
@GetMapping("/recommendations")
|
||||||
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
||||||
@@ -72,16 +106,19 @@ public class MatchmakerOperatorController {
|
|||||||
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
|
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务详情(ZC-MM-005 / E2 防探测 404)。
|
||||||
|
*
|
||||||
|
* <p>语义变更:非持有人与查无 serviceId 抛出完全相同的
|
||||||
|
* BusinessException(NOT_FOUND, "服务不存在") —— 响应体逐字段一致
|
||||||
|
* (success=false / code=NOT_FOUND / message=服务不存在),无法区分"无权"与"不存在"。
|
||||||
|
* 此前 002 的 viewerIsOwner 双态(持有人完整 / 非负责人脱敏)已删除。
|
||||||
|
*/
|
||||||
@GetMapping("/services/{id}")
|
@GetMapping("/services/{id}")
|
||||||
public ApiResponse<Map<String, Object>> getServiceDetail(
|
public ApiResponse<Map<String, Object>> getServiceDetail(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
|
return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id()));
|
||||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id, principal.id());
|
|
||||||
if (detail == null) {
|
|
||||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
return ApiResponse.ok(detail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/services/{id}/recommend")
|
@PostMapping("/services/{id}/recommend")
|
||||||
@@ -114,7 +151,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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
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.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/operator/workbench/details")
|
||||||
|
@PreAuthorize("hasAuthority('workbench.view')")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerDashboardDetailController {
|
||||||
|
private final MatchmakerDashboardDetailService service;
|
||||||
|
|
||||||
|
@GetMapping("/{type}")
|
||||||
|
public ApiResponse<Map<String, Object>> list(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String type,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
return ApiResponse.ok(service.list(principal.id(), type, page, pageSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.entity.Role;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.RoleMapper;
|
||||||
|
import com.danyang.match.platform.permission.service.PermissionService;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerDashboardDetailService {
|
||||||
|
private final OperatorMapper operatorMapper;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
|
private final PermissionService permissionService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public Map<String, Object> list(String operatorId, String type, int page, int pageSize) {
|
||||||
|
MatchmakerDashboardScope scope = resolve(operatorId);
|
||||||
|
String permission = permissionFor(type);
|
||||||
|
if (!permissionService.hasPermission(operatorId, permission)) {
|
||||||
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权查看该工作台明细");
|
||||||
|
}
|
||||||
|
Query query = queryFor(type, scope);
|
||||||
|
int safePage = Math.max(1, page);
|
||||||
|
int safeSize = Math.min(100, Math.max(1, pageSize));
|
||||||
|
long total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM (" + query.sql + ") q", Long.class, query.args);
|
||||||
|
List<Map<String, Object>> items = jdbcTemplate.queryForList(
|
||||||
|
query.sql + " ORDER BY created_at DESC NULLS LAST LIMIT ? OFFSET ?",
|
||||||
|
append(query.args, safeSize, (safePage - 1) * safeSize));
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("type", type);
|
||||||
|
result.put("scope", scope.type());
|
||||||
|
result.put("total", total);
|
||||||
|
result.put("page", safePage);
|
||||||
|
result.put("pageSize", safeSize);
|
||||||
|
result.put("items", items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MatchmakerDashboardScope resolve(String operatorId) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.UNAUTHORIZED, "运营账号不存在"));
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(operatorId);
|
||||||
|
if (roles.stream().anyMatch(r -> Set.of("platform_admin", "super_admin").contains(String.valueOf(r.getCode())))) {
|
||||||
|
return new MatchmakerDashboardScope("ALL", operatorId, operator.getOrganizationId());
|
||||||
|
}
|
||||||
|
boolean team = roles.stream().anyMatch(r -> "matchmaker_leader".equals(r.getCode()));
|
||||||
|
if (team && (operator.getOrganizationId() == null || operator.getOrganizationId().isBlank())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CROSS_ORGANIZATION_DENIED, "红娘组长必须绑定组织");
|
||||||
|
}
|
||||||
|
return new MatchmakerDashboardScope(team ? "TEAM" : "SELF", operatorId, operator.getOrganizationId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String permissionFor(String type) {
|
||||||
|
return switch (type) {
|
||||||
|
case "review" -> "review.view";
|
||||||
|
case "matchmaker" -> "matchmaker.view";
|
||||||
|
case "counseling" -> "counseling.service.view";
|
||||||
|
case "appointment" -> "service_appointment.view";
|
||||||
|
case "match" -> "analytics.view";
|
||||||
|
case "activity" -> "activity.review_registration";
|
||||||
|
default -> throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的工作台明细类型");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Query queryFor(String type, MatchmakerDashboardScope scope) {
|
||||||
|
String table = switch (type) {
|
||||||
|
case "review" -> "review_tasks";
|
||||||
|
case "matchmaker" -> "matchmaker_assignments";
|
||||||
|
case "counseling" -> "counseling_services";
|
||||||
|
case "appointment" -> "service_appointments";
|
||||||
|
case "activity" -> "activity_registrations";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (table != null) {
|
||||||
|
String owner = "activity".equals(type) ? "created_by_operator_id"
|
||||||
|
: "matchmaker".equals(type) ? "matchmaker_operator_id" : "assigned_operator_id";
|
||||||
|
String sql = "SELECT id, user_id, status, created_at FROM " + table
|
||||||
|
+ " WHERE deleted_at IS NULL AND status IN ("
|
||||||
|
+ statusFor(type) + ")"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = " + table + ".user_id"
|
||||||
|
+ " AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision = up.profile_revision)";
|
||||||
|
if (scope.team()) return new Query(sql + " AND " + owner + " IN (SELECT id FROM operators WHERE organization_id = ?)", new Object[]{scope.organizationId()});
|
||||||
|
if (!scope.all()) return new Query(sql + " AND " + owner + " = ?", new Object[]{scope.operatorId()});
|
||||||
|
return new Query(sql, new Object[]{});
|
||||||
|
}
|
||||||
|
String sql = "SELECT m.id, m.user_a_id, m.user_b_id, m.status, m.matched_at AS created_at FROM matches m"
|
||||||
|
+ " WHERE m.status = 'active' AND m.source = 'matchmaker'"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id=m.user_a_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision=up.profile_revision)"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id=m.user_b_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision=up.profile_revision)";
|
||||||
|
if (scope.all()) return new Query(sql, new Object[]{});
|
||||||
|
String owner = scope.team() ? "o.organization_id = ?" : "(r.operator_id = ? OR s.assigned_operator_id = ?)";
|
||||||
|
String suffix = " AND EXISTS (SELECT 1 FROM matchmaker_recommendations r JOIN matchmaker_services s ON s.id=r.service_id JOIN operators o ON o.id=s.assigned_operator_id WHERE ((r.user_id = m.user_a_id AND r.target_user_id = m.user_b_id) OR (r.user_id = m.user_b_id AND r.target_user_id = m.user_a_id)) AND " + owner + ")";
|
||||||
|
return scope.team() ? new Query(sql + suffix, new Object[]{scope.organizationId()}) : new Query(sql + suffix, new Object[]{scope.operatorId(), scope.operatorId()});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String statusFor(String type) {
|
||||||
|
return switch (type) {
|
||||||
|
case "review" -> "'pending','processing'";
|
||||||
|
case "matchmaker" -> "'active'";
|
||||||
|
case "counseling" -> "'pending','assigned','scheduled','in_progress'";
|
||||||
|
case "appointment" -> "'submitted','pending_confirmation','confirmed'";
|
||||||
|
default -> "'pending','approved'";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object[] append(Object[] source, Object... values) {
|
||||||
|
Object[] result = java.util.Arrays.copyOf(source, source.length + values.length);
|
||||||
|
System.arraycopy(values, 0, result, source.length, values.length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Query(String sql, Object[] args) {}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
public record MatchmakerDashboardScope(String type, String operatorId, String organizationId) {
|
||||||
|
public boolean all() { return "ALL".equals(type); }
|
||||||
|
public boolean team() { return "TEAM".equals(type); }
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
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.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/operator/workbench")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@PreAuthorize("hasAuthority('workbench.view')")
|
||||||
|
public class MatchmakerWorkbenchController {
|
||||||
|
private final MatchmakerWorkbenchService service;
|
||||||
|
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ApiResponse<Map<String, Object>> dashboard(@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(service.dashboard(principal.id()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.entity.Role;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.RoleMapper;
|
||||||
|
import com.danyang.match.platform.permission.service.PermissionService;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerWorkbenchService {
|
||||||
|
private final OperatorMapper operatorMapper;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
|
private final PermissionService permissionService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public Map<String, Object> dashboard(String operatorId) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.UNAUTHORIZED, "运营账号不存在"));
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(operatorId);
|
||||||
|
MatchmakerDashboardScope scope = resolveScope(operator, roles);
|
||||||
|
|
||||||
|
Map<String, Object> metrics = new LinkedHashMap<>();
|
||||||
|
metrics.put("pendingReviews", metric(operatorId, "review.view", () -> count("review_tasks", "status IN ('pending','processing')", scope)));
|
||||||
|
metrics.put("counselingTodos", metric(operatorId, "counseling.service.view", () -> count("counseling_services", "status IN ('pending','assigned','scheduled','in_progress')", scope)));
|
||||||
|
metrics.put("serviceStationAppointments", metric(operatorId, "service_appointment.view", () -> count("service_appointments", "status IN ('submitted','pending_confirmation','confirmed')", scope)));
|
||||||
|
metrics.put("effectiveMatches", metric(operatorId, "analytics.view", () -> countMatches(scope)));
|
||||||
|
metrics.put("activityRegistrations", metric(operatorId, "activity.review_registration", () -> count("activity_registrations", "status IN ('pending','approved')", scope)));
|
||||||
|
metrics.put("matchmakerTodos", metric(operatorId, "matchmaker.view", () -> count("matchmaker_assignments", "status = 'active'", scope)));
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("scope", scope.type());
|
||||||
|
result.put("scopeLabel", "ALL".equals(scope.type()) ? "全平台" : "TEAM".equals(scope.type()) ? "本组织团队" : "本人服务");
|
||||||
|
result.put("metrics", metrics);
|
||||||
|
result.put("generatedAt", java.time.Instant.now());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MatchmakerDashboardScope resolveScope(String operatorId) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.UNAUTHORIZED, "运营账号不存在"));
|
||||||
|
return resolveScope(operator, roleMapper.findByOperatorId(operatorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MatchmakerDashboardScope resolveScope(Operator operator, List<Role> roles) {
|
||||||
|
boolean all = roles.stream().anyMatch(r -> Set.of("super_admin", "platform_admin").contains(r.getCode()));
|
||||||
|
if (all) return new MatchmakerDashboardScope("ALL", operator.getId(), operator.getOrganizationId());
|
||||||
|
boolean team = roles.stream().anyMatch(r -> "matchmaker_leader".equals(r.getCode()));
|
||||||
|
if (team && (operator.getOrganizationId() == null || operator.getOrganizationId().isBlank())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CROSS_ORGANIZATION_DENIED, "红娘组长必须绑定组织");
|
||||||
|
}
|
||||||
|
return new MatchmakerDashboardScope(team ? "TEAM" : "SELF", operator.getId(), operator.getOrganizationId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> metric(String operatorId, String detailPermission, java.util.function.LongSupplier valueSupplier) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("detailPermission", detailPermission);
|
||||||
|
if (!permissionService.hasPermission(operatorId, detailPermission)) {
|
||||||
|
item.put("available", false);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
item.put("value", valueSupplier.getAsLong());
|
||||||
|
item.put("available", true);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long count(String table, String statusPredicate, MatchmakerDashboardScope scope) {
|
||||||
|
String ownerColumn = "activity_registrations".equals(table) ? "created_by_operator_id"
|
||||||
|
: "matchmaker_assignments".equals(table) ? "matchmaker_operator_id" : "assigned_operator_id";
|
||||||
|
String sql = "SELECT COUNT(*) FROM " + table + " x WHERE x.deleted_at IS NULL AND " + statusPredicate
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = x.user_id"
|
||||||
|
+ " AND up.published_profile_revision IS NOT NULL"
|
||||||
|
+ " AND up.published_profile_revision = up.profile_revision)";
|
||||||
|
if (scope.all()) return jdbcTemplate.queryForObject(sql, Long.class);
|
||||||
|
if (scope.team()) {
|
||||||
|
return jdbcTemplate.queryForObject(sql + " AND x." + ownerColumn + " IN (SELECT id FROM operators WHERE organization_id = ?)", Long.class, scope.organizationId());
|
||||||
|
}
|
||||||
|
return jdbcTemplate.queryForObject(sql + " AND x." + ownerColumn + " = ?", Long.class, scope.operatorId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countMatches(MatchmakerDashboardScope scope) {
|
||||||
|
String base = "SELECT COUNT(DISTINCT m.id) FROM matches m WHERE m.status='active' AND m.source='matchmaker'"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = m.user_a_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision = up.profile_revision)"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = m.user_b_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision = up.profile_revision)";
|
||||||
|
if (scope.all()) return jdbcTemplate.queryForObject(base, Long.class);
|
||||||
|
String join = " AND EXISTS (SELECT 1 FROM matchmaker_recommendations r JOIN matchmaker_services s ON s.id=r.service_id JOIN operators o ON o.id=s.assigned_operator_id WHERE ((r.user_id=m.user_a_id AND r.target_user_id=m.user_b_id) OR (r.user_id=m.user_b_id AND r.target_user_id=m.user_a_id)) AND "
|
||||||
|
+ (scope.team() ? "o.organization_id = ?" : "(r.operator_id=? OR s.assigned_operator_id=?)") + ")";
|
||||||
|
return scope.team() ? jdbcTemplate.queryForObject(base + join, Long.class, scope.organizationId())
|
||||||
|
: jdbcTemplate.queryForObject(base + join, Long.class, scope.operatorId(), scope.operatorId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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;
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
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-003 / B1 例外通道,架构师设计 H4)。
|
||||||
|
*
|
||||||
|
* <p>状态机(六态):pending_approval → pending_consent → active / rejected;
|
||||||
|
* pending_consent / active 双态惰性收敛为 expired(有效期或同意窗口届满);
|
||||||
|
* active → revoked(申请人或审批人撤销)。V056 建表。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("matchmaker_contact_authorizations")
|
||||||
|
public class MatchmakerContactAuthorization extends BaseEntity {
|
||||||
|
|
||||||
|
/** 发起申请的红娘 operator id */
|
||||||
|
private String operatorId;
|
||||||
|
/** 被联系用户 id */
|
||||||
|
private String userId;
|
||||||
|
/** 联系渠道:phone / offline_meeting / full_contact */
|
||||||
|
private String channel;
|
||||||
|
/** 授权目的(必填,用户同意页明示) */
|
||||||
|
private String purpose;
|
||||||
|
/** 状态:pending_approval / pending_consent / active / rejected / expired / revoked */
|
||||||
|
private String status;
|
||||||
|
/** 审批岗 operator id */
|
||||||
|
private String approvedBy;
|
||||||
|
private Instant approvedAt;
|
||||||
|
/** 用户单独同意时间(独立端点,无预同意参数) */
|
||||||
|
private Instant userConsentedAt;
|
||||||
|
/** 生效截止:pending 期为同意窗口上界,consent 后重算为 consent + validity days */
|
||||||
|
private Instant expiresAt;
|
||||||
|
private Instant revokedAt;
|
||||||
|
private String revokedBy;
|
||||||
|
private String revokeReason;
|
||||||
|
}
|
||||||
+4
@@ -25,6 +25,10 @@ public class MatchmakerServiceRecord extends BaseEntity {
|
|||||||
private String userId;
|
private String userId;
|
||||||
private String serviceType;
|
private String serviceType;
|
||||||
private String description;
|
private String description;
|
||||||
|
private String targetUserId;
|
||||||
|
private java.time.Instant authorizationAt;
|
||||||
|
private java.time.Instant withdrawnAt;
|
||||||
|
private String withdrawReason;
|
||||||
private String status;
|
private String status;
|
||||||
private String organizationId;
|
private String organizationId;
|
||||||
private String assignedOperatorId;
|
private String assignedOperatorId;
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
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.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务对象分配 Mapper(ZC-MM-002 / A1)
|
||||||
|
*
|
||||||
|
* <p>「生效中」的统一口径:status = 'active' 且(expires_at IS NULL OR expires_at > 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();
|
||||||
|
|
||||||
|
// ==================== ZC-MM-005 / E4:组长团队统计(纯脱敏计数) ====================
|
||||||
|
// 组织边界:organization_id IS NOT DISTINCT FROM #{organizationId} ——
|
||||||
|
// NULL=NULL 平台直属规则在 SQL 层等价复用(有组织组长统计本组织;NULL 组长统计平台直属)。
|
||||||
|
|
||||||
|
/** 某组织范围内生效中的分配总数 */
|
||||||
|
@Select("SELECT COUNT(*) FROM matchmaker_assignments ma " +
|
||||||
|
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||||
|
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||||
|
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||||
|
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
|
||||||
|
long countActiveByOrganization(@Param("organizationId") String organizationId);
|
||||||
|
|
||||||
|
/** 某组织范围内各红娘的生效分配数(operatorId/name/activeCount;别名加引号保持驼峰键名) */
|
||||||
|
@Select("SELECT o.id AS \"operatorId\", o.name AS \"operatorName\", COUNT(*) AS \"activeCount\" " +
|
||||||
|
"FROM matchmaker_assignments ma " +
|
||||||
|
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||||
|
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||||
|
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||||
|
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId} " +
|
||||||
|
"GROUP BY o.id, o.name ORDER BY \"activeCount\" DESC")
|
||||||
|
List<Map<String, Object>> countActiveGroupByOperator(@Param("organizationId") String organizationId);
|
||||||
|
|
||||||
|
/** 某组织范围内持有生效分配的红娘数(去重) */
|
||||||
|
@Select("SELECT COUNT(DISTINCT ma.matchmaker_operator_id) FROM matchmaker_assignments ma " +
|
||||||
|
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||||
|
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||||
|
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||||
|
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
|
||||||
|
long countActiveOperators(@Param("organizationId") String organizationId);
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||||
|
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-003 / B1 立项)。
|
||||||
|
*
|
||||||
|
* <p>双态惰性收敛(对照 005 expireOverdue 范式,读前触发、无定时任务):
|
||||||
|
* ① active 且 expires_at 届满 → expired(授权有效期到);
|
||||||
|
* ② pending_consent 且同意窗口(expires_at 在 request/approve 期承载窗口上界)届满 → expired。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface MatchmakerContactAuthorizationMapper extends BaseMapper<MatchmakerContactAuthorization> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<MatchmakerContactAuthorization> findById(@Param("id") String id);
|
||||||
|
|
||||||
|
/** 某红娘的全部授权记录(工作台列表,倒序) */
|
||||||
|
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
|
||||||
|
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||||
|
List<MatchmakerContactAuthorization> findByOperator(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
|
/** 审批队列:仅返回待审批申请,组织范围由服务层按审批权限过滤。 */
|
||||||
|
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE status = 'pending_approval' " +
|
||||||
|
"AND deleted_at IS NULL ORDER BY created_at ASC")
|
||||||
|
List<MatchmakerContactAuthorization> findPendingApproval();
|
||||||
|
|
||||||
|
/** 用户待同意队列(pending_consent,C 端同意页数据源) */
|
||||||
|
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
|
||||||
|
"AND status = 'pending_consent' AND expires_at > NOW() AND deleted_at IS NULL " +
|
||||||
|
"ORDER BY created_at DESC")
|
||||||
|
List<MatchmakerContactAuthorization> findPendingConsentByUser(@Param("userId") String userId);
|
||||||
|
|
||||||
|
/** 某红娘对某用户某渠道当前生效中的授权 */
|
||||||
|
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
|
||||||
|
"AND user_id = #{userId} AND channel = #{channel} AND status = 'active' " +
|
||||||
|
"AND expires_at > NOW() AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<MatchmakerContactAuthorization> findActiveByOperatorUserChannel(@Param("operatorId") String operatorId,
|
||||||
|
@Param("userId") String userId,
|
||||||
|
@Param("channel") String channel);
|
||||||
|
|
||||||
|
/** 某用户的全部授权记录(C 端我的授权列表) */
|
||||||
|
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
|
||||||
|
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||||
|
List<MatchmakerContactAuthorization> findByUser(@Param("userId") String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双态惰性收敛:
|
||||||
|
* ① active 过期 → expired;
|
||||||
|
* ② pending_consent 超同意窗口 → expired(expires_at 即窗口上界)。
|
||||||
|
*
|
||||||
|
* @return 本次被收敛的行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE matchmaker_contact_authorizations SET status = 'expired', updated_at = NOW() " +
|
||||||
|
"WHERE status IN ('active', 'pending_consent') AND expires_at < NOW() AND deleted_at IS NULL")
|
||||||
|
int expireOverdue();
|
||||||
|
}
|
||||||
+11
-5
@@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
|
|||||||
int markViewed(@Param("id") String id);
|
int markViewed(@Param("id") String id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
|
* 某红娘推荐列表(运营端工作台用)。
|
||||||
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
|
*
|
||||||
* 避免运营端越权看到他人推荐。
|
* <p>ZC-MM-005 收口(P1-1):范围事实源由 matchmaker_services.assigned_operator_id
|
||||||
|
* 残留列切换为 matchmaker_assignments 生效中分配(active 且未过有效期)。
|
||||||
|
* 语义 = 「本人当前有效分配对象(user)名下服务单上的推荐」—— 分配 revoke/end 后
|
||||||
|
* 残留列不再构成可见凭据(DEC-002 规则7:撤销即失权)。
|
||||||
*/
|
*/
|
||||||
@Select("SELECT r.* FROM matchmaker_recommendations r " +
|
@Select("SELECT r.* FROM matchmaker_recommendations r " +
|
||||||
"JOIN matchmaker_services s ON s.id = r.service_id " +
|
"JOIN matchmaker_services s ON s.id = r.service_id AND s.deleted_at IS NULL " +
|
||||||
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
|
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||||
|
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||||
|
"AND a.deleted_at IS NULL " +
|
||||||
"ORDER BY r.created_at DESC")
|
"ORDER BY r.created_at DESC")
|
||||||
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
|
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-19
@@ -5,6 +5,7 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
|||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -12,6 +13,23 @@ import java.util.Optional;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
|
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
|
||||||
|
|
||||||
|
@Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " +
|
||||||
|
"s.description, s.target_user_id AS \"targetUserId\", s.authorization_at AS \"authorizationAt\", " +
|
||||||
|
"s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
|
||||||
|
"u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " +
|
||||||
|
"tu.display_number AS \"targetUserDisplayNumber\", " +
|
||||||
|
"(SELECT f.content FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
|
||||||
|
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupContent\", " +
|
||||||
|
"(SELECT f.created_at FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
|
||||||
|
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupAt\" " +
|
||||||
|
"FROM matchmaker_services s LEFT JOIN users u ON u.id=s.user_id " +
|
||||||
|
"LEFT JOIN users tu ON tu.id=s.target_user_id " +
|
||||||
|
"LEFT JOIN operators o ON o.id=s.assigned_operator_id AND o.deleted_at IS NULL " +
|
||||||
|
"WHERE s.deleted_at IS NULL <if test='!global'>AND o.id IS NOT NULL " +
|
||||||
|
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}</if> ORDER BY s.created_at DESC</script>")
|
||||||
|
List<java.util.Map<String, Object>> findManagementServices(@Param("global") boolean global,
|
||||||
|
@Param("organizationId") String organizationId);
|
||||||
|
|
||||||
@Select("SELECT * FROM matchmaker_services WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
@Select("SELECT * FROM matchmaker_services WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||||
Optional<MatchmakerServiceRecord> findById(@Param("id") String id);
|
Optional<MatchmakerServiceRecord> findById(@Param("id") String id);
|
||||||
|
|
||||||
@@ -26,35 +44,64 @@ 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();
|
||||||
|
|
||||||
/** 某红娘进行中的服务数(工作台统计) */
|
/**
|
||||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
* 某红娘进行中的服务数(工作台统计)。
|
||||||
"AND status = 'processing' AND deleted_at IS NULL")
|
* ZC-MM-005 收口(P1-2):范围事实源由 assigned_operator_id 残留列切换为
|
||||||
|
* matchmaker_assignments 生效中分配(active 且未过有效期)——「本人名下进行中」
|
||||||
|
* = 本人 active 分配关联 user 的服务单中 status='in_service'。
|
||||||
|
* 分配 end/revoke 后,该用户的服务单不再计入本人(DEC-002 规则7:服务结束失去访问权)。
|
||||||
|
* 状态口径沿用 ZC-MM-002(D4)修正:'in_service'('processing' 不在 V009 白名单)。
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||||
|
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||||
|
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||||
|
"AND a.deleted_at IS NULL " +
|
||||||
|
"WHERE s.status = 'in_service' AND s.deleted_at IS NULL")
|
||||||
long countInProgressByOperator(@Param("operatorId") String operatorId);
|
long countInProgressByOperator(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
|
/** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-005 收口 / P1-2:按生效分配 join) */
|
||||||
|
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||||
|
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||||
|
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||||
|
"AND a.deleted_at IS NULL " +
|
||||||
|
"WHERE CAST(s.assigned_at AS DATE) = CURRENT_DATE AND s.deleted_at IS NULL")
|
||||||
|
long countTodayByOperator(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-005 收口 / P1-2)。
|
||||||
|
* 口径说明(与 E1「服务结束失去访问权」一致):按当前生效分配统计 ——
|
||||||
|
* 分配 end/revoke 后,该用户的历史 completed 服务单<b>不再计入</b>本人完成数;
|
||||||
|
* 仅当分配仍生效时,其名下 completed 单才计入。
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||||
|
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||||
|
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||||
|
"AND a.deleted_at IS NULL " +
|
||||||
|
"WHERE s.status = 'completed' AND s.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);
|
||||||
|
|
||||||
/** 某红娘名下全部服务(含历史)—— 红娘工作台 */
|
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id=#{userId} AND target_user_id=#{targetUserId} " +
|
||||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
"AND service_type='recommendation_assistance' AND status NOT IN ('completed','cancelled') AND deleted_at IS NULL")
|
||||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
long countActiveAssistance(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
|
||||||
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")
|
@Update("UPDATE matchmaker_services SET status='cancelled', withdrawn_at=NOW(), withdraw_reason=#{reason}, updated_at=NOW() " +
|
||||||
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
|
"WHERE id=#{serviceId} AND user_id=#{userId} AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
|
||||||
|
int withdraw(@Param("serviceId") String serviceId, @Param("userId") String userId, @Param("reason") String reason);
|
||||||
|
|
||||||
/**
|
/** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */
|
||||||
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
|
@Update("UPDATE matchmaker_services SET status = 'in_service', updated_at = NOW() " +
|
||||||
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
|
"WHERE id = #{serviceId} AND user_id = #{userId} AND status = 'waiting_user' AND deleted_at IS NULL")
|
||||||
*/
|
int confirmWaitingForUser(@Param("serviceId") String serviceId, @Param("userId") String userId);
|
||||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
|
||||||
"AND user_id = #{userId} AND status IN ('assigned','in_service','waiting_user','submitted') " +
|
|
||||||
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
|
||||||
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
|
|
||||||
@Param("userId") String userId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
|
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
|
||||||
* 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。
|
* 状态口径(submitted/assigned/in_service/waiting_user)。
|
||||||
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
|
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
|
||||||
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
|
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
|
||||||
*/
|
*/
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受控联系授权判定器(ZC-MM-003 / B1 立项,架构师设计 G4)。
|
||||||
|
*
|
||||||
|
* <p>红娘对某用户某渠道能否披露联系方式,唯一事实源是
|
||||||
|
* {@code matchmaker_contact_authorizations} 中「生效中的授权」
|
||||||
|
* (status=active 且未过期,读前双态惰性收敛)。
|
||||||
|
*
|
||||||
|
* <p>判定无效时走 {@link DataScopeGuard#denyAsNotFound}:先落 DATA_SCOPE_DENIED 审计,
|
||||||
|
* 再抛 404「服务不存在」(G4 防探测范式,与分配判定器同款)。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContactAuthorizationPolicy {
|
||||||
|
|
||||||
|
private final MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||||
|
private final DataScopeGuard dataScopeGuard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断言生效中的授权;无效时先审计后 404。
|
||||||
|
*
|
||||||
|
* @return 生效中的授权记录(永不返回 null)
|
||||||
|
*/
|
||||||
|
public MatchmakerContactAuthorization assertContactAllowed(String operatorId, String userId,
|
||||||
|
String channel, String purpose) {
|
||||||
|
Optional<MatchmakerContactAuthorization> found = canContact(operatorId, userId, channel);
|
||||||
|
if (found.isPresent()) {
|
||||||
|
return found.get();
|
||||||
|
}
|
||||||
|
dataScopeGuard.denyAsNotFound(operatorId, "matchmaker_contact_authorization", userId,
|
||||||
|
"服务不存在", null, null);
|
||||||
|
return null; // denyAsNotFound 必然抛出,此行不可达
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 徽标查询(不审计):双态惰性收敛后查生效授权。
|
||||||
|
* 供 UI「可联系」徽标等高频读场景,避免每徽标一次拒绝审计。
|
||||||
|
*/
|
||||||
|
public Optional<MatchmakerContactAuthorization> canContact(String operatorId, String userId,
|
||||||
|
String channel) {
|
||||||
|
authorizationMapper.expireOverdue();
|
||||||
|
return authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId,
|
||||||
|
channel == null ? Constants.CONTACT_CHANNEL_PHONE : channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
+516
@@ -0,0 +1,516 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.config.ContactAuthorizationConfig;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||||
|
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 com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受控联系授权服务(ZC-MM-003 / B1 立项,架构师设计 H4)。
|
||||||
|
*
|
||||||
|
* <p>六操作状态机(全矩阵):
|
||||||
|
* <pre>
|
||||||
|
* request : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
|
||||||
|
* approve : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
|
||||||
|
* {@link #assertApproverAuthority})pending_approval → pending_consent;
|
||||||
|
* 违规 403(无资源枚举面维持 403)
|
||||||
|
* reject : 审批岗(分层同 approve)拒绝 pending_approval → rejected
|
||||||
|
* consent : C 端用户本人(归属校验,独立端点、无预同意参数)
|
||||||
|
* pending_consent → active(expires_at = consent + default-validity-days)
|
||||||
|
* decline : C 端用户本人 pending_consent → rejected
|
||||||
|
* revoke : 申请人或审批人,reason 必填 → revoked
|
||||||
|
* disclose : H4 唯一披露出口 —— enabled(fail-safe) → 持分配 → 生效授权;
|
||||||
|
* 返回 {phone, disclosedAt, authorizationId};每次落 SensitiveAccessLog;
|
||||||
|
* phone 不落任何应用日志
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>拒绝口径(G4 防探测):disclose 及授权有效性判定一律 404 化 deny
|
||||||
|
* (先 DATA_SCOPE_DENIED 审计再 404「服务不存在」);approve/管理类违规维持 403。
|
||||||
|
* 状态机非法转换一律 CONFLICT。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContactAuthorizationService {
|
||||||
|
|
||||||
|
private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
|
||||||
|
/** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
|
||||||
|
public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure";
|
||||||
|
/**
|
||||||
|
* 兜底审批岗权限键(用户裁决① 固定):持有者可审批全部申请且不做组织校验。
|
||||||
|
* 主审岗权限键仍由 approver-authorities 列表首段配置(默认 matchmaker.team.assign)。
|
||||||
|
*/
|
||||||
|
private static final String FALLBACK_APPROVER_AUTHORITY = "matchmaker.assign";
|
||||||
|
|
||||||
|
private final MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||||
|
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||||
|
private final MatchmakerAssignmentService matchmakerAssignmentService;
|
||||||
|
private final ContactAuthorizationPolicy contactAuthorizationPolicy;
|
||||||
|
private final ContactAuthorizationConfig config;
|
||||||
|
private final DataScopeGuard dataScopeGuard;
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final OperatorMapper operatorMapper;
|
||||||
|
|
||||||
|
/** afterData JSON 序列化(静态初始化,不参与构造注入,避免测试桩连带改动)。 */
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
// ==================== ① request:红娘发起申请 ====================
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerContactAuthorization request(String operatorId, String userId,
|
||||||
|
String channel, String purpose) {
|
||||||
|
// fail-safe 最先(与 disclose 同口径):机制关闭(配置缺失/显式关闭)→ 404 化拒绝。
|
||||||
|
// ZC-MM-003 裁决落地 T-C:request 原实现漏查 enabled(真实缺口,本轮补齐)。
|
||||||
|
if (!config.isEnabled()) {
|
||||||
|
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
|
||||||
|
"服务不存在", null, null);
|
||||||
|
}
|
||||||
|
if (purpose == null || purpose.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
|
||||||
|
}
|
||||||
|
requireValidChannel(channel);
|
||||||
|
|
||||||
|
// 未持分配申请 → 403 + DATA_SCOPE_DENIED 审计(申请凭据是分配而非授权本身)
|
||||||
|
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignment(
|
||||||
|
operatorId, userId, RESOURCE_TYPE);
|
||||||
|
|
||||||
|
// 同 (operator,user,channel) 已有生效授权或待处理申请 → CONFLICT(防重复申请洪泛)
|
||||||
|
if (authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId, channel).isPresent()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在生效中的授权");
|
||||||
|
}
|
||||||
|
boolean hasPending = authorizationMapper.findByOperator(operatorId).stream()
|
||||||
|
.anyMatch(a -> a.getUserId().equals(userId) && a.getChannel().equals(channel)
|
||||||
|
&& (Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(a.getStatus())
|
||||||
|
|| Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(a.getStatus())));
|
||||||
|
if (hasPending) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在待处理的授权申请");
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchmakerContactAuthorization auth = new MatchmakerContactAuthorization();
|
||||||
|
auth.setOperatorId(operatorId);
|
||||||
|
auth.setUserId(userId);
|
||||||
|
auth.setChannel(channel);
|
||||||
|
auth.setPurpose(purpose.trim());
|
||||||
|
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL);
|
||||||
|
// pending 期 expires_at 承载同意窗口上界(consent 后重算为有效期)
|
||||||
|
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
|
||||||
|
authorizationMapper.insert(auth);
|
||||||
|
|
||||||
|
log.info("受控联系授权申请: authId={}, operatorId={}, userId={}, channel={}",
|
||||||
|
auth.getId(), operatorId, userId, channel);
|
||||||
|
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(),
|
||||||
|
assignment != null ? assignment.getId() : null,
|
||||||
|
"matchmaker.contact.request", jsonOf("channel", channel, "purpose", auth.getPurpose()));
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ② approve / ③ reject:审批岗(分层) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底)。
|
||||||
|
*
|
||||||
|
* <p>分层规则:
|
||||||
|
* <ol>
|
||||||
|
* <li>持 {@code matchmaker.assign}(兜底岗,平台管理员)→ 可审批全部申请,
|
||||||
|
* <b>不做组织校验</b>(见 {@link #assertOrgScopeForApprover});</li>
|
||||||
|
* <li>否则持主审岗权限键(approver-authorities 列表首段,默认
|
||||||
|
* {@code matchmaker.team.assign},组长)→ 主审,须通过
|
||||||
|
* {@code assertTeamScope} 组织范围校验(NULL=NULL 平台直属,现状维持);</li>
|
||||||
|
* <li>两者皆无 → 403 deny(不变)。</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>主审岗键仍由配置驱动(列表首段),兜底岗键固定为 {@code matchmaker.assign}
|
||||||
|
* —— 配置只能收紧/调整主审岗,不能扩大兜底岗权限面。
|
||||||
|
*/
|
||||||
|
private void assertApproverAuthority(String approverOperatorId, String authId,
|
||||||
|
Set<String> principalPermissions) {
|
||||||
|
boolean authorized = isFallbackApprover(principalPermissions)
|
||||||
|
|| isPrimaryApprover(principalPermissions);
|
||||||
|
if (!authorized) {
|
||||||
|
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
|
||||||
|
dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId,
|
||||||
|
"非审批岗,禁止审批受控联系授权");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兜底岗判定:持固定键 matchmaker.assign(平台管理员)→ 全量审批、无组织校验。 */
|
||||||
|
private boolean isFallbackApprover(Set<String> principalPermissions) {
|
||||||
|
return principalPermissions != null && principalPermissions.contains(FALLBACK_APPROVER_AUTHORITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主审岗判定:principal 权限 ∈ approver-authorities 任一(默认首段 team.assign 组长)。 */
|
||||||
|
private boolean isPrimaryApprover(Set<String> principalPermissions) {
|
||||||
|
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
return principalPermissions != null
|
||||||
|
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分层组织范围校验(用户裁决①):兜底岗(持 matchmaker.assign)跳过组织校验;
|
||||||
|
* 主审岗(组长)固定走 {@code assertTeamScope}(hasGlobalAssign=false ——
|
||||||
|
* 是否兜底由本层判定,不再向底层传组织豁免,防止权限面双通道扩大)。
|
||||||
|
*/
|
||||||
|
private void assertOrgScopeForApprover(String approverOperatorId, String authId,
|
||||||
|
Set<String> principalPermissions, String targetOperatorId) {
|
||||||
|
if (isFallbackApprover(principalPermissions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matchmakerAssignmentService.assertTeamScope(approverOperatorId, false,
|
||||||
|
targetOperatorId, authId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
|
||||||
|
Set<String> principalPermissions) {
|
||||||
|
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
|
||||||
|
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||||
|
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"当前状态(" + auth.getStatus() + ")不允许审批");
|
||||||
|
}
|
||||||
|
// 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属)
|
||||||
|
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
|
||||||
|
auth.getOperatorId());
|
||||||
|
|
||||||
|
auth.setApprovedBy(approverOperatorId);
|
||||||
|
auth.setApprovedAt(Instant.now());
|
||||||
|
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT);
|
||||||
|
// 刷新同意窗口上界(给用户完整窗口)
|
||||||
|
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
|
||||||
|
authorizationMapper.updateById(auth);
|
||||||
|
|
||||||
|
log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId);
|
||||||
|
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
|
||||||
|
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
|
||||||
|
jsonOf("result", "approved", "channel", auth.getChannel()));
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerContactAuthorization reject(String authId, String approverOperatorId,
|
||||||
|
Set<String> principalPermissions) {
|
||||||
|
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
|
||||||
|
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||||
|
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"当前状态(" + auth.getStatus() + ")不允许拒绝");
|
||||||
|
}
|
||||||
|
// 分层组织范围校验(用户裁决①,与 approve 同口径)
|
||||||
|
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
|
||||||
|
auth.getOperatorId());
|
||||||
|
|
||||||
|
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
|
||||||
|
authorizationMapper.updateById(auth);
|
||||||
|
|
||||||
|
log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId);
|
||||||
|
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
|
||||||
|
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
|
||||||
|
jsonOf("result", "rejected", "channel", auth.getChannel()));
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ④ consent / ⑤ decline:C 端用户本人 ====================
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerContactAuthorization consent(String authId, String userId) {
|
||||||
|
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
|
||||||
|
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"当前状态(" + auth.getStatus() + ")不允许同意");
|
||||||
|
}
|
||||||
|
auth.setUserConsentedAt(Instant.now());
|
||||||
|
auth.setExpiresAt(Instant.now().plusSeconds(config.getDefaultValidityDays() * 86400L));
|
||||||
|
auth.setStatus(Constants.CONTACT_AUTH_STATUS_ACTIVE);
|
||||||
|
authorizationMapper.updateById(auth);
|
||||||
|
|
||||||
|
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
|
||||||
|
saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
|
||||||
|
"matchmaker.contact.consent", jsonOf("channel", auth.getChannel()));
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerContactAuthorization decline(String authId, String userId) {
|
||||||
|
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
|
||||||
|
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"当前状态(" + auth.getStatus() + ")不允许拒绝");
|
||||||
|
}
|
||||||
|
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
|
||||||
|
authorizationMapper.updateById(auth);
|
||||||
|
|
||||||
|
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
|
||||||
|
saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
|
||||||
|
"matchmaker.contact.decline", jsonOf("channel", auth.getChannel()));
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ⑥ revoke:申请人或审批人 ====================
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerContactAuthorization revoke(String authId, String operatorId, String reason) {
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "撤销理由必填");
|
||||||
|
}
|
||||||
|
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||||
|
boolean isApplicant = operatorId.equals(auth.getOperatorId());
|
||||||
|
boolean isApprover = operatorId.equals(auth.getApprovedBy());
|
||||||
|
if (!isApplicant && !isApprover) {
|
||||||
|
dataScopeGuard.deny(operatorId, RESOURCE_TYPE, authId,
|
||||||
|
"仅申请人或审批人可撤销授权");
|
||||||
|
}
|
||||||
|
if (!Constants.CONTACT_AUTH_STATUS_ACTIVE.equals(auth.getStatus())
|
||||||
|
&& !Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())
|
||||||
|
&& !Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"当前状态(" + auth.getStatus() + ")不允许撤销");
|
||||||
|
}
|
||||||
|
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REVOKED);
|
||||||
|
auth.setRevokedAt(Instant.now());
|
||||||
|
auth.setRevokedBy(operatorId);
|
||||||
|
auth.setRevokeReason(reason.trim());
|
||||||
|
authorizationMapper.updateById(auth);
|
||||||
|
|
||||||
|
log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId);
|
||||||
|
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId,
|
||||||
|
activeAssignmentIdOf(auth), "matchmaker.contact.revoke",
|
||||||
|
jsonOf("reason", auth.getRevokeReason()));
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ⑦ disclose:H4 唯一披露出口 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 披露用户电话(H4 唯一出口)。
|
||||||
|
* 链路:enabled fail-safe → 持分配(404 化)→ 生效授权(404 化)→ 返回三键;
|
||||||
|
* 每次落 SensitiveAccessLog(user_phone_disclosure);<b>phone 不落任何应用日志</b>。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> disclose(String operatorId, String userId, String channel,
|
||||||
|
String purpose) {
|
||||||
|
// fail-safe:机制关闭(配置缺失/显式关闭)→ 404 化拒绝
|
||||||
|
if (!config.isEnabled()) {
|
||||||
|
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
|
||||||
|
"服务不存在", null, null);
|
||||||
|
}
|
||||||
|
if (purpose == null || purpose.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "披露目的必填");
|
||||||
|
}
|
||||||
|
// 持分配校验(404 化,G4)
|
||||||
|
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
|
||||||
|
operatorId, userId, RESOURCE_TYPE, userId);
|
||||||
|
// 生效授权校验(404 化;未同意/已拒绝/已过期/已撤销全部落同一响应)
|
||||||
|
MatchmakerContactAuthorization auth = contactAuthorizationPolicy.assertContactAllowed(
|
||||||
|
operatorId, userId, channel, purpose);
|
||||||
|
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
String phone = user != null ? user.getPhone() : null;
|
||||||
|
|
||||||
|
SensitiveAccessLog accessLog = new SensitiveAccessLog();
|
||||||
|
accessLog.setOperatorId(operatorId);
|
||||||
|
accessLog.setUserId(userId);
|
||||||
|
accessLog.setDataType(SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE);
|
||||||
|
accessLog.setReason(purpose.trim());
|
||||||
|
accessLog.setAccessTarget(auth.getId());
|
||||||
|
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
|
||||||
|
sensitiveAccessLogMapper.insert(accessLog);
|
||||||
|
|
||||||
|
// 审计不含 phone(afterData 只记 channel;log.info 不打 phone)
|
||||||
|
log.info("受控联系披露: authId={}, operatorId={}, userId={}, channel={}",
|
||||||
|
auth.getId(), operatorId, userId, auth.getChannel());
|
||||||
|
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(),
|
||||||
|
assignment != null ? assignment.getId() : null,
|
||||||
|
"matchmaker.contact.disclose", jsonOf("channel", auth.getChannel()));
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("phone", phone);
|
||||||
|
result.put("disclosedAt", Instant.now());
|
||||||
|
result.put("authorizationId", auth.getId());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 查询 ====================
|
||||||
|
|
||||||
|
/** 红娘工作台:本人全部授权(含保留期元数据 retentionDays)。 */
|
||||||
|
public Map<String, Object> myAuthorizations(String operatorId) {
|
||||||
|
List<Map<String, Object>> items = authorizationMapper.findByOperator(operatorId).stream()
|
||||||
|
.map(this::toView).collect(Collectors.toList());
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("list", items);
|
||||||
|
result.put("retentionDays", config.getRetentionDays());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批队列:平台管理员全量可见,组长仅可见本组织申请。 */
|
||||||
|
public Map<String, Object> pendingApprovals(String approverOperatorId, Set<String> permissions) {
|
||||||
|
assertApproverAuthority(approverOperatorId, "pending", permissions);
|
||||||
|
List<MatchmakerContactAuthorization> rows = authorizationMapper.findPendingApproval();
|
||||||
|
if (!isFallbackApprover(permissions)) {
|
||||||
|
String approverOrg = operatorMapper.findById(approverOperatorId)
|
||||||
|
.map(Operator::getOrganizationId).orElse(null);
|
||||||
|
rows = rows.stream().filter(row -> operatorMapper.findById(row.getOperatorId())
|
||||||
|
.map(Operator::getOrganizationId)
|
||||||
|
.map(org -> java.util.Objects.equals(org, approverOrg)).orElse(false))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("list", rows.stream().map(this::toView).collect(Collectors.toList()));
|
||||||
|
result.put("retentionDays", config.getRetentionDays());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C 端:我的授权列表 + 待同意队列。 */
|
||||||
|
public Map<String, Object> myAuthorizationsForUser(String userId) {
|
||||||
|
List<Map<String, Object>> items = authorizationMapper.findByUser(userId).stream()
|
||||||
|
.map(this::toView).collect(Collectors.toList());
|
||||||
|
List<Map<String, Object>> pendingConsent = authorizationMapper
|
||||||
|
.findPendingConsentByUser(userId).stream()
|
||||||
|
.map(this::toView).collect(Collectors.toList());
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("list", items);
|
||||||
|
result.put("pendingConsent", pendingConsent);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视图投影:不含任何联系方式字段(授权表本身无 phone 列)。 */
|
||||||
|
private Map<String, Object> toView(MatchmakerContactAuthorization auth) {
|
||||||
|
Map<String, Object> item = new HashMap<>();
|
||||||
|
item.put("id", auth.getId());
|
||||||
|
item.put("operatorId", auth.getOperatorId());
|
||||||
|
item.put("userId", auth.getUserId());
|
||||||
|
item.put("channel", auth.getChannel());
|
||||||
|
item.put("purpose", auth.getPurpose());
|
||||||
|
item.put("status", auth.getStatus());
|
||||||
|
item.put("createdAt", auth.getCreatedAt());
|
||||||
|
item.put("approvedAt", auth.getApprovedAt());
|
||||||
|
item.put("userConsentedAt", auth.getUserConsentedAt());
|
||||||
|
item.put("expiresAt", auth.getExpiresAt());
|
||||||
|
item.put("revokeReason", auth.getRevokeReason());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内部实现 ====================
|
||||||
|
|
||||||
|
private void requireValidChannel(String channel) {
|
||||||
|
boolean valid = Constants.CONTACT_CHANNEL_PHONE.equals(channel)
|
||||||
|
|| Constants.CONTACT_CHANNEL_OFFLINE_MEETING.equals(channel)
|
||||||
|
|| Constants.CONTACT_CHANNEL_FULL_CONTACT.equals(channel);
|
||||||
|
if (!valid) {
|
||||||
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "不支持的授权渠道: " + channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MatchmakerContactAuthorization requireAuthorization(String authId) {
|
||||||
|
return authorizationMapper.findById(authId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "授权记录不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C 端归属校验:仅授权记录的本人用户可 consent/decline,违规 403。 */
|
||||||
|
private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) {
|
||||||
|
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||||
|
if (!auth.getUserId().equals(userId)) {
|
||||||
|
// V057:C 端主体走 denyUser(subject_user_id 维度)——userId 写 operator_id 维度
|
||||||
|
// 必撞 audit_logs_operator_id_fkey(→operators),H2 无 FK 假绿、生产零审计
|
||||||
|
dataScopeGuard.denyUser(userId, RESOURCE_TYPE, authId, "仅本人可处理该授权请求");
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审计用 service_assignment_id:当前 (红娘,用户) 生效中的分配 id,无则 null。 */
|
||||||
|
private String activeAssignmentIdOf(MatchmakerContactAuthorization auth) {
|
||||||
|
Optional<MatchmakerAssignment> assignment =
|
||||||
|
serviceAssignmentPolicy.findActiveAssignment(auth.getOperatorId(), auth.getUserId());
|
||||||
|
return assignment.map(MatchmakerAssignment::getId).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计写入:purpose_code(matchmaker.contact.*)+ service_assignment_id +
|
||||||
|
* request_trace_id(MDC requestId)。afterData 一律不含 phone。
|
||||||
|
*/
|
||||||
|
private void saveAudit(String operatorId, String action, String authId,
|
||||||
|
String serviceAssignmentId, String purposeCode, String afterData) {
|
||||||
|
AuditLog auditLog = new AuditLog();
|
||||||
|
auditLog.setOperatorId(operatorId);
|
||||||
|
auditLog.setAction(action);
|
||||||
|
auditLog.setResourceType(RESOURCE_TYPE);
|
||||||
|
auditLog.setResourceId(authId);
|
||||||
|
auditLog.setAfterData(afterData);
|
||||||
|
auditLog.setPurposeCode(purposeCode);
|
||||||
|
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||||
|
auditLog.setRequestTraceId(MDC.get("requestId"));
|
||||||
|
auditLogMapper.insert(auditLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V057 / 运行时实测修复:C 端用户主体的审计写入(consent/decline)。
|
||||||
|
* subject_user_id 维度(operator_id=null)——userId 写 operator_id 必撞
|
||||||
|
* audit_logs_operator_id_fkey(→operators):H2 无 FK 假绿,生产 FK violation 500。
|
||||||
|
* service_assignment_id 不适用(用户动作不经分配),purpose_code 照旧落。
|
||||||
|
*/
|
||||||
|
private void saveUserAudit(String userId, String action, String authId,
|
||||||
|
String purposeCode, String afterData) {
|
||||||
|
AuditLog auditLog = new AuditLog();
|
||||||
|
auditLog.setSubjectUserId(userId);
|
||||||
|
auditLog.setAction(action);
|
||||||
|
auditLog.setResourceType(RESOURCE_TYPE);
|
||||||
|
auditLog.setResourceId(authId);
|
||||||
|
auditLog.setAfterData(afterData);
|
||||||
|
auditLog.setPurposeCode(purposeCode);
|
||||||
|
auditLog.setRequestTraceId(MDC.get("requestId"));
|
||||||
|
auditLogMapper.insert(auditLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* afterData 必须是合法 JSON(PG audit_logs.after_data 为 jsonb 严格解析;
|
||||||
|
* H2 测试库 CLOB 不校验——裸 key=value 拼接在测试侧假绿、生产侧 500)。
|
||||||
|
* kv 成对传入,Jackson 负责用户输入(purpose/reason)的 JSON 转义,防 JSON 注入。
|
||||||
|
*/
|
||||||
|
private String jsonOf(Object... kvPairs) {
|
||||||
|
if (kvPairs.length % 2 != 0) {
|
||||||
|
throw new IllegalStateException("jsonOf requires key-value pairs");
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
for (int i = 0; i < kvPairs.length; i += 2) {
|
||||||
|
data.put(String.valueOf(kvPairs[i]), kvPairs[i + 1]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return OBJECT_MAPPER.writeValueAsString(data);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("audit afterData JSON serialization failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+428
@@ -0,0 +1,428 @@
|
|||||||
|
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 com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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 static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
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, "目标红娘不存在");
|
||||||
|
// create/assign 类无资源寻址端点维持 403(ZC-MM-006 / T04 口径)
|
||||||
|
assertAssignScope(assignerOperatorId, hasGlobalAssign, matchmakerOperatorId,
|
||||||
|
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
|
||||||
|
|
||||||
|
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
|
||||||
|
// 历史服务已完成/取消时,旧分配只是残留状态,不应阻塞用户再次申请服务。
|
||||||
|
// 仍在进行中的服务继续按唯一主责约束返回 409。
|
||||||
|
MatchmakerServiceRecord linked = existing.getServiceId() == null
|
||||||
|
? null : serviceMapper.selectById(existing.getServiceId());
|
||||||
|
if (linked != null && (Constants.MATCHMAKER_STATUS_COMPLETED.equals(linked.getStatus())
|
||||||
|
|| Constants.MATCHMAKER_STATUS_CANCELLED.equals(linked.getStatus()))) {
|
||||||
|
existing.setStatus(Constants.ASSIGNMENT_STATUS_ENDED);
|
||||||
|
existing.setEndedAt(Instant.now());
|
||||||
|
existing.setEndReason("关联服务已完成或取消,自动结束旧分配");
|
||||||
|
assignmentMapper.updateById(existing);
|
||||||
|
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_END, existing.getId(),
|
||||||
|
auditSummary(existing.getMatchmakerOperatorId(), existing.getAssignmentScope(), existing.getEndReason()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 红娘持有的分配"顺手"转走)。
|
||||||
|
// ZC-MM-006 / T04:transfer 为资源寻址写端点,范围违规 404 化(防探测)。
|
||||||
|
assertAssignScope(assignerOperatorId, hasGlobalAssign, old.getMatchmakerOperatorId(),
|
||||||
|
"组长只能处置同组织红娘持有的分配(NULL=NULL 平台直属)", assignmentId, true);
|
||||||
|
|
||||||
|
requireActiveOperator(newMatchmakerOperatorId, "目标红娘不存在");
|
||||||
|
assertAssignScope(assignerOperatorId, hasGlobalAssign, newMatchmakerOperatorId,
|
||||||
|
"组长只能转分配给同组织的红娘(NULL=NULL 平台直属)", assignmentId, true);
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组长团队统计(ZC-MM-005 / E4 新增):以组长本人 organization_id 为边界
|
||||||
|
* (NULL 组长统计平台直属,SQL 侧 IS NOT DISTINCT FROM 复用 NULL=NULL 规则)。
|
||||||
|
* 返回纯脱敏计数,不含任何用户身份或联系方式:
|
||||||
|
* <ul>
|
||||||
|
* <li>activeAssignmentCount —— 组织内生效中的分配总数;</li>
|
||||||
|
* <li>perMatchmakerCounts —— 各红娘的生效分配数(operatorId/name/activeCount);</li>
|
||||||
|
* <li>activeMatchmakerCount —— 持有生效分配的红娘数(去重)。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p><b>字段脱敏决策(P2-4,对照《决策》§5.2 字段分类 + 功能权限矩阵)</b>:
|
||||||
|
* perMatchmakerCounts 中的 <b>operatorName(红娘姓名)保留、不做脱敏</b>。依据:
|
||||||
|
* <ol>
|
||||||
|
* <li>红娘姓名属于「本人/团队范围内的工作人员标识」,<b>不是用户 PII</b>
|
||||||
|
* (§5.2 隐私清单的禁放字段是 phone/wechat/idNumber 等用户敏感数据);</li>
|
||||||
|
* <li>组长统计本团队工作量属其管理职责(DEC-MATCHMAKER-002 红娘组长:
|
||||||
|
* 团队内进度与工作量管理),「查看脱敏服务统计:组长=团队范围」是既有授权口径;</li>
|
||||||
|
* <li>组长已可经 GET /assignable-matchmakers 拿到同批红娘姓名 —— 此字段
|
||||||
|
* 非新增泄露面,管理范围 ≠ 用户敏感数据。</li>
|
||||||
|
* </ol>
|
||||||
|
* 决策依据同时登记于 docs/governance/open-questions.md(OQ-MATCHMAKER-002),
|
||||||
|
* 避免后人误判"统计含姓名=越权"。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> teamStatistics(String leaderOperatorId) {
|
||||||
|
Operator leader = operatorMapper.findById(leaderOperatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
|
||||||
|
String orgId = leader.getOrganizationId();
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("organizationId", orgId);
|
||||||
|
result.put("activeAssignmentCount", assignmentMapper.countActiveByOrganization(orgId));
|
||||||
|
result.put("perMatchmakerCounts", assignmentMapper.countActiveGroupByOperator(orgId));
|
||||||
|
result.put("activeMatchmakerCount", assignmentMapper.countActiveOperators(orgId));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内部实现 ====================
|
||||||
|
|
||||||
|
/** 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 同组织)
|
||||||
|
// ZC-MM-006 / T04:revoke/end 为资源寻址写端点,范围违规 404 化(防探测)
|
||||||
|
assertAssignScope(operatorId, hasGlobalAssign, assignment.getMatchmakerOperatorId(),
|
||||||
|
"组长只能处置同组织红娘的分配(NULL=NULL 平台直属)", assignmentId, true);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受控联系审批的组织范围校验(ZC-MM-003 / B1 立项)。
|
||||||
|
* {@link #assertAssignScope} 的公开入口:审批岗(team.assign 组长)只能审批
|
||||||
|
* 同组织红娘的授权申请(NULL=NULL 平台直属);matchmaker.assign 持有者不限组织。
|
||||||
|
* 违规走 DataScopeGuard.deny(403,approve 无资源枚举面维持 403)。
|
||||||
|
*
|
||||||
|
* @param approverOperatorId 审批人
|
||||||
|
* @param hasGlobalAssign 审批人是否持有 matchmaker.assign(true=不限组织)
|
||||||
|
* @param targetOperatorId 被审批授权的红娘
|
||||||
|
* @param auditResourceId 审计 resourceId(授权记录 id)
|
||||||
|
*/
|
||||||
|
public void assertTeamScope(String approverOperatorId, boolean hasGlobalAssign,
|
||||||
|
String targetOperatorId, String auditResourceId) {
|
||||||
|
assertAssignScope(approverOperatorId, hasGlobalAssign, targetOperatorId,
|
||||||
|
"组长只能审批同组织红娘的授权申请(NULL=NULL 平台直属)", auditResourceId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组织范围判定(D2):hasGlobalAssign(matchmaker.assign)不限组织;
|
||||||
|
* team.assign 时校验操作者与目标红娘 organization_id 一致。
|
||||||
|
* <b>NULL=NULL 视为平台直属</b>:平台直属组长只能分配平台直属红娘;
|
||||||
|
* 有组织组长不能分配 org=NULL 红娘。违规统一先落 DATA_SCOPE_DENIED 审计。
|
||||||
|
*
|
||||||
|
* <p>拒绝口径(ZC-MM-006 / T04):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code notFoundOnDeny=false}(create/assign 类,无资源寻址)→ 403 AccessDeniedException;</li>
|
||||||
|
* <li>{@code notFoundOnDeny=true}(transfer/revoke/end,资源寻址写端点)→ 404 化
|
||||||
|
* {@link DataScopeGuard#denyAsNotFound}(响应与"资源不存在"逐字段一致,防探测)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private void assertAssignScope(String assignerOperatorId, boolean hasGlobalAssign,
|
||||||
|
String targetOperatorId, String denyMessage,
|
||||||
|
String auditResourceId, boolean notFoundOnDeny) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (notFoundOnDeny) {
|
||||||
|
dataScopeGuard.denyAsNotFound(assignerOperatorId, RESOURCE_TYPE, auditResourceId,
|
||||||
|
"服务不存在", null, null);
|
||||||
|
} else {
|
||||||
|
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) {
|
||||||
|
Map<String, String> summary = Map.of(
|
||||||
|
"matchmakerOperatorId", matchmakerOperatorId == null ? "" : matchmakerOperatorId,
|
||||||
|
"scope", scope == null ? "" : scope,
|
||||||
|
"reason", reason == null ? "" : reason);
|
||||||
|
try {
|
||||||
|
return OBJECT_MAPPER.writeValueAsString(summary);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("audit afterData JSON serialization failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
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.mapper.MatchmakerFollowupMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 管理岗位只读服务进展;不复用普通红娘的本人分配权限,也不授予代操作权限。 */
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerManagementService {
|
||||||
|
private final MatchmakerServiceMapper serviceMapper;
|
||||||
|
private final MatchmakerFollowupMapper followupMapper;
|
||||||
|
private final OperatorMapper operatorMapper;
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
|
public List<Map<String, Object>> list(OperatorPrincipal principal) {
|
||||||
|
boolean global = principal.hasPermission("matchmaker.assign");
|
||||||
|
if (!global && !principal.hasPermission("matchmaker.team.assign")) {
|
||||||
|
throw new AccessDeniedException("无红娘服务管理权限");
|
||||||
|
}
|
||||||
|
String org = global ? null : operatorMapper.findById(principal.id())
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"))
|
||||||
|
.getOrganizationId();
|
||||||
|
return serviceMapper.findManagementServices(global, org);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> detail(OperatorPrincipal principal, String serviceId) {
|
||||||
|
Map<String, Object> summary = list(principal).stream()
|
||||||
|
.filter(row -> serviceId.equals(String.valueOf(row.get("serviceId"))))
|
||||||
|
.findFirst().orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"));
|
||||||
|
var detail = new HashMap<>(summary);
|
||||||
|
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
||||||
|
AuditLog audit = new AuditLog();
|
||||||
|
audit.setOperatorId(principal.id());
|
||||||
|
audit.setAction("MATCHMAKER_MANAGEMENT_VIEW");
|
||||||
|
audit.setResourceType("matchmaker_service");
|
||||||
|
audit.setResourceId(serviceId);
|
||||||
|
audit.setPurposeCode("matchmaker.management.view");
|
||||||
|
auditLogMapper.insert(audit);
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-4
@@ -48,6 +48,8 @@ public class MatchmakerService {
|
|||||||
private final RelationService relationService;
|
private final RelationService relationService;
|
||||||
private final MatchMapper matchMapper;
|
private final MatchMapper matchMapper;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper followupMapper;
|
||||||
|
private final com.danyang.match.platform.shared.id.PublicIdMapper publicIdMapper;
|
||||||
|
|
||||||
// ==================== 用户端:提交红娘服务请求 ====================
|
// ==================== 用户端:提交红娘服务请求 ====================
|
||||||
|
|
||||||
@@ -62,22 +64,57 @@ public class MatchmakerService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
|
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
|
||||||
|
return submitServiceRequest(userId, serviceType, description, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description,
|
||||||
|
String targetUserId, Boolean authorizationAccepted) {
|
||||||
|
boolean assistance = "recommendation_assistance".equals(serviceType);
|
||||||
|
if (assistance) {
|
||||||
|
if (targetUserId == null || targetUserId.isBlank()) throw new IllegalArgumentException("请选择需要协助的对象");
|
||||||
|
if (!Boolean.TRUE.equals(authorizationAccepted)) throw new IllegalArgumentException("请先确认授权红娘查看必要公开资料");
|
||||||
|
if (userId.equals(targetUserId)) throw new IllegalArgumentException("不能申请协助了解自己");
|
||||||
|
if (!relationService.canInteract(userId, targetUserId)) throw new IllegalStateException("该对象当前不可申请红娘协助");
|
||||||
|
if (serviceMapper.countActiveAssistance(userId, targetUserId) > 0) throw new IllegalStateException("已提交过该对象的红娘协助申请");
|
||||||
|
}
|
||||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||||
svc.setId(UUID.randomUUID().toString());
|
svc.setId(UUID.randomUUID().toString());
|
||||||
svc.setUserId(userId);
|
svc.setUserId(userId);
|
||||||
svc.setServiceType(serviceType);
|
svc.setServiceType(serviceType);
|
||||||
svc.setDescription(description);
|
svc.setDescription(description);
|
||||||
|
svc.setTargetUserId(targetUserId);
|
||||||
|
if (assistance) svc.setAuthorizationAt(Instant.now());
|
||||||
svc.setStatus("submitted");
|
svc.setStatus("submitted");
|
||||||
serviceMapper.insert(svc);
|
serviceMapper.insert(svc);
|
||||||
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
|
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
|
||||||
return svc;
|
return svc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void withdrawService(String serviceId, String userId, String reason) {
|
||||||
|
if (serviceMapper.withdraw(serviceId, userId, reason) != 1) throw new IllegalStateException("服务不存在或当前状态不可撤回");
|
||||||
|
}
|
||||||
|
|
||||||
/** 查询我的红娘服务历史 */
|
/** 查询我的红娘服务历史 */
|
||||||
public List<MatchmakerServiceRecord> myServices(String userId, int limit) {
|
public List<MatchmakerServiceRecord> myServices(String userId, int limit) {
|
||||||
return serviceMapper.findByUser(userId, Math.min(limit, 50));
|
return serviceMapper.findByUser(userId, Math.min(limit, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用户本人确认红娘方案,服务恢复为进行中。 */
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerServiceRecord confirmService(String serviceId, String userId) {
|
||||||
|
if (serviceId == null || userId == null) {
|
||||||
|
throw new IllegalArgumentException("服务和用户不能为空");
|
||||||
|
}
|
||||||
|
int updated = serviceMapper.confirmWaitingForUser(serviceId, userId);
|
||||||
|
if (updated != 1) {
|
||||||
|
throw new IllegalStateException("服务不存在或当前状态无需确认");
|
||||||
|
}
|
||||||
|
return serviceMapper.findById(serviceId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("服务不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 操作人端:分配 + 推荐 + 转介 ====================
|
// ==================== 操作人端:分配 + 推荐 + 转介 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,9 +194,7 @@ public class MatchmakerService {
|
|||||||
throw new IllegalStateException("您不是此服务的负责人");
|
throw new IllegalStateException("您不是此服务的负责人");
|
||||||
}
|
}
|
||||||
|
|
||||||
// referral 记录写进 MatchmakerServiceRecord.description 的扩展 JSON
|
// 转介建议复用跟进记录持久化,独立 ZF 编号不替用户创建申请。
|
||||||
// + 单独一条 Followup 记录(DB V009 已有 matchmaker_followups)
|
|
||||||
// 这里用 Map 简单记录,不引入新实体
|
|
||||||
ReferralRecord record = new ReferralRecord(
|
ReferralRecord record = new ReferralRecord(
|
||||||
UUID.randomUUID().toString(),
|
UUID.randomUUID().toString(),
|
||||||
serviceId,
|
serviceId,
|
||||||
@@ -171,11 +206,22 @@ public class MatchmakerService {
|
|||||||
Instant.now()
|
Instant.now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var followup = new com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup();
|
||||||
|
followup.setId(record.id());
|
||||||
|
followup.setServiceId(serviceId);
|
||||||
|
followup.setOperatorId(operatorId);
|
||||||
|
followup.setFollowupType("system");
|
||||||
|
followup.setContent("转介建议:" + referralLabel(referralType) + (note == null ? "" : "," + note));
|
||||||
|
followup.setCreatedAt(record.createdAt());
|
||||||
|
followupMapper.insert(followup);
|
||||||
|
String referralNumber = publicIdMapper.nextReferral(record.createdAt());
|
||||||
|
if (publicIdMapper.saveReferral(record.id(), referralNumber) != 1) throw new IllegalStateException("转介编号写入失败");
|
||||||
|
|
||||||
// 通知用户被转介了
|
// 通知用户被转介了
|
||||||
notificationService.send(userId, "matchmaker_referral",
|
notificationService.send(userId, "matchmaker_referral",
|
||||||
"红娘为你转介了一个" + referralLabel(referralType),
|
"红娘为你转介了一个" + referralLabel(referralType),
|
||||||
"公益红娘为你转介了" + referralLabel(referralType) +
|
"公益红娘为你转介了" + referralLabel(referralType) +
|
||||||
"(" + (referralTarget != null ? referralTarget : "") + "),你可以在" +
|
"(转介编号:" + referralNumber + "),你可以在" +
|
||||||
serviceLabel(referralType) + "页面申请。",
|
serviceLabel(referralType) + "页面申请。",
|
||||||
"matchmaker_service", serviceId);
|
"matchmaker_service", serviceId);
|
||||||
|
|
||||||
|
|||||||
+226
-88
@@ -1,9 +1,11 @@
|
|||||||
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.entity.SensitiveAccessLog;
|
||||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
|
||||||
import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO;
|
import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
||||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||||
@@ -11,7 +13,9 @@ import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper;
|
|||||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
import com.danyang.match.platform.notification.service.NotificationService;
|
import com.danyang.match.platform.notification.service.NotificationService;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
|
||||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
|
||||||
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 lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -27,6 +31,31 @@ 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>
|
||||||
|
*
|
||||||
|
* <p>ZC-MM-005(E1 双轨切纯判定器)变更说明:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>删除 assigned_operator_id 回退分支</b>:002 的 "assignment 优先 +
|
||||||
|
* assigned_operator_id 回退" 有真漏洞 —— 分配 revoke/end 后服务单残留
|
||||||
|
* assigned_operator_id,回退分支会放行已失去访问权的红娘(违反 DEC-002 规则7)。
|
||||||
|
* V053 已回填全部存量进行中服务单,数据一致性已建立;终态服务单不回填是有意设计
|
||||||
|
* (服务结束即失去访问权)。{@link ServiceAssignmentPolicy} 成为唯一事实源;</li>
|
||||||
|
* <li>读详情(getServiceDetail)改用 E2 防探测 404:非持有人响应与"不存在"逐字段一致,
|
||||||
|
* 删除 viewerIsOwner 双态与"非负责人脱敏详情"整段;</li>
|
||||||
|
* <li>行为变化:红娘读自己历史 completed 服务单详情将 404(符合 DEC-002 决策)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MatchmakerServiceService {
|
public class MatchmakerServiceService {
|
||||||
@@ -37,8 +66,12 @@ public class MatchmakerServiceService {
|
|||||||
private final MatchmakerRecommendationMapper recommendationMapper;
|
private final MatchmakerRecommendationMapper recommendationMapper;
|
||||||
private final MatchmakerFollowupMapper followupMapper;
|
private final MatchmakerFollowupMapper followupMapper;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
|
private final UserPrivacySettingsMapper userPrivacySettingsMapper;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final AuditLogMapper auditLogMapper;
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||||
|
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||||
|
private final MatchmakerAssignmentService matchmakerAssignmentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户申请红娘服务
|
* 用户申请红娘服务
|
||||||
@@ -82,43 +115,46 @@ 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);
|
MatchmakerAssignment createdAssignment = 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);
|
||||||
|
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||||
|
saveAuditLog(assignerOperatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null,
|
||||||
|
"matchmaker.service.assign", createdAssignment.getId());
|
||||||
|
return after;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,10 +168,10 @@ public class MatchmakerServiceService {
|
|||||||
/**
|
/**
|
||||||
* 红娘推荐候选人给用户
|
* 红娘推荐候选人给用户
|
||||||
*
|
*
|
||||||
* Phase 13 复验修复(D3):补 assigned_operator 归属校验。
|
* Phase 13 复验修复(D3):补归属校验。
|
||||||
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator
|
* ZC-MM-002(D4):归属判定切换到 {@link ServiceAssignmentPolicy}(matchmaker_assignments
|
||||||
* 都能对**任意** serviceId 创建推荐(越权)。与
|
* 是服务对象归属的唯一事实源,无任何 assigned_operator_id 回退);拒绝统一走
|
||||||
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。
|
* DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403),不再抛裸 IllegalStateException。
|
||||||
*
|
*
|
||||||
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
|
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
|
||||||
*/
|
*/
|
||||||
@@ -145,10 +181,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())) {
|
MatchmakerAssignment assignment = 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("不能推荐用户给自己");
|
||||||
@@ -171,7 +205,9 @@ public class MatchmakerServiceService {
|
|||||||
"matchmaker_recommendation", rec.getId().toString());
|
"matchmaker_recommendation", rec.getId().toString());
|
||||||
|
|
||||||
log.info("红娘推荐: serviceId={}, operatorId={}, targetUserId={}", serviceId, operatorId, targetUserId);
|
log.info("红娘推荐: serviceId={}, operatorId={}, targetUserId={}", serviceId, operatorId, targetUserId);
|
||||||
saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null);
|
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||||
|
saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null,
|
||||||
|
"matchmaker.service.recommend", assignment != null ? assignment.getId() : null);
|
||||||
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("recommendationId", rec.getId());
|
result.put("recommendationId", rec.getId());
|
||||||
@@ -182,11 +218,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, "服务不存在");
|
||||||
|
MatchmakerAssignment followupAssignment = assertServiceAccess(operatorId, service);
|
||||||
|
|
||||||
MatchmakerFollowup followup = new MatchmakerFollowup();
|
MatchmakerFollowup followup = new MatchmakerFollowup();
|
||||||
followup.setServiceId(serviceId);
|
followup.setServiceId(serviceId);
|
||||||
followup.setOperatorId(operatorId);
|
followup.setOperatorId(operatorId);
|
||||||
@@ -198,17 +241,23 @@ public class MatchmakerServiceService {
|
|||||||
followupMapper.insert(followup);
|
followupMapper.insert(followup);
|
||||||
|
|
||||||
log.info("红娘跟进: serviceId={}, operatorId={}, type={}", serviceId, operatorId, followupType);
|
log.info("红娘跟进: serviceId={}, operatorId={}, type={}", serviceId, operatorId, followupType);
|
||||||
saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null);
|
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||||
|
saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null,
|
||||||
|
"matchmaker.service.followup", followupAssignment != null ? followupAssignment.getId() : null);
|
||||||
return followup;
|
return followup;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 完成服务
|
* 完成服务
|
||||||
|
*
|
||||||
|
* 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, "服务不存在");
|
||||||
|
MatchmakerAssignment completeAssignment = 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, "当前状态不能完成");
|
||||||
@@ -219,7 +268,9 @@ public class MatchmakerServiceService {
|
|||||||
serviceMapper.updateById(service);
|
serviceMapper.updateById(service);
|
||||||
|
|
||||||
log.info("红娘服务完成: serviceId={}", serviceId);
|
log.info("红娘服务完成: serviceId={}", serviceId);
|
||||||
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null);
|
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||||
|
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null,
|
||||||
|
"matchmaker.service.complete", completeAssignment != null ? completeAssignment.getId() : null);
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +308,24 @@ public class MatchmakerServiceService {
|
|||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取红娘工作台数据。
|
||||||
|
*
|
||||||
|
* ZC-MM-002(D4):today / completed 改为本人口径(该红娘名下的服务),
|
||||||
|
* pendingCount 保留全局口径(待分配队列是分配权持有者的全局视图);
|
||||||
|
* inProgress 统计的状态口径由错误的 'processing' 修正为 'in_service'
|
||||||
|
* ('processing' 不在 V009 chk_matchmaker_status 白名单,恒为 0)。
|
||||||
|
*
|
||||||
|
* <p>ZC-MM-005 收口(P1-2):inProgress / today / completed 三项计数统一切换为
|
||||||
|
* <b>matchmaker_assignments 生效中分配</b>口径(SQL 侧 join assignments,见
|
||||||
|
* MatchmakerServiceMapper 计数查询)。口径说明:
|
||||||
|
* <ul>
|
||||||
|
* <li>三项均只统计「本人当前生效分配对象名下」的服务单;</li>
|
||||||
|
* <li>分配 end / revoke 后(含他人接手后原红娘),该用户的服务单
|
||||||
|
* <b>不再计入</b>原红娘的 dashboard 计数(DEC-002 规则7:服务结束失去访问权);</li>
|
||||||
|
* <li>completed 计数同理:历史 completed 单仅在分配仍生效时计入本人。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
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,57 +333,69 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取我的服务列表(红娘工作台)
|
* 获取服务详情(ZC-MM-005 / E1+E2 重构)。
|
||||||
*/
|
|
||||||
public List<MatchmakerServiceRecord> getOperatorServices(String operatorId) {
|
|
||||||
return serviceMapper.findByOperatorId(operatorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取服务详情
|
|
||||||
*
|
*
|
||||||
* Phase 13 复验修复(D4):改为按调用者授权范围返回。
|
* <p>此前(002 双轨)语义:负责人 → 完整详情;其他 operator → 脱敏详情(含 viewerIsOwner
|
||||||
* 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取
|
* 双态)。该设计有两个问题:① assigned_operator_id 回退分支在分配 revoke/end 后仍放行
|
||||||
* **任意** serviceId 的详情,其中包含该用户的完整资料
|
* (服务单残留该列);② 脱敏详情响应可被用于按 serviceId 枚举探测资源存在性。
|
||||||
* (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。
|
|
||||||
*
|
*
|
||||||
* 现语义:
|
* <p>现语义(判定器唯一事实源,无回退):
|
||||||
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups)
|
* <ul>
|
||||||
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情
|
* <li>查无此 serviceId → 抛 BusinessException(NOT_FOUND, "服务不存在");</li>
|
||||||
* (不含 userProfile;followups 正文也不返回,仅给条数)
|
* <li>非持有人(无生效中的分配)→ {@link ServiceAssignmentPolicy}
|
||||||
|
* #assertActiveAssignmentOrNotFound:先落 DATA_SCOPE_DENIED 审计,
|
||||||
|
* 再抛与"查无"完全相同的 NOT_FOUND 异常 —— 两种失败响应逐字段一致,防探测;</li>
|
||||||
|
* <li>持有人 → 完整详情(申请人 userId / 诉求描述 / 白名单 userProfile / 跟进正文 /
|
||||||
|
* 推荐列表),不再有 viewerIsOwner 双态字段。</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @param serviceId 服务 ID
|
* <p>行为变化:红娘读自己历史 completed 服务单详情将 404(分配已终态,符合 DEC-002)。
|
||||||
|
*
|
||||||
|
* @param serviceId 服务 ID
|
||||||
* @param requesterId 调用者 operator ID
|
* @param requesterId 调用者 operator ID
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
|
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
|
||||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||||
if (service == null) return null;
|
if (service == null) {
|
||||||
|
// 与"非持有人"路径抛出完全相同的异常 → 响应体逐字段一致(防探测,E2)
|
||||||
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||||
|
}
|
||||||
|
|
||||||
boolean isOwner = requesterId != null && requesterId.equals(service.getAssignedOperatorId());
|
// E1:判定器唯一事实源;E2:非持有人防探测 404(先审计后 NOT_FOUND)
|
||||||
|
serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
|
||||||
|
requesterId, service.getUserId(), "matchmaker_service", serviceId);
|
||||||
|
|
||||||
|
// ZC-MM-006 / T03:持有人读取服务对象详情 = 敏感数据访问,必须落 sensitive_access_logs
|
||||||
|
// (AGENTS.md #8:查看动作本身必须可溯源)。拒绝路径走判定器的 DATA_SCOPE_DENIED 审计,
|
||||||
|
// 双记录互斥 —— 非持有人不会走到本行(不重复写 SensitiveAccessLog)。
|
||||||
|
SensitiveAccessLog accessLog = new SensitiveAccessLog();
|
||||||
|
accessLog.setOperatorId(requesterId);
|
||||||
|
accessLog.setUserId(service.getUserId());
|
||||||
|
accessLog.setDataType(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL);
|
||||||
|
accessLog.setReason(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL);
|
||||||
|
accessLog.setAccessTarget(serviceId);
|
||||||
|
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
|
||||||
|
sensitiveAccessLogMapper.insert(accessLog);
|
||||||
|
|
||||||
Map<String, Object> detail = new HashMap<>();
|
Map<String, Object> detail = new HashMap<>();
|
||||||
detail.put("serviceId", service.getId());
|
detail.put("serviceId", service.getId());
|
||||||
detail.put("serviceType", service.getServiceType());
|
detail.put("serviceType", service.getServiceType());
|
||||||
detail.put("status", service.getStatus());
|
detail.put("status", service.getStatus());
|
||||||
detail.put("assignedOperatorId", service.getAssignedOperatorId());
|
|
||||||
detail.put("createdAt", service.getCreatedAt());
|
detail.put("createdAt", service.getCreatedAt());
|
||||||
detail.put("assignedAt", service.getAssignedAt());
|
detail.put("assignedAt", service.getAssignedAt());
|
||||||
detail.put("completedAt", service.getCompletedAt());
|
detail.put("completedAt", service.getCompletedAt());
|
||||||
detail.put("viewerIsOwner", isOwner);
|
|
||||||
|
|
||||||
// 加载推荐列表(推荐是服务进展的必要摘要,两者都可见)
|
// 推荐列表(服务进展摘要)
|
||||||
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
||||||
detail.put("recommendations", recs.stream().map(r -> {
|
detail.put("recommendations", recs.stream().map(r -> {
|
||||||
Map<String, Object> map = new HashMap<>();
|
Map<String, Object> map = new HashMap<>();
|
||||||
@@ -326,35 +407,78 @@ public class MatchmakerServiceService {
|
|||||||
return map;
|
return map;
|
||||||
}).collect(Collectors.toList()));
|
}).collect(Collectors.toList()));
|
||||||
|
|
||||||
if (isOwner) {
|
// 仅持有人可见:申请人 userId / 诉求描述 / 白名单用户资料 / 跟进正文
|
||||||
// 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文
|
detail.put("userId", service.getUserId());
|
||||||
detail.put("userId", service.getUserId());
|
detail.put("description", service.getDescription());
|
||||||
detail.put("description", service.getDescription());
|
UserPrivacySettings privacy = userPrivacySettingsMapper.findByUserId(service.getUserId())
|
||||||
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
|
.orElse(null);
|
||||||
Map<String, Object> userProfile = new HashMap<>();
|
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile ->
|
||||||
userProfile.put("displayName", profile.getDisplayName());
|
detail.put("userProfile", assembleMatchmakerUserProfile(profile, privacy)));
|
||||||
userProfile.put("gender", profile.getGender());
|
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
||||||
userProfile.put("age", calculateAge(profile.getBirthDate()));
|
|
||||||
userProfile.put("regionName", profile.getRegionName());
|
|
||||||
userProfile.put("educationCode", profile.getEducationCode());
|
|
||||||
userProfile.put("occupationCode", profile.getOccupationCode());
|
|
||||||
userProfile.put("maritalStatus", profile.getMaritalStatus());
|
|
||||||
detail.put("userProfile", userProfile);
|
|
||||||
});
|
|
||||||
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
|
||||||
} else {
|
|
||||||
// 非负责人:只给跟进条数作为进度摘要,不返回正文
|
|
||||||
detail.put("followupCount", followupMapper.findByServiceId(serviceId).size());
|
|
||||||
}
|
|
||||||
|
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红娘侧用户资料装配(ZC-MM-005 / E3 字段白名单显式化)。
|
||||||
|
*
|
||||||
|
* <p>逐字段对照《安全、隐私、合规与风控体系设计》§5.2 字段级隐私清单:
|
||||||
|
* <ul>
|
||||||
|
* <li>displayName —— 基础展示字段,允许</li>
|
||||||
|
* <li>gender —— 基础展示字段,允许</li>
|
||||||
|
* <li>age —— 由 birthDate 计算的派生值(<b>不输出原始 birthDate</b>)</li>
|
||||||
|
* <li>regionName —— 地区展示名(非精确坐标),允许</li>
|
||||||
|
* <li>educationCode —— 枚举码,允许</li>
|
||||||
|
* <li>occupationCode —— 枚举码,<b>受隐私开关 showOccupation 控制</b>:
|
||||||
|
* 用户明确关闭(false)时不输出;隐私记录缺失或开关未设置(null)按缺省放行</li>
|
||||||
|
* <li>maritalStatus —— 枚举码,允许</li>
|
||||||
|
* </ul>
|
||||||
|
* §5.2 禁放字段(phone/mobile/wechat/idNumber/birthDate/address/id_card 等)
|
||||||
|
* 一律不进入装配;守卫见 MatchmakerFieldPrivacyGuardTest(源码级白名单扫描)。
|
||||||
|
*
|
||||||
|
* @param profile 用户资料(可空字段自然输出 null,由前端按缺失处理)
|
||||||
|
* @param privacy 用户隐私设置;null 表示用户未设置过(缺省=全放行)
|
||||||
|
*/
|
||||||
|
private Map<String, Object> assembleMatchmakerUserProfile(UserProfile profile,
|
||||||
|
UserPrivacySettings privacy) {
|
||||||
|
// 缺省=全放行:仅在用户显式关闭(showOccupation == false)时隐藏职业
|
||||||
|
boolean showOccupation = privacy == null || !Boolean.FALSE.equals(privacy.getShowOccupation());
|
||||||
|
Map<String, Object> userProfile = new HashMap<>();
|
||||||
|
userProfile.put("displayName", profile.getDisplayName());
|
||||||
|
userProfile.put("gender", profile.getGender());
|
||||||
|
// 派生年龄:不输出原始 birthDate(§5.2 禁放)
|
||||||
|
userProfile.put("age", calculateAge(profile.getBirthDate()));
|
||||||
|
userProfile.put("regionName", profile.getRegionName());
|
||||||
|
userProfile.put("educationCode", profile.getEducationCode());
|
||||||
|
if (showOccupation) {
|
||||||
|
userProfile.put("occupationCode", profile.getOccupationCode());
|
||||||
|
}
|
||||||
|
userProfile.put("maritalStatus", profile.getMaritalStatus());
|
||||||
|
return userProfile;
|
||||||
|
}
|
||||||
|
|
||||||
private int calculateAge(java.time.LocalDate birthDate) {
|
private int calculateAge(java.time.LocalDate birthDate) {
|
||||||
if (birthDate == null) return 0;
|
if (birthDate == null) return 0;
|
||||||
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
|
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务归属校验(ZC-MM-005 / E1 纯判定器版;ZC-MM-006 / T04 写接口 404 化)。
|
||||||
|
*
|
||||||
|
* <p><b>无任何回退</b>:matchmaker_assignments 是服务对象归属的唯一事实源。
|
||||||
|
* 002 的 assigned_operator_id 回退分支已删除 —— 分配 revoke/end 后服务单残留的
|
||||||
|
* assigned_operator_id 不再构成访问凭据(DEC-002 规则7:撤销即失权)。
|
||||||
|
* 写接口(recommend/followup/complete,资源寻址端点)口径:判定无效 →
|
||||||
|
* {@link ServiceAssignmentPolicy#assertActiveAssignmentOrNotFound}:
|
||||||
|
* 先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」—— 与读详情口径逐字段一致,防探测。
|
||||||
|
*
|
||||||
|
* @return 生效中的分配记录(供审计写入 service_assignment_id;deny 必然抛出,不会返回 null)
|
||||||
|
*/
|
||||||
|
private MatchmakerAssignment assertServiceAccess(String operatorId, MatchmakerServiceRecord service) {
|
||||||
|
return serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(operatorId, service.getUserId(),
|
||||||
|
"matchmaker_service", service.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取红娘为该用户推荐的人员列表
|
* 获取红娘为该用户推荐的人员列表
|
||||||
@@ -394,11 +518,25 @@ public class MatchmakerServiceService {
|
|||||||
|
|
||||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||||
String resourceId, String beforeData, String afterData) {
|
String resourceId, String beforeData, String afterData) {
|
||||||
|
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写(负向用例覆盖)
|
||||||
|
saveAuditLog(operatorId, action, resourceType, resourceId, beforeData, afterData, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T01 扩展版审计写入:附加 purpose_code 与 service_assignment_id。
|
||||||
|
*/
|
||||||
|
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||||
|
String resourceId, String beforeData, String afterData,
|
||||||
|
String purposeCode, String serviceAssignmentId) {
|
||||||
AuditLog auditLog = new AuditLog();
|
AuditLog auditLog = new AuditLog();
|
||||||
auditLog.setOperatorId(operatorId);
|
auditLog.setOperatorId(operatorId);
|
||||||
auditLog.setAction(action);
|
auditLog.setAction(action);
|
||||||
auditLog.setResourceType(resourceType);
|
auditLog.setResourceType(resourceType);
|
||||||
auditLog.setResourceId(resourceId);
|
auditLog.setResourceId(resourceId);
|
||||||
|
auditLog.setBeforeData(beforeData);
|
||||||
|
auditLog.setAfterData(afterData);
|
||||||
|
auditLog.setPurposeCode(purposeCode);
|
||||||
|
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||||
auditLogMapper.insert(auditLog);
|
auditLogMapper.insert(auditLog);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务对象分配判定器(ZC-MM-002 / A1,架构师设计 D6;ZC-MM-005 / E1-E2 增强)。
|
||||||
|
*
|
||||||
|
* <p>红娘对某个用户能否读/写,唯一事实源是 matchmaker_assignments 里
|
||||||
|
* 「生效中的分配」(active 且未过有效期)。所有读路径前先 {@link #expireOverdue}
|
||||||
|
* 惰性过期(无定时任务,D5 决策),保证过期分配立即失效。
|
||||||
|
*
|
||||||
|
* <p>判定无效时的两种处置:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #assertActiveAssignment}:写接口口径 —— 落 DATA_SCOPE_DENIED 审计 +
|
||||||
|
* 抛 AccessDeniedException(403);</li>
|
||||||
|
* <li>{@link #assertActiveAssignmentOrNotFound}(ZC-MM-005 / E2):读详情口径 ——
|
||||||
|
* 先落 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),再抛
|
||||||
|
* BusinessException(NOT_FOUND)(响应 404,与"资源不存在"逐字段一致),
|
||||||
|
* 防止枚举 serviceId 探测资源存在性。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@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 必然抛出,此行不可达
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读详情口径的断言(ZC-MM-005 / E2 防探测 404)。
|
||||||
|
*
|
||||||
|
* <p>无效时先写 DATA_SCOPE_DENIED 审计(字段口径与 {@link DataScopeGuard#deny} 对齐,
|
||||||
|
* 语义上这是一次越权尝试),再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}。
|
||||||
|
* 全局异常处理器将其映射为 404 响应体 {@code {success:false, code:NOT_FOUND,
|
||||||
|
* message:"服务不存在"}} —— 与查询一个真正不存在的 serviceId 的响应逐字段一致,
|
||||||
|
* 调用方无法区分"无权"与"不存在",杜绝按 serviceId 枚举探测。
|
||||||
|
*
|
||||||
|
* <p>适用面(ZC-MM-006 / T04 扩展):读详情 + <b>带资源寻址的写端点</b>
|
||||||
|
* (recommend / followup / complete / 转介)范围校验失败统一 404 化(防探测);
|
||||||
|
* create / assign 类无资源寻址端点维持 {@link #assertActiveAssignment} 的 403 口径。
|
||||||
|
*
|
||||||
|
* @param resourceType 审计 resourceType(如 matchmaker_service)
|
||||||
|
* @param resourceId 审计 resourceId(被探测的 serviceId)
|
||||||
|
* @return 生效中的分配记录(永不返回 null)
|
||||||
|
*/
|
||||||
|
public MatchmakerAssignment assertActiveAssignmentOrNotFound(String operatorId, String userId,
|
||||||
|
String resourceType,
|
||||||
|
String resourceId) {
|
||||||
|
Optional<MatchmakerAssignment> found = findActiveAssignment(operatorId, userId);
|
||||||
|
if (found.isPresent()) {
|
||||||
|
return found.get();
|
||||||
|
}
|
||||||
|
// ZC-MM-006 / T04:防探测 404 化 deny —— 审计与 404 抛出统一收敛到
|
||||||
|
// {@link DataScopeGuard#denyAsNotFound}(审计含权限快照 + 请求追踪 ID)。
|
||||||
|
// 该方法同时服务读详情与(本阶段起)写接口范围校验,拒绝口径全程一致。
|
||||||
|
dataScopeGuard.denyAsNotFound(operatorId, resourceType, resourceId, "服务不存在", null, null);
|
||||||
|
return null; // denyAsNotFound 必然抛出,此行不可达
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某红娘名下全部生效中的分配(GET /my 口径),读前惰性过期。 */
|
||||||
|
public List<MatchmakerAssignment> findActiveByOperator(String operatorId) {
|
||||||
|
expireOverdue();
|
||||||
|
return assignmentMapper.findActiveByOperator(operatorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.danyang.match.platform.operator.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.operator.service.OperatorManagementService;
|
||||||
|
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.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/operator/matchmaker-team")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.role.manage')")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerTeamController {
|
||||||
|
private static final Set<String> MANAGED_ROLES = Set.of("matchmaker", "matchmaker_leader");
|
||||||
|
private final OperatorManagementService operatorService;
|
||||||
|
|
||||||
|
@GetMapping("/members")
|
||||||
|
public ApiResponse<Map<String, Object>> members(
|
||||||
|
@RequestParam(defaultValue = "0") Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") Integer pageSize,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) String status) {
|
||||||
|
return ApiResponse.ok(operatorService.listMatchmakerOperators(page, pageSize, keyword, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/roles")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> roles() {
|
||||||
|
return ApiResponse.ok(operatorService.listMatchmakerRoleTemplates());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/members")
|
||||||
|
public ApiResponse<?> create(@RequestBody Map<String, Object> params,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(operatorService.createMatchmakerOperator(params, principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/members/{id}")
|
||||||
|
public ApiResponse<Map<String, Object>> update(@PathVariable String id,
|
||||||
|
@RequestBody Map<String, Object> params,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(operatorService.updateMatchmakerOperator(id, params, principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/members/{id}/enable")
|
||||||
|
public ApiResponse<Void> enable(@PathVariable String id, @AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
operatorService.assertMatchmakerTarget(id);
|
||||||
|
operatorService.enableOperator(id, principal.id());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/members/{id}/disable")
|
||||||
|
public ApiResponse<Void> disable(@PathVariable String id, @AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
operatorService.assertMatchmakerTarget(id);
|
||||||
|
operatorService.disableOperator(id, principal.id());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/members/{id}/reset-password")
|
||||||
|
public ApiResponse<Void> resetPassword(@PathVariable String id,
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
operatorService.assertMatchmakerTarget(id);
|
||||||
|
String newPassword = body.get("newPassword");
|
||||||
|
if (newPassword == null || newPassword.length() < 8) {
|
||||||
|
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "密码至少8位");
|
||||||
|
}
|
||||||
|
operatorService.resetPassword(id, newPassword, principal.id());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-6
@@ -34,12 +34,9 @@ public class OperatorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ApiResponse<?> create(@RequestBody Map<String, Object> params) {
|
public ApiResponse<?> create(@RequestBody Map<String, Object> params,
|
||||||
String name = (String) params.get("name");
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
String username = (String) params.get("username");
|
return ApiResponse.ok(operatorService.createOperator(params, principal.id()));
|
||||||
String password = (String) params.get("password");
|
|
||||||
String organizationId = (String) params.get("organizationId");
|
|
||||||
return ApiResponse.ok(operatorService.createOperator(name, username, password, organizationId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/disable")
|
@PutMapping("/{id}/disable")
|
||||||
@@ -73,4 +70,21 @@ public class OperatorController {
|
|||||||
return ApiResponse.ok(operatorService.listOrganizations());
|
return ApiResponse.ok(operatorService.listOrganizations());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/roles")
|
||||||
|
public ApiResponse<List<?>> listRoles(@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(operatorService.listAssignableRoles(principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<Map<String, Object>> detail(@PathVariable String id) {
|
||||||
|
return ApiResponse.ok(operatorService.getOperatorDetail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/roles")
|
||||||
|
public ApiResponse<Map<String, Object>> updateRoles(@PathVariable String id,
|
||||||
|
@RequestBody Map<String, Object> params,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(operatorService.updateRoles(id, params, principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-2
@@ -2,10 +2,17 @@ package com.danyang.match.platform.operator.service;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.danyang.match.platform.organization.entity.Organization;
|
import com.danyang.match.platform.organization.entity.Organization;
|
||||||
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
import com.danyang.match.platform.permission.entity.Operator;
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.PermissionMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.RoleMapper;
|
||||||
|
import com.danyang.match.platform.permission.entity.Role;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
@@ -21,9 +28,14 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class OperatorManagementService {
|
public class OperatorManagementService {
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
private final OperatorMapper operatorMapper;
|
private final OperatorMapper operatorMapper;
|
||||||
private final OrganizationMapper organizationMapper;
|
private final OrganizationMapper organizationMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
|
private final PermissionMapper permissionMapper;
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
public Map<String, Object> listOperators(Integer page, Integer pageSize, String keyword, String status) {
|
public Map<String, Object> listOperators(Integer page, Integer pageSize, String keyword, String status) {
|
||||||
LambdaQueryWrapper<Operator> query = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<Operator> query = new LambdaQueryWrapper<>();
|
||||||
@@ -44,9 +56,14 @@ public class OperatorManagementService {
|
|||||||
List<Map<String, Object>> items = operators.stream().map(op -> {
|
List<Map<String, Object>> items = operators.stream().map(op -> {
|
||||||
Map<String, Object> m = new HashMap<>();
|
Map<String, Object> m = new HashMap<>();
|
||||||
m.put("id", op.getId());
|
m.put("id", op.getId());
|
||||||
|
m.put("displayId", op.getDisplayId());
|
||||||
m.put("name", op.getName());
|
m.put("name", op.getName());
|
||||||
m.put("username", op.getUsername());
|
m.put("username", op.getUsername());
|
||||||
m.put("status", op.getStatus());
|
m.put("status", op.getStatus());
|
||||||
|
m.put("organizationId", op.getOrganizationId());
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(op.getId());
|
||||||
|
m.put("roles", roles.stream().map(Role::getCode).toList());
|
||||||
|
m.put("scope", deriveScope(roles));
|
||||||
m.put("createdAt", op.getCreatedAt());
|
m.put("createdAt", op.getCreatedAt());
|
||||||
return m;
|
return m;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -59,9 +76,121 @@ public class OperatorManagementService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> listMatchmakerOperators(Integer page, Integer pageSize, String keyword, String status) {
|
||||||
|
List<Operator> filtered = operatorMapper.findMatchmakerOperators().stream()
|
||||||
|
.filter(op -> keyword == null || keyword.isBlank()
|
||||||
|
|| (op.getName() != null && op.getName().contains(keyword))
|
||||||
|
|| (op.getUsername() != null && op.getUsername().contains(keyword)))
|
||||||
|
.filter(op -> status == null || status.isBlank() || status.equals(op.getStatus()))
|
||||||
|
.toList();
|
||||||
|
int currentPage = Math.max(page == null ? 0 : page, 0);
|
||||||
|
int limit = Math.max(pageSize == null ? 20 : pageSize, 1);
|
||||||
|
int from = Math.min(currentPage * limit, filtered.size());
|
||||||
|
int to = Math.min(from + limit, filtered.size());
|
||||||
|
List<Map<String, Object>> items = filtered.subList(from, to).stream().map(this::operatorSummary).toList();
|
||||||
|
return Map.of("items", items, "total", filtered.size(), "page", currentPage, "pageSize", limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listMatchmakerRoleTemplates() {
|
||||||
|
return roleMapper.selectList(new LambdaQueryWrapper<Role>()
|
||||||
|
.in(Role::getCode, List.of("matchmaker", "matchmaker_leader"))
|
||||||
|
.orderByAsc(Role::getCode)).stream()
|
||||||
|
.map(role -> {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("code", role.getCode());
|
||||||
|
item.put("name", role.getName());
|
||||||
|
item.put("description", role.getDescription());
|
||||||
|
item.put("scope", scopeForRole(role.getCode()));
|
||||||
|
item.put("permissions", roleMapper.findPermissionCodes(role.getId()));
|
||||||
|
return item;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Operator createOperator(String name, String username, String password, String organizationId) {
|
public Map<String, Object> createMatchmakerOperator(Map<String, Object> params, String byOperatorId) {
|
||||||
|
List<String> roles = roleCodes(params.get("roleCodes"));
|
||||||
|
if (roles.isEmpty() && params.get("roleCode") != null) roles = List.of(String.valueOf(params.get("roleCode")));
|
||||||
|
assertMatchmakerRoles(roles);
|
||||||
|
Map<String, Object> normalized = new HashMap<>(params);
|
||||||
|
String name = requireText(params, "name");
|
||||||
|
validateMatchmakerName(name);
|
||||||
|
normalized.put("name", name);
|
||||||
|
normalized.put("username", generateMatchmakerUsername(name));
|
||||||
|
normalized.put("roleCodes", roles);
|
||||||
|
Operator created = createOperator(normalized, byOperatorId);
|
||||||
|
Map<String, Object> result = getOperatorDetail(created.getId());
|
||||||
|
result.put("usernameGenerated", true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> updateMatchmakerOperator(String operatorId, Map<String, Object> params, String byOperatorId) {
|
||||||
|
if (operatorId.equals(byOperatorId)) throw new BusinessException(ApiResponseCode.FORBIDDEN, "不能修改自己的角色");
|
||||||
|
assertMatchmakerTarget(operatorId);
|
||||||
|
List<String> roles = roleCodes(params.get("roleCodes"));
|
||||||
|
if (roles.isEmpty() && params.get("roleCode") != null) roles = List.of(String.valueOf(params.get("roleCode")));
|
||||||
|
assertMatchmakerRoles(roles);
|
||||||
|
Map<String, Object> normalized = new HashMap<>(params);
|
||||||
|
normalized.put("roleCodes", roles);
|
||||||
|
return updateRoles(operatorId, normalized, byOperatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void assertMatchmakerTarget(String operatorId) {
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(operatorId);
|
||||||
|
if (roles.stream().noneMatch(role -> Set.of("matchmaker", "matchmaker_leader").contains(role.getCode()))) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "目标账号不是红娘团队成员");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertMatchmakerRoles(List<String> roleCodes) {
|
||||||
|
if (roleCodes.size() != 1 || roleCodes.stream().noneMatch(code -> Set.of("matchmaker", "matchmaker_leader").contains(code))) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "只能选择普通红娘或红娘组长角色");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMatchmakerName(String name) {
|
||||||
|
if (!name.matches("^[\\p{IsHan}]{2,20}$")) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "红娘姓名必须为2至20个中文字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateMatchmakerUsername(String name) {
|
||||||
|
String prefix = "红娘" + name;
|
||||||
|
for (int sequence = 1; sequence <= 9999; sequence++) {
|
||||||
|
String candidate = prefix + String.format("%04d", sequence);
|
||||||
|
if (operatorMapper.findByUsername(candidate).isEmpty()) return candidate;
|
||||||
|
}
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "该姓名的红娘用户名已达到可分配上限");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> operatorSummary(Operator op) {
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(op.getId());
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("id", op.getId());
|
||||||
|
item.put("displayId", op.getDisplayId());
|
||||||
|
item.put("name", op.getName());
|
||||||
|
item.put("username", op.getUsername());
|
||||||
|
item.put("status", op.getStatus());
|
||||||
|
item.put("organizationId", op.getOrganizationId());
|
||||||
|
item.put("roles", roles.stream().map(Role::getCode).toList());
|
||||||
|
item.put("scope", deriveScope(roles));
|
||||||
|
item.put("createdAt", op.getCreatedAt());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Operator createOperator(Map<String, Object> params, String byOperatorId) {
|
||||||
|
String name = requireText(params, "name");
|
||||||
|
String username = requireText(params, "username");
|
||||||
|
String password = requireText(params, "password");
|
||||||
|
String organizationId = (String) params.get("organizationId");
|
||||||
|
List<String> roleCodes = roleCodes(params.get("roleCodes"));
|
||||||
|
validateRoles(roleCodes, organizationId, byOperatorId, null);
|
||||||
|
if (operatorMapper.findByUsername(username).isPresent()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在");
|
||||||
|
}
|
||||||
Operator operator = new Operator();
|
Operator operator = new Operator();
|
||||||
|
operator.setId(UUID.randomUUID().toString());
|
||||||
operator.setName(name);
|
operator.setName(name);
|
||||||
operator.setUsername(username);
|
operator.setUsername(username);
|
||||||
operator.setPasswordHash(passwordEncoder.encode(password));
|
operator.setPasswordHash(passwordEncoder.encode(password));
|
||||||
@@ -69,9 +198,115 @@ public class OperatorManagementService {
|
|||||||
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
operator.setCreatedAt(Instant.now());
|
operator.setCreatedAt(Instant.now());
|
||||||
operatorMapper.insert(operator);
|
operatorMapper.insert(operator);
|
||||||
|
bindRoles(operator.getId(), roleCodes);
|
||||||
|
audit(byOperatorId, "CREATE_OPERATOR", operator.getId(), roleCodes);
|
||||||
return operator;
|
return operator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listAssignableRoles(String operatorId) {
|
||||||
|
boolean superAdmin = permissionMapper.findPermissionsByOperatorId(operatorId).stream()
|
||||||
|
.anyMatch(p -> "account.retention.purge".equals(p.getCode()));
|
||||||
|
return roleMapper.findAssignableTemplates().stream()
|
||||||
|
.filter(r -> superAdmin || !Set.of("platform_admin").contains(r.getCode()))
|
||||||
|
.map(r -> {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("code", r.getCode());
|
||||||
|
item.put("name", r.getName());
|
||||||
|
item.put("description", r.getDescription());
|
||||||
|
item.put("scope", scopeForRole(r.getCode()));
|
||||||
|
item.put("permissions", roleMapper.findPermissionCodes(r.getId()));
|
||||||
|
return item;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getOperatorDetail(String operatorId) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在"));
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(operatorId);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("id", operator.getId());
|
||||||
|
result.put("username", operator.getUsername());
|
||||||
|
result.put("name", operator.getName());
|
||||||
|
result.put("organizationId", operator.getOrganizationId());
|
||||||
|
result.put("status", operator.getStatus());
|
||||||
|
result.put("roles", roles.stream().map(Role::getCode).toList());
|
||||||
|
result.put("scope", deriveScope(roles));
|
||||||
|
result.put("permissions", roles.stream().flatMap(r -> roleMapper.findPermissionCodes(r.getId()).stream()).distinct().sorted().toList());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> updateRoles(String operatorId, Map<String, Object> params, String byOperatorId) {
|
||||||
|
if (operatorId.equals(byOperatorId)) throw new BusinessException(ApiResponseCode.FORBIDDEN, "不能修改自己的角色");
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在"));
|
||||||
|
List<String> roleCodes = roleCodes(params.get("roleCodes"));
|
||||||
|
String organizationId = params.containsKey("organizationId") ? (String) params.get("organizationId") : operator.getOrganizationId();
|
||||||
|
validateRoles(roleCodes, organizationId, byOperatorId, operatorId);
|
||||||
|
operator.setOrganizationId(organizationId);
|
||||||
|
operator.setUpdatedAt(Instant.now());
|
||||||
|
operatorMapper.updateById(operator);
|
||||||
|
roleMapper.unbindAll(operatorId);
|
||||||
|
bindRoles(operatorId, roleCodes);
|
||||||
|
audit(byOperatorId, "UPDATE_OPERATOR_ROLES", operatorId, roleCodes);
|
||||||
|
return getOperatorDetail(operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindRoles(String operatorId, List<String> roleCodes) {
|
||||||
|
for (String code : roleCodes) {
|
||||||
|
Role role = roleMapper.selectOne(new LambdaQueryWrapper<Role>().eq(Role::getCode, code));
|
||||||
|
if (role == null) throw new BusinessException(ApiResponseCode.BAD_REQUEST, "角色不存在: " + code);
|
||||||
|
roleMapper.bind(operatorId, role.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRoles(List<String> roleCodes, String organizationId, String byOperatorId, String targetId) {
|
||||||
|
if (roleCodes.isEmpty()) throw new BusinessException(ApiResponseCode.BAD_REQUEST, "至少选择一个角色");
|
||||||
|
if (roleCodes.contains("matchmaker_leader") && organizationId == null) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "红娘组长必须选择所属组织");
|
||||||
|
}
|
||||||
|
boolean superAdmin = permissionMapper.findPermissionsByOperatorId(byOperatorId).stream()
|
||||||
|
.anyMatch(p -> "account.retention.purge".equals(p.getCode()));
|
||||||
|
if (!superAdmin && roleCodes.stream().anyMatch(Set.of("super_admin", "platform_admin")::contains)) {
|
||||||
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "当前账号不能分配管理员角色");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> roleCodes(Object value) {
|
||||||
|
if (!(value instanceof List<?> list)) return List.of();
|
||||||
|
return list.stream().map(String::valueOf).filter(s -> !s.isBlank()).distinct().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireText(Map<String, Object> params, String key) {
|
||||||
|
String value = params.get(key) == null ? "" : String.valueOf(params.get(key)).trim();
|
||||||
|
if (value.isBlank()) throw new BusinessException(ApiResponseCode.BAD_REQUEST, key + "不能为空");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deriveScope(List<Role> roles) {
|
||||||
|
if (roles.stream().anyMatch(r -> Set.of("super_admin", "platform_admin").contains(r.getCode()))) return "ALL";
|
||||||
|
if (roles.stream().anyMatch(r -> "matchmaker_leader".equals(r.getCode()))) return "TEAM";
|
||||||
|
return "SELF";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scopeForRole(String code) {
|
||||||
|
return Set.of("super_admin", "platform_admin").contains(code) ? "ALL" : "matchmaker_leader".equals(code) ? "TEAM" : "SELF";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void audit(String byOperatorId, String action, String targetId, List<String> roles) {
|
||||||
|
AuditLog log = new AuditLog();
|
||||||
|
log.setOperatorId(byOperatorId);
|
||||||
|
log.setAction(action);
|
||||||
|
log.setResourceType("operator");
|
||||||
|
log.setResourceId(targetId);
|
||||||
|
try {
|
||||||
|
log.setAfterData(OBJECT_MAPPER.writeValueAsString(Map.of("roles", roles)));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("审计数据序列化失败", e);
|
||||||
|
}
|
||||||
|
auditLogMapper.insert(log);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void disableOperator(String operatorId, String byOperatorId) {
|
public void disableOperator(String operatorId, String byOperatorId) {
|
||||||
Operator operator = operatorMapper.selectById(operatorId);
|
Operator operator = operatorMapper.selectById(operatorId);
|
||||||
@@ -102,7 +337,7 @@ public class OperatorManagementService {
|
|||||||
public List<Organization> listOrganizations() {
|
public List<Organization> listOrganizations() {
|
||||||
return organizationMapper.selectList(
|
return organizationMapper.selectList(
|
||||||
new LambdaQueryWrapper<Organization>()
|
new LambdaQueryWrapper<Organization>()
|
||||||
.eq(Organization::getStatus, Constants.ACCOUNT_STATUS_NORMAL)
|
.eq(Organization::getStatus, Constants.ORG_STATUS_ACTIVE)
|
||||||
.orderByDesc(Organization::getCreatedAt));
|
.orderByDesc(Organization::getCreatedAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public class OperatorService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Operator operator = new Operator();
|
Operator operator = new Operator();
|
||||||
|
operator.setId(java.util.UUID.randomUUID().toString());
|
||||||
operator.setName(name);
|
operator.setName(name);
|
||||||
operator.setUsername(username);
|
operator.setUsername(username);
|
||||||
operator.setPasswordHash(passwordEncoder.encode(password));
|
operator.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
|||||||
+1
-1
@@ -166,7 +166,7 @@ public class OrganizationService {
|
|||||||
private static void requireAllowedType(String type) {
|
private static void requireAllowedType(String type) {
|
||||||
if (type == null || !ALLOWED_TYPES.contains(type)) {
|
if (type == null || !ALLOWED_TYPES.contains(type)) {
|
||||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
|
||||||
"机构类型不合法:" + type + ",允许值 " + ALLOWED_TYPES);
|
"机构类型不合法,请选择妇联、工会、公益服务中心、志愿者团队、民政合作单位或其他合作机构");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user