Compare commits
17
Commits
v1.1.1
..
3c2b967274
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c2b967274 | ||
|
|
827c23db1d | ||
|
|
ef61d51f61 | ||
|
|
02bc01f9c9 | ||
|
|
16c82739db | ||
|
|
f73a116a10 | ||
|
|
21275ef770 | ||
|
|
d3735e3afa | ||
|
|
4248e9574e | ||
|
|
6c2601950b | ||
|
|
1be0d98ebd | ||
|
|
5e484d0e74 | ||
|
|
3caf5f8816 | ||
|
|
861c78efb5 | ||
|
|
6187ec69df | ||
|
|
2d60f7899f | ||
|
|
f76bda1efa |
@@ -32,8 +32,12 @@ server {
|
||||
}
|
||||
|
||||
# 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/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -42,7 +46,7 @@ server {
|
||||
|
||||
# WebSocket proxy for chat
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
packages:
|
||||
- '.'
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- vue-demi
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 672 KiB |
@@ -1,26 +1,52 @@
|
||||
<template>
|
||||
<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">
|
||||
<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">
|
||||
<strong>善缘丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
<div class="brand-name-row">
|
||||
<strong>爱在丹阳</strong>
|
||||
<em class="brand-badge">公益运营</em>
|
||||
</div>
|
||||
<span class="brand-tagline">公益婚恋服务平台</span>
|
||||
<span class="brand-ornament" aria-hidden="true">❖ ● ❖ ● ❖</span>
|
||||
</div>
|
||||
<div class="scene-title">
|
||||
<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 class="scene-slogan" aria-hidden="true">
|
||||
<span>善缘相牵</span>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span class="slogan-top">爱在丹阳</span>
|
||||
<strong class="slogan-main">原来是你</strong>
|
||||
<em class="slogan-en">Love In Danyang</em>
|
||||
</div>
|
||||
|
||||
<div class="scene-benefits" aria-label="平台服务原则">
|
||||
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span>
|
||||
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span>
|
||||
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span>
|
||||
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><StarFilled /></el-icon></i><b>公益为本</b><small>不以营利为目的</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><Lock /></el-icon></i><b>真实可靠</b><small>严格审核·多重保障</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><LocationFilled /></el-icon></i><b>本地服务</b><small>深耕丹阳·服务家乡</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><Opportunity /></el-icon></i><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -29,7 +55,7 @@
|
||||
<div class="brand" :class="{ compact: isCollapse }">
|
||||
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
|
||||
<div v-if="!isCollapse" class="brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,14 +147,16 @@
|
||||
</el-container>
|
||||
|
||||
<footer class="scene-footer">
|
||||
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span>
|
||||
<strong>爱在丹阳</strong><span>公益婚恋服务平台</span>
|
||||
<small>真实 · 真诚 · 公益 · 幸福</small>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
import { summarizeMatchmakerServices } from '@/services/dashboard'
|
||||
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -199,6 +227,25 @@ const isCollapse = ref(false)
|
||||
const globalSearch = ref('')
|
||||
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('matchmaker.assign') || perms.includes('matchmaker.team.assign')) {
|
||||
const response: any = await get('/operator/matchmaker/management/services')
|
||||
if (response.success && Array.isArray(response.data)) todayStats.value = summarizeMatchmakerServices(response.data)
|
||||
} else if (perms.includes('matchmaker.view')) {
|
||||
const response: any = await get('/operator/matchmaker/dashboard')
|
||||
if (response.success) todayStats.value = { total: response.data.todayCount, pending: response.data.pendingCount + response.data.inProgressCount }
|
||||
}
|
||||
} catch {
|
||||
todayStats.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path === '/users/detail') return '/users'
|
||||
return route.path
|
||||
@@ -258,137 +305,307 @@ function handleLogout() {
|
||||
<style scoped>
|
||||
.admin-scene {
|
||||
min-width: 1100px;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px 34px 14px;
|
||||
overflow: hidden;
|
||||
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
|
||||
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
|
||||
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
|
||||
/* 先给纯色兜底,避免图未加载完成时闪白 */
|
||||
background: var(--admin-coral-lighter) center / cover fixed no-repeat;
|
||||
background-image: url(v-bind('BRAND_BACKDROP'));
|
||||
/* 外层:纯色粉渐变兜底,再叠加背景图。
|
||||
原实现把 background 和 background-image 拆两行在某些浏览器会覆盖 —— 这里统一写一条,
|
||||
并显式声明 position/size/repeat/attachment,确保 cover 全区域。 */
|
||||
background:
|
||||
linear-gradient(180deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 40%, var(--admin-scene-bg-end) 100%),
|
||||
url(v-bind('BRAND_BACKDROP')) center / cover fixed no-repeat;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
max-width: var(--admin-shell-max-width);
|
||||
height: 188px;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: minmax(640px, 1fr) auto;
|
||||
grid-template-rows: 112px 76px;
|
||||
/* 100vw 撑满浏览器:用负 margin 抵消 admin-scene 的 padding */
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 105px;
|
||||
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;
|
||||
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-benefits,
|
||||
.scene-benefits span,
|
||||
.scene-brand-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scene-brand > img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
.scene-brand {
|
||||
grid-area: brand;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding-right: var(--space-6);
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
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;
|
||||
justify-content: center;
|
||||
color: var(--admin-coral-strong);
|
||||
font-family: var(--font-family-brand);
|
||||
line-height: 1.05;
|
||||
transform: rotate(-7deg);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.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;
|
||||
.slogan-top {
|
||||
font-size: 12px;
|
||||
letter-spacing: 6px;
|
||||
color: var(--admin-coral-strong);
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: var(--font-size-4xl);
|
||||
line-height: 1.15;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.scene-brand-copy span {
|
||||
margin-top: 5px;
|
||||
font-size: var(--font-size-xs);
|
||||
.slogan-main {
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
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 {
|
||||
grid-column: 1 / -1;
|
||||
align-self: start;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(38px, 5vw, 92px);
|
||||
padding: var(--space-1) 0 0 50px;
|
||||
grid-area: benefits;
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: clamp(10px, 1.6vw, 28px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scene-benefits span {
|
||||
display: grid;
|
||||
grid-template-columns: 36px auto;
|
||||
column-gap: var(--space-2);
|
||||
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
|
||||
color: var(--admin-coral-strong);
|
||||
}
|
||||
|
||||
.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);
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 14px 4px 8px;
|
||||
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 {
|
||||
width: min(var(--admin-shell-max-width), 100%);
|
||||
height: calc(100vh - 238px);
|
||||
min-height: 560px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background: var(--admin-shell-bg);
|
||||
@@ -427,6 +644,8 @@ function handleLogout() {
|
||||
height: 42px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
@@ -629,10 +848,15 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -656,62 +880,49 @@ function handleLogout() {
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1450px), (max-height: 820px) {
|
||||
@media (max-width: 1280px), (max-height: 780px) {
|
||||
.admin-scene {
|
||||
padding: 12px 24px 10px;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: grid;
|
||||
height: 120px;
|
||||
grid-template-columns: minmax(520px, 1fr) auto;
|
||||
grid-template-rows: 66px 54px;
|
||||
column-gap: 18px;
|
||||
height: 160px;
|
||||
grid-template-columns: minmax(480px, 1fr) auto;
|
||||
grid-template-rows: 92px 64px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.scene-slogan {
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
.brand-logo-wrap { width: 64px; height: 64px; }
|
||||
.brand-logo-wrap img { width: 56px; height: 56px; }
|
||||
.brand-dot { display: none; }
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
.brand-name-row strong { font-size: 30px; letter-spacing: 3px; }
|
||||
.brand-badge { display: none; }
|
||||
.brand-tagline { font-size: 12px; letter-spacing: 2px; }
|
||||
.brand-ornament { display: none; }
|
||||
|
||||
.scene-title {
|
||||
min-width: 310px;
|
||||
margin-left: 18px;
|
||||
padding-left: 18px;
|
||||
min-width: 280px;
|
||||
margin-left: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.scene-title h1 { font-size: 22px; letter-spacing: 1px; }
|
||||
.title-sub { font-size: 12px; }
|
||||
.title-meta { display: none; }
|
||||
|
||||
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
|
||||
.scene-title h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
.slogan-top { font-size: 13px; letter-spacing: 6px; }
|
||||
.slogan-main { font-size: 34px; letter-spacing: 4px; }
|
||||
.slogan-en { display: none; }
|
||||
|
||||
.scene-benefits {
|
||||
padding: 0 0 0 28px;
|
||||
gap: clamp(22px, 4vw, 54px);
|
||||
}
|
||||
.benefit-icon { width: 34px; height: 34px; }
|
||||
.benefit-icon .el-icon { font-size: 16px; }
|
||||
|
||||
.scene-benefits { gap: clamp(10px, 2vw, 28px); }
|
||||
.scene-benefits b { font-size: 12px; }
|
||||
.scene-benefits small { display: none; font-size: 12px; }
|
||||
|
||||
.admin-shell {
|
||||
height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
.scene-footer {
|
||||
height: 28px;
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="inner-card">
|
||||
<template #header><span>手工签到(特殊情形)</span></template>
|
||||
<el-input v-model="manualUserId" placeholder="输入用户 ID" />
|
||||
<el-input v-model="manualUserNumber" placeholder="输入13位用户编号" />
|
||||
<div class="btn-row">
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserId"
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserNumber"
|
||||
@click="manual">手工签到</el-button>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
@@ -65,8 +65,8 @@
|
||||
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
|
||||
<template #header><span>最近一次签到</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="用户 ID">{{ lastResult.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户编号">{{ lastResult.userDisplayNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动编号">{{ lastResult.activityDisplayId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本次是否为重复签到">
|
||||
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
|
||||
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
|
||||
@@ -96,7 +96,7 @@ import { get, post } from '@/api/client'
|
||||
const activities = ref<any[]>([])
|
||||
const activityId = ref('')
|
||||
const token = ref('')
|
||||
const manualUserId = ref('')
|
||||
const manualUserNumber = ref('')
|
||||
const stats = ref<any>(null)
|
||||
const lastResult = ref<any>(null)
|
||||
const scanning = ref(false)
|
||||
@@ -151,15 +151,15 @@ async function scan() {
|
||||
}
|
||||
|
||||
async function manual() {
|
||||
if (!activityId.value || !manualUserId.value.trim()) return
|
||||
if (!activityId.value || !manualUserNumber.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await post(
|
||||
`/activities/operator/${activityId.value}/checkin/manual`,
|
||||
{ userId: manualUserId.value.trim() },
|
||||
{ userId: manualUserNumber.value.trim() },
|
||||
authHeader())
|
||||
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value }
|
||||
manualUserId.value = ''
|
||||
lastResult.value = { ...res.data, userDisplayNumber: res.data?.userDisplayNumber || manualUserNumber.value.trim(), activityDisplayId: activities.value.find((item: any) => item.id === activityId.value)?.displayId }
|
||||
manualUserNumber.value = ''
|
||||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
|
||||
await loadStats()
|
||||
} finally {
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 label="双向互选对数" width="140">
|
||||
<template #default="{row}">{{ row.mutualPairs }} 对</template>
|
||||
@@ -31,8 +31,8 @@
|
||||
<div v-if="currentActivity">
|
||||
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
|
||||
<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="targetUserId" label="被表达者ID" min-width="180" />
|
||||
<el-table-column prop="sourceUserDisplayNumber" label="表达者编号" min-width="180" />
|
||||
<el-table-column prop="targetUserDisplayNumber" label="被表达者编号" min-width="180" />
|
||||
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
|
||||
</el-table>
|
||||
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
|
||||
@@ -46,7 +46,7 @@ import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const searchActivityId = ref('')
|
||||
const searchActivityNumber = ref('')
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
@@ -59,8 +59,8 @@ async function loadData() {
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
|
||||
const rows = res.data || []
|
||||
tableData.value = searchActivityId.value
|
||||
? rows.filter((row: any) => row.activityId.includes(searchActivityId.value.trim()))
|
||||
tableData.value = searchActivityNumber.value
|
||||
? rows.filter((row: any) => (row.activityDisplayId || '').includes(searchActivityNumber.value.trim()))
|
||||
: rows
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<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="activityType" label="活动类型" width="130">
|
||||
<template #default="{ row }">
|
||||
@@ -128,7 +129,6 @@
|
||||
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
|
||||
<h4>组织关系</h4>
|
||||
<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="role" label="角色" width="120">
|
||||
<template #default="{ row }">{{ roleText(row.role) }}</template>
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
</template>
|
||||
|
||||
<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">
|
||||
<template #default="{row}">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resourceType" label="资源类型" width="120">
|
||||
<template #default="{row}">{{ row.resourceType }}</template>
|
||||
</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="createdAt" label="时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
|
||||
@@ -179,7 +179,7 @@ function isExternalLink(value: string) {
|
||||
function previewTargetText(banner: any) {
|
||||
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
|
||||
if (!banner.targetValue) return dest
|
||||
return `${dest} · ${banner.targetValue}`
|
||||
return `${dest} · 已配置站内目标`
|
||||
}
|
||||
function assignForm(source: any) {
|
||||
form.value = {
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<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>
|
||||
<template #footer>
|
||||
@@ -143,7 +143,7 @@ const assignedServices = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const followupDialogVisible = ref(false)
|
||||
const currentId = ref('')
|
||||
const assignForm = ref({ counselorId: '' })
|
||||
const assignForm = ref({ counselorNumber: '' })
|
||||
const followupForm = ref({ type: 'phone_call', content: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
@@ -211,19 +211,19 @@ function onTabChange(name: string | number) {
|
||||
|
||||
function openAssign(row: any) {
|
||||
currentId.value = row.id
|
||||
assignForm.value.counselorId = ''
|
||||
assignForm.value.counselorNumber = ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doAssign() {
|
||||
if (!assignForm.value.counselorId) {
|
||||
ElMessage.warning('请填写咨询师 ID')
|
||||
if (!assignForm.value.counselorNumber) {
|
||||
ElMessage.warning('请填写咨询师编号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post(`/counseling/services/operator/${currentId.value}/assign`,
|
||||
{ counselorId: assignForm.value.counselorId })
|
||||
{ counselorId: assignForm.value.counselorNumber })
|
||||
ElMessage.success('分配成功')
|
||||
assignDialogVisible.value = false
|
||||
loadQueue()
|
||||
|
||||
@@ -2,10 +2,24 @@
|
||||
<div class="matchmaker-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="服务请求队列" name="services">
|
||||
<el-tabs v-model="activeTab" @tab-change="refresh">
|
||||
<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-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">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -19,7 +33,8 @@
|
||||
<el-table-column label="操作" width="200">
|
||||
<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==='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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -27,8 +42,8 @@
|
||||
|
||||
<el-tab-pane label="推荐列表" name="recommendations">
|
||||
<el-table :data="recommendations" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户" width="110" />
|
||||
<el-table-column prop="targetUserId" label="推荐对象" width="110" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户" width="110" />
|
||||
<el-table-column prop="targetUserDisplayNumber" label="推荐对象" width="110" />
|
||||
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="130">
|
||||
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
|
||||
@@ -38,16 +53,80 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</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-card>
|
||||
|
||||
<!-- 分配弹窗 -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
|
||||
<el-option label="王红娘" value="operator_wang" />
|
||||
<el-option label="陈红娘" value="operator_chen" />
|
||||
</el-select></el-form-item>
|
||||
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="红娘">
|
||||
<el-select v-model="assignForm.matchmakerOperatorId" 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="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||
@@ -55,11 +134,42 @@
|
||||
</template>
|
||||
</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-form label-width="100px">
|
||||
<el-form-item label="推荐给用户"><el-input v-model="recForm.userId" 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.userDisplayNumber" disabled /></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>
|
||||
<template #footer>
|
||||
@@ -67,33 +177,108 @@
|
||||
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 { 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 services = ref<any[]>([])
|
||||
const recommendations = ref<any[]>([])
|
||||
const assignments = ref<any[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignableMatchmakers = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const transferDialogVisible = ref(false)
|
||||
const terminateDialogVisible = ref(false)
|
||||
const recDialogVisible = ref(false)
|
||||
const currentServiceId = ref('')
|
||||
const assignForm = ref({ operatorId: '' })
|
||||
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
||||
const currentUserId = ref('')
|
||||
const currentAssignmentId = ref('')
|
||||
const terminateAction = ref<'revoke' | 'end'>('revoke')
|
||||
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
|
||||
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
|
||||
const terminateForm = ref({ reason: '' })
|
||||
const recForm = ref({ 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> = {
|
||||
recommendation: '推荐对象', referral_counseling: '转介咨询',
|
||||
referral_activity: '转介活动', referral_station: '转介服务站'
|
||||
}
|
||||
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> = {
|
||||
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> = {
|
||||
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
|
||||
@@ -103,18 +288,128 @@ const REC_STATUS_TAG: Record<string, string> = {
|
||||
pending_view: 'warning', viewed: 'info', liked: 'primary',
|
||||
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 statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
|
||||
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 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() {
|
||||
loading.value = true
|
||||
// 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 }
|
||||
}
|
||||
async function loadRecommendations() {
|
||||
@@ -123,36 +418,109 @@ async function loadRecommendations() {
|
||||
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
|
||||
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() {
|
||||
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() {
|
||||
// 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 {
|
||||
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()
|
||||
} 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) {
|
||||
currentServiceId.value = row.id
|
||||
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
|
||||
recForm.value = { userDisplayNumber: row.userDisplayNumber || '实名后生成', targetUserDisplayNumber: '', reason: '' }
|
||||
recDialogVisible.value = true
|
||||
}
|
||||
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
|
||||
try {
|
||||
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()
|
||||
} 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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<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="organizationId" label="所属机构" width="160">
|
||||
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
|
||||
</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">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
|
||||
<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">
|
||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -42,7 +43,8 @@
|
||||
|
||||
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
|
||||
<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">
|
||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -65,7 +67,7 @@
|
||||
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
|
||||
<template v-else-if="currentTask">
|
||||
<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="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<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="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</el-radio-group>
|
||||
</div></template>
|
||||
<el-table :data="appointments" stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="100" />
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="displayId" label="预约编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="180" />
|
||||
<el-table-column prop="serviceType" label="服务类型" width="150" />
|
||||
<el-table-column prop="source" label="来源" width="140">
|
||||
<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>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<section v-else-if="user" class="surface-card detail-card">
|
||||
<div class="identity-summary">
|
||||
<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)" />
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
</el-alert>
|
||||
|
||||
<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">
|
||||
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -18,6 +18,20 @@ interface ApiEnvelope<T> {
|
||||
|
||||
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 value = source?.[key]
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
@@ -45,9 +59,10 @@ const emptyMetric = (
|
||||
|
||||
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
|
||||
return {
|
||||
followups: [],
|
||||
metrics: [
|
||||
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
|
||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'),
|
||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/matchmaker'),
|
||||
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
|
||||
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
|
||||
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
|
||||
@@ -65,8 +80,11 @@ export function createEmptyDashboard(notice = '当前账号暂无可展示的工
|
||||
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
|
||||
const canViewAnalytics = permissions.includes('analytics.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 [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
|
||||
canViewAnalytics
|
||||
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
@@ -76,14 +94,33 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
canViewActivities
|
||||
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
|
||||
: 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 core = fulfilledData<JsonRecord>(coreResult)
|
||||
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||
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 matchmakerPending = canManageMatchmaker
|
||||
? managedStats.pending
|
||||
: numberOf(matchmaker || undefined, 'pendingCount') + numberOf(matchmaker || undefined, 'inProgressCount')
|
||||
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 eligibility = recordOf(service || undefined, 'localEligibility')
|
||||
const counseling = recordOf(service || undefined, 'counseling')
|
||||
const appointments = recordOf(service || undefined, 'serviceAppointments')
|
||||
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
||||
@@ -92,12 +129,13 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
|
||||
const metrics: DashboardMetric[] = [
|
||||
{
|
||||
key: 'pending_reviews', label: '待审核资料', value: numberOf(eligibility, 'pending'),
|
||||
trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review',
|
||||
key: 'pending_reviews', label: '待审核资料', value: numberOf(reviews || undefined, 'pending') + numberOf(reviews || undefined, 'processing'),
|
||||
trendText: reviews ? '待领取与审核中资料' : '暂无授权数据', tone: 'rose', available: Boolean(reviews), route: '/review',
|
||||
},
|
||||
{
|
||||
key: 'matchmaker_todos', label: '红娘服务待办', value: 0,
|
||||
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral',
|
||||
key: 'matchmaker_todos', label: '红娘服务待办',
|
||||
value: matchmakerPending,
|
||||
trendText: matchmakerAvailable ? (canManageMatchmaker ? '管理范围内待完成服务' : '本人服务对象待跟进') : '暂无授权数据', tone: 'amber', available: matchmakerAvailable, route: '/matchmaker',
|
||||
},
|
||||
{
|
||||
key: 'counseling_todos', label: '咨询服务待办',
|
||||
@@ -120,9 +158,10 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
]
|
||||
|
||||
const todos = ([
|
||||
{ key: 'review', title: '资料审核', description: '本地资格材料等待核验', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
|
||||
{ key: 'review', title: '资料审核', description: '待领取与审核中资料', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
|
||||
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available },
|
||||
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available },
|
||||
{ key: 'matchmaker', title: '红娘服务', description: metrics[1].trendText, count: metrics[1].value, actionText: '查看服务', route: '/matchmaker', tone: 'amber', available: metrics[1].available },
|
||||
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
|
||||
] as DashboardTodo[]).filter((item) => item.available)
|
||||
|
||||
@@ -142,6 +181,7 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
{ 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: '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)
|
||||
|
||||
const effectiveness = ([
|
||||
@@ -152,12 +192,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) },
|
||||
] as DashboardEffectivenessItem[]).filter((item) => item.available)
|
||||
|
||||
const rejectedCount = [coreResult, serviceResult, activityResult].filter((item) => item.status === 'rejected').length
|
||||
const notice = !analyticsAvailable && !activityRows
|
||||
const rejectedCount = [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
|
||||
const anyData = analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
|
||||
const notice = !anyData
|
||||
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
||||
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
||||
|
||||
return {
|
||||
followups,
|
||||
metrics,
|
||||
todos,
|
||||
activities,
|
||||
|
||||
@@ -31,6 +31,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value && !!operator.value)
|
||||
const permissions = computed(() => operator.value?.permissions || [])
|
||||
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
|
||||
const hasPermission = (code: string) => permissions.value.includes(code)
|
||||
|
||||
function applyAuthData(data: any) {
|
||||
token.value = data.accessToken || ''
|
||||
@@ -87,5 +89,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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-lighter: #FFF8F7; /* 更浅一档,hover 底 */
|
||||
--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。 */
|
||||
--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 预览)。 */
|
||||
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
|
||||
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface DashboardEffectivenessItem {
|
||||
}
|
||||
|
||||
export interface DashboardModel {
|
||||
followups: { serviceId: string; userDisplayNumber: string; content: string; createdAt: string }[]
|
||||
metrics: DashboardMetric[]
|
||||
todos: DashboardTodo[]
|
||||
activities: DashboardActivity[]
|
||||
|
||||
@@ -106,6 +106,17 @@
|
||||
</article>
|
||||
</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">
|
||||
<article class="dashboard-panel service-overview-panel">
|
||||
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
|
||||
@@ -136,7 +147,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onActivated, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
@@ -277,15 +288,19 @@ async function retryLoad() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(retryLoad)
|
||||
onActivated(retryLoad)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
max-width: var(--admin-content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) 26px 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dashboard-welcome {
|
||||
@@ -478,6 +493,19 @@ onMounted(retryLoad)
|
||||
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 {
|
||||
min-width: 0;
|
||||
padding: 14px var(--space-4);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<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="" />
|
||||
<div class="brand-overlay">
|
||||
<div class="brand-title">
|
||||
<img :src="BRAND_LOGO" alt="" />
|
||||
<div>
|
||||
<strong>善缘丹阳</strong>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer>善缘丹阳 · 公益婚恋服务平台</footer>
|
||||
<footer>爱在丹阳 · 公益婚恋服务平台</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -135,6 +135,8 @@ async function handleLogin() {
|
||||
.brand-title img {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
}
|
||||
|
||||
.brand-title div {
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
<groupId>com.danyang.match</groupId>
|
||||
<artifactId>danyang-match-platform</artifactId>
|
||||
<version>1.1.0</version>
|
||||
<version>1.2.0</version>
|
||||
<name>Danyang Match Platform</name>
|
||||
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
|
||||
|
||||
|
||||
@@ -19,4 +19,20 @@ public class AuditLog extends BaseEntity {
|
||||
private String afterData;
|
||||
private String ip;
|
||||
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("userAgent", log.getUserAgent());
|
||||
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;
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
|
||||
@@ -73,11 +78,48 @@ public class AuditLogService {
|
||||
*/
|
||||
@Transactional
|
||||
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.setOperatorId(operatorId);
|
||||
auditLog.setAction("SENSITIVE_ACCESS");
|
||||
auditLog.setResourceType(resourceType);
|
||||
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) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
auditLog.setIp(req.getRemoteAddr());
|
||||
|
||||
+7
-1
@@ -29,6 +29,10 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
@Value("${wechat.app-id:dev-app-id}")
|
||||
private String appId;
|
||||
|
||||
/** 演示环境可固定到预置用户;生产真实微信登录不使用此 Provider。 */
|
||||
@Value("${wechat.mock.openid:}")
|
||||
private String configuredOpenid;
|
||||
|
||||
// 模拟 openid 映射
|
||||
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -37,7 +41,9 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
log.debug("[Mock] 微信登录 code: {}", code);
|
||||
|
||||
// 模拟:相同 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(
|
||||
"openid", openid,
|
||||
|
||||
@@ -101,6 +101,9 @@ public class OperatorAuthService {
|
||||
.toList();
|
||||
|
||||
// 签发 Token
|
||||
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
|
||||
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
|
||||
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("operatorId", operator.getId());
|
||||
claims.put("organizationId", operator.getOrganizationId());
|
||||
@@ -121,6 +124,7 @@ public class OperatorAuthService {
|
||||
result.put("accessToken", accessToken);
|
||||
result.put("refreshToken", refreshToken);
|
||||
result.put("operatorId", operator.getId());
|
||||
result.put("operatorDisplayId", operator.getDisplayId());
|
||||
result.put("name", operator.getName());
|
||||
result.put("username", operator.getUsername());
|
||||
result.put("organizationId", operator.getOrganizationId());
|
||||
@@ -154,6 +158,7 @@ public class OperatorAuthService {
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("operatorId", operator.getId());
|
||||
result.put("operatorDisplayId", operator.getDisplayId());
|
||||
result.put("name", operator.getName());
|
||||
result.put("username", operator.getUsername());
|
||||
result.put("username", operator.getUsername());
|
||||
@@ -201,6 +206,7 @@ public class OperatorAuthService {
|
||||
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
|
||||
result.put("operatorId", operatorInfo.get("operatorId"));
|
||||
result.put("operatorDisplayId", operatorInfo.get("operatorDisplayId"));
|
||||
result.put("name", operatorInfo.get("name"));
|
||||
result.put("username", operatorInfo.get("username"));
|
||||
result.put("organizationId", operatorInfo.get("organizationId"));
|
||||
|
||||
+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()));
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -51,7 +51,9 @@ public class MatchController {
|
||||
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
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
|
||||
return ApiResponse.ok(CursorPage.all(items));
|
||||
}
|
||||
@@ -67,7 +69,7 @@ public class MatchController {
|
||||
if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) {
|
||||
return ApiResponse.forbidden();
|
||||
}
|
||||
return ApiResponse.ok(MatchResponse.from(m));
|
||||
return ApiResponse.ok(MatchResponse.from(m, principal.id()));
|
||||
}
|
||||
|
||||
@Idempotent
|
||||
@@ -166,13 +168,13 @@ public class MatchController {
|
||||
private java.time.Instant lastActivityAt;
|
||||
private Integer episode;
|
||||
|
||||
public static MatchResponse from(Match m) {
|
||||
public static MatchResponse from(Match m, String currentUserId) {
|
||||
MatchResponse r = new MatchResponse();
|
||||
r.id = m.getId();
|
||||
r.source = m.getSource();
|
||||
r.sourceId = m.getSourceId();
|
||||
r.status = m.getStatus();
|
||||
r.peerUserId = m.getUserAId(); // 客户端需要自己判断对方
|
||||
r.peerUserId = currentUserId.equals(m.getUserAId()) ? m.getUserBId() : m.getUserAId();
|
||||
r.matchedAt = m.getMatchedAt();
|
||||
r.lastActivityAt = m.getLastActivityAt();
|
||||
r.episode = m.getEpisode();
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
+8
@@ -64,6 +64,14 @@ public class MatchmakerController {
|
||||
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();
|
||||
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
|
||||
}
|
||||
|
||||
@GetMapping("/recommendations")
|
||||
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
|
||||
+64
-26
@@ -13,6 +13,11 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* 红娘工作台控制器(Operator调用)
|
||||
*
|
||||
* <p>ZC-MM-002(A1)状态机变更:原 {@code POST /services/{id}/claim}(红娘接单)端点已删除。
|
||||
* 服务对象归属统一由 matchmaker_assignments 建模(D3 决策:不保留组长认领),
|
||||
* 分配入口收敛为 {@code MatchmakerAssignmentController}(POST /operator/matchmaker/assignments),
|
||||
* 服务单状态由分配动作同事务联动(submitted → assigned)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/matchmaker")
|
||||
@@ -21,6 +26,20 @@ import java.util.Map;
|
||||
public class MatchmakerOperatorController {
|
||||
|
||||
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")
|
||||
public ApiResponse<Map<String, Object>> getDashboard(
|
||||
@@ -29,42 +48,57 @@ public class MatchmakerOperatorController {
|
||||
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")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<List<Map<String, Object>>> getPending(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(matchmakerServiceService.getPendingServices());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配服务给红娘(兼容入口)。ZC-MM-002(D4):
|
||||
* 同事务联动「创建 matchmaker_assignments 分配 + 服务单置 assigned」。
|
||||
*/
|
||||
@PostMapping("/services/{id}/assign")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign')")
|
||||
public ApiResponse<?> assignService(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = principal.id();
|
||||
String assignOperatorId = body.get("operatorId");
|
||||
return ApiResponse.ok(matchmakerServiceService.assignService(id, assignOperatorId, null));
|
||||
String assignerOperatorId = principal.id();
|
||||
String targetOperatorId = body.get("operatorId");
|
||||
String reason = body.getOrDefault("reason", "红娘服务单分配");
|
||||
return ApiResponse.ok(matchmakerServiceService.assignService(
|
||||
id, assignerOperatorId, targetOperatorId, reason));
|
||||
}
|
||||
|
||||
@PostMapping("/services/{id}/claim")
|
||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||
public ApiResponse<?> claimService(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(matchmakerServiceService.claimService(operatorId, id));
|
||||
}
|
||||
|
||||
@GetMapping("/my-services")
|
||||
public ApiResponse<?> getMyServices(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
|
||||
}
|
||||
// GET /my-services 已删除(ZC-MM-005 / E4 死端点清理):
|
||||
// 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空),
|
||||
// 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径:
|
||||
// GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。
|
||||
// getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。
|
||||
|
||||
/**
|
||||
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
|
||||
* 红娘名下服务的全部推荐列表(运营端工作台用)。
|
||||
* 补此前 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")
|
||||
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
||||
@@ -72,16 +106,19 @@ public class MatchmakerOperatorController {
|
||||
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}")
|
||||
public ApiResponse<Map<String, Object>> getServiceDetail(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
|
||||
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);
|
||||
return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id()));
|
||||
}
|
||||
|
||||
@PostMapping("/services/{id}/recommend")
|
||||
@@ -114,7 +151,8 @@ public class MatchmakerOperatorController {
|
||||
public ApiResponse<?> completeService(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(matchmakerServiceService.completeService(id));
|
||||
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy(服务层完成)
|
||||
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
+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);
|
||||
|
||||
/**
|
||||
* 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 " +
|
||||
"JOIN matchmaker_services s ON s.id = r.service_id " +
|
||||
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
|
||||
"JOIN matchmaker_services s ON s.id = r.service_id 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")
|
||||
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
|
||||
}
|
||||
|
||||
+57
-21
@@ -5,6 +5,7 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
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;
|
||||
@@ -12,6 +13,20 @@ import java.util.Optional;
|
||||
@Mapper
|
||||
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
|
||||
|
||||
@Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " +
|
||||
"s.description, 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\", " +
|
||||
"(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 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")
|
||||
Optional<MatchmakerServiceRecord> findById(@Param("id") String id);
|
||||
|
||||
@@ -26,35 +41,56 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
|
||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
|
||||
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);
|
||||
|
||||
/** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,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}")
|
||||
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
|
||||
/** 某红娘名下全部服务(含历史)—— 红娘工作台 */
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId);
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
|
||||
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
|
||||
|
||||
/**
|
||||
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
|
||||
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
|
||||
*/
|
||||
@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);
|
||||
/** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */
|
||||
@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);
|
||||
|
||||
/**
|
||||
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
|
||||
* 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。
|
||||
* 状态口径(submitted/assigned/in_service/waiting_user)。
|
||||
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
|
||||
* 这两个常量不在生产 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
package com.danyang.match.platform.matchmaker.service;
|
||||
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 服务对象分配服务(ZC-MM-002 / A1,架构师设计 D5)。
|
||||
*
|
||||
* <p>状态机矩阵(status ∈ active/ended/revoked/transferred/expired):
|
||||
* <pre>
|
||||
* active → transferred(transfer:旧记录置 transferred + end_reason,同事务新建 active)
|
||||
* active → revoked (撤销,end_reason 必填)
|
||||
* active → ended (结束,end_reason 必填)
|
||||
* 终态(ended/revoked/transferred/expired)一律拒绝再转换 → CONFLICT
|
||||
* </pre>
|
||||
*
|
||||
* <p>权限模型(D2):
|
||||
* <ul>
|
||||
* <li>{@code matchmaker.assign}:平台授权人员,不限组织;</li>
|
||||
* <li>{@code matchmaker.team.assign}:红娘组长,团队边界 = 同 organization_id。</li>
|
||||
* </ul>
|
||||
* 组织边界规则(技术默认,治理登记后续补充):
|
||||
* <b>NULL=NULL 视为平台直属</b> —— 平台直属组长只能分配平台直属红娘;
|
||||
* 有组织组长不能分配 org=NULL 红娘;org=NULL 普通红娘只能由 matchmaker.assign 持有者分配。
|
||||
* 范围违规统一走 {@link DataScopeGuard#deny}(自动落 DATA_SCOPE_DENIED 审计,403)。
|
||||
*
|
||||
* <p>唯一性:同 user_id + primary scope 同时最多一条 active(DB 部分唯一索引兜底),
|
||||
* 业务层预检 + 捕获 DuplicateKeyException 双保险,均映射 CONFLICT。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MatchmakerAssignmentService {
|
||||
|
||||
private static final String RESOURCE_TYPE = "matchmaker_assignment";
|
||||
|
||||
private final MatchmakerAssignmentMapper assignmentMapper;
|
||||
private final MatchmakerServiceMapper serviceMapper;
|
||||
private final OperatorMapper operatorMapper;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
/** 读路径复用判定器(含惰性过期);Policy 不反向依赖本 Service,无环。 */
|
||||
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||
|
||||
/**
|
||||
* 创建分配(primary)。含可选服务单联动:serviceId 非空时同事务把服务单置为 assigned。
|
||||
*
|
||||
* @param assignerOperatorId 当前操作者(分配人)
|
||||
* @param hasGlobalAssign 操作者是否持有 matchmaker.assign(true=不限组织)
|
||||
* @param userId 被服务用户
|
||||
* @param matchmakerOperatorId 目标红娘
|
||||
* @param serviceId 关联服务单(可空)
|
||||
* @param reason 分配理由(必填)
|
||||
* @param expiresAt 有效期(可空=长期)
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerAssignment create(String assignerOperatorId, boolean hasGlobalAssign,
|
||||
String userId, String matchmakerOperatorId,
|
||||
String serviceId, String reason, Instant expiresAt) {
|
||||
requireReason(reason, "分配理由必填");
|
||||
|
||||
Operator target = requireActiveOperator(matchmakerOperatorId, "目标红娘不存在");
|
||||
// create/assign 类无资源寻址端点维持 403(ZC-MM-006 / T04 口径)
|
||||
assertAssignScope(assignerOperatorId, hasGlobalAssign, matchmakerOperatorId,
|
||||
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
|
||||
|
||||
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"该用户已存在生效中的主责分配,请先转分配或撤销");
|
||||
});
|
||||
|
||||
if (serviceId != null && !serviceId.isBlank()) {
|
||||
linkServiceRow(serviceId, matchmakerOperatorId);
|
||||
}
|
||||
|
||||
MatchmakerAssignment assignment = new MatchmakerAssignment();
|
||||
assignment.setUserId(userId);
|
||||
assignment.setMatchmakerOperatorId(matchmakerOperatorId);
|
||||
assignment.setServiceId(serviceId);
|
||||
assignment.setAssignedBy(assignerOperatorId);
|
||||
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
|
||||
assignment.setAssignmentReason(reason);
|
||||
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||
assignment.setAssignedAt(Instant.now());
|
||||
assignment.setExpiresAt(expiresAt);
|
||||
try {
|
||||
assignmentMapper.insert(assignment);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// 并发竞态兜底:uq_mm_assignment_active_primary 部分唯一索引拒绝第二条 active+primary
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"该用户已存在生效中的主责分配(并发创建被唯一索引拒绝)");
|
||||
}
|
||||
|
||||
log.info("服务对象分配创建: assignmentId={}, userId={}, matchmakerOperatorId={}, by={}",
|
||||
assignment.getId(), userId, matchmakerOperatorId, assignerOperatorId);
|
||||
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, assignment.getId(),
|
||||
auditSummary(matchmakerOperatorId, assignment.getAssignmentScope(), reason));
|
||||
return assignment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转分配(active → transferred + 同事务新建 active)。
|
||||
* 旧记录置 transferred + end_reason,新记录 assigned_by = 当前操作者。
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerAssignment transfer(String assignmentId, String assignerOperatorId,
|
||||
boolean hasGlobalAssign, String newMatchmakerOperatorId,
|
||||
String reason) {
|
||||
requireReason(reason, "转分配理由必填");
|
||||
|
||||
MatchmakerAssignment old = requireAssignment(assignmentId);
|
||||
assertActiveForTransition(old, "转分配");
|
||||
|
||||
// 防御纵深(QA P2-1):不仅新目标红娘要在操作者范围内,
|
||||
// 被转记录的**原持有人**也必须在范围内(与 terminate 路径同口径;
|
||||
// 否则 org=X 组长可把 org=Y 红娘持有的分配"顺手"转走)。
|
||||
// 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) {
|
||||
String summary = "matchmakerOperatorId=" + matchmakerOperatorId
|
||||
+ ";scope=" + scope
|
||||
+ ";reason=" + (reason == null ? "" : reason);
|
||||
return summary.length() > 500 ? summary.substring(0, 500) : summary;
|
||||
}
|
||||
|
||||
private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(RESOURCE_TYPE);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setAfterData(afterData);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+29
-4
@@ -48,6 +48,8 @@ public class MatchmakerService {
|
||||
private final RelationService relationService;
|
||||
private final MatchMapper matchMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper followupMapper;
|
||||
private final com.danyang.match.platform.shared.id.PublicIdMapper publicIdMapper;
|
||||
|
||||
// ==================== 用户端:提交红娘服务请求 ====================
|
||||
|
||||
@@ -78,6 +80,20 @@ public class MatchmakerService {
|
||||
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 +173,7 @@ public class MatchmakerService {
|
||||
throw new IllegalStateException("您不是此服务的负责人");
|
||||
}
|
||||
|
||||
// referral 记录写进 MatchmakerServiceRecord.description 的扩展 JSON
|
||||
// + 单独一条 Followup 记录(DB V009 已有 matchmaker_followups)
|
||||
// 这里用 Map 简单记录,不引入新实体
|
||||
// 转介建议复用跟进记录持久化,独立 ZF 编号不替用户创建申请。
|
||||
ReferralRecord record = new ReferralRecord(
|
||||
UUID.randomUUID().toString(),
|
||||
serviceId,
|
||||
@@ -171,11 +185,22 @@ public class MatchmakerService {
|
||||
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",
|
||||
"红娘为你转介了一个" + referralLabel(referralType),
|
||||
"公益红娘为你转介了" + referralLabel(referralType) +
|
||||
"(" + (referralTarget != null ? referralTarget : "") + "),你可以在" +
|
||||
"(转介编号:" + referralNumber + "),你可以在" +
|
||||
serviceLabel(referralType) + "页面申请。",
|
||||
"matchmaker_service", serviceId);
|
||||
|
||||
|
||||
+226
-88
@@ -1,9 +1,11 @@
|
||||
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.SensitiveAccessLog;
|
||||
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.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||
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.MatchmakerServiceMapper;
|
||||
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.mapper.UserPrivacySettingsMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
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.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
|
||||
@RequiredArgsConstructor
|
||||
public class MatchmakerServiceService {
|
||||
@@ -37,8 +66,12 @@ public class MatchmakerServiceService {
|
||||
private final MatchmakerRecommendationMapper recommendationMapper;
|
||||
private final MatchmakerFollowupMapper followupMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final UserPrivacySettingsMapper userPrivacySettingsMapper;
|
||||
private final NotificationService notificationService;
|
||||
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
|
||||
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);
|
||||
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
|
||||
}
|
||||
|
||||
service.setAssignedOperatorId(operatorId);
|
||||
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, "当前状态不允许接单");
|
||||
if (targetOperatorId == null || targetOperatorId.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "目标红娘必填");
|
||||
}
|
||||
|
||||
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||
serviceMapper.updateById(service);
|
||||
// ① 创建分配(matchmaker.assign 入口为平台授权,hasGlobalAssign=true)
|
||||
MatchmakerAssignment createdAssignment = matchmakerAssignmentService.create(assignerOperatorId, true,
|
||||
service.getUserId(), targetOperatorId, serviceId, reason, null);
|
||||
|
||||
log.info("红娘接单: operatorId={}, serviceId={}", operatorId, serviceId);
|
||||
return service;
|
||||
// ② 服务单联动(create 内已对 submitted 服务单置 assigned;此处再校验一次确保联动生效)
|
||||
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 归属校验。
|
||||
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator
|
||||
* 都能对**任意** serviceId 创建推荐(越权)。与
|
||||
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。
|
||||
* Phase 13 复验修复(D3):补归属校验。
|
||||
* ZC-MM-002(D4):归属判定切换到 {@link ServiceAssignmentPolicy}(matchmaker_assignments
|
||||
* 是服务对象归属的唯一事实源,无任何 assigned_operator_id 回退);拒绝统一走
|
||||
* DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403),不再抛裸 IllegalStateException。
|
||||
*
|
||||
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
|
||||
*/
|
||||
@@ -145,10 +181,8 @@ public class MatchmakerServiceService {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||
|
||||
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐
|
||||
if (!operatorId.equals(service.getAssignedOperatorId())) {
|
||||
throw new IllegalStateException("您不是此服务的负责人");
|
||||
}
|
||||
// D4:归属校验 —— 只有该服务对象的负责人(分配持有人)才能推荐
|
||||
MatchmakerAssignment assignment = assertServiceAccess(operatorId, service);
|
||||
// 不能推荐给自己(服务申请人)
|
||||
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
|
||||
throw new IllegalArgumentException("不能推荐用户给自己");
|
||||
@@ -171,7 +205,9 @@ public class MatchmakerServiceService {
|
||||
"matchmaker_recommendation", rec.getId().toString());
|
||||
|
||||
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<>();
|
||||
result.put("recommendationId", rec.getId());
|
||||
@@ -182,11 +218,18 @@ public class MatchmakerServiceService {
|
||||
|
||||
/**
|
||||
* 红娘添加跟进记录
|
||||
*
|
||||
* ZC-MM-002(D4):补归属校验(此前无任何校验,任何持 matchmaker.service 权限的
|
||||
* operator 可对任意 serviceId 写跟进 → 越权写)。判定口径同 recommendPartner。
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerFollowup addFollowup(String serviceId, String operatorId,
|
||||
String followupType, String content,
|
||||
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();
|
||||
followup.setServiceId(serviceId);
|
||||
followup.setOperatorId(operatorId);
|
||||
@@ -198,17 +241,23 @@ public class MatchmakerServiceService {
|
||||
followupMapper.insert(followup);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成服务
|
||||
*
|
||||
* ZC-MM-002(D4):补归属校验(判定口径同 recommendPartner);
|
||||
* operatorId 由 Controller 传入 principal,不信任请求参数。
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerServiceRecord completeService(String serviceId) {
|
||||
public MatchmakerServiceRecord completeService(String serviceId, String operatorId) {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||
MatchmakerAssignment completeAssignment = assertServiceAccess(operatorId, service);
|
||||
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
|
||||
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
|
||||
@@ -219,7 +268,9 @@ public class MatchmakerServiceService {
|
||||
serviceMapper.updateById(service);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -257,6 +308,24 @@ public class MatchmakerServiceService {
|
||||
.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) {
|
||||
long pendingCount = serviceMapper.countSubmitted();
|
||||
long inProgressCount = serviceMapper.countInProgressByOperator(operatorId);
|
||||
@@ -264,57 +333,69 @@ public class MatchmakerServiceService {
|
||||
Map<String, Object> dashboard = new HashMap<>();
|
||||
dashboard.put("pendingCount", pendingCount);
|
||||
dashboard.put("inProgressCount", inProgressCount);
|
||||
long todayCount = serviceMapper.selectCount(
|
||||
new LambdaQueryWrapper<MatchmakerServiceRecord>()
|
||||
.eq(MatchmakerServiceRecord::getStatus, "submitted"));
|
||||
// 本人口径:今天新流转到该红娘名下(按 assigned_at)的服务数
|
||||
long todayCount = serviceMapper.countTodayByOperator(operatorId);
|
||||
dashboard.put("todayCount", todayCount);
|
||||
long completedCount = serviceMapper.selectCount(
|
||||
new LambdaQueryWrapper<MatchmakerServiceRecord>()
|
||||
.eq(MatchmakerServiceRecord::getStatus, "completed"));
|
||||
// 本人口径:该红娘名下已完成的服务数
|
||||
long completedCount = serviceMapper.countCompletedByOperator(operatorId);
|
||||
dashboard.put("completedCount", completedCount);
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的服务列表(红娘工作台)
|
||||
*/
|
||||
public List<MatchmakerServiceRecord> getOperatorServices(String operatorId) {
|
||||
return serviceMapper.findByOperatorId(operatorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务详情
|
||||
* 获取服务详情(ZC-MM-005 / E1+E2 重构)。
|
||||
*
|
||||
* Phase 13 复验修复(D4):改为按调用者授权范围返回。
|
||||
* 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取
|
||||
* **任意** serviceId 的详情,其中包含该用户的完整资料
|
||||
* (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。
|
||||
* <p>此前(002 双轨)语义:负责人 → 完整详情;其他 operator → 脱敏详情(含 viewerIsOwner
|
||||
* 双态)。该设计有两个问题:① assigned_operator_id 回退分支在分配 revoke/end 后仍放行
|
||||
* (服务单残留该列);② 脱敏详情响应可被用于按 serviceId 枚举探测资源存在性。
|
||||
*
|
||||
* 现语义:
|
||||
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups)
|
||||
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情
|
||||
* (不含 userProfile;followups 正文也不返回,仅给条数)
|
||||
* <p>现语义(判定器唯一事实源,无回退):
|
||||
* <ul>
|
||||
* <li>查无此 serviceId → 抛 BusinessException(NOT_FOUND, "服务不存在");</li>
|
||||
* <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
|
||||
*/
|
||||
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
|
||||
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<>();
|
||||
detail.put("serviceId", service.getId());
|
||||
detail.put("serviceType", service.getServiceType());
|
||||
detail.put("status", service.getStatus());
|
||||
detail.put("assignedOperatorId", service.getAssignedOperatorId());
|
||||
detail.put("createdAt", service.getCreatedAt());
|
||||
detail.put("assignedAt", service.getAssignedAt());
|
||||
detail.put("completedAt", service.getCompletedAt());
|
||||
detail.put("viewerIsOwner", isOwner);
|
||||
|
||||
// 加载推荐列表(推荐是服务进展的必要摘要,两者都可见)
|
||||
// 推荐列表(服务进展摘要)
|
||||
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
||||
detail.put("recommendations", recs.stream().map(r -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
@@ -326,35 +407,78 @@ public class MatchmakerServiceService {
|
||||
return map;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
if (isOwner) {
|
||||
// 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文
|
||||
detail.put("userId", service.getUserId());
|
||||
detail.put("description", service.getDescription());
|
||||
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
|
||||
Map<String, Object> userProfile = new HashMap<>();
|
||||
userProfile.put("displayName", profile.getDisplayName());
|
||||
userProfile.put("gender", profile.getGender());
|
||||
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());
|
||||
}
|
||||
// 仅持有人可见:申请人 userId / 诉求描述 / 白名单用户资料 / 跟进正文
|
||||
detail.put("userId", service.getUserId());
|
||||
detail.put("description", service.getDescription());
|
||||
UserPrivacySettings privacy = userPrivacySettingsMapper.findByUserId(service.getUserId())
|
||||
.orElse(null);
|
||||
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile ->
|
||||
detail.put("userProfile", assembleMatchmakerUserProfile(profile, privacy)));
|
||||
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
||||
|
||||
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) {
|
||||
if (birthDate == null) return 0;
|
||||
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,
|
||||
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.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setBeforeData(beforeData);
|
||||
auditLog.setAfterData(afterData);
|
||||
auditLog.setPurposeCode(purposeCode);
|
||||
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+2
@@ -44,6 +44,7 @@ public class OperatorManagementService {
|
||||
List<Map<String, Object>> items = operators.stream().map(op -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", op.getId());
|
||||
m.put("displayId", op.getDisplayId());
|
||||
m.put("name", op.getName());
|
||||
m.put("username", op.getUsername());
|
||||
m.put("status", op.getStatus());
|
||||
@@ -62,6 +63,7 @@ public class OperatorManagementService {
|
||||
@Transactional
|
||||
public Operator createOperator(String name, String username, String password, String organizationId) {
|
||||
Operator operator = new Operator();
|
||||
operator.setId(UUID.randomUUID().toString());
|
||||
operator.setName(name);
|
||||
operator.setUsername(username);
|
||||
operator.setPasswordHash(passwordEncoder.encode(password));
|
||||
|
||||
@@ -54,6 +54,7 @@ public class OperatorService {
|
||||
});
|
||||
|
||||
Operator operator = new Operator();
|
||||
operator.setId(java.util.UUID.randomUUID().toString());
|
||||
operator.setName(name);
|
||||
operator.setUsername(username);
|
||||
operator.setPasswordHash(passwordEncoder.encode(password));
|
||||
|
||||
@@ -15,6 +15,8 @@ public class Operator extends BaseEntity {
|
||||
private String phoneEnc;
|
||||
private String phoneHash;
|
||||
private String username;
|
||||
/** 对外展示编号,内部 id 仍为 UUID。 */
|
||||
private String displayId;
|
||||
private String passwordHash;
|
||||
private String status;
|
||||
private java.time.Instant lastLoginAt;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package com.danyang.match.platform.profile.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.danyang.match.platform.shared.typehandler.JsonNodeTypeHandler;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("user_preferences")
|
||||
@TableName(value = "user_preferences", autoResultMap = true)
|
||||
public class UserPreference extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
@@ -17,8 +19,10 @@ public class UserPreference extends BaseEntity {
|
||||
private Integer minHeightCm;
|
||||
private Integer maxHeightCm;
|
||||
private String heightPriority;
|
||||
@TableField(typeHandler = JsonNodeTypeHandler.class)
|
||||
private com.fasterxml.jackson.databind.JsonNode regionScope;
|
||||
private String regionPriority;
|
||||
@TableField(typeHandler = JsonNodeTypeHandler.class)
|
||||
private com.fasterxml.jackson.databind.JsonNode maritalStatuses;
|
||||
private String maritalPriority;
|
||||
private String minEducationCode;
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserPreference;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.ResultMap;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -20,5 +21,6 @@ public interface UserPreferenceMapper extends BaseMapper<UserPreference> {
|
||||
* <p>{@code deleted_at IS NULL}:V037 已为 user_preferences 补上软删列。
|
||||
*/
|
||||
@Select("SELECT * FROM user_preferences WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||
@ResultMap("mybatis-plus_UserPreference")
|
||||
Optional<UserPreference> findByUserId(String userId);
|
||||
}
|
||||
|
||||
+18
-9
@@ -30,7 +30,7 @@ public class ReferralService {
|
||||
private final ActivityRegistrationMapper activityRegistrationMapper;
|
||||
private final OperatorMapper operatorMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
private final com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||
|
||||
/**
|
||||
* Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。
|
||||
@@ -39,16 +39,24 @@ public class ReferralService {
|
||||
* 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站,
|
||||
* 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。
|
||||
*
|
||||
* 修复后:先断言存在进行中的 matchmaker_service(assigned_operator_id=红娘, user_id=目标用户),
|
||||
* 否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
|
||||
* 修复后:先断言存在生效中的分配关系,否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
|
||||
*
|
||||
* <p>ZC-MM-005 收口(P1-3):授权事实源切换为
|
||||
* {@link com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy}
|
||||
* 判定器 —— 与写接口 recommend / followup 同口径(matchmaker_assignments 生效中分配)。
|
||||
* 分配 revoke / end 后残留列不再构成转介凭据(DEC-002 规则7:撤销即失权)。
|
||||
*
|
||||
* <p>ZC-MM-006 / T04(写接口 404 统一·防探测):转介为资源寻址写端点,拒绝路径
|
||||
* 404 化 {@code denyAsNotFound}(先 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」,
|
||||
* 与读详情口径逐字段一致);并消灭裸 IllegalArgumentException ——
|
||||
* 缺失目标用户也统一映射为同一 404 响应,不泄露"目标是否存在"的任何信号。
|
||||
*/
|
||||
private void assertMatchmakerServesUser(String matchmakerId, String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new IllegalArgumentException("缺少转介目标用户");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||
}
|
||||
matchmakerServiceMapper.findActiveByOperatorAndUser(matchmakerId, userId)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"您不是该用户的服务负责人,无法发起转介"));
|
||||
serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(matchmakerId, userId,
|
||||
"matchmaker_referral", userId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -219,9 +227,10 @@ public class ReferralService {
|
||||
private String getOperatorName(String operatorId) {
|
||||
try {
|
||||
var operator = operatorMapper.selectById(operatorId);
|
||||
return operator != null ? operator.getName() : operatorId;
|
||||
return operator != null ? operator.getName() : "公益红娘";
|
||||
} catch (Exception e) {
|
||||
return operatorId;
|
||||
log.warn("转介操作员名称查询失败", e);
|
||||
return "公益红娘";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
@org.springframework.core.annotation.Order(0)
|
||||
public class ApiResponseStatusAdvice implements ResponseBodyAdvice<ApiResponse> {
|
||||
|
||||
private static final Map<String, ApiResponseCode> BY_CODE =
|
||||
|
||||
@@ -100,7 +100,9 @@ public class SecurityConfig {
|
||||
"/activities/operator/**",
|
||||
"/service-appointments/operator/**",
|
||||
"/service-programs/operator/**",
|
||||
"/referrals/operator/**")
|
||||
"/referrals/operator/**",
|
||||
"/referrals/service-station",
|
||||
"/referrals/activity")
|
||||
.hasRole("OPERATOR")
|
||||
/**
|
||||
* 其余接口必须持用户身份(ROLE_USER),不再只是"已认证"。
|
||||
|
||||
@@ -330,9 +330,68 @@ public final class Constants {
|
||||
public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker";
|
||||
public static final String PROFILE_STATUS_PENDING_REVIEW = "pending_review";
|
||||
|
||||
// ========== 服务对象分配(ZC-MM-002 / A1,与 V053 数据库 CHECK 白名单一致)==========
|
||||
// 状态机:active → transferred / revoked / ended;expires_at 届满由判定器惰性置 expired。
|
||||
// 终态(ended/revoked/transferred/expired)一律拒绝再转换。
|
||||
public static final String ASSIGNMENT_STATUS_ACTIVE = "active";
|
||||
public static final String ASSIGNMENT_STATUS_ENDED = "ended";
|
||||
public static final String ASSIGNMENT_STATUS_REVOKED = "revoked";
|
||||
public static final String ASSIGNMENT_STATUS_TRANSFERRED = "transferred";
|
||||
public static final String ASSIGNMENT_STATUS_EXPIRED = "expired";
|
||||
|
||||
// 分配范围:primary(主责,同用户仅一条生效中)/ collaborating(协同)
|
||||
public static final String ASSIGNMENT_SCOPE_PRIMARY = "primary";
|
||||
public static final String ASSIGNMENT_SCOPE_COLLABORATING = "collaborating";
|
||||
|
||||
// 服务对象分配审计 action(resourceType = matchmaker_assignment)
|
||||
public static final String AUDIT_ACTION_ASSIGNMENT_CREATE = "MATCHMAKER_ASSIGNMENT_CREATE";
|
||||
public static final String AUDIT_ACTION_ASSIGNMENT_TRANSFER = "MATCHMAKER_ASSIGNMENT_TRANSFER";
|
||||
public static final String AUDIT_ACTION_ASSIGNMENT_REVOKE = "MATCHMAKER_ASSIGNMENT_REVOKE";
|
||||
public static final String AUDIT_ACTION_ASSIGNMENT_END = "MATCHMAKER_ASSIGNMENT_END";
|
||||
|
||||
// ========== V1.1 本地资格审核 ==========
|
||||
/** 本地资格审核任务类型 */
|
||||
public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility";
|
||||
/** 本地资格佐证数据类型(用于敏感访问审计) */
|
||||
public static final String SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE = "local_eligibility_evidence";
|
||||
|
||||
// ========== ZC-MM-006 / T03 红娘域敏感访问审计 ==========
|
||||
/** 红娘工作台服务对象详情数据类型(用于敏感访问审计) */
|
||||
public static final String SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL = "matchmaker_service_detail";
|
||||
/** 敏感访问成功结果(与 SensitiveAccessLog.accessResult 白名单 success/denied/failed 对齐) */
|
||||
public static final String SENSITIVE_ACCESS_RESULT_SUCCESS = "success";
|
||||
/** 红娘查看服务对象详情的访问理由文案 */
|
||||
public static final String SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL = "matchmaker 工作台查看服务对象详情";
|
||||
|
||||
// ========== ZC-MM-003 / B1 立项:受控联系授权(V056) ==========
|
||||
/** 授权状态:待审批 */
|
||||
public static final String CONTACT_AUTH_STATUS_PENDING_APPROVAL = "pending_approval";
|
||||
/** 授权状态:待用户同意 */
|
||||
public static final String CONTACT_AUTH_STATUS_PENDING_CONSENT = "pending_consent";
|
||||
/** 授权状态:生效中 */
|
||||
public static final String CONTACT_AUTH_STATUS_ACTIVE = "active";
|
||||
/** 授权状态:用户已拒绝 */
|
||||
public static final String CONTACT_AUTH_STATUS_REJECTED = "rejected";
|
||||
/** 授权状态:已过期(有效期/同意窗口届满,双态惰性收敛) */
|
||||
public static final String CONTACT_AUTH_STATUS_EXPIRED = "expired";
|
||||
/** 授权状态:已撤销 */
|
||||
public static final String CONTACT_AUTH_STATUS_REVOKED = "revoked";
|
||||
/** 渠道:电话联系 */
|
||||
public static final String CONTACT_CHANNEL_PHONE = "phone";
|
||||
/** 渠道:线下见面 */
|
||||
public static final String CONTACT_CHANNEL_OFFLINE_MEETING = "offline_meeting";
|
||||
/** 渠道:完整联系方式 */
|
||||
public static final String CONTACT_CHANNEL_FULL_CONTACT = "full_contact";
|
||||
/** 审计动作:红娘发起授权申请 */
|
||||
public static final String CONTACT_AUDIT_ACTION_REQUEST = "MATCHMAKER_CONTACT_REQUEST";
|
||||
/** 审计动作:审批岗批准/拒绝申请 */
|
||||
public static final String CONTACT_AUDIT_ACTION_APPROVE = "MATCHMAKER_CONTACT_APPROVE";
|
||||
/** 审计动作:用户单独同意 */
|
||||
public static final String CONTACT_AUDIT_ACTION_CONSENT = "MATCHMAKER_CONTACT_CONSENT";
|
||||
/** 审计动作:用户拒绝 */
|
||||
public static final String CONTACT_AUDIT_ACTION_DECLINE = "MATCHMAKER_CONTACT_DECLINE";
|
||||
/** 审计动作:披露联系方式(H4 唯一披露出口) */
|
||||
public static final String CONTACT_AUDIT_ACTION_DISCLOSE = "MATCHMAKER_CONTACT_DISCLOSE";
|
||||
/** 审计动作:撤销授权 */
|
||||
public static final String CONTACT_AUDIT_ACTION_REVOKE = "MATCHMAKER_CONTACT_REVOKE";
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.danyang.match.platform.shared.filter;
|
||||
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.permission.service.PermissionService;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
|
||||
@@ -34,6 +35,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenService jwtTokenService;
|
||||
private final PermissionService permissionService;
|
||||
private final OperatorMapper operatorMapper;
|
||||
|
||||
private static final List<String> PERMITTED_PATHS = List.of(
|
||||
"/api/v1/auth/**",
|
||||
@@ -76,7 +78,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
if (token == null) {
|
||||
chain.doFilter(request, response);
|
||||
sendUnauthorized(response);
|
||||
return;
|
||||
}
|
||||
if (!jwtTokenService.validateToken(token)) {
|
||||
@@ -87,6 +89,20 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
String subject = jwtTokenService.getUserIdFromToken(token);
|
||||
String tokenType = jwtTokenService.parseToken(token).get("type", String.class);
|
||||
boolean operatorToken = "operator_access".equals(tokenType);
|
||||
if (operatorToken) {
|
||||
// ZC-MM-006 / T02(JWT 权限回收·实时查库路线,不建撤销名单):
|
||||
// 运营 token 每请求实时查 operators.status —— 账号不存在(含软删除)或非 active
|
||||
// 一律 401、不建 principal,已签发 token 立即失效;零缓存。
|
||||
// 注意:此处只接受 "active";"normal" 是用户账号状态(ACCOUNT_STATUS_NORMAL),
|
||||
// 运营账号禁用/待激活均不得通过过滤器。
|
||||
var operator = operatorMapper.findById(subject);
|
||||
if (operator.isEmpty() || !"active".equals(operator.get().getStatus())) {
|
||||
sendUnauthorized(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ZC-MM-006 / T02:权限实时查库(PermissionMapper join role_permissions/operator_roles),
|
||||
// 每请求生效;token claims 中的 permissions 仅作展示快照(只写不读),不是授权事实源。
|
||||
List<String> permissions = operatorToken
|
||||
? permissionService.getPermissions(subject)
|
||||
: List.of();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter;
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Map;
|
||||
|
||||
/** 仅解析已有写接口的人员/活动编号;业务层仍校验原 UUID 的权限与数据范围。 */
|
||||
@RestControllerAdvice
|
||||
@RequiredArgsConstructor
|
||||
public class PublicIdInputAdvice extends RequestBodyAdviceAdapter {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PublicIdMapper mapper;
|
||||
private static final Map<String, String> TYPES = Map.of(
|
||||
"userId", "users", "targetUserId", "users", "reportedUserId", "users",
|
||||
"counselorId", "operators", "assignedOperatorId", "operators",
|
||||
"matchmakerOperatorId", "operators", "newMatchmakerOperatorId", "operators",
|
||||
"activityId", "activities");
|
||||
@Override
|
||||
public boolean supports(MethodParameter method, Type targetType, Class<? extends HttpMessageConverter<?>> converter) {
|
||||
return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converter);
|
||||
}
|
||||
@Override
|
||||
public HttpInputMessage beforeBodyRead(HttpInputMessage message, MethodParameter method, Type targetType,
|
||||
Class<? extends HttpMessageConverter<?>> converter) throws IOException {
|
||||
JsonNode body = objectMapper.readTree(message.getBody());
|
||||
resolve(body);
|
||||
byte[] bytes = objectMapper.writeValueAsBytes(body);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.putAll(message.getHeaders());
|
||||
headers.setContentLength(bytes.length);
|
||||
return new HttpInputMessage() {
|
||||
public InputStream getBody() { return new ByteArrayInputStream(bytes); }
|
||||
public HttpHeaders getHeaders() { return headers; }
|
||||
};
|
||||
}
|
||||
void resolve(JsonNode node) {
|
||||
if (node == null) return;
|
||||
if (node.isArray()) { node.forEach(this::resolve); return; }
|
||||
if (!node.isObject()) return;
|
||||
ObjectNode object = (ObjectNode) node;
|
||||
for (var entry : TYPES.entrySet()) {
|
||||
JsonNode value = object.get(entry.getKey());
|
||||
if (value == null || !value.isTextual() || !value.asText().matches("[A-Z]{2}[0-9]{11}")) continue;
|
||||
String id = mapper.resolve(value.asText(), entry.getValue());
|
||||
if (id == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "编号对应的对象不存在或不可用");
|
||||
object.put(entry.getKey(), id);
|
||||
}
|
||||
object.elements().forEachRemaining(child -> { if (child.isContainerNode()) resolve(child); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface PublicIdMapper {
|
||||
@Select({"<script>SELECT id AS \"id\", display_id AS \"displayId\", entity_type AS \"entityType\", referral_display_id AS \"referralDisplayId\" FROM public_display_ids WHERE id IN",
|
||||
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>CAST(#{id} AS text)</foreach></script>"})
|
||||
List<Map<String, Object>> findAll(@Param("ids") List<String> ids);
|
||||
|
||||
@Select("SELECT id FROM public_display_ids WHERE display_id=#{number} AND entity_type=#{entityType}")
|
||||
String resolve(@Param("number") String number, @Param("entityType") String entityType);
|
||||
|
||||
@Select("SELECT public_next_display_id('ZF', (#{createdAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date)")
|
||||
String nextReferral(@Param("createdAt") java.time.Instant createdAt);
|
||||
|
||||
@Update("UPDATE matchmaker_followups SET referral_display_id=#{number} WHERE id=#{id}::uuid AND referral_display_id IS NULL")
|
||||
int saveReferral(@Param("id") String id, @Param("number") String number);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** 只补充原响应已允许返回的对象编号;不加载资料,不改变 UUID 与权限。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PublicIdResponse {
|
||||
private final PublicIdMapper mapper;
|
||||
private static final Pattern UUID = Pattern.compile("[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}");
|
||||
private static boolean reference(String name) {
|
||||
return "id".equals(name) || name.endsWith("Id");
|
||||
}
|
||||
public void enrich(JsonNode data) {
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
collect(data, ids);
|
||||
if (ids.isEmpty()) return;
|
||||
Map<String, Map<String, Object>> numbers = new HashMap<>();
|
||||
for (var row : mapper.findAll(new ArrayList<>(ids))) {
|
||||
String id = row.get("id").toString();
|
||||
if (numbers.putIfAbsent(id, row) != null) throw new IllegalStateException("跨实体 UUID 冲突,不能确定公开编号");
|
||||
}
|
||||
apply(data, numbers);
|
||||
}
|
||||
private void collect(JsonNode node, Set<String> ids) {
|
||||
if (node.isObject()) node.fields().forEachRemaining(field -> {
|
||||
if (reference(field.getKey()) && field.getValue().isTextual() && UUID.matcher(field.getValue().asText()).matches()) ids.add(field.getValue().asText());
|
||||
else if (field.getValue().isContainerNode()) collect(field.getValue(), ids);
|
||||
});
|
||||
else if (node.isArray()) node.forEach(child -> collect(child, ids));
|
||||
}
|
||||
private void apply(JsonNode node, Map<String, Map<String, Object>> numbers) {
|
||||
if (node.isArray()) { node.forEach(child -> apply(child, numbers)); return; }
|
||||
if (!node.isObject()) return;
|
||||
var object = (ObjectNode) node;
|
||||
List<Map.Entry<String, JsonNode>> fields = new ArrayList<>();
|
||||
object.fields().forEachRemaining(fields::add);
|
||||
for (var field : fields) {
|
||||
String name = field.getKey();
|
||||
JsonNode value = field.getValue();
|
||||
if (value.isContainerNode()) { apply(value, numbers); continue; }
|
||||
if (!reference(name) || !value.isTextual() || !UUID.matcher(value.asText()).matches()) continue;
|
||||
var row = numbers.get(value.asText());
|
||||
String stem = name.equals("id") ? "" : name.substring(0, name.length()-2);
|
||||
boolean user = stem.toLowerCase(Locale.ROOT).endsWith("user") || (row != null && "users".equals(row.get("entityType")));
|
||||
String key = stem.isEmpty() ? "displayId" : stem + (user ? "DisplayNumber" : "DisplayId");
|
||||
Object number = row == null ? null : row.get("displayId");
|
||||
if (row != null && row.get("referralDisplayId") != null && "matchmaker_followups".equals(row.get("entityType"))) number = row.get("referralDisplayId");
|
||||
if (number == null) object.putNull(key); else object.put(key, number.toString());
|
||||
if (stem.isEmpty() && user) object.put("displayNumber", number == null ? null : number.toString());
|
||||
if (stem.isEmpty() && row != null && row.get("referralDisplayId") != null) object.put("referralDisplayId", row.get("referralDisplayId").toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.http.server.*;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||
@RequiredArgsConstructor
|
||||
public class PublicIdResponseAdvice implements ResponseBodyAdvice<Object> {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PublicIdResponse publicIds;
|
||||
@Override
|
||||
public boolean supports(MethodParameter method, Class<? extends HttpMessageConverter<?>> converter) {
|
||||
return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converter);
|
||||
}
|
||||
@Override
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
public Object beforeBodyWrite(Object body, MethodParameter method, MediaType type,
|
||||
Class<? extends HttpMessageConverter<?>> converter, ServerHttpRequest request, ServerHttpResponse response) {
|
||||
if (body instanceof ApiResponse api && api.isSuccess() && api.getData() != null) {
|
||||
var data = objectMapper.valueToTree(api.getData());
|
||||
publicIds.enrich(data);
|
||||
api.setData(data);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -2,6 +2,7 @@ package com.danyang.match.platform.shared.security;
|
||||
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.audit.service.AuditDenyWriter;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -21,6 +22,7 @@ import java.util.UUID;
|
||||
public class AuthorizationAuditListener {
|
||||
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final AuditDenyWriter auditDenyWriter;
|
||||
|
||||
@EventListener
|
||||
public void onAuthorizationDenied(AuthorizationDeniedEvent<?> event) {
|
||||
@@ -35,6 +37,9 @@ public class AuthorizationAuditListener {
|
||||
audit.setAction("AUTHORIZATION_DENIED");
|
||||
audit.setResourceType("authorization");
|
||||
audit.setResourceId(limit(String.valueOf(event.getObject()), 128));
|
||||
// ZC-MM-006 / T01:权限快照(permissions 逗号拼接,截断 128)+ 请求追踪 ID
|
||||
audit.setActorRoleSnapshot(limit(String.join(",", principal.permissions()), 128));
|
||||
audit.setRequestTraceId(org.slf4j.MDC.get("requestId"));
|
||||
audit.setCreatedAt(Instant.now());
|
||||
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
|
||||
@@ -43,7 +48,8 @@ public class AuthorizationAuditListener {
|
||||
}
|
||||
|
||||
try {
|
||||
auditLogMapper.insert(audit);
|
||||
// 拒绝审计独立事务提交:@PreAuthorize 拒绝若发生在业务事务内,同事务写入会被回滚吞掉
|
||||
auditDenyWriter.insertIndependent(audit);
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("写入授权拒绝审计失败: operatorId={}", principal.id(), exception);
|
||||
}
|
||||
|
||||
+105
-2
@@ -2,13 +2,19 @@ package com.danyang.match.platform.shared.security;
|
||||
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.audit.service.AuditDenyWriter;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/** Denies out-of-scope operator access and records the attempt before returning 403. */
|
||||
@Slf4j
|
||||
@@ -17,21 +23,118 @@ import java.util.UUID;
|
||||
public class DataScopeGuard {
|
||||
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final AuditDenyWriter auditDenyWriter;
|
||||
|
||||
public void deny(String operatorId, String resourceType, String resourceId, String message) {
|
||||
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写
|
||||
deny(operatorId, resourceType, resourceId, message, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZC-MM-006 / T01 扩展版拒绝审计:附加业务目的码与关联分配 ID。
|
||||
*
|
||||
* @param purposeCode 业务目的码(如 matchmaker.service.view),可空
|
||||
* @param serviceAssignmentId 关联的 matchmaker_assignments.id(拒绝路径通常无生效分配 → null)
|
||||
*/
|
||||
public void deny(String operatorId, String resourceType, String resourceId, String message,
|
||||
String purposeCode, String serviceAssignmentId) {
|
||||
AuditLog audit = new AuditLog();
|
||||
audit.setId(UUID.randomUUID().toString());
|
||||
audit.setOperatorId(operatorId);
|
||||
audit.setAction("DATA_SCOPE_DENIED");
|
||||
audit.setResourceType(resourceType);
|
||||
audit.setResourceId(resourceId);
|
||||
audit.setPurposeCode(purposeCode);
|
||||
audit.setServiceAssignmentId(serviceAssignmentId);
|
||||
audit.setActorRoleSnapshot(currentActorRoleSnapshot());
|
||||
audit.setRequestTraceId(MDC.get("requestId"));
|
||||
audit.setCreatedAt(Instant.now());
|
||||
try {
|
||||
auditLogMapper.insert(audit);
|
||||
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
|
||||
auditDenyWriter.insertIndependent(audit);
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
|
||||
operatorId, resourceType, resourceId, exception);
|
||||
}
|
||||
throw new AccessDeniedException(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ZC-MM-006 / T04(写接口 404 统一·防探测):范围校验失败的写端点 ——
|
||||
* 先落与 {@link #deny} 完全相同的 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),
|
||||
* 再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}:响应与"资源不存在"逐字段一致,
|
||||
* 调用方无法区分"无权"与"不存在",杜绝按资源 ID 枚举探测写端点。
|
||||
*
|
||||
* <p>适用面:带资源寻址的写端点(transfer/revoke/end/recommend/followup/complete/转介);
|
||||
* create/assign 类无资源寻址端点维持 {@link #deny} 的 403 口径。
|
||||
*
|
||||
* @param message 404 响应 message(统一 "服务不存在",与读详情口径一致)
|
||||
*/
|
||||
public void denyAsNotFound(String operatorId, String resourceType, String resourceId, String message,
|
||||
String purposeCode, String serviceAssignmentId) {
|
||||
AuditLog audit = new AuditLog();
|
||||
audit.setId(UUID.randomUUID().toString());
|
||||
audit.setOperatorId(operatorId);
|
||||
audit.setAction("DATA_SCOPE_DENIED");
|
||||
audit.setResourceType(resourceType);
|
||||
audit.setResourceId(resourceId);
|
||||
audit.setPurposeCode(purposeCode);
|
||||
audit.setServiceAssignmentId(serviceAssignmentId);
|
||||
audit.setActorRoleSnapshot(currentActorRoleSnapshot());
|
||||
audit.setRequestTraceId(MDC.get("requestId"));
|
||||
audit.setCreatedAt(Instant.now());
|
||||
try {
|
||||
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
|
||||
auditDenyWriter.insertIndependent(audit);
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
|
||||
operatorId, resourceType, resourceId, exception);
|
||||
}
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* V057 / 运行时实测修复:C 端用户主体的数据范围拒绝。
|
||||
* 与 {@link #deny} 同「先审计后拒绝」语义,但审计主体写 subject_user_id
|
||||
* (operator_id 置 null)——userId 写 operator_id 维度必撞
|
||||
* audit_logs_operator_id_fkey(→operators),H2 无 FK 假绿、生产零审计。
|
||||
*/
|
||||
public void denyUser(String userId, String resourceType, String resourceId, String message) {
|
||||
AuditLog audit = new AuditLog();
|
||||
audit.setId(UUID.randomUUID().toString());
|
||||
audit.setSubjectUserId(userId);
|
||||
audit.setAction("DATA_SCOPE_DENIED");
|
||||
audit.setResourceType(resourceType);
|
||||
audit.setResourceId(resourceId);
|
||||
audit.setRequestTraceId(MDC.get("requestId"));
|
||||
audit.setCreatedAt(Instant.now());
|
||||
try {
|
||||
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
|
||||
auditDenyWriter.insertIndependent(audit);
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("写入用户主体数据范围拒绝审计失败: subjectUserId={}, resourceType={}, resourceId={}",
|
||||
userId, resourceType, resourceId, exception);
|
||||
}
|
||||
throw new AccessDeniedException(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前操作者权限快照(与 {@link AuthorizationAuditListener} 同口径):
|
||||
* OperatorPrincipal.permissions() 逗号拼接,截断 128。
|
||||
*/
|
||||
private String currentActorRoleSnapshot() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.getPrincipal()
|
||||
instanceof com.danyang.match.platform.shared.security.principal.OperatorPrincipal principal) {
|
||||
return limit(String.join(",", principal.permissions()), 128);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String limit(String value, int maxLength) {
|
||||
if (value == null || value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.danyang.match.platform.shared.typehandler;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import org.apache.ibatis.type.MappedJdbcTypes;
|
||||
import org.apache.ibatis.type.MappedTypes;
|
||||
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
|
||||
/** PostgreSQL JSONB 与 Jackson JsonNode 的参数/结果转换。 */
|
||||
@MappedTypes(JsonNode.class)
|
||||
@MappedJdbcTypes(JdbcType.OTHER)
|
||||
public class JsonNodeTypeHandler extends BaseTypeHandler<JsonNode> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType)
|
||||
throws SQLException {
|
||||
String json = parameter.toString();
|
||||
if ("PostgreSQL".equalsIgnoreCase(ps.getConnection().getMetaData().getDatabaseProductName())) {
|
||||
ps.setObject(i, json, Types.OTHER);
|
||||
} else {
|
||||
ps.setString(i, json);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||
return parse(rs.getString(columnName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||
return parse(rs.getString(columnIndex));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||
return parse(cs.getString(columnIndex));
|
||||
}
|
||||
|
||||
private JsonNode parse(String value) throws SQLException {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try {
|
||||
return MAPPER.readTree(value);
|
||||
} catch (Exception ex) {
|
||||
throw new SQLException("JSONB 数据格式无效", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.danyang.match.platform.user;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/** 用户对外展示编号;内部 UUID 仍是唯一主键。 */
|
||||
public final class UserDisplayNumber {
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.BASIC_ISO_DATE;
|
||||
|
||||
private UserDisplayNumber() {}
|
||||
|
||||
public static String format(String gender, LocalDate registeredDate, int genderRank) {
|
||||
if ((!"male".equals(gender) && !"female".equals(gender)) || registeredDate == null || genderRank < 1) {
|
||||
throw new IllegalArgumentException("性别、注册日期或排名无效");
|
||||
}
|
||||
if (genderRank > ("male".equals(gender) ? 500 : 499)) throw new IllegalArgumentException("当日同性序号超过三位编号容量");
|
||||
int sequence = genderRank * 2 - ("male".equals(gender) ? 1 : 0);
|
||||
if (sequence > 999) throw new IllegalArgumentException("当日同性交序超过三位编号容量");
|
||||
return "YH" + DATE.format(registeredDate) + String.format("%03d", sequence);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package com.danyang.match.platform.user.dto;
|
||||
*/
|
||||
public class UserDetailDTO {
|
||||
private String id;
|
||||
private String displayNumber;
|
||||
private String displayName;
|
||||
private String gender;
|
||||
private Integer age;
|
||||
@@ -34,6 +35,8 @@ public class UserDetailDTO {
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getDisplayNumber() { return displayNumber; }
|
||||
public void setDisplayNumber(String displayNumber) { this.displayNumber = displayNumber; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getGender() { return gender; }
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.danyang.match.platform.user.dto;
|
||||
*/
|
||||
public class UserListItemDTO {
|
||||
private String id;
|
||||
private String displayNumber;
|
||||
private String displayName;
|
||||
private String gender;
|
||||
private Integer age;
|
||||
@@ -19,6 +20,8 @@ public class UserListItemDTO {
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getDisplayNumber() { return displayNumber; }
|
||||
public void setDisplayNumber(String displayNumber) { this.displayNumber = displayNumber; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getGender() { return gender; }
|
||||
|
||||
@@ -19,6 +19,8 @@ public class User extends BaseEntity {
|
||||
|
||||
@TableField("phone")
|
||||
private String phone;
|
||||
@TableField("display_number")
|
||||
private String displayNumber;
|
||||
@TableField("account_status")
|
||||
private String accountStatus;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ public class UserService {
|
||||
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
query.and(q -> q.like(User::getId, keyword)
|
||||
.or().like(User::getDisplayNumber, keyword)
|
||||
.or().like(User::getWechatOpenid, keyword));
|
||||
}
|
||||
if (accountStatus != null && !accountStatus.isBlank()) {
|
||||
@@ -71,7 +72,7 @@ public class UserService {
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<User> countQuery = new LambdaQueryWrapper<User>();
|
||||
if (keyword != null && !keyword.isBlank()) countQuery.and(q -> q.like(User::getId, keyword).or().like(User::getWechatOpenid, keyword));
|
||||
if (keyword != null && !keyword.isBlank()) countQuery.and(q -> q.like(User::getId, keyword).or().like(User::getDisplayNumber, keyword).or().like(User::getWechatOpenid, keyword));
|
||||
if (accountStatus != null && !accountStatus.isBlank()) countQuery.eq(User::getAccountStatus, accountStatus);
|
||||
if (profileStatus != null && !profileStatus.isBlank()) {
|
||||
List<String> userIds = userProfileMapper.selectList(
|
||||
@@ -155,6 +156,7 @@ public class UserService {
|
||||
private UserListItemDTO toListItemDTO(User user) {
|
||||
UserListItemDTO dto = new UserListItemDTO();
|
||||
dto.setId(user.getId());
|
||||
dto.setDisplayNumber(user.getDisplayNumber());
|
||||
dto.setAccountStatus(user.getAccountStatus());
|
||||
dto.setRegisteredAt(user.getCreatedAt());
|
||||
dto.setRelationshipStatus(user.getRelationshipStatus());
|
||||
@@ -189,6 +191,7 @@ public class UserService {
|
||||
int reportCount, boolean hasSensitiveView) {
|
||||
UserDetailDTO dto = new UserDetailDTO();
|
||||
dto.setId(user.getId());
|
||||
dto.setDisplayNumber(user.getDisplayNumber());
|
||||
dto.setAccountStatus(user.getAccountStatus());
|
||||
dto.setRelationshipStatus(user.getRelationshipStatus());
|
||||
dto.setRegisteredAt(user.getCreatedAt());
|
||||
|
||||
+12
@@ -7,10 +7,22 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.time.Instant;
|
||||
|
||||
@Mapper
|
||||
public interface UserIdentityMapper extends BaseMapper<UserIdentity> {
|
||||
|
||||
@Select("SELECT pg_advisory_xact_lock(hashtext(#{gender} || ':' || ((#{registeredAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date)::text))")
|
||||
void lockDisplayNumberSlot(@Param("gender") String gender, @Param("registeredAt") Instant registeredAt);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM user_identities i JOIN users u ON u.id = i.user_id "
|
||||
+ "WHERE i.gender = #{gender} AND i.verification_status = 'verified' "
|
||||
+ "AND (u.created_at AT TIME ZONE 'Asia/Shanghai')::date = (#{registeredAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date "
|
||||
+ "AND (u.created_at < #{registeredAt} OR (u.created_at = #{registeredAt} AND u.id < #{userId}::uuid))")
|
||||
int countEarlierSameDayGender(@Param("gender") String gender,
|
||||
@Param("registeredAt") Instant registeredAt,
|
||||
@Param("userId") String userId);
|
||||
|
||||
@Select("SELECT * FROM user_identities WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<UserIdentity> findByUserId(@Param("userId") String userId);
|
||||
|
||||
|
||||
+2
@@ -119,6 +119,8 @@ public class IdentityVerificationService {
|
||||
identity.setVerifiedAt(java.time.Instant.now());
|
||||
userIdentityMapper.insert(identity);
|
||||
|
||||
// V060 在实名记录写入事务中通过数据库原子计数器生成展示编号。
|
||||
|
||||
// 11. 更新用户表的认证状态标记
|
||||
saveAuditLog(userId, null, "IDENTITY_VERIFY", "user", userId, null, null);
|
||||
log.info("用户实名认证成功: userId={}, age={}, gender={}", userId, age, gender);
|
||||
|
||||
@@ -16,6 +16,8 @@ spring:
|
||||
wechat:
|
||||
app-id: ${WECHAT_APP_ID:}
|
||||
app-secret: ${WECHAT_APP_SECRET:}
|
||||
mock:
|
||||
openid: ${WECHAT_MOCK_OPENID:}
|
||||
|
||||
encryption:
|
||||
key: ${ENCRYPTION_KEY}
|
||||
|
||||
@@ -92,3 +92,13 @@ spring.security:
|
||||
rate-limit:
|
||||
max-requests-per-window: 20
|
||||
window-seconds: 60
|
||||
|
||||
# 受控联系授权(ZC-MM-003 / B1 立项,canonical key 见 ContactAuthorizationConfig;治理登记 T06 config-register)
|
||||
# 注意:代码缺省 enabled=false(fail-safe),生产显式开启;新环境漏配时机制整体关闭而非意外开放。
|
||||
matchmaker:
|
||||
contact:
|
||||
enabled: true
|
||||
approver-authorities: matchmaker.team.assign,matchmaker.assign
|
||||
consent-deadline-hours: 72
|
||||
default-validity-days: 7
|
||||
retention-days: 365
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
-- ============================================================
|
||||
-- V053: 服务对象分配表(ZC-MM-002 / A1 服务对象分配建模)
|
||||
--
|
||||
-- 建模决策(架构师设计 D1/D5):
|
||||
-- * 决策文档 5.1 的 matchmaker_user_id 对应 operators 主体,
|
||||
-- 定名 matchmaker_operator_id(沿用 matchmaker_services.assigned_operator_id 惯例)。
|
||||
-- * status 状态机:active → transferred / revoked / ended;
|
||||
-- expires_at < now 的 active 行由 ServiceAssignmentPolicy 惰性置 expired(无定时任务);
|
||||
-- 终态(transferred/revoked/ended/expired)一律不允许再次转换。
|
||||
-- * assignment_scope:primary(主责)/ collaborating(协同)。
|
||||
-- * 同一用户同时最多一条 active + primary 分配 → 部分唯一索引兜底
|
||||
-- (业务层 SELECT-then-INSERT 有竞态,只有 DB 唯一索引能兜住)。
|
||||
--
|
||||
-- 存量回填说明:
|
||||
-- matchmaker_services.assigned_operator_id IS NOT NULL 且服务仍处于非终态
|
||||
-- (assigned / in_service / waiting_user,与 V009 chk_matchmaker_status 白名单一致)的行,
|
||||
-- 回填为 active + primary 分配。assigned_by 无历史记录可考,取技术回填值
|
||||
-- = assigned_operator_id(仅表示"分配关系成立",不代表当年实际操作者)。
|
||||
-- 幂等:NOT EXISTS 防重复回填(按 service_id 关联)。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS matchmaker_assignments (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
matchmaker_operator_id UUID NOT NULL REFERENCES operators(id),
|
||||
service_id UUID REFERENCES matchmaker_services(id),
|
||||
assigned_by UUID NOT NULL REFERENCES operators(id),
|
||||
assignment_scope VARCHAR(32) NOT NULL DEFAULT 'primary',
|
||||
assignment_reason VARCHAR(500) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
assigned_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
end_reason VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
ALTER TABLE matchmaker_assignments ADD CONSTRAINT chk_mm_assignment_status CHECK (status IN (
|
||||
'active', 'ended', 'revoked', 'transferred', 'expired'
|
||||
));
|
||||
ALTER TABLE matchmaker_assignments ADD CONSTRAINT chk_mm_assignment_scope CHECK (assignment_scope IN (
|
||||
'primary', 'collaborating'
|
||||
));
|
||||
|
||||
-- 部分唯一索引:同一用户最多一条「生效中的主责分配」。
|
||||
-- 终态行(ended/revoked/transferred/expired)不占位,允许历史多条。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_mm_assignment_active_primary
|
||||
ON matchmaker_assignments(user_id)
|
||||
WHERE status = 'active' AND assignment_scope = 'primary';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mm_assignment_operator ON matchmaker_assignments(matchmaker_operator_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mm_assignment_user ON matchmaker_assignments(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mm_assignment_service ON matchmaker_assignments(service_id);
|
||||
|
||||
-- 存量回填(幂等)
|
||||
INSERT INTO matchmaker_assignments (
|
||||
id, user_id, matchmaker_operator_id, service_id, assigned_by,
|
||||
assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
ms.user_id,
|
||||
ms.assigned_operator_id,
|
||||
ms.id,
|
||||
ms.assigned_operator_id, -- assigned_by 为技术回填值:历史分配操作者未记录
|
||||
'primary',
|
||||
'V053 存量回填:原始分配人未记录',
|
||||
'active',
|
||||
COALESCE(ms.assigned_at, NOW()),
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM matchmaker_services ms
|
||||
WHERE ms.assigned_operator_id IS NOT NULL
|
||||
AND ms.status IN ('assigned', 'in_service', 'waiting_user')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM matchmaker_assignments ma WHERE ma.service_id = ms.id
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- ============================================================
|
||||
-- V054: 新增红娘组长角色与团队分配权限(ZC-MM-002 / A1,架构师设计 D2)
|
||||
--
|
||||
-- 角色权限边界:
|
||||
-- * matchmaker_leader(014)授 view(020) + service(022) + team.assign(023),
|
||||
-- **不给** matchmaker.assign(021) —— 组长只能在同组织范围内分配;
|
||||
-- 组织边界判定:组长团队边界 = 同 organization_id,NULL=NULL 视为平台直属
|
||||
-- (平台直属组长只能分配平台直属红娘;有组织组长不能分配 org=NULL 红娘;
|
||||
-- org=NULL 普通红娘只能由 matchmaker.assign 持有者分配 —— 该技术默认
|
||||
-- 写在 ServiceAssignmentPolicy / MatchmakerAssignmentService 代码注释,
|
||||
-- 治理登记后续补充)。
|
||||
-- * 普通红娘(005)权限现状不变(view/service),由 V052 授予。
|
||||
--
|
||||
-- 幂等:全部 ON CONFLICT DO NOTHING(对照 V052 风格)。
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO roles (id, code, name, description, system_role) VALUES (
|
||||
'00000000-0000-0000-0000-000000000014',
|
||||
'matchmaker_leader',
|
||||
'红娘组长',
|
||||
'公益红娘团队组长,可在同组织范围内分配/转分配服务对象',
|
||||
TRUE
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO permissions (id, code, name, module, description) VALUES (
|
||||
'10000000-0000-0000-0000-000000000023',
|
||||
'matchmaker.team.assign',
|
||||
'红娘团队分配',
|
||||
'matchmaker',
|
||||
'在组长同组织范围内分配/转分配/撤销/结束服务对象分配'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 红娘组长:view + service + team.assign(不含 matchmaker.assign)
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT '00000000-0000-0000-0000-000000000014', id
|
||||
FROM permissions
|
||||
WHERE code IN ('matchmaker.view', 'matchmaker.service', 'matchmaker.team.assign')
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- ============================================================
|
||||
-- V055: audit_logs 扩展红娘域审计字段(ZC-MM-006 / T01)
|
||||
--
|
||||
-- 新增 4 列(全部可空、存量不回填、语句幂等):
|
||||
-- purpose_code 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view
|
||||
-- service_assignment_id 关联的 matchmaker_assignments.id(可空:非分配场景为 NULL)
|
||||
-- actor_role_snapshot 审计落库时操作者的权限快照(OperatorPrincipal.permissions() 逗号拼接,截断 128)
|
||||
-- request_trace_id 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId,16 位)
|
||||
--
|
||||
-- 兼容性:全部可空 → 缺省调用(不传新字段)仍可正常写入,存量行为不变。
|
||||
-- 幂等:H2/PostgreSQL 均无 ADD COLUMN IF NOT EXISTS 统一保障时,
|
||||
-- 用 information_schema 判空后执行(对照 V034/V035 风格)。
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS purpose_code VARCHAR(64);
|
||||
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS service_assignment_id UUID;
|
||||
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS actor_role_snapshot VARCHAR(128);
|
||||
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS request_trace_id VARCHAR(64);
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
-- ============================================================
|
||||
-- V056: 受控联系授权表 matchmaker_contact_authorizations
|
||||
-- (ZC-MM-003 / B1 例外通道立项,架构师设计 H4/G4)
|
||||
--
|
||||
-- 语义:红娘对分配对象发起联系方式/线下见面授权申请,经审批岗批准、
|
||||
-- 用户单独明示同意(独立端点,无预同意参数)后方可生效;
|
||||
-- 唯一披露出口为 ContactAuthorizationService.disclose(每次落敏感访问审计)。
|
||||
--
|
||||
-- 状态机(六态):
|
||||
-- pending_approval → pending_consent(审批岗批准)
|
||||
-- pending_consent → active(用户单独同意)/ rejected(用户拒绝)
|
||||
-- active / pending_consent → expired(双态惰性收敛:有效期/同意窗口届满)
|
||||
-- active → revoked(申请人或审批人撤销)
|
||||
--
|
||||
-- 幂等:CREATE TABLE / INDEX 全部 IF NOT EXISTS,可重放。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS matchmaker_contact_authorizations (
|
||||
id UUID PRIMARY KEY,
|
||||
operator_id UUID NOT NULL REFERENCES operators(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
purpose VARCHAR(200) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending_approval',
|
||||
approved_by UUID,
|
||||
approved_at TIMESTAMPTZ,
|
||||
user_consented_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by UUID,
|
||||
revoke_reason VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_contact_auth_channel CHECK (channel IN ('phone', 'offline_meeting', 'full_contact')),
|
||||
CONSTRAINT chk_contact_auth_status CHECK (status IN (
|
||||
'pending_approval', 'pending_consent', 'active', 'rejected', 'expired', 'revoked'
|
||||
))
|
||||
);
|
||||
|
||||
-- 同一 (红娘, 用户, 渠道) 同时最多一条 active(防重复生效授权)
|
||||
-- H2 测试库以生成列 + 唯一索引等价实现(见 schema-h2.sql 注释)。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_mm_contact_auth_active
|
||||
ON matchmaker_contact_authorizations(operator_id, user_id, channel)
|
||||
WHERE status = 'active';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_auth_operator_status
|
||||
ON matchmaker_contact_authorizations(operator_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_auth_user_status
|
||||
ON matchmaker_contact_authorizations(user_id, status);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- V057 (ZC-MM-003 B1 运行时实测修复): audit_logs 增加 C 端用户主体维度
|
||||
--
|
||||
-- 背景:运行时实测(本地栈真 PostgreSQL)发现 audit_logs.operator_id 存在
|
||||
-- FK → operators(id)(audit_logs_operator_id_fkey)。受控联系 C 端
|
||||
-- (/contact-authorizations/**)以「用户身份」触发的审计(consent/decline 成功、
|
||||
-- 越权 deny)把 userId 写进 operator_id → FK violation:
|
||||
-- * consent/decline 成功路径:审计 insert 无兜底 → 事务回滚 → C 端同意必然 500;
|
||||
-- * deny 路径:写失败被 catch 吞掉 → C 端越权尝试零审计(违反 DEC-006 全程留痕)。
|
||||
-- H2 测试库 audit_logs 无 FK → 集成测试假绿(测试库缺约束类假绿第二例)。
|
||||
--
|
||||
-- 方案:审计主体双维度——operator 主体照旧写 operator_id(FK operators);
|
||||
-- user 主体写 subject_user_id(FK users)且 operator_id 置 null(本就可空)。
|
||||
-- DEC-006 第八节 actor 语义扩展:operator_id 与 subject_user_id 恰有一者非空。
|
||||
|
||||
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS subject_user_id UUID REFERENCES users(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_subject_user ON audit_logs (subject_user_id);
|
||||
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_number VARCHAR(32);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_display_number ON users(display_number)
|
||||
WHERE display_number IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
-- 历史用户的编号由实名性别、注册日和当日同性交序生成;未实名或 gender=other 暂不生成。
|
||||
WITH ranked AS (
|
||||
SELECT u.id, i.gender, (u.created_at AT TIME ZONE 'Asia/Shanghai')::date AS registered_date,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY (u.created_at AT TIME ZONE 'Asia/Shanghai')::date, i.gender
|
||||
ORDER BY u.created_at, u.id
|
||||
) AS gender_rank
|
||||
FROM users u
|
||||
JOIN user_identities i ON i.user_id = u.id
|
||||
WHERE u.display_number IS NULL
|
||||
AND i.verification_status = 'verified'
|
||||
AND i.gender IN ('male', 'female')
|
||||
AND u.deleted_at IS NULL
|
||||
)
|
||||
UPDATE users u
|
||||
SET display_number = '321181' || TO_CHAR(r.registered_date, 'YYYYMMDD') ||
|
||||
LPAD((r.gender_rank * 2 - CASE WHEN r.gender = 'male' THEN 1 ELSE 0 END)::text, 4, '0')
|
||||
FROM ranked r
|
||||
WHERE u.id = r.id;
|
||||
@@ -0,0 +1,41 @@
|
||||
ALTER TABLE operators ADD COLUMN IF NOT EXISTS display_id VARCHAR(32);
|
||||
|
||||
-- 历史用户编号统一为 YH + 注册日期 + 当日性别序号(男奇女偶)。
|
||||
WITH ranked AS (
|
||||
SELECT u.id, i.gender,
|
||||
(u.created_at AT TIME ZONE 'Asia/Shanghai')::date AS registered_date,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY (u.created_at AT TIME ZONE 'Asia/Shanghai')::date, i.gender
|
||||
ORDER BY u.created_at, u.id
|
||||
) AS gender_rank
|
||||
FROM users u JOIN user_identities i ON i.user_id = u.id
|
||||
WHERE u.display_number IS NOT NULL AND i.verification_status = 'verified'
|
||||
AND i.gender IN ('male', 'female') AND u.deleted_at IS NULL
|
||||
)
|
||||
UPDATE users u SET display_number = 'YH' || TO_CHAR(r.registered_date, 'YYYYMMDD') ||
|
||||
LPAD((r.gender_rank * 2 - CASE WHEN r.gender = 'male' THEN 1 ELSE 0 END)::text, 2, '0')
|
||||
FROM ranked r WHERE u.id = r.id AND r.gender_rank <= 50;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_operators_display_id ON operators(display_id)
|
||||
WHERE display_id IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
-- 运营人员:红娘前缀 HN,其他管理员/运营人员前缀 GL。
|
||||
WITH ranked AS (
|
||||
SELECT o.id,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM operator_roles ort JOIN roles r ON r.id = ort.role_id
|
||||
WHERE ort.operator_id = o.id AND r.code IN ('matchmaker', 'matchmaker_leader')
|
||||
) THEN 'HN' ELSE 'GL' END AS prefix,
|
||||
(o.created_at AT TIME ZONE 'Asia/Shanghai')::date AS created_date,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY (o.created_at AT TIME ZONE 'Asia/Shanghai')::date,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM operator_roles ort JOIN roles r ON r.id = ort.role_id
|
||||
WHERE ort.operator_id = o.id AND r.code IN ('matchmaker', 'matchmaker_leader')
|
||||
) THEN 'HN' ELSE 'GL' END
|
||||
ORDER BY o.created_at, o.id
|
||||
) AS seq
|
||||
FROM operators o WHERE o.display_id IS NULL AND o.deleted_at IS NULL
|
||||
)
|
||||
UPDATE operators o SET display_id = r.prefix || TO_CHAR(r.created_date, 'YYYYMMDD') || LPAD(r.seq::text, 2, '0')
|
||||
FROM ranked r WHERE o.id = r.id AND r.seq <= 99;
|
||||
@@ -0,0 +1,195 @@
|
||||
-- 公开编号由 PostgreSQL 与领域写入同事务分配;所有 UUID 主外键保持不变。
|
||||
CREATE TABLE public_display_id_counters (
|
||||
prefix VARCHAR(2) NOT NULL,
|
||||
local_date DATE NOT NULL,
|
||||
sequence_group VARCHAR(8) NOT NULL DEFAULT '',
|
||||
last_sequence INTEGER NOT NULL CHECK (last_sequence BETWEEN 1 AND 999),
|
||||
PRIMARY KEY (prefix, local_date, sequence_group)
|
||||
);
|
||||
CREATE TABLE public_display_id_aliases (
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
old_display_id VARCHAR(64) NOT NULL,
|
||||
new_display_id VARCHAR(13),
|
||||
PRIMARY KEY (entity_type, entity_id, old_display_id)
|
||||
);
|
||||
INSERT INTO public_display_id_aliases(entity_type, entity_id, old_display_id)
|
||||
SELECT 'users', id, display_number FROM users WHERE display_number IS NOT NULL
|
||||
UNION ALL SELECT 'operators', id, display_id FROM operators WHERE display_id IS NOT NULL;
|
||||
|
||||
CREATE FUNCTION public_next_display_id(p_prefix TEXT, p_date DATE, p_group TEXT DEFAULT '')
|
||||
RETURNS TEXT LANGUAGE plpgsql AS $$
|
||||
DECLARE n INTEGER; step INTEGER; first_value INTEGER;
|
||||
BEGIN
|
||||
IF p_prefix NOT IN ('YH','HN','GL','RW','HD','FW','TJ','LX','FP','GJ','JB','ZF','YY')
|
||||
OR p_date IS NULL OR p_group IS NULL
|
||||
OR (p_prefix = 'YH' AND p_group NOT IN ('male','female'))
|
||||
OR (p_prefix <> 'YH' AND p_group <> '') THEN
|
||||
RAISE EXCEPTION '公开编号类型、日期或性别无效';
|
||||
END IF;
|
||||
step := CASE WHEN p_prefix = 'YH' THEN 2 ELSE 1 END;
|
||||
first_value := CASE WHEN p_group = 'female' THEN 2 ELSE 1 END;
|
||||
INSERT INTO public_display_id_counters(prefix, local_date, sequence_group, last_sequence)
|
||||
VALUES (p_prefix, p_date, p_group, first_value)
|
||||
ON CONFLICT (prefix, local_date, sequence_group) DO UPDATE
|
||||
SET last_sequence = public_display_id_counters.last_sequence + step
|
||||
WHERE public_display_id_counters.last_sequence + step <= 999
|
||||
RETURNING last_sequence INTO n;
|
||||
IF n IS NULL THEN RAISE EXCEPTION '当日公开编号容量已满'; END IF;
|
||||
RETURN p_prefix || TO_CHAR(p_date, 'YYYYMMDD') || LPAD(n::TEXT, 3, '0');
|
||||
END $$;
|
||||
|
||||
-- 历史数据预检先于回填;包含软删除记录,避免编号复用。
|
||||
DO $$
|
||||
DECLARE item RECORD; overflow_count INTEGER;
|
||||
BEGIN
|
||||
FOR item IN SELECT * FROM (VALUES
|
||||
('activities','HD'),('review_tasks','RW'),('reports','JB'),
|
||||
('matchmaker_services','FW'),('matchmaker_recommendations','TJ'),
|
||||
('matchmaker_contact_authorizations','LX'),('matchmaker_assignments','FP'),
|
||||
('matchmaker_followups','GJ'),('service_appointments','YY')) AS t(tab,prefix)
|
||||
LOOP
|
||||
EXECUTE FORMAT('SELECT COUNT(*) FROM (SELECT 1 FROM %I GROUP BY (created_at AT TIME ZONE ''Asia/Shanghai'')::date HAVING COUNT(*) > 999) s', item.tab) INTO overflow_count;
|
||||
IF overflow_count > 0 THEN RAISE EXCEPTION '历史编号超出容量: %', item.tab; END IF;
|
||||
EXECUTE FORMAT('ALTER TABLE %I ADD COLUMN display_id VARCHAR(13)', item.tab);
|
||||
EXECUTE FORMAT('WITH ranked AS (SELECT id, (created_at AT TIME ZONE ''Asia/Shanghai'')::date AS d, ROW_NUMBER() OVER (PARTITION BY (created_at AT TIME ZONE ''Asia/Shanghai'')::date ORDER BY created_at,id) AS n FROM %I) UPDATE %I t SET display_id = %L || TO_CHAR(r.d,''YYYYMMDD'') || LPAD(r.n::text,3,''0'') FROM ranked r WHERE t.id=r.id', item.tab, item.tab, item.prefix);
|
||||
EXECUTE FORMAT('ALTER TABLE %I ALTER COLUMN display_id SET NOT NULL', item.tab);
|
||||
EXECUTE FORMAT('CREATE UNIQUE INDEX %I ON %I(display_id)', 'uk_' || item.tab || '_public_display_id', item.tab);
|
||||
EXECUTE FORMAT('ALTER TABLE %I ADD CONSTRAINT %I CHECK (display_id ~ %L)', item.tab, 'ck_' || item.tab || '_public_display_id', '^' || item.prefix || '[0-9]{11}$');
|
||||
EXECUTE FORMAT('INSERT INTO public_display_id_counters SELECT %L, (created_at AT TIME ZONE ''Asia/Shanghai'')::date, '''', MAX(RIGHT(display_id,3)::int) FROM %I GROUP BY (created_at AT TIME ZONE ''Asia/Shanghai'')::date', item.prefix, item.tab);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
WITH identities AS (
|
||||
SELECT DISTINCT ON (user_id) user_id, gender FROM user_identities
|
||||
WHERE verification_status='verified' AND gender IN ('male','female')
|
||||
ORDER BY user_id, verified_at DESC NULLS LAST, id
|
||||
), ranked AS (
|
||||
SELECT u.id, i.gender, (u.created_at AT TIME ZONE 'Asia/Shanghai')::date AS d,
|
||||
ROW_NUMBER() OVER (PARTITION BY (u.created_at AT TIME ZONE 'Asia/Shanghai')::date,i.gender ORDER BY u.created_at,u.id) * 2
|
||||
- CASE WHEN i.gender='male' THEN 1 ELSE 0 END AS n
|
||||
FROM users u JOIN identities i ON i.user_id=u.id
|
||||
)
|
||||
UPDATE users u SET display_number='YH'||TO_CHAR(r.d,'YYYYMMDD')||LPAD(r.n::text,3,'0')
|
||||
FROM ranked r WHERE u.id=r.id AND r.n<=999;
|
||||
|
||||
-- 未实名者允许无编号;已有旧号不能静默丢失或截断。
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM users WHERE display_number IS NOT NULL AND display_number !~ '^YH[0-9]{11}$') THEN
|
||||
RAISE EXCEPTION '用户历史编号或性别异常,需核对后再迁移';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM (
|
||||
SELECT u.id, ROW_NUMBER() OVER (PARTITION BY (u.created_at AT TIME ZONE 'Asia/Shanghai')::date, i.gender ORDER BY u.created_at,u.id)*2-CASE WHEN i.gender='male' THEN 1 ELSE 0 END n
|
||||
FROM users u JOIN (SELECT DISTINCT ON(user_id) user_id,gender FROM user_identities WHERE verification_status='verified' AND gender IN ('male','female') ORDER BY user_id,verified_at DESC NULLS LAST,id) i ON i.user_id=u.id
|
||||
) x WHERE n>999) THEN RAISE EXCEPTION '历史用户编号容量不足'; END IF;
|
||||
END $$;
|
||||
CREATE UNIQUE INDEX uk_users_public_display_number ON users(display_number) WHERE display_number IS NOT NULL;
|
||||
ALTER TABLE users ADD CONSTRAINT ck_users_public_display_number CHECK (display_number IS NULL OR display_number ~ '^YH[0-9]{11}$');
|
||||
INSERT INTO public_display_id_counters
|
||||
SELECT 'YH', (created_at AT TIME ZONE 'Asia/Shanghai')::date,
|
||||
CASE WHEN RIGHT(display_number,3)::int % 2=1 THEN 'male' ELSE 'female' END,
|
||||
MAX(RIGHT(display_number,3)::int)
|
||||
FROM users WHERE display_number IS NOT NULL GROUP BY 2,3;
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT o.id, (o.created_at AT TIME ZONE 'Asia/Shanghai')::date AS d,
|
||||
CASE WHEN EXISTS(SELECT 1 FROM operator_roles ort JOIN roles r ON r.id=ort.role_id WHERE ort.operator_id=o.id AND r.code IN ('matchmaker','matchmaker_leader')) THEN 'HN' ELSE 'GL' END AS prefix
|
||||
FROM operators o
|
||||
), numbered AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY d,prefix ORDER BY (SELECT created_at FROM operators WHERE id=ranked.id),id) AS n FROM ranked
|
||||
)
|
||||
UPDATE operators o SET display_id=r.prefix||TO_CHAR(r.d,'YYYYMMDD')||LPAD(r.n::text,3,'0') FROM numbered r WHERE o.id=r.id AND r.n<=999;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM operators WHERE display_id IS NULL OR display_id !~ '^(HN|GL)[0-9]{11}$') THEN RAISE EXCEPTION '工作人员历史编号容量或日期异常'; END IF;
|
||||
END $$;
|
||||
ALTER TABLE operators ALTER COLUMN display_id SET NOT NULL;
|
||||
ALTER TABLE operators ADD CONSTRAINT ck_operators_public_display_id CHECK (display_id ~ '^(HN|GL)[0-9]{11}$');
|
||||
CREATE UNIQUE INDEX uk_operators_public_display_id ON operators(display_id);
|
||||
INSERT INTO public_display_id_counters SELECT LEFT(display_id,2), (created_at AT TIME ZONE 'Asia/Shanghai')::date,'',MAX(RIGHT(display_id,3)::int) FROM operators GROUP BY 1,2;
|
||||
UPDATE public_display_id_aliases a SET new_display_id=u.display_number FROM users u WHERE a.entity_type='users' AND a.entity_id=u.id;
|
||||
UPDATE public_display_id_aliases a SET new_display_id=o.display_id FROM operators o WHERE a.entity_type='operators' AND a.entity_id=o.id;
|
||||
|
||||
ALTER TABLE service_appointments ADD COLUMN referral_display_id VARCHAR(13);
|
||||
ALTER TABLE activity_registrations ADD COLUMN referral_display_id VARCHAR(13);
|
||||
ALTER TABLE matchmaker_followups ADD COLUMN referral_display_id VARCHAR(13);
|
||||
WITH referrals AS (
|
||||
SELECT 'service_appointments' tab,id,created_at FROM service_appointments WHERE source='matchmaker_referral'
|
||||
UNION ALL SELECT 'activity_registrations',id,created_at FROM activity_registrations WHERE source='matchmaker_referral'
|
||||
), numbered AS (
|
||||
SELECT *, ROW_NUMBER() OVER(PARTITION BY (created_at AT TIME ZONE 'Asia/Shanghai')::date ORDER BY created_at,tab,id) n FROM referrals
|
||||
)
|
||||
INSERT INTO public_display_id_aliases(entity_type,entity_id,old_display_id,new_display_id)
|
||||
SELECT tab,id,'referral:'||id::text,'ZF'||TO_CHAR(created_at AT TIME ZONE 'Asia/Shanghai','YYYYMMDD')||LPAD(n::text,3,'0') FROM numbered WHERE n<=999;
|
||||
UPDATE service_appointments t SET referral_display_id=a.new_display_id FROM public_display_id_aliases a WHERE a.entity_type='service_appointments' AND a.entity_id=t.id;
|
||||
UPDATE activity_registrations t SET referral_display_id=a.new_display_id FROM public_display_id_aliases a WHERE a.entity_type='activity_registrations' AND a.entity_id=t.id;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM service_appointments WHERE source='matchmaker_referral' AND referral_display_id IS NULL)
|
||||
OR EXISTS (SELECT 1 FROM activity_registrations WHERE source='matchmaker_referral' AND referral_display_id IS NULL) THEN RAISE EXCEPTION '历史转介编号容量不足'; END IF;
|
||||
END $$;
|
||||
INSERT INTO public_display_id_counters SELECT 'ZF',TO_DATE(SUBSTRING(new_display_id,3,8),'YYYYMMDD'),'',MAX(RIGHT(new_display_id,3)::int) FROM public_display_id_aliases WHERE new_display_id LIKE 'ZF%' GROUP BY 2;
|
||||
|
||||
CREATE FUNCTION public_assign_display_id() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE prefix TEXT;
|
||||
BEGIN
|
||||
IF TG_OP='UPDATE' THEN
|
||||
IF OLD.display_id IS DISTINCT FROM NEW.display_id THEN RAISE EXCEPTION '公开编号不可更改'; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
prefix:=TG_ARGV[0];
|
||||
IF prefix='operator' THEN
|
||||
prefix:=CASE WHEN EXISTS(SELECT 1 FROM operator_roles ort JOIN roles r ON r.id=ort.role_id WHERE ort.operator_id=NEW.id AND r.code IN ('matchmaker','matchmaker_leader')) THEN 'HN' ELSE 'GL' END;
|
||||
END IF;
|
||||
NEW.display_id:=public_next_display_id(prefix,(COALESCE(NEW.created_at,NOW()) AT TIME ZONE 'Asia/Shanghai')::date);
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DO $$ DECLARE item RECORD; BEGIN
|
||||
FOR item IN SELECT * FROM (VALUES ('activities','HD'),('review_tasks','RW'),('reports','JB'),('matchmaker_services','FW'),('matchmaker_recommendations','TJ'),('matchmaker_contact_authorizations','LX'),('matchmaker_assignments','FP'),('matchmaker_followups','GJ'),('service_appointments','YY'),('operators','operator')) t(tab,prefix)
|
||||
LOOP EXECUTE FORMAT('CREATE TRIGGER assign_public_display_id BEFORE INSERT OR UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION public_assign_display_id(%L)',item.tab,item.prefix); END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION public_assign_user_number() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE d DATE; number TEXT;
|
||||
BEGIN
|
||||
IF NEW.verification_status='verified' AND NEW.gender IN ('male','female') THEN
|
||||
SELECT display_number,(created_at AT TIME ZONE 'Asia/Shanghai')::date INTO number,d FROM users WHERE id=NEW.user_id FOR UPDATE;
|
||||
IF number IS NULL THEN UPDATE users SET display_number=public_next_display_id('YH',d,NEW.gender) WHERE id=NEW.user_id; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER assign_public_user_number AFTER INSERT OR UPDATE ON user_identities FOR EACH ROW EXECUTE FUNCTION public_assign_user_number();
|
||||
|
||||
CREATE FUNCTION public_assign_referral_number() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP='UPDATE' AND OLD.referral_display_id IS NOT NULL AND OLD.referral_display_id IS DISTINCT FROM NEW.referral_display_id THEN RAISE EXCEPTION '转介编号不可更改'; END IF;
|
||||
IF NEW.source='matchmaker_referral' AND NEW.referral_display_id IS NULL THEN NEW.referral_display_id:=public_next_display_id('ZF',(COALESCE(NEW.created_at,NOW()) AT TIME ZONE 'Asia/Shanghai')::date); END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER assign_referral_number BEFORE INSERT OR UPDATE ON service_appointments FOR EACH ROW EXECUTE FUNCTION public_assign_referral_number();
|
||||
CREATE TRIGGER assign_referral_number BEFORE INSERT OR UPDATE ON activity_registrations FOR EACH ROW EXECUTE FUNCTION public_assign_referral_number();
|
||||
CREATE UNIQUE INDEX uk_appointments_referral_number ON service_appointments(referral_display_id);
|
||||
CREATE UNIQUE INDEX uk_registrations_referral_number ON activity_registrations(referral_display_id);
|
||||
CREATE UNIQUE INDEX uk_followups_referral_number ON matchmaker_followups(referral_display_id);
|
||||
ALTER TABLE service_appointments ADD CONSTRAINT ck_appointments_referral_number CHECK (referral_display_id IS NULL OR referral_display_id ~ '^ZF[0-9]{11}$');
|
||||
ALTER TABLE activity_registrations ADD CONSTRAINT ck_registrations_referral_number CHECK (referral_display_id IS NULL OR referral_display_id ~ '^ZF[0-9]{11}$');
|
||||
ALTER TABLE matchmaker_followups ADD CONSTRAINT ck_followups_referral_number CHECK (referral_display_id IS NULL OR referral_display_id ~ '^ZF[0-9]{11}$');
|
||||
|
||||
CREATE FUNCTION public_keep_user_number() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF OLD.display_number IS NOT NULL AND OLD.display_number IS DISTINCT FROM NEW.display_number THEN RAISE EXCEPTION '用户公开编号不可更改'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER keep_public_user_number BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION public_keep_user_number();
|
||||
|
||||
CREATE VIEW public_display_ids AS
|
||||
SELECT id::text id,display_number display_id,'users'::text entity_type,NULL::text referral_display_id FROM users
|
||||
UNION ALL SELECT id::text,display_id,'operators',NULL FROM operators
|
||||
UNION ALL SELECT id::text,display_id,'activities',NULL FROM activities
|
||||
UNION ALL SELECT id::text,display_id,'review_tasks',NULL FROM review_tasks
|
||||
UNION ALL SELECT id::text,display_id,'reports',NULL FROM reports
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_services',NULL FROM matchmaker_services
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_recommendations',NULL FROM matchmaker_recommendations
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_contact_authorizations',NULL FROM matchmaker_contact_authorizations
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_assignments',NULL FROM matchmaker_assignments
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_followups',referral_display_id FROM matchmaker_followups
|
||||
UNION ALL SELECT id::text,display_id,'service_appointments',referral_display_id FROM service_appointments
|
||||
UNION ALL SELECT id::text,referral_display_id,'activity_registrations',referral_display_id FROM activity_registrations WHERE referral_display_id IS NOT NULL;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.danyang.match.platform.auth.provider;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class MockWechatLoginProviderTest {
|
||||
@Test
|
||||
void configuredDemoOpenidAlwaysResolvesToTheExistingDemoUser() {
|
||||
MockWechatLoginProvider provider = new MockWechatLoginProvider();
|
||||
ReflectionTestUtils.setField(provider, "configuredOpenid", "demo_openid_f06");
|
||||
ReflectionTestUtils.setField(provider, "appId", "dev");
|
||||
|
||||
assertEquals("demo_openid_f06", provider.getCodeSession("wx-code-1").get("openid"));
|
||||
assertEquals("demo_openid_f06", provider.getCodeSession("wx-code-2").get("openid"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -493,7 +493,7 @@ class EntityMigrationParityTest {
|
||||
|
||||
for (Path f : entityFiles) {
|
||||
String src = Files.readString(f);
|
||||
Matcher tm = Pattern.compile("@TableName\\(\\s*\"([^\"]+)\"").matcher(src);
|
||||
Matcher tm = Pattern.compile("@TableName\\(\\s*(?:value\\s*=\\s*)?\"([^\"]+)\"").matcher(src);
|
||||
if (!tm.find()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+9
-1
@@ -32,7 +32,15 @@ class MigrationValidationTest {
|
||||
// V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一
|
||||
// —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled),
|
||||
// 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭)
|
||||
private static final int LATEST_MIGRATION = 51;
|
||||
// ZC-MM-002(A1 服务对象分配建模):V052 补齐 matchmaker 角色权限(V052 其实早于本轮,漏计);
|
||||
// V053 matchmaker_assignments 建表 + 存量回填;V054 matchmaker_leader 角色与团队分配权限
|
||||
// ZC-MM-006(T01 审计字段扩展):V055 audit_logs 补 purpose_code / service_assignment_id /
|
||||
// actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等)
|
||||
// ZC-MM-003(B1 例外通道立项):V056 matchmaker_contact_authorizations 受控联系授权表
|
||||
// (六态状态机 + active 部分唯一索引)
|
||||
// 用户展示编号:V058 users.display_number 及历史数据回填。
|
||||
// 公共身份展示编号:V059 operators.display_id 及历史数据回填。
|
||||
private static final int LATEST_MIGRATION = 60;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.auth.token.TokenBlacklistService;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import com.danyang.match.platform.referral.service.ReferralService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* ZC-MM-004(禁止代替用户决定复核)+ MM-CONTACT-001 行为锁定
|
||||
* ({@code @SpringBootTest} 走真实安全过滤器链,沿用 MatchmakerReferralPhase13Test 数据构造范式)。
|
||||
*
|
||||
* <p>锁定四条行为不变量(架构师设计 T02):
|
||||
* <ol>
|
||||
* <li><b>MM-ACTION-001 强化(负向)</b>:operator 主体 token 打用户专属决定端点
|
||||
* (推荐 accept / pass、转介 confirm)→ 认证层 403
|
||||
* (SecurityConfig 兜底 {@code hasRole("USER")}:运营身份根本进不到 Controller,
|
||||
* SecurityPathCoverageTest 已机械锁定该兜底不得退回 authenticated()),
|
||||
* 且 recommendation / registration 状态零变化 —— 「红娘不能替用户 Like/Pass/确认转介」。</li>
|
||||
* <li><b>MM-ACTION-002</b>:会话开启端点(POST /conversations/from-match/{matchId})同为
|
||||
* UserPrincipal 路径 → operator token 403(match 的产生同样只在用户 Like 路径,
|
||||
* 无 operator 触达面)。</li>
|
||||
* <li><b>MM-ACTION-003 / must-confirm 语义</b>:红娘创建转介 → 响应
|
||||
* needsUserConfirmation=true、库中 userConfirmedAt=null、status 停留 pending;
|
||||
* 红娘侧 recommend / followup / complete 动作不改变用户确认状态 ——
|
||||
* 「转介代建 = 代填草稿、用户确认」语义被永久锁定。</li>
|
||||
* <li><b>MM-CONTACT-001(正向)</b>:红娘对本人分配对象创建推荐/转介 → notifications 表
|
||||
* 落平台内服务通知(type=matchmaker),title/body 不含手机号/微信号模式;
|
||||
* 通知收件面只有 C 端用户(无 operator 收件行)—— 平台内联系经通知中转,
|
||||
* 红娘与用户之间不存在直连联系方式交换。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
@AutoConfigureMockMvc
|
||||
class MatchmakerActionGuardTest {
|
||||
|
||||
@Autowired private MockMvc mockMvc;
|
||||
@Autowired private JwtTokenService jwtTokenService;
|
||||
/** 测试环境无 Redis:黑名单查询打桩为"未拉黑"(ChatPhase09IntegrationTest 同款) */
|
||||
@MockBean private TokenBlacklistService tokenBlacklistService;
|
||||
/**
|
||||
* ZC-MM-006 / T02 起由真实 DB 路径接管权限装载(schema-h2 已补权限四表),
|
||||
* 此处打桩仅为兼容既有断言口径(本测试只依赖身份类型 403,不依赖具体权限集)。
|
||||
*/
|
||||
@MockBean private com.danyang.match.platform.permission.service.PermissionService permissionService;
|
||||
@Autowired private com.danyang.match.platform.permission.mapper.OperatorMapper operatorMapper;
|
||||
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
@Autowired private MatchmakerAssignmentMapper matchmakerAssignmentMapper;
|
||||
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||
@Autowired private ActivityRegistrationMapper activityRegistrationMapper;
|
||||
@Autowired private NotificationMapper notificationMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
|
||||
@Autowired private UserIdentityMapper userIdentityMapper;
|
||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||
@Autowired private ReferralService referralService;
|
||||
|
||||
private static final String MATCHMAKER_OWNER = "00000000-0000-0000-0000-000000000103";
|
||||
|
||||
private String user;
|
||||
private String target;
|
||||
private String serviceId;
|
||||
private String operatorToken;
|
||||
|
||||
/** 手机号模式(中国大陆 11 位) */
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("1[3-9]\\d{9}");
|
||||
/** 微信号/微信引导词模式 */
|
||||
private static final List<String> WECHAT_TOKENS = List.of("wechat", "weixin", "微信", "vx", "加v", "v信");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
user = createUser("ag-user");
|
||||
target = createUser("ag-target");
|
||||
// 一条归属 MATCHMAKER_OWNER 的红娘服务(assigned),并补生效分配(E1 纯判定器口径)
|
||||
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||
MatchmakerAssignment assignment = new MatchmakerAssignment();
|
||||
assignment.setUserId(user);
|
||||
assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER);
|
||||
assignment.setServiceId(serviceId);
|
||||
assignment.setAssignedBy(MATCHMAKER_OWNER);
|
||||
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
|
||||
assignment.setAssignmentReason("003/004 守卫前置分配");
|
||||
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||
assignment.setAssignedAt(Instant.now());
|
||||
matchmakerAssignmentMapper.insert(assignment);
|
||||
|
||||
// operator token:type=operator_access → JwtAuthFilter 授 ROLE_OPERATOR
|
||||
// ZC-MM-006 / T02:JwtAuthFilter 实时查 operators.status,须为 token subject 落一条 active 运营行
|
||||
com.danyang.match.platform.permission.entity.Operator op =
|
||||
new com.danyang.match.platform.permission.entity.Operator();
|
||||
op.setId(MATCHMAKER_OWNER);
|
||||
op.setName("003/004 守卫红娘");
|
||||
op.setUsername("ag_mm_owner_" + System.nanoTime());
|
||||
op.setPasswordHash("test-hash");
|
||||
op.setStatus("active");
|
||||
operatorMapper.insert(op);
|
||||
when(permissionService.getPermissions(anyString())).thenReturn(List.of());
|
||||
operatorToken = jwtTokenService.generateOperatorToken(MATCHMAKER_OWNER, null);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ==================== 数据构造(Phase13 范式) ====================
|
||||
|
||||
/** 基础资格满足的用户:正常账号 + 实名 + 资料通过(RelationService.like 路径可走) */
|
||||
private String createUser(String tag) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("ag_" + tag + "_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
u.setRecommendationEnabled(true);
|
||||
userMapper.insert(u);
|
||||
|
||||
var profile = new com.danyang.match.platform.profile.entity.UserProfile();
|
||||
profile.setUserId(u.getId());
|
||||
profile.setDisplayName(tag);
|
||||
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||
userProfileMapper.insert(profile);
|
||||
|
||||
var identity = new com.danyang.match.platform.verification.entity.UserIdentity();
|
||||
identity.setUserId(u.getId());
|
||||
identity.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
|
||||
userIdentityMapper.insert(identity);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
private String createService(String userId, String operatorId, String status) {
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setId(UUID.randomUUID().toString());
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setStatus(status);
|
||||
svc.setAssignedOperatorId(operatorId);
|
||||
matchmakerServiceMapper.insert(svc);
|
||||
return svc.getId();
|
||||
}
|
||||
|
||||
private MatchmakerRecommendation insertRecommendation(String recUser, String recTarget) {
|
||||
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||
rec.setId(UUID.randomUUID().toString());
|
||||
rec.setServiceId(serviceId);
|
||||
rec.setUserId(recUser);
|
||||
rec.setTargetUserId(recTarget);
|
||||
rec.setOperatorId(MATCHMAKER_OWNER);
|
||||
rec.setReason("004 守卫测试推荐");
|
||||
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
|
||||
recommendationMapper.insert(rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
private ActivityRegistration insertReferralRegistration(String regUser, String activityId) {
|
||||
ActivityRegistration reg = new ActivityRegistration();
|
||||
reg.setId(UUID.randomUUID().toString());
|
||||
reg.setActivityId(activityId);
|
||||
reg.setUserId(regUser);
|
||||
reg.setStatus(Constants.REGISTRATION_STATUS_PENDING);
|
||||
reg.setSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
|
||||
reg.setRequestSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
|
||||
reg.setCreatedByOperatorId(MATCHMAKER_OWNER);
|
||||
reg.setCreatedAt(Instant.now());
|
||||
activityRegistrationMapper.insert(reg);
|
||||
return reg;
|
||||
}
|
||||
|
||||
// ==================== MM-ACTION-001:红娘不能替用户做 Like/Pass/确认决定 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 accept → 403,推荐状态零变化")
|
||||
void operatorCannotAcceptRecommendationOnBehalfOfUser() throws Exception {
|
||||
MatchmakerRecommendation rec = insertRecommendation(user, target);
|
||||
|
||||
mockMvc.perform(post("/matchmaker/recommendations/{id}/accept", rec.getId())
|
||||
.header("Authorization", "Bearer " + operatorToken))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
|
||||
"越权 accept 被认证层 403 拒绝后,推荐状态必须零变化");
|
||||
assertNull(after.getRespondedAt(), "越权 accept 不得写入 respondedAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 pass → 403,推荐状态零变化")
|
||||
void operatorCannotPassRecommendationOnBehalfOfUser() throws Exception {
|
||||
MatchmakerRecommendation rec = insertRecommendation(user, target);
|
||||
|
||||
mockMvc.perform(post("/matchmaker/recommendations/{id}/pass", rec.getId())
|
||||
.header("Authorization", "Bearer " + operatorToken))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
|
||||
"越权 pass 被拒绝后,推荐状态必须零变化(仍 pending_view)");
|
||||
assertNull(after.getRespondedAt(), "越权 pass 不得写入 respondedAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-001(负向): operator token 打转介 confirm → 403,用户确认状态零变化")
|
||||
void operatorCannotConfirmReferralOnBehalfOfUser() throws Exception {
|
||||
ActivityRegistration reg = insertReferralRegistration(user, "act-ag-001");
|
||||
|
||||
mockMvc.perform(post("/referrals/{id}/confirm", reg.getId())
|
||||
.header("Authorization", "Bearer " + operatorToken)
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"activity\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
ActivityRegistration after = activityRegistrationMapper.selectById(reg.getId());
|
||||
assertNull(after.getUserConfirmedAt(),
|
||||
"越权 confirm 被拒绝后,userConfirmedAt 必须仍为 null(用户决定权)");
|
||||
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
|
||||
"越权 confirm 后报名状态必须停留 pending");
|
||||
}
|
||||
|
||||
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)")
|
||||
void operatorCannotOpenConversationFromMatch() throws Exception {
|
||||
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在
|
||||
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
|
||||
.header("Authorization", "Bearer " + operatorToken))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-003: 转介代建=代填草稿(needsUserConfirmation=true、userConfirmedAt=null);红娘后续动作不改变确认状态")
|
||||
void referralCreationStaysPendingUntilUserConfirms() {
|
||||
// 红娘创建转介(本人分配对象)
|
||||
Map<String, Object> result = referralService.referToActivity(
|
||||
MATCHMAKER_OWNER, user, "act-ag-002", "紧急联系人", "enc-phone");
|
||||
|
||||
// must-confirm 语义:响应标记待确认,库中确认字段为空、状态停留 pending
|
||||
assertEquals(Boolean.TRUE, result.get("needsUserConfirmation"),
|
||||
"红娘创建转介必须标记 needsUserConfirmation=true(不替用户完成报名)");
|
||||
String regId = (String) result.get("registrationId");
|
||||
ActivityRegistration reg = activityRegistrationMapper.selectById(regId);
|
||||
assertNotNull(reg, "转介必须真实落库");
|
||||
assertNull(reg.getUserConfirmedAt(), "创建时 userConfirmedAt 必须为 null");
|
||||
assertEquals(Constants.REGISTRATION_STATUS_PENDING, reg.getStatus(),
|
||||
"创建时报名状态必须停留 pending");
|
||||
|
||||
// 红娘侧后续动作:recommend / followup / complete —— 均不得改变用户确认状态
|
||||
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "合适人选");
|
||||
matchmakerServiceService.addFollowup(serviceId, MATCHMAKER_OWNER, "note", "003 守卫跟进", null);
|
||||
|
||||
MatchmakerServiceRecord svc = matchmakerServiceMapper.selectById(serviceId);
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||
matchmakerServiceMapper.updateById(svc);
|
||||
matchmakerServiceService.completeService(serviceId, MATCHMAKER_OWNER);
|
||||
|
||||
ActivityRegistration after = activityRegistrationMapper.selectById(regId);
|
||||
assertNull(after.getUserConfirmedAt(),
|
||||
"recommend/followup/complete 红娘侧动作不得代替用户确认(userConfirmedAt 必须仍为 null)");
|
||||
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
|
||||
"红娘侧动作不得推动报名状态(必须停留 pending 等待用户确认)");
|
||||
}
|
||||
|
||||
// ==================== MM-CONTACT-001:平台内联系 = 通知中转,无联系方式交换 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-CONTACT-001(正向): 推荐/转介经平台通知触达用户,文案无手机号/微信号模式,收件面仅用户")
|
||||
void platformNotificationsCarryNoContactPatternsAndOnlyTargetUsers() {
|
||||
// 推荐路径:recommendPartner → sendAsync(type=matchmaker, businessType=matchmaker_recommendation)
|
||||
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "003 正向推荐");
|
||||
// 转介路径:referToServiceStation → sendMatchmakerReferralCreated(type=matchmaker, businessType=service_station)
|
||||
referralService.referToServiceStation(MATCHMAKER_OWNER, user, "loc-ag-001",
|
||||
"counselor_consultation", null, "备注");
|
||||
|
||||
List<Notification> notes = notificationMapper.selectList(new LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getUserId, user)
|
||||
.eq(Notification::getType, Constants.NOTIFICATION_TYPE_MATCHMAKER));
|
||||
assertFalse(notes.isEmpty(), "推荐/转介必须为用户落平台内服务通知(type=matchmaker)");
|
||||
|
||||
for (Notification note : notes) {
|
||||
String title = note.getTitle() == null ? "" : note.getTitle();
|
||||
String content = note.getContent() == null ? "" : note.getContent();
|
||||
assertFalse(PHONE_PATTERN.matcher(title).find(),
|
||||
"通知标题不得含手机号模式: " + title);
|
||||
assertFalse(PHONE_PATTERN.matcher(content).find(),
|
||||
"通知正文不得含手机号模式: " + content);
|
||||
for (String token : WECHAT_TOKENS) {
|
||||
assertFalse(title.toLowerCase().contains(token),
|
||||
"通知标题不得含微信引导词「" + token + "」: " + title);
|
||||
assertFalse(content.toLowerCase().contains(token),
|
||||
"通知正文不得含微信引导词「" + token + "」: " + content);
|
||||
}
|
||||
}
|
||||
|
||||
// 负向佐证:通知收件面只有 C 端用户 —— 红娘 operatorId 不在 notification 收件面
|
||||
long operatorInbox = notificationMapper.selectCount(new LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getUserId, MATCHMAKER_OWNER));
|
||||
assertEquals(0L, operatorInbox,
|
||||
"红娘不得成为通知收件人(联系方式交换无 operator 侧触达面)");
|
||||
}
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
|
||||
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
import com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* ZC-MM-002(A1 服务对象分配建模)验收测试 —— 真实 H2 数据构造。
|
||||
*
|
||||
* <p>覆盖:
|
||||
* <ul>
|
||||
* <li>MM-AUTH-001:红娘对本人有效分配对象 canAccessUser=true,GET /assignments/my 含该对象;</li>
|
||||
* <li>MM-AUTH-002(负向):访问未分配用户 → DataScopeGuard 拒绝 + DATA_SCOPE_DENIED 审计;</li>
|
||||
* <li>MM-AUTH-003(负向):访问他人(红娘D)分配对象 → 拒绝 + 审计;</li>
|
||||
* <li>MM-AUTH-004(负向):POST /services/{id}/claim → 404(D3:claim 端点已删除);</li>
|
||||
* <li>MM-AUDIT-001:create/transfer/revoke/end 各产生对应 action 审计且 operatorId=操作者;</li>
|
||||
* <li>MM-LIFECYCLE-001:end/revoke 后 canAccessUser=false;expires_at 届满惰性 expired;</li>
|
||||
* <li>负向:组长跨组织分配 → 403 + 审计;普通红娘调 /pending → 403 + 审计。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerAssignmentA1Test {
|
||||
|
||||
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||
@Autowired private ServiceAssignmentPolicy assignmentPolicy;
|
||||
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||
@Autowired private OperatorMapper operatorMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
@Autowired private MatchmakerAssignmentController assignmentController;
|
||||
@Autowired private MatchmakerOperatorController operatorController;
|
||||
|
||||
private String leaderA; // 组长,org X
|
||||
private String mmB; // 红娘,org X
|
||||
private String mmC; // 红娘,org Y
|
||||
private String mmD; // 红娘,org X
|
||||
private String user1; // B 的服务对象
|
||||
private String user2; // 无人分配
|
||||
private String user3; // D 的服务对象
|
||||
|
||||
private static final String ORG_X = "11111111-1111-1111-1111-111111111111";
|
||||
private static final String ORG_Y = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
leaderA = createOperator("组长A", ORG_X);
|
||||
mmB = createOperator("红娘B", ORG_X);
|
||||
mmC = createOperator("红娘C", ORG_Y);
|
||||
mmD = createOperator("红娘D", ORG_X);
|
||||
user1 = createUser("a1-user1");
|
||||
user2 = createUser("a1-user2");
|
||||
user3 = createUser("a1-user3");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ==================== 数据构造 ====================
|
||||
|
||||
private String createOperator(String name, String organizationId) {
|
||||
Operator op = new Operator();
|
||||
op.setName(name);
|
||||
op.setOrganizationId(organizationId);
|
||||
op.setUsername("a1_" + name + "_" + System.nanoTime());
|
||||
op.setPasswordHash("test-hash");
|
||||
op.setStatus("active");
|
||||
operatorMapper.insert(op);
|
||||
return op.getId();
|
||||
}
|
||||
|
||||
private String createUser(String tag) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("a1_" + tag + "_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
userMapper.insert(u);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
/** 以指定操作者身份 + 权限集设置 SecurityContext(方法级 @PreAuthorize 据此判定)。 */
|
||||
private void authenticate(String operatorId, String... permissions) {
|
||||
Set<String> permSet = Set.of(permissions);
|
||||
var authorities = Stream.concat(
|
||||
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
|
||||
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
UsernamePasswordAuthenticationToken.authenticated(
|
||||
new OperatorPrincipal(operatorId, permSet), null, authorities));
|
||||
}
|
||||
|
||||
private long countAudits(String action, String operatorId) {
|
||||
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, action)
|
||||
.eq(operatorId != null, AuditLog::getOperatorId, operatorId));
|
||||
}
|
||||
|
||||
// ==================== MM-AUTH-001 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-AUTH-001: 红娘B 对本人有效分配对象 canAccessUser=true,GET /assignments/my 含该对象")
|
||||
void mmAuth001_matchmakerAccessesOwnAssignedUser() {
|
||||
// 组长A(同组织 X,team.assign 路径)创建 B → user1 分配
|
||||
MatchmakerAssignment created = assignmentService.create(
|
||||
leaderA, false, user1, mmB, null, "同一社区,就近服务", null);
|
||||
assertNotNull(created.getId());
|
||||
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, created.getStatus());
|
||||
assertEquals(Constants.ASSIGNMENT_SCOPE_PRIMARY, created.getAssignmentScope());
|
||||
|
||||
assertTrue(assignmentPolicy.canAccessUser(mmB, user1),
|
||||
"红娘B 必须能访问其名下生效分配的服务对象");
|
||||
|
||||
// GET /assignments/my(matchmaker.service 权限)包含该对象
|
||||
authenticate(mmB, "matchmaker.view", "matchmaker.service");
|
||||
var result = assignmentController.my(new OperatorPrincipal(mmB,
|
||||
Set.of("matchmaker.view", "matchmaker.service")));
|
||||
assertTrue(result.isSuccess());
|
||||
List<Map<String, Object>> data = result.getData();
|
||||
assertNotNull(data);
|
||||
assertEquals(1, data.size(), "GET /my 应恰好返回红娘B 名下的生效分配");
|
||||
assertEquals(user1, data.get(0).get("userId"));
|
||||
}
|
||||
|
||||
// ==================== MM-AUTH-002 / 003 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-AUTH-002(负向): B 访问未分配用户 → DataScopeGuard 拒绝 + DATA_SCOPE_DENIED 审计")
|
||||
void mmAuth002_accessUnassignedUserDeniedAndAudited() {
|
||||
assertFalse(assignmentPolicy.canAccessUser(mmB, user2));
|
||||
|
||||
long before = countAudits("DATA_SCOPE_DENIED", mmB);
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> assignmentPolicy.assertActiveAssignment(mmB, user2, "matchmaker_user"),
|
||||
"未持有分配的红娘访问用户必须被拒绝");
|
||||
long after = countAudits("DATA_SCOPE_DENIED", mmB);
|
||||
assertEquals(before + 1, after, "拒绝必须落一条 DATA_SCOPE_DENIED 审计行");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-AUTH-003(负向): B 访问他人(红娘D)分配对象 → 拒绝 + 审计")
|
||||
void mmAuth003_accessOthersAssignedUserDeniedAndAudited() {
|
||||
// D 名下分配(组长A 同组织创建)
|
||||
assignmentService.create(leaderA, false, user3, mmD, null, "D 负责该用户", null);
|
||||
|
||||
assertTrue(assignmentPolicy.canAccessUser(mmD, user3));
|
||||
assertFalse(assignmentPolicy.canAccessUser(mmB, user3), "他人的分配对象对 B 不可见");
|
||||
|
||||
long before = countAudits("DATA_SCOPE_DENIED", mmB);
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> assignmentPolicy.assertActiveAssignment(mmB, user3, "matchmaker_user"),
|
||||
"B 访问红娘D 的分配对象必须被拒绝");
|
||||
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", mmB),
|
||||
"拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||
}
|
||||
|
||||
// ==================== MM-AUTH-004 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-AUTH-004(负向): POST /services/{id}/claim → 404(claim 端点已删除)")
|
||||
void mmAuth004_claimEndpointRemoved() throws Exception {
|
||||
// standalone MockMvc:仅注册真实控制器路由,验证 /claim 已不在映射中(D3 决策)
|
||||
MatchmakerServiceService stubService = Mockito.mock(MatchmakerServiceService.class);
|
||||
MockMvc mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new MatchmakerOperatorController(stubService,
|
||||
org.mockito.Mockito.mock(com.danyang.match.platform.matchmaker.service.MatchmakerManagementService.class)))
|
||||
.build();
|
||||
|
||||
mockMvc.perform(post("/operator/matchmaker/services/some-id/claim"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// ==================== MM-AUDIT-001 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-AUDIT-001: create/transfer/revoke/end 各产生对应 action 审计且 operatorId=操作者")
|
||||
void mmAudit001_eachOperationWritesAuditWithActor() {
|
||||
// 1) create: A 分配 B → user1
|
||||
MatchmakerAssignment a1 = assignmentService.create(
|
||||
leaderA, false, user1, mmB, null, "初始分配", null);
|
||||
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, leaderA) > 0,
|
||||
"create 必须落 MATCHMAKER_ASSIGNMENT_CREATE 审计");
|
||||
|
||||
// 2) transfer: B → D
|
||||
MatchmakerAssignment a2 = assignmentService.transfer(
|
||||
a1.getId(), leaderA, false, mmD, "B 休假,转给 D");
|
||||
assertEquals(mmD, a2.getMatchmakerOperatorId());
|
||||
assertEquals(Constants.ASSIGNMENT_STATUS_TRANSFERRED,
|
||||
assignmentMapper.findById(a1.getId()).orElseThrow().getStatus());
|
||||
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER, leaderA) > 0,
|
||||
"transfer 必须落 MATCHMAKER_ASSIGNMENT_TRANSFER 审计");
|
||||
|
||||
// 3) revoke: 撤销 D 名下的新分配
|
||||
assignmentService.revoke(a2.getId(), leaderA, false, "用户主动申请更换");
|
||||
assertEquals(Constants.ASSIGNMENT_STATUS_REVOKED,
|
||||
assignmentMapper.findById(a2.getId()).orElseThrow().getStatus());
|
||||
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE, leaderA) > 0,
|
||||
"revoke 必须落 MATCHMAKER_ASSIGNMENT_REVOKE 审计");
|
||||
|
||||
// 4) end: 新建再结束
|
||||
MatchmakerAssignment a3 = assignmentService.create(
|
||||
leaderA, false, user2, mmB, null, "第二次分配", null);
|
||||
assignmentService.end(a3.getId(), leaderA, false, "服务完结");
|
||||
assertEquals(Constants.ASSIGNMENT_STATUS_ENDED,
|
||||
assignmentMapper.findById(a3.getId()).orElseThrow().getStatus());
|
||||
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_END, leaderA) > 0,
|
||||
"end 必须落 MATCHMAKER_ASSIGNMENT_END 审计");
|
||||
|
||||
// 审计行 operatorId 必须是操作者(组长A),且 resourceType 正确
|
||||
// 注:本用例共 5 次操作(create×2 + transfer + revoke + end),审计 1:1 对应
|
||||
List<AuditLog> audits = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getOperatorId, leaderA)
|
||||
.in(AuditLog::getAction, List.of(
|
||||
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE,
|
||||
Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER,
|
||||
Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE,
|
||||
Constants.AUDIT_ACTION_ASSIGNMENT_END)));
|
||||
assertEquals(5, audits.size(), "五次操作各产生一条审计(create×2/transfer/revoke/end)");
|
||||
assertEquals(2, audits.stream().filter(a ->
|
||||
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count());
|
||||
assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())),
|
||||
"resourceType 必须是 matchmaker_assignment");
|
||||
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason
|
||||
assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null
|
||||
&& a.getAfterData().contains("matchmakerOperatorId=")
|
||||
&& a.getAfterData().contains("scope=")
|
||||
&& a.getAfterData().contains("reason=")),
|
||||
"afterData 必须记录 operatorId/scope/reason 摘要");
|
||||
}
|
||||
|
||||
// ==================== MM-LIFECYCLE-001 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-LIFECYCLE-001: end/revoke 后 canAccessUser=false;expires_at 届满惰性置 expired")
|
||||
void mmLifecycle001_terminalStatesRevokeAccessAndLazyExpiry() {
|
||||
// end 之后不可访问
|
||||
MatchmakerAssignment a1 = assignmentService.create(
|
||||
leaderA, false, user1, mmB, null, "待结束分配", null);
|
||||
assertTrue(assignmentPolicy.canAccessUser(mmB, user1));
|
||||
assignmentService.end(a1.getId(), leaderA, false, "服务完结");
|
||||
assertFalse(assignmentPolicy.canAccessUser(mmB, user1), "end 后必须立即失去访问权");
|
||||
|
||||
// revoke 之后不可访问
|
||||
MatchmakerAssignment a2 = assignmentService.create(
|
||||
leaderA, false, user2, mmB, null, "待撤销分配", null);
|
||||
assignmentService.revoke(a2.getId(), leaderA, false, "误分配");
|
||||
assertFalse(assignmentPolicy.canAccessUser(mmB, user2), "revoke 后必须立即失去访问权");
|
||||
|
||||
// expires_at 届满(固定时间注入:写入已过期的 expires_at)→ 读路径惰性置 expired
|
||||
MatchmakerAssignment a3 = assignmentService.create(
|
||||
leaderA, false, user3, mmB, null, "短期分配",
|
||||
Instant.now().minusSeconds(3600));
|
||||
assertTrue(assignmentPolicy.canAccessUser(mmB, user3) == false,
|
||||
"已过有效期的分配必须被视为失效");
|
||||
MatchmakerAssignment after = assignmentMapper.findById(a3.getId()).orElseThrow();
|
||||
assertEquals(Constants.ASSIGNMENT_STATUS_EXPIRED, after.getStatus(),
|
||||
"惰性过期必须落库(无定时任务,读路径触发)");
|
||||
assertNotNull(after.getEndedAt());
|
||||
assertNotNull(after.getEndReason());
|
||||
|
||||
// 终态一律拒绝再转换(D5 状态机矩阵)
|
||||
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.revoke(a3.getId(), leaderA, false, "对已过期分配撤销"),
|
||||
"expired 是终态,不允许再转换");
|
||||
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.end(a1.getId(), leaderA, false, "对已结束分配再结束"),
|
||||
"ended 是终态,不允许再转换");
|
||||
}
|
||||
|
||||
// ==================== 组长组织边界(负向) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("负向: 组长A 跨组织分配红娘C(org=Y) → 403 + DATA_SCOPE_DENIED 审计")
|
||||
void leaderCannotAssignCrossOrganization() {
|
||||
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> assignmentService.create(leaderA, false, user2, mmC, null, "跨组织分配", null),
|
||||
"组长(org=X)不得分配 org=Y 的红娘");
|
||||
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
|
||||
"跨组织分配拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
|
||||
.eq(MatchmakerAssignment::getUserId, user2)).isEmpty(),
|
||||
"被拒绝的分配不得留下任何分配记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("负向: 平台授权人员(matchmaker.assign)可跨组织分配;组长不可分配 org=NULL 红娘")
|
||||
void globalAssignScopeAndNullOrgBoundary() {
|
||||
// matchmaker.assign 持有者不受组织限制(leaderA 只是被用作操作者身份)
|
||||
assertDoesNotThrow(() ->
|
||||
assignmentService.create(leaderA, true, user1, mmC, null, "平台授权分配", null),
|
||||
"matchmaker.assign 持有者不限组织");
|
||||
|
||||
// org=NULL 的红娘:平台直属组长只能分配平台直属红娘(NULL=NULL 规则)
|
||||
String platformLeader = createOperator("平台直属组长", null);
|
||||
String platformMm = createOperator("平台直属红娘", null);
|
||||
assertDoesNotThrow(() ->
|
||||
assignmentService.create(platformLeader, false, user2, platformMm, null, "平台直属互配", null),
|
||||
"NULL=NULL(平台直属 → 平台直属)必须放行");
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> assignmentService.create(platformLeader, false, user3, mmB, null, "平台直属分配有组织红娘", null),
|
||||
"平台直属组长不得分配有组织的红娘(NULL≠org X)");
|
||||
}
|
||||
|
||||
// ==================== /pending 方法级权限(负向) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("负向: 普通红娘(无分配权限)调 /pending → 403 + AUTHORIZATION_DENIED 审计")
|
||||
void normalMatchmakerCannotListPendingQueue() {
|
||||
authenticate(mmB, "matchmaker.view", "matchmaker.service");
|
||||
|
||||
long before = countAudits("AUTHORIZATION_DENIED", mmB);
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> operatorController.getPending(
|
||||
new OperatorPrincipal(mmB, Set.of("matchmaker.view", "matchmaker.service"))),
|
||||
"/pending 方法级权限收敛后,普通红娘必须 403");
|
||||
assertEquals(before + 1, countAudits("AUTHORIZATION_DENIED", mmB),
|
||||
"方法级授权拒绝必须落 AUTHORIZATION_DENIED 审计");
|
||||
}
|
||||
|
||||
// ==================== QA 验收补充(Edward):边界与异常路径 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("QA补充(负向): 理由为 null/空串/纯空格 → 400 VALIDATION_ERROR,且不落任何分配记录或 CREATE 审计")
|
||||
void blankReasonMustBeRejectedWithValidationError() {
|
||||
// null 理由
|
||||
var ex1 = assertThrows(
|
||||
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.create(leaderA, false, user1, mmB, null, null, null),
|
||||
"reason=null 必须被拒绝");
|
||||
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex1.getCode(),
|
||||
"理由缺失必须是 400 VALIDATION_ERROR,而非 500/403");
|
||||
|
||||
// 空串理由
|
||||
var ex2 = assertThrows(
|
||||
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.create(leaderA, false, user1, mmB, null, "", null));
|
||||
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex2.getCode());
|
||||
|
||||
// 纯空格理由(isBlank 语义,防止" "绕过)
|
||||
var ex3 = assertThrows(
|
||||
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.create(leaderA, false, user1, mmB, null, " ", null));
|
||||
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex3.getCode());
|
||||
|
||||
// revoke/end/transfer 同口径
|
||||
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR,
|
||||
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.transfer("any-id", leaderA, false, mmB, " ")).getCode(),
|
||||
"转分配理由空白同样必须 VALIDATION_ERROR");
|
||||
|
||||
// 三次被拒的 create 不得留下分配记录,也不得落 CREATE 审计
|
||||
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
|
||||
.eq(MatchmakerAssignment::getUserId, user1)).isEmpty(),
|
||||
"被拒绝的分配不得留下任何分配记录");
|
||||
assertEquals(0, countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, leaderA),
|
||||
"被拒绝的分配不得产生 CREATE 审计");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("QA补充: collaborating 分配与 active+primary 共存,不触发主责部分唯一索引冲突")
|
||||
void collaboratingAssignmentMustNotConflictWithActivePrimary() {
|
||||
// 先建立主责分配(组长A → 红娘B)
|
||||
assignmentService.create(leaderA, false, user1, mmB, null, "主责分配", null);
|
||||
|
||||
// 直接落一条同用户、同红娘的 collaborating 生效中分配(协同注记不占主责唯一位)
|
||||
MatchmakerAssignment collab = new MatchmakerAssignment();
|
||||
collab.setUserId(user1);
|
||||
collab.setMatchmakerOperatorId(mmB);
|
||||
collab.setAssignedBy(leaderA);
|
||||
collab.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_COLLABORATING);
|
||||
collab.setAssignmentReason("协同服务(组长指派协同)");
|
||||
collab.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||
collab.setAssignedAt(Instant.now());
|
||||
assertDoesNotThrow(() -> assignmentMapper.insert(collab),
|
||||
"collaborating 生效中分配不得触发 uq_mm_assignment_active_primary 冲突");
|
||||
|
||||
// 主责仍在、协同也在,两条均为生效中
|
||||
assertEquals(2, assignmentMapper.selectCount(new LambdaQueryWrapper<MatchmakerAssignment>()
|
||||
.eq(MatchmakerAssignment::getUserId, user1)
|
||||
.eq(MatchmakerAssignment::getStatus, Constants.ASSIGNMENT_STATUS_ACTIVE)),
|
||||
"同一用户应同时存在 1 条 active 主责 + 1 条 active 协同");
|
||||
|
||||
// 主责唯一性仍然生效:同用户第二条 primary 必须被拒(对照组)
|
||||
assertThrows(Exception.class,
|
||||
() -> assignmentService.create(leaderA, false, user1, mmD, null, "第二条主责", null),
|
||||
"同用户第二条 active primary 必须被唯一性约束拒绝");
|
||||
// 预检路径的 CONFLICT
|
||||
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
|
||||
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.create(leaderA, false, user1, mmD, null, "第二条主责", null)).getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("QA补充(负向): 有组织组长分配 org=NULL(平台直属)红娘 → 403(Objects.equals 非对称方向的显式覆盖)")
|
||||
void orgLeaderMustNotAssignPlatformDirectMatchmaker() {
|
||||
// org=NULL 红娘(平台直属)
|
||||
String platformMm = createOperator("平台直属红娘-供拒配", null);
|
||||
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
|
||||
// leaderA(org X)→ org=NULL 红娘:Objects.equals(orgX, null)=false,必须拒绝
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> assignmentService.create(leaderA, false, user2, platformMm, null, "有组织组长碰平台直属红娘", null),
|
||||
"有组织组长不得分配 org=NULL(平台直属)红娘");
|
||||
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
|
||||
"拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||
// 同理 transfer 的范围校验也覆盖该方向(ZC-MM-006 / T04:transfer 404 化)
|
||||
MatchmakerAssignment a = assignmentService.create(leaderA, false, user3, mmB, null, "主责", null);
|
||||
var transferDenied = assertThrows(
|
||||
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.transfer(a.getId(), leaderA, false, platformMm, "转给平台直属红娘"),
|
||||
"有组织组长不得把分配转给 org=NULL 红娘(404 化拒绝)");
|
||||
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, transferDenied.getCode(),
|
||||
"transfer 范围违规必须 404 化(防探测,T04)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("P2-1补充(负向): org=X 组长转分配 org=Y 红娘持有的分配 → 404 化拒绝 + DATA_SCOPE_DENIED 审计(原持有人范围校验)")
|
||||
void leaderCannotTransferAssignmentHeldByCrossOrgMatchmaker() {
|
||||
// org=Y 红娘C 持有一条分配(由平台授权路径创建,绕过 create 的范围校验以构造前置状态)
|
||||
MatchmakerAssignment held = assignmentService.create(
|
||||
leaderA, true, user1, mmC, null, "平台授权分配给C", null);
|
||||
assertEquals(mmC, held.getMatchmakerOperatorId());
|
||||
|
||||
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
|
||||
// org=X 组长A 把 C(org=Y)持有的分配转给同组织成员 B:原持有人范围违规,必须拒绝
|
||||
// (ZC-MM-006 / T04:transfer 为资源寻址写端点,拒绝 404 化,审计仍为 DATA_SCOPE_DENIED)
|
||||
var denied = assertThrows(
|
||||
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> assignmentService.transfer(held.getId(), leaderA, false, mmB, "转回本组织"),
|
||||
"组长不得处置跨组织红娘持有的分配(原持有人范围校验,404 化)");
|
||||
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, denied.getCode(),
|
||||
"transfer 范围违规必须 404 化(防探测,T04)");
|
||||
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
|
||||
"拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||
|
||||
// 被转记录必须原样保留:仍 active 且持有人仍是 C,未产生新分配
|
||||
MatchmakerAssignment after = assignmentMapper.findById(held.getId()).orElseThrow();
|
||||
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, after.getStatus(),
|
||||
"被拒绝的转分配不得改变原分配状态");
|
||||
assertEquals(mmC, after.getMatchmakerOperatorId(),
|
||||
"被拒绝的转分配不得改变原持有人");
|
||||
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
|
||||
.eq(MatchmakerAssignment::getUserId, user1)
|
||||
.eq(MatchmakerAssignment::getMatchmakerOperatorId, mmB))
|
||||
.isEmpty(),
|
||||
"被拒绝的转分配不得给目标红娘留下任何分配记录");
|
||||
}
|
||||
|
||||
// ==================== 分配同事务联动服务单 ====================
|
||||
|
||||
@Autowired
|
||||
private com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper realServiceMapper;
|
||||
|
||||
@Test
|
||||
@DisplayName("联动: 带 serviceId 的分配同事务把服务单置 assigned 并写回 assigned_operator_id")
|
||||
void createWithServiceIdLinksServiceRow() {
|
||||
// 真实落库一条 submitted 服务单
|
||||
var record = new com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord();
|
||||
record.setUserId(user1);
|
||||
record.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
record.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
realServiceMapper.insert(record);
|
||||
|
||||
assignmentService.create(leaderA, false, user1, mmB, record.getId(), "带服务单分配", null);
|
||||
|
||||
var updated = realServiceMapper.selectById(record.getId());
|
||||
assertEquals(Constants.MATCHMAKER_STATUS_ASSIGNED, updated.getStatus(),
|
||||
"分配联动必须把服务单置为 assigned");
|
||||
assertEquals(mmB, updated.getAssignedOperatorId(),
|
||||
"服务单 assigned_operator_id 必须写为目标红娘");
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
|
||||
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-002(A1)权限契约守卫(standalone 反射扫描,无需 Spring 上下文)。
|
||||
*
|
||||
* <p>锁定两条不变量:
|
||||
* <ol>
|
||||
* <li>{@code MatchmakerAssignmentController} 每个端点都有显式 @PreAuthorize;
|
||||
* 写操作(create/transfer/revoke/end/assignable)必须同时包含
|
||||
* {@code matchmaker.assign} 或 {@code matchmaker.team.assign};
|
||||
* 读自己名下(/my)必须是 {@code matchmaker.service}。</li>
|
||||
* <li>{@code MatchmakerOperatorController} 无 claim 残留(D3:claim 端点/方法已删除)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
class MatchmakerAssignmentPermissionTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("AssignmentController:全部端点有 @PreAuthorize,写操作限定分配权限")
|
||||
void assignmentControllerPermissionContract() {
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (Method method : MatchmakerAssignmentController.class.getDeclaredMethods()) {
|
||||
if (paths(method).isEmpty()) {
|
||||
continue; // 非端点方法
|
||||
}
|
||||
PreAuthorize preAuthorize = AnnotatedElementUtils.findMergedAnnotation(method, PreAuthorize.class);
|
||||
if (preAuthorize == null) {
|
||||
violations.add(method.getName() + ": 缺少 @PreAuthorize");
|
||||
continue;
|
||||
}
|
||||
String expr = preAuthorize.value();
|
||||
boolean isReadMy = method.getName().equals("my");
|
||||
boolean isTeamStatistics = method.getName().equals("teamStatistics");
|
||||
if (isReadMy) {
|
||||
if (!expr.contains("matchmaker.service")) {
|
||||
violations.add("my: 读接口必须要求 matchmaker.service,实际=" + expr);
|
||||
}
|
||||
} else if (isTeamStatistics) {
|
||||
// ZC-MM-005/T03:纯组长数据,只要求 team.assign(更强而非更弱)
|
||||
if (!expr.contains("matchmaker.team.assign")) {
|
||||
violations.add("teamStatistics: 必须要求 matchmaker.team.assign,实际=" + expr);
|
||||
}
|
||||
} else {
|
||||
boolean hasAssignPermission = expr.contains("matchmaker.assign")
|
||||
&& expr.contains("matchmaker.team.assign");
|
||||
if (!hasAssignPermission) {
|
||||
violations.add(method.getName()
|
||||
+ ": 写操作必须同时限定 matchmaker.assign / matchmaker.team.assign,实际=" + expr);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations, "AssignmentController 权限契约被破坏");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫自检:必须真的扫到 AssignmentController 端点(防守卫失效)")
|
||||
void guardMustActuallySeeEndpoints() {
|
||||
long endpointCount = Arrays.stream(MatchmakerAssignmentController.class.getDeclaredMethods())
|
||||
.filter(m -> !paths(m).isEmpty())
|
||||
.count();
|
||||
assertTrue(endpointCount >= 5,
|
||||
"扫描到的端点数异常偏少(" + endpointCount + "),守卫可能失效");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OperatorController:无 claim 残留(端点路径与方法名均不得含 claim)")
|
||||
void operatorControllerMustNotHaveClaimResidue() {
|
||||
List<String> residue = new ArrayList<>();
|
||||
for (Method method : MatchmakerOperatorController.class.getDeclaredMethods()) {
|
||||
if (method.getName().toLowerCase().contains("claim")) {
|
||||
residue.add("方法名残留: " + method.getName());
|
||||
}
|
||||
if (paths(method).stream().anyMatch(p -> p.toLowerCase().contains("claim"))) {
|
||||
residue.add("路径残留: " + method.getName());
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), residue,
|
||||
"D3 决策:claim(红娘接单)端点必须彻底删除,归属由 matchmaker_assignments 建模");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ZC-MM-005/E4:OperatorController 无 my-services 残留(死端点已删除)")
|
||||
void operatorControllerMustNotHaveMyServicesResidue() {
|
||||
List<String> residue = new ArrayList<>();
|
||||
for (Method method : MatchmakerOperatorController.class.getDeclaredMethods()) {
|
||||
String lower = method.getName().toLowerCase();
|
||||
if (lower.contains("myservices") || lower.contains("my_services")) {
|
||||
residue.add("方法名残留: " + method.getName());
|
||||
}
|
||||
if (paths(method).stream().anyMatch(p -> p.toLowerCase().contains("my-services"))) {
|
||||
residue.add("路径残留: " + method.getName());
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), residue,
|
||||
"GET /my-services 是死端点(按 C 端 user_id 查 operator 恒空、前后端零调用),"
|
||||
+ "替代路径为 GET /operator/matchmaker/assignments/my");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ZC-MM-005/T03:team/statistics 端点必须显式限定 matchmaker.team.assign")
|
||||
void teamStatisticsMustRequireTeamAssignPermission() {
|
||||
Method statistics = Arrays.stream(MatchmakerAssignmentController.class.getDeclaredMethods())
|
||||
.filter(m -> m.getName().equals("teamStatistics"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("teamStatistics 端点不存在"));
|
||||
PreAuthorize preAuthorize = AnnotatedElementUtils.findMergedAnnotation(statistics, PreAuthorize.class);
|
||||
assertNotNull(preAuthorize, "team/statistics 缺少 @PreAuthorize");
|
||||
assertTrue(preAuthorize.value().contains("matchmaker.team.assign"),
|
||||
"team/statistics 必须限定 matchmaker.team.assign(纯组长数据),实际="
|
||||
+ preAuthorize.value());
|
||||
}
|
||||
|
||||
private List<String> paths(java.lang.reflect.AnnotatedElement element) {
|
||||
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
|
||||
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
|
||||
? mapping.path() : mapping.value());
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.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.matchmaker.service.MatchmakerAssignmentService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
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.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-006 / T01 验收:audit_logs 扩展字段(V055)写入链路。
|
||||
*
|
||||
* <p>覆盖:
|
||||
* <ul>
|
||||
* <li>扩展写入(正向):assign / recommend 审计行落 purpose_code + service_assignment_id;</li>
|
||||
* <li>兼容性(负向·缺省调用):applyService 走旧签名(purposeCode/assignmentId 缺省)仍可写,
|
||||
* purpose_code 为 null —— 证明扩展列全可空、存量行为不变;</li>
|
||||
* <li>拒绝路径(负向):无分配 recommend → AccessDeniedException + DATA_SCOPE_DENIED 审计,
|
||||
* 且审计行带 actor_role_snapshot(SecurityContext 权限快照)+ request_trace_id(MDC)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerAuditFieldsTest {
|
||||
|
||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||
@Autowired private OperatorMapper operatorMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
|
||||
private String matchmakerId;
|
||||
private String userId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
matchmakerId = createOperator();
|
||||
userId = createUser();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
MDC.remove("requestId");
|
||||
}
|
||||
|
||||
// ==================== 数据构造(Phase13 范式) ====================
|
||||
|
||||
private String createOperator() {
|
||||
Operator op = new Operator();
|
||||
op.setName("审计字段测试红娘");
|
||||
op.setUsername("audit_fields_" + System.nanoTime());
|
||||
op.setPasswordHash("test-hash");
|
||||
op.setStatus("active");
|
||||
operatorMapper.insert(op);
|
||||
return op.getId();
|
||||
}
|
||||
|
||||
private String createUser() {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("audit_fields_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
userMapper.insert(u);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
private String createService(String operatorId) {
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setDescription("审计字段测试服务单");
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
svc.setAssignedOperatorId(operatorId);
|
||||
serviceMapper.insert(svc);
|
||||
return svc.getId();
|
||||
}
|
||||
|
||||
private void authenticate(String operatorId, String... permissions) {
|
||||
Set<String> permSet = Set.of(permissions);
|
||||
var authorities = Stream.concat(
|
||||
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
|
||||
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
UsernamePasswordAuthenticationToken.authenticated(
|
||||
new OperatorPrincipal(operatorId, permSet), null, authorities));
|
||||
}
|
||||
|
||||
private AuditLog latestAudit(String action, String resourceId) {
|
||||
List<AuditLog> rows = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, action)
|
||||
.eq(AuditLog::getResourceId, resourceId)
|
||||
.orderByDesc(AuditLog::getCreatedAt));
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
// ==================== 正向:扩展写入 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("T01 正向: assignService 审计行落 purpose_code=matchmaker.service.assign + service_assignment_id")
|
||||
void assignAuditCarriesPurposeCodeAndAssignmentId() {
|
||||
String serviceId = createService(matchmakerId);
|
||||
|
||||
matchmakerServiceService.assignService(serviceId, matchmakerId, matchmakerId, "T01 分配审计");
|
||||
|
||||
MatchmakerAssignment assignment = assignmentMapper
|
||||
.findActiveByOperatorAndUser(matchmakerId, userId).orElseThrow();
|
||||
AuditLog audit = latestAudit("MATCHMAKER_ASSIGN", serviceId);
|
||||
assertNotNull(audit, "分配审计行必须存在");
|
||||
assertEquals("matchmaker.service.assign", audit.getPurposeCode(), "purpose_code 必须落库");
|
||||
assertEquals(assignment.getId(), audit.getServiceAssignmentId(),
|
||||
"service_assignment_id 必须关联新建分配");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("T01 正向: recommendPartner 审计行落 purpose_code=matchmaker.service.recommend + 分配 ID")
|
||||
void recommendAuditCarriesPurposeCodeAndAssignmentId() {
|
||||
String serviceId = createService(matchmakerId);
|
||||
MatchmakerAssignment assignment = assignmentService.create(matchmakerId, true,
|
||||
userId, matchmakerId, serviceId, "T01 前置分配", null);
|
||||
|
||||
matchmakerServiceService.recommendPartner(serviceId, matchmakerId,
|
||||
"audit-target-" + System.nanoTime(), "T01 推荐审计");
|
||||
|
||||
AuditLog audit = latestAudit("MATCHMAKER_RECOMMEND", serviceId);
|
||||
assertNotNull(audit, "推荐审计行必须存在");
|
||||
assertEquals("matchmaker.service.recommend", audit.getPurposeCode());
|
||||
assertEquals(assignment.getId(), audit.getServiceAssignmentId());
|
||||
}
|
||||
|
||||
// ==================== 负向·兼容性:缺省调用仍可写 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("T01 负向(兼容): 旧签名审计调用(applyService)不传 purposeCode/assignmentId 仍成功写入,扩展列为 null")
|
||||
void legacyAuditCallStillWritableWithNullExtendedFields() {
|
||||
String serviceId = matchmakerServiceService
|
||||
.applyService(userId, Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER, "T01 兼容性申请")
|
||||
.get("serviceId").toString();
|
||||
|
||||
AuditLog audit = latestAudit("MATCHMAKER_APPLY", serviceId);
|
||||
assertNotNull(audit, "缺省调用(旧签名)审计行必须仍可写入");
|
||||
assertNull(audit.getPurposeCode(), "缺省调用 purpose_code 必须为 null(全可空)");
|
||||
assertNull(audit.getServiceAssignmentId(), "缺省调用 service_assignment_id 必须为 null");
|
||||
}
|
||||
|
||||
// ==================== 负向:拒绝路径带快照 + 追踪 ID ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("T01 负向: 无分配 recommend → 404 化拒绝(T04)+ DATA_SCOPE_DENIED 审计含权限快照与追踪 ID")
|
||||
void deniedAuditCarriesActorRoleSnapshotAndTraceId() {
|
||||
String serviceId = createService(matchmakerId);
|
||||
// 无分配的红娘(matchmakerId 对 userId 无生效分配)+ SecurityContext 携带权限 → 快照非空
|
||||
authenticate(matchmakerId, "matchmaker.service");
|
||||
MDC.put("requestId", "trace01test0001");
|
||||
|
||||
BusinessException denied = assertThrows(BusinessException.class,
|
||||
() -> matchmakerServiceService.recommendPartner(serviceId, matchmakerId,
|
||||
"audit-target-x", "T01 拒绝路径"),
|
||||
"无生效分配必须拒绝(ZC-MM-006 / T04 起为 404 化拒绝)");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, denied.getCode(),
|
||||
"写接口范围违规 404 化(T04 防探测)");
|
||||
|
||||
AuditLog audit = latestAudit("DATA_SCOPE_DENIED", serviceId);
|
||||
assertNotNull(audit, "拒绝路径必须落 DATA_SCOPE_DENIED 审计");
|
||||
assertEquals("matchmaker.service", audit.getActorRoleSnapshot(),
|
||||
"快照必须来自 SecurityContext 权限(逗号拼接,截断 128)");
|
||||
assertEquals("trace01test0001", audit.getRequestTraceId(),
|
||||
"追踪 ID 必须取自 MDC(requestId)");
|
||||
}
|
||||
}
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||
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.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-003 / B1 立项验收:受控联系授权机制(V056 + Policy/Service 六操作)。
|
||||
*
|
||||
* <p>覆盖:
|
||||
* <ul>
|
||||
* <li>正例全生命周期:request → approve → consent → disclose(phone 返回 +
|
||||
* SensitiveAccessLog success 行)→ revoke → 再 disclose 404;</li>
|
||||
* <li>负向×6:无分配申请 403 / 非审批岗 approve 403 / 未同意 disclose 404 /
|
||||
* decline 后 404 / 过期后 404+审计 / 非本人 consent 403;</li>
|
||||
* <li>状态机非法转换 CONFLICT(approve 二次 / 越状态 consent / 终态 revoke);</li>
|
||||
* <li>purpose_code 断言:request/approve/consent/disclose/revoke 审计行均含
|
||||
* matchmaker.contact.* + service_assignment_id + request_trace_id。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerContactAuthorizationTest {
|
||||
|
||||
@Autowired private ContactAuthorizationService contactAuthorizationService;
|
||||
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||
@Autowired private MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||
@Autowired private OperatorMapper operatorMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||
@Autowired private org.mybatis.spring.SqlSessionTemplate sqlSessionTemplate;
|
||||
|
||||
private String mmOwner; // 分配持有人(红娘,org X)
|
||||
private String leaderA; // 组长,org X(team.assign 审批岗)
|
||||
private String leaderY; // 组长,org Y(跨组织)
|
||||
private String user;
|
||||
private String userPhone;
|
||||
private String assignmentId;
|
||||
|
||||
private static final String ORG_X = "55555555-5555-5555-5555-555555555551";
|
||||
private static final String ORG_Y = "55555555-5555-5555-5555-555555555552";
|
||||
private static final Set<String> P_LEADER = Set.of("matchmaker.view", "matchmaker.team.assign");
|
||||
private static final Set<String> P_PLATFORM = Set.of("matchmaker.view", "matchmaker.assign");
|
||||
/** 双岗:team.assign(主审)+ assign(兜底)——按分层规则含 assign 即兜底全量 */
|
||||
private static final Set<String> P_DUAL =
|
||||
Set.of("matchmaker.view", "matchmaker.team.assign", "matchmaker.assign");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mmOwner = createOperator("联系授权红娘", ORG_X);
|
||||
leaderA = createOperator("联系授权组长X", ORG_X);
|
||||
leaderY = createOperator("联系授权组长Y", ORG_Y);
|
||||
userPhone = "13800001234";
|
||||
user = createUser(userPhone);
|
||||
|
||||
// 前置:红娘持分配
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setUserId(user);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setDescription("联系授权测试服务单");
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
svc.setAssignedOperatorId(mmOwner);
|
||||
serviceMapper.insert(svc);
|
||||
MatchmakerAssignment assignment = assignmentService.create(leaderA, true,
|
||||
user, mmOwner, svc.getId(), "联系授权前置分配", null);
|
||||
assignmentId = assignment.getId();
|
||||
MDC.put("requestId", "contacttest0001");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
MDC.remove("requestId");
|
||||
org.springframework.security.core.context.SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ==================== 数据构造 ====================
|
||||
|
||||
private String createOperator(String name, String organizationId) {
|
||||
Operator op = new Operator();
|
||||
op.setName(name);
|
||||
op.setOrganizationId(organizationId);
|
||||
op.setUsername("ca_" + name + "_" + System.nanoTime());
|
||||
op.setPasswordHash("test-hash");
|
||||
op.setStatus("active");
|
||||
operatorMapper.insert(op);
|
||||
return op.getId();
|
||||
}
|
||||
|
||||
private String createUser(String phone) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("ca_user_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setPhone(phone);
|
||||
userMapper.insert(u);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
private long deniedAudits(String operatorId, String resourceId) {
|
||||
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||
.eq(AuditLog::getOperatorId, operatorId)
|
||||
.eq(AuditLog::getResourceId, resourceId));
|
||||
}
|
||||
|
||||
private AuditLog latestContactAudit(String action, String authId) {
|
||||
return auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, action)
|
||||
.eq(AuditLog::getResourceId, authId)
|
||||
.orderByDesc(AuditLog::getCreatedAt))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
// ==================== 正例:全生命周期 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 正例: request→approve→consent→disclose(phone+敏感审计)→revoke→再 disclose 404")
|
||||
void fullLifecycleRequestApproveConsentDiscloseRevoke() {
|
||||
// ① request → pending_approval
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "安排线下初次见面,需电话沟通时间地点");
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL, auth.getStatus());
|
||||
assertNotNull(auth.getId());
|
||||
assertNotNull(auth.getExpiresAt(), "pending 期 expires_at 承载同意窗口上界");
|
||||
|
||||
// ② approve(组长 X,team.assign)→ pending_consent
|
||||
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
|
||||
auth.getId(), leaderA, P_LEADER);
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
|
||||
assertEquals(leaderA, approved.getApprovedBy());
|
||||
|
||||
// ③ consent(用户本人)→ active,有效期 = consent + 7d
|
||||
Instant beforeConsent = Instant.now();
|
||||
MatchmakerContactAuthorization consented = contactAuthorizationService.consent(auth.getId(), user);
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_ACTIVE, consented.getStatus());
|
||||
assertNotNull(consented.getUserConsentedAt());
|
||||
long days = ChronoUnit.HOURS.between(beforeConsent, consented.getExpiresAt());
|
||||
assertTrue(days >= 24 * 7 - 1 && days <= 24 * 7 + 1,
|
||||
"有效期应为 consent + 7 天,实际小时数=" + days);
|
||||
|
||||
// ④ disclose(H4 唯一出口)→ phone + SensitiveAccessLog success 行
|
||||
Map<String, Object> disclosed = contactAuthorizationService.disclose(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话约定见面时间");
|
||||
assertEquals(userPhone, disclosed.get("phone"));
|
||||
assertEquals(auth.getId(), disclosed.get("authorizationId"));
|
||||
assertNotNull(disclosed.get("disclosedAt"));
|
||||
// H4 唯一披露出口返回体必须收敛为恰好三键(phone/disclosedAt/authorizationId),
|
||||
// 严禁夹带任何其他字段(含联系方式或用户资料面)——守卫①对本文件白名单放行,
|
||||
// 键收敛只能由本行为断言锁定(独立验收补充,QA 严过关)。
|
||||
assertEquals(Set.of("phone", "disclosedAt", "authorizationId"),
|
||||
disclosed.keySet(), "disclose 返回体必须恰好三键,不得夹带额外字段");
|
||||
List<SensitiveAccessLog> accessRows = sensitiveAccessLogMapper.selectList(
|
||||
new LambdaQueryWrapper<SensitiveAccessLog>()
|
||||
.eq(SensitiveAccessLog::getDataType, "user_phone_disclosure")
|
||||
.eq(SensitiveAccessLog::getAccessTarget, auth.getId()));
|
||||
assertEquals(1, accessRows.size(), "披露必须落 user_phone_disclosure 敏感访问行");
|
||||
assertEquals("success", accessRows.get(0).getAccessResult());
|
||||
assertEquals(mmOwner, accessRows.get(0).getOperatorId());
|
||||
|
||||
// ⑤ revoke(申请人)→ revoked
|
||||
contactAuthorizationService.revoke(auth.getId(), mmOwner, "服务关系结束");
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_REVOKED,
|
||||
authorizationMapper.findById(auth.getId()).orElseThrow().getStatus());
|
||||
|
||||
// ⑥ 再 disclose → 404 + DATA_SCOPE_DENIED 审计(G4 防探测)
|
||||
long before = deniedAudits(mmOwner, user);
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "再次获取"));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||
assertEquals("服务不存在", e.getMessage());
|
||||
assertEquals(before + 1, deniedAudits(mmOwner, user), "失效后披露必须 404 化拒绝并落审计");
|
||||
}
|
||||
|
||||
// ==================== purpose_code 审计断言 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 审计: request/approve/consent/disclose/revoke 各落行含 matchmaker.contact.* + assignment_id + trace")
|
||||
void auditRowsCarryPurposeCodeAssignmentIdAndTraceId() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通服务安排");
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
contactAuthorizationService.consent(auth.getId(), user);
|
||||
contactAuthorizationService.disclose(mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "联系用户");
|
||||
contactAuthorizationService.revoke(auth.getId(), mmOwner, "测试撤销");
|
||||
|
||||
String[][] expected = {
|
||||
{Constants.CONTACT_AUDIT_ACTION_REQUEST, "matchmaker.contact.request"},
|
||||
{Constants.CONTACT_AUDIT_ACTION_APPROVE, "matchmaker.contact.approve"},
|
||||
{Constants.CONTACT_AUDIT_ACTION_CONSENT, "matchmaker.contact.consent"},
|
||||
{Constants.CONTACT_AUDIT_ACTION_DISCLOSE, "matchmaker.contact.disclose"},
|
||||
{Constants.CONTACT_AUDIT_ACTION_REVOKE, "matchmaker.contact.revoke"},
|
||||
};
|
||||
for (String[] pair : expected) {
|
||||
AuditLog audit = latestContactAudit(pair[0], auth.getId());
|
||||
assertNotNull(audit, pair[0] + " 审计行必须存在");
|
||||
assertEquals(pair[1], audit.getPurposeCode(), pair[0] + " purpose_code 必须落库");
|
||||
assertEquals("contacttest0001", audit.getRequestTraceId(),
|
||||
pair[0] + " 必须携带请求追踪 ID");
|
||||
assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone),
|
||||
pair[0] + " 审计内容不得含 phone");
|
||||
// afterData 必须是合法 JSON(PG audit_logs.after_data 为 jsonb 严格解析;
|
||||
// H2 CLOB 不校验——裸 key=value 拼接在测试侧假绿、生产侧 500,此断言为运行时事故回归守卫)
|
||||
com.fasterxml.jackson.databind.JsonNode afterJson =
|
||||
assertDoesNotThrow(() -> new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readTree(String.valueOf(audit.getAfterData())),
|
||||
pair[0] + " afterData 必须可 JSON 解析(PG jsonb 严格校验口径)");
|
||||
assertTrue(afterJson.isObject(), pair[0] + " afterData 必须是 JSON 对象");
|
||||
// V057 主体维度分流:C 端用户动作(consent)写 subject_user_id 且 operator_id=null;
|
||||
// operator 动作写 operator_id + service_assignment_id。混写必撞 audit_logs_operator_id_fkey。
|
||||
if (Constants.CONTACT_AUDIT_ACTION_CONSENT.equals(pair[0])) {
|
||||
assertNull(audit.getOperatorId(), pair[0] + " 用户主体审计 operator_id 必须为 null");
|
||||
assertEquals(user, audit.getSubjectUserId(), pair[0] + " 必须写 subject_user_id 维度");
|
||||
assertNull(audit.getServiceAssignmentId(), pair[0] + " 用户动作无分配关联");
|
||||
} else {
|
||||
assertEquals(assignmentId, audit.getServiceAssignmentId(),
|
||||
pair[0] + " 必须关联 service_assignment_id");
|
||||
assertNull(audit.getSubjectUserId(), pair[0] + " operator 审计不得写 subject_user_id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 运行时回归:拒绝审计独立事务(REQUIRES_NEW) ====================
|
||||
|
||||
/**
|
||||
* 运行时实测缺陷回归(判别性用例):NOT_SUPPORTED 挂起测试事务,让 service 跑真事务——
|
||||
* deny 抛异常触发业务回滚后,拒绝审计必须仍然落库(REQUIRES_NEW 独立提交)。
|
||||
* 旧实现(同事务写入)在本用例必红:回滚吞掉审计。类级 @Transactional 用例对此结构性假绿
|
||||
* (测试事务内可见未提交行),故本用例独立夹具+显式清理。
|
||||
*/
|
||||
@Test
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("B1 运行时回归: 业务事务回滚不得吞掉拒绝审计(REQUIRES_NEW 独立提交)")
|
||||
void denyAuditSurvivesBusinessRollback() {
|
||||
String opOwner = createOperator("独立事务红娘", ORG_X);
|
||||
String opLeader = createOperator("独立事务组长", ORG_X);
|
||||
String ownUser = createUser("13811112222");
|
||||
String otherUser = createUser("13811113333");
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setUserId(ownUser);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setDescription("独立事务验证服务单");
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
svc.setAssignedOperatorId(opOwner);
|
||||
serviceMapper.insert(svc);
|
||||
MatchmakerAssignment assignment = assignmentService.create(opLeader, true,
|
||||
ownUser, opOwner, svc.getId(), "独立事务前置分配", null);
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
opOwner, ownUser, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
contactAuthorizationService.approve(auth.getId(), opLeader, P_LEADER);
|
||||
try {
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.consent(auth.getId(), otherUser));
|
||||
AuditLog denyAudit = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||
.eq(AuditLog::getSubjectUserId, otherUser)
|
||||
.eq(AuditLog::getResourceId, auth.getId()))
|
||||
.stream().findFirst().orElse(null);
|
||||
assertNotNull(denyAudit, "业务回滚后拒绝审计必须独立事务落库(REQUIRES_NEW)");
|
||||
} finally {
|
||||
// 显式清理(本用例数据真实提交,不随测试事务回滚)
|
||||
auditLogMapper.delete(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getSubjectUserId, otherUser));
|
||||
authorizationMapper.deleteById(auth.getId());
|
||||
assignmentMapper.deleteById(assignment.getId());
|
||||
serviceMapper.deleteById(svc.getId());
|
||||
operatorMapper.deleteById(opOwner);
|
||||
operatorMapper.deleteById(opLeader);
|
||||
userMapper.deleteById(ownUser);
|
||||
userMapper.deleteById(otherUser);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 负向×6 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向①: 未持分配申请 → 403 + DATA_SCOPE_DENIED 审计")
|
||||
void requestWithoutAssignmentDenied403() {
|
||||
String outsiderUser = createUser("13900009999");
|
||||
long before = deniedAudits(mmOwner, outsiderUser);
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.request(mmOwner, outsiderUser,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "无分配申请"),
|
||||
"未持分配的红娘不得发起授权申请");
|
||||
assertEquals(before + 1, deniedAudits(mmOwner, outsiderUser));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向②: 非审批岗 approve → 403(权限码动态判定)")
|
||||
void nonApproverCannotApprove() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
long before = deniedAudits(leaderA, auth.getId());
|
||||
// leaderA 只有 matchmaker.view(无 team.assign/assign)→ 非审批岗
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.approve(auth.getId(), leaderA, Set.of("matchmaker.view")),
|
||||
"非审批岗不得 approve");
|
||||
sqlSessionTemplate.clearCache();
|
||||
assertEquals(before + 1, deniedAudits(leaderA, auth.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向③: 仅 team.assign 组长审他组织 → 403 + DATA_SCOPE_DENIED 审计(分层·主审岗)")
|
||||
void crossOrgLeaderCannotApprove() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
long before = deniedAudits(leaderY, auth.getId());
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER),
|
||||
"跨组织组长(仅主审岗)不得审批");
|
||||
sqlSessionTemplate.clearCache();
|
||||
assertEquals(before + 1, deniedAudits(leaderY, auth.getId()),
|
||||
"跨组织组长审批拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||
}
|
||||
|
||||
// ==================== 分层审批语义(用户裁决①:组长主审 + 管理员兜底) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("分层正例①: 仅 assign 管理员(兜底岗)审批跨组织申请 → 成功 + 审计行")
|
||||
void platformFallbackApproverCanApproveCrossOrg() {
|
||||
// 管理员挂任意组织(org Y)——兜底岗不做组织校验
|
||||
String admin = createOperator("联系授权平台管理员", ORG_Y);
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "跨组织管理员兜底审批");
|
||||
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
|
||||
auth.getId(), admin, P_PLATFORM);
|
||||
// 结果码断言:approve 成功 → pending_consent
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
|
||||
assertEquals(admin, approved.getApprovedBy());
|
||||
// 审计行断言:approve 审计行存在且 purpose_code 正确、操作者=管理员
|
||||
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
|
||||
assertNotNull(audit, "兜底审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
|
||||
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
|
||||
assertEquals(admin, audit.getOperatorId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("分层正例②: 平台直属组长(org NULL)审平台直属红娘申请 → 成功 + 审计行")
|
||||
void platformDirectLeaderCanApproveNullOrgMatchmaker() {
|
||||
// 平台直属链路:org=NULL 组长 + org=NULL 红娘(NULL=NULL 视为平台直属,现状⑤维持)
|
||||
String leaderN = createOperator("平台直属组长", null);
|
||||
String mmNull = createOperator("平台直属红娘", null);
|
||||
String userNull = createUser("13600009998");
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setUserId(userNull);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setDescription("平台直属联系授权测试服务单");
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
svc.setAssignedOperatorId(mmNull);
|
||||
serviceMapper.insert(svc);
|
||||
// 主审组长(hasGlobalAssign=false)走 NULL=NULL 组织校验创建分配
|
||||
assignmentService.create(leaderN, false, userNull, mmNull, svc.getId(),
|
||||
"平台直属分配", null);
|
||||
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmNull, userNull, Constants.CONTACT_CHANNEL_PHONE, "平台直属电话沟通");
|
||||
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
|
||||
auth.getId(), leaderN, P_LEADER);
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
|
||||
assertEquals(leaderN, approved.getApprovedBy());
|
||||
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
|
||||
assertNotNull(audit, "平台直属组长审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
|
||||
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
|
||||
assertEquals(leaderN, audit.getOperatorId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("分层正例③: 双岗(team.assign+assign)审他组织 → 含 assign 即兜底全量 + 审计行")
|
||||
void dualRoleApproverFallsBackToFullScope() {
|
||||
// 用户裁决①分层规则的既定语义:兜底岗键固定 matchmaker.assign,
|
||||
// 双岗持有者按兜底全量处理(不做组织校验)——权限面以最高档收敛,不叠加受限。
|
||||
String dual = createOperator("双岗审批人", ORG_Y);
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "双岗跨组织审批");
|
||||
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
|
||||
auth.getId(), dual, P_DUAL);
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
|
||||
assertEquals(dual, approved.getApprovedBy());
|
||||
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
|
||||
assertNotNull(audit, "双岗兜底审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
|
||||
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
|
||||
assertEquals(dual, audit.getOperatorId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向④: 未同意 disclose → 404 + DATA_SCOPE_DENIED(G4 防探测)")
|
||||
void discloseBeforeConsentNotFound() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
|
||||
long before = deniedAudits(mmOwner, user);
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "尚未同意"));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||
assertEquals("服务不存在", e.getMessage());
|
||||
assertEquals(before + 1, deniedAudits(mmOwner, user));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向⑤: decline 后 disclose → 404;过期后 disclose → 404 + 审计")
|
||||
void discloseAfterDeclineOrExpiryNotFound() {
|
||||
// decline 后
|
||||
MatchmakerContactAuthorization auth1 = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通A");
|
||||
contactAuthorizationService.approve(auth1.getId(), leaderA, P_LEADER);
|
||||
contactAuthorizationService.decline(auth1.getId(), user);
|
||||
BusinessException e1 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "被拒后披露"));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e1.getCode());
|
||||
|
||||
// 过期后(直接注入已过期的 expires_at,模拟窗口/有效期届满)
|
||||
MatchmakerContactAuthorization auth2 = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通B");
|
||||
contactAuthorizationService.approve(auth2.getId(), leaderA, P_LEADER);
|
||||
contactAuthorizationService.consent(auth2.getId(), user);
|
||||
MatchmakerContactAuthorization expired = authorizationMapper.findById(auth2.getId()).orElseThrow();
|
||||
expired.setExpiresAt(Instant.now().minusSeconds(60));
|
||||
authorizationMapper.updateById(expired);
|
||||
|
||||
long before = deniedAudits(mmOwner, user);
|
||||
BusinessException e2 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "过期后披露"));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode());
|
||||
assertEquals(before + 1, deniedAudits(mmOwner, user), "过期披露必须 404 化拒绝并落审计");
|
||||
// 惰性收敛必须落库
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_EXPIRED,
|
||||
authorizationMapper.findById(auth2.getId()).orElseThrow().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向⑥: 非本人 consent → 403")
|
||||
void nonOwnerCannotConsent() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
|
||||
String otherUser = createUser("13700009999");
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.consent(auth.getId(), otherUser),
|
||||
"仅本人可同意授权");
|
||||
// V057:C 端越权尝试的 deny 审计必须落 subject_user_id 维度
|
||||
//(此前写 operator_id 维度撞 FK 被吞 → 生产零审计)
|
||||
AuditLog denyAudit = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||
.eq(AuditLog::getSubjectUserId, otherUser)
|
||||
.eq(AuditLog::getResourceId, auth.getId())
|
||||
.orderByDesc(AuditLog::getCreatedAt))
|
||||
.stream().findFirst().orElse(null);
|
||||
assertNotNull(denyAudit, "非本人 consent 的越权尝试必须落 DATA_SCOPE_DENIED(subject_user_id 维度)");
|
||||
assertNull(denyAudit.getOperatorId(), "用户主体 deny 审计 operator_id 必须为 null");
|
||||
}
|
||||
|
||||
// ==================== 状态机非法转换 CONFLICT ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 状态机: approve 二次 / 越状态 consent / 终态 revoke 一律 CONFLICT")
|
||||
void illegalTransitionsAreConflict() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
|
||||
// pending_approval 直接 consent → CONFLICT
|
||||
BusinessException e1 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.consent(auth.getId(), user));
|
||||
assertEquals(ApiResponseCode.CONFLICT, e1.getCode());
|
||||
|
||||
// approve → pending_consent 后再 approve → CONFLICT
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
BusinessException e2 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER));
|
||||
assertEquals(ApiResponseCode.CONFLICT, e2.getCode());
|
||||
|
||||
// consent → active 后 revoke → revoked,再 revoke → CONFLICT
|
||||
contactAuthorizationService.consent(auth.getId(), user);
|
||||
contactAuthorizationService.revoke(auth.getId(), mmOwner, "一次撤销");
|
||||
BusinessException e3 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.revoke(auth.getId(), mmOwner, "二次撤销"));
|
||||
assertEquals(ApiResponseCode.CONFLICT, e3.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 request 防重: 同渠道生效授权存在时再申请 → CONFLICT")
|
||||
void duplicateRequestConflict() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
contactAuthorizationService.consent(auth.getId(), user);
|
||||
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.request(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "重复申请"));
|
||||
assertEquals(ApiResponseCode.CONFLICT, e.getCode());
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||
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 org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/**
|
||||
* OQ-010 验收:机制总开关关闭(matchmaker.contact.enabled=false)时的 fail-safe 行为。
|
||||
*
|
||||
* <p>fail-safe 语义(ContactAuthorizationConfig.enabled Java 缺省 false,配置缺失=机制关闭):
|
||||
* <ul>
|
||||
* <li>disclose → 404 化拒绝(fail-safe 校验最先,G4 防探测:与"资源不存在"同响应);</li>
|
||||
* <li>request → 404 化拒绝(ZC-MM-003 裁决落地 T-C 补齐:原实现漏查 enabled,
|
||||
* 属真实缺口,本轮在 Service 层补 fail-safe 校验,口径与 disclose 一致);</li>
|
||||
* <li>拒绝时<b>不落</b> SensitiveAccessLog、<b>不落</b>任何 matchmaker.contact.* 成功审计行
|
||||
* (DATA_SCOPE_DENIED deny 审计可有,用于事后追责)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SpringBootTest(properties = "matchmaker.contact.enabled=false")
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerContactDisabledTest {
|
||||
|
||||
private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000101";
|
||||
private static final String USER_ID = "00000000-0000-0000-0000-000000000102";
|
||||
|
||||
@Autowired private ContactAuthorizationService contactAuthorizationService;
|
||||
@Autowired private MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||
|
||||
@Test
|
||||
@DisplayName("开关关闭: disclose → 404 化拒绝,不落 SensitiveAccessLog / 成功审计行")
|
||||
void discloseRejectedWhenDisabled() {
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(OPERATOR_ID, USER_ID,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "机制关闭后披露"),
|
||||
"机制关闭时披露必须 fail-safe 拒绝");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "fail-safe 必须 404 化(G4 防探测)");
|
||||
assertEquals("服务不存在", e.getMessage());
|
||||
|
||||
// 不落 SensitiveAccessLog(成功行)
|
||||
Long sensitiveCount = sensitiveAccessLogMapper.selectCount(new LambdaQueryWrapper<SensitiveAccessLog>()
|
||||
.eq(SensitiveAccessLog::getOperatorId, OPERATOR_ID)
|
||||
.eq(SensitiveAccessLog::getDataType,
|
||||
ContactAuthorizationService.SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE));
|
||||
assertEquals(0L, sensitiveCount, "机制关闭的拒绝披露不得落 SensitiveAccessLog");
|
||||
|
||||
// 不落 matchmaker.contact.disclose 成功审计行(DATA_SCOPE_DENIED deny 审计可有)
|
||||
Long discloseAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, Constants.CONTACT_AUDIT_ACTION_DISCLOSE)
|
||||
.eq(AuditLog::getOperatorId, OPERATOR_ID));
|
||||
assertEquals(0L, discloseAuditCount, "机制关闭的拒绝披露不得落成功审计行");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开关关闭: request → 404 化拒绝(T-C 补齐真实缺口),不落任何成功审计行")
|
||||
void requestRejectedWhenDisabled() {
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.request(OPERATOR_ID, USER_ID,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "机制关闭后申请"),
|
||||
"机制关闭时申请必须 fail-safe 拒绝");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "fail-safe 必须 404 化(与 disclose 同口径)");
|
||||
assertEquals("服务不存在", e.getMessage());
|
||||
|
||||
// 不产生任何授权记录(fail-safe 校验先于一切业务逻辑)
|
||||
Long authCount = authorizationMapper.selectCount(new LambdaQueryWrapper<MatchmakerContactAuthorization>()
|
||||
.eq(MatchmakerContactAuthorization::getOperatorId, OPERATOR_ID));
|
||||
assertEquals(0L, authCount, "机制关闭的拒绝申请不得产生授权记录");
|
||||
|
||||
// 不落 matchmaker.contact.request 成功审计行(DATA_SCOPE_DENIED deny 审计可有)
|
||||
Long requestAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, Constants.CONTACT_AUDIT_ACTION_REQUEST)
|
||||
.eq(AuditLog::getOperatorId, OPERATOR_ID));
|
||||
assertEquals(0L, requestAuditCount, "机制关闭的拒绝申请不得落成功审计行");
|
||||
|
||||
// fail-safe 必须先于 purpose/channel 业务校验(缺目的的申请同样 404 化拒绝)
|
||||
BusinessException e2 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.request(OPERATOR_ID, USER_ID,
|
||||
Constants.CONTACT_CHANNEL_PHONE, null));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode(),
|
||||
"fail-safe 必须最先判定,不得让业务校验异常(400/422)穿透");
|
||||
assertNotNull(e2.getMessage());
|
||||
}
|
||||
}
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-003(B1 例外通道立项)守卫 —— standalone 源码扫描
|
||||
* (沿用 {@code MatchmakerFieldPrivacyGuardTest} 范式,无需 Spring 上下文)。
|
||||
*
|
||||
* <p><b>ZC-MM-006→003 守卫翻转说明</b>:受控联系授权机制(V056 + ContactAuthorizationPolicy/
|
||||
* Service,架构师设计 H4/G4)立项后,本守卫从「锁机制不存在」翻转为「锁新契约」:
|
||||
* <ol>
|
||||
* <li><b>① 联系回显文件收敛</b>:全仓响应装配中含 phone/mobile/wechat 等联系方式字样的
|
||||
* put 键,仅允许出现在白名单文件(受控联系披露出口 + 既有合法落点,见
|
||||
* {@link #CONTACT_ECHO_ALLOWED_FILES});其余文件出现即红;</li>
|
||||
* <li><b>② 联系类端点前缀收敛</b>:contact/phone/channel/offline 字样端点仅限
|
||||
* {@code /operator/matchmaker/contact-authorizations/**} 与
|
||||
* {@code /contact-authorizations/**} 两前缀;</li>
|
||||
* <li><b>③ 到期语义完整契约</b>:双态惰性收敛(active 过期 / pending_consent 超窗 → expired)
|
||||
* 机制必须存在,disclose 必须走 assertContactAllowed(404 化拒绝)+ 落敏感访问审计;
|
||||
* 行为面(未同意/已拒绝/已过期/已撤销 disclose → 404+审计)由
|
||||
* {@code MatchmakerContactAuthorizationTest} 行为锁定;</li>
|
||||
* <li><b>④ consent 独立性</b>:同意/拒绝必须是独立 POST 端点、无 @RequestBody、
|
||||
* 全仓无预同意/预勾选参数 —— PIPL 单独同意动作不可被捆绑;</li>
|
||||
* <li><b>⑤ 保留不变</b>:ReferralService 16 键白名单 + 联系字样禁词(红娘侧转介仍零联系回显)
|
||||
* 与 matchmaker/referral 包无 @Scheduled(无批处理代操作面)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
class MatchmakerContactGuardTest {
|
||||
|
||||
private static final Path SRC_ROOT = resolveSourceRoot();
|
||||
private static final Path REFERRAL_SERVICE =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral/service/ReferralService.java");
|
||||
private static final Path CONTACT_AUTH_CONTROLLER =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/controller/MatchmakerContactAuthorizationController.java");
|
||||
private static final Path CONTACT_USER_CONTROLLER =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/contactauth/controller/ContactAuthorizationUserController.java");
|
||||
private static final Path CONTACT_AUTH_MAPPER =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/mapper/MatchmakerContactAuthorizationMapper.java");
|
||||
private static final Path CONTACT_AUTH_SERVICE =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java");
|
||||
private static final List<Path> CONTROLLER_DIRS = List.of(
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/controller"),
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral/controller"),
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/contactauth/controller"));
|
||||
private static final List<Path> PACKAGE_DIRS = List.of(
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker"),
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral"));
|
||||
|
||||
/**
|
||||
* ReferralService 响应键白名单(ZC-MM-003 原契约,053 阶段逐键核定,本阶段保留不变)。
|
||||
*/
|
||||
private static final Set<String> RESPONSE_KEY_WHITELIST = Set.of(
|
||||
"appointmentId", "registrationId", "activityId", "id", "status", "type",
|
||||
"serviceType", "referralType", "referralFromOperatorId", "needsUserConfirmation",
|
||||
"confirmedAt", "userConfirmedAt", "createdByOperatorId", "createdAt",
|
||||
"registeredAt", "userId");
|
||||
|
||||
/** 联系方式字样(出现在响应键名中即触发收敛判定) */
|
||||
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
|
||||
"emergencycontact", "phone", "mobile", "wechat", "weixin", "address",
|
||||
"idnumber", "id_card");
|
||||
|
||||
/** 联系/触达类端点词(出现在 mapping 路径中即触发前缀收敛判定) */
|
||||
private static final List<String> CONTACT_ENDPOINT_TOKENS = List.of(
|
||||
"contact", "phone", "channel", "offline");
|
||||
|
||||
/** 联系类端点仅允许的两个前缀(受控联系授权机制专属) */
|
||||
private static final List<String> CONTACT_ENDPOINT_ALLOWED_PREFIXES = List.of(
|
||||
"/operator/matchmaker/contact-authorizations",
|
||||
"/contact-authorizations");
|
||||
|
||||
/**
|
||||
* ① 联系回显白名单文件(逐个核定,出现新落点必须显式登记于此并过 007 评审):
|
||||
* <ul>
|
||||
* <li>ContactAuthorizationService —— H4 唯一披露出口(disclose 返回 phone 三键);</li>
|
||||
* <li>MatchmakerContactAuthorizationController —— 受控联系授权运营端控制器(授权数据通道);</li>
|
||||
* <li>AuthService —— {@code phoneBound} 布尔标志(是否已绑手机号,非号码本体);</li>
|
||||
* <li>ActivityService —— {@code emergencyContactName}(用户报名数据本人可见);</li>
|
||||
* <li>RealIdentityVerificationProvider —— {@code idNumber}/{@code idNumberHash}
|
||||
* (服务端 → 实名核验 provider 请求体,非 C 端响应回显)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static final Set<String> CONTACT_ECHO_ALLOWED_FILES = Set.of(
|
||||
"ContactAuthorizationService.java",
|
||||
"MatchmakerContactAuthorizationController.java",
|
||||
"AuthService.java",
|
||||
"ActivityService.java",
|
||||
"RealIdentityVerificationProvider.java");
|
||||
|
||||
// ==================== ① 联系回显文件收敛 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("① 联系回显收敛: 含联系方式字样的响应装配仅允许白名单文件(披露出口 + 既有合法落点)")
|
||||
void contactEchoConfinedToWhitelistedFiles() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
try (Stream<Path> files = Files.walk(SRC_ROOT)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String fileName = f.getFileName().toString();
|
||||
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(src);
|
||||
while (put.find()) {
|
||||
String key = put.group(1);
|
||||
String lower = key.toLowerCase();
|
||||
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||
if (lower.contains(token) && !CONTACT_ECHO_ALLOWED_FILES.contains(fileName)) {
|
||||
violations.add(fileName + " 装配联系字段响应键: " + key
|
||||
+ "(含 " + token + ")—— 联系方式回显必须收敛到受控联系披露出口,"
|
||||
+ "新增落点须登记白名单并过 007 评审");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"联系方式回显出现白名单外落点(守卫①)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== ② 联系类端点前缀收敛 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("② 端点收敛: 受治理域内 contact/phone/channel/offline 字样端点仅限受控联系授权两前缀")
|
||||
void contactEndpointsConfinedToAuthorizationPrefixes() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
// 扫描面 = B1 联系规则受治理域(matchmaker / referral / contactauth 三包的 Controller)。
|
||||
// 域外既有端点(如 /auth/phone/bind 用户本人绑手机号、/content/.../offline 内容下线)
|
||||
// 由各自域的规则治理,不在本守卫职责内。
|
||||
for (Path dir : CONTROLLER_DIRS) {
|
||||
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
String classPrefix = classMappingPrefix(src);
|
||||
// 类级前缀 + 方法级路径任一含联系字样 → 完整路径必须落在白名单前缀内
|
||||
Matcher mapping = Pattern.compile(
|
||||
"@(Get|Post|Put|Delete|Patch)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
|
||||
while (mapping.find()) {
|
||||
String rawArgs = mapping.group(2).replaceAll("\"", "").trim();
|
||||
String fullPath = compose(classPrefix, rawArgs);
|
||||
String lowerPath = fullPath.toLowerCase();
|
||||
for (String token : CONTACT_ENDPOINT_TOKENS) {
|
||||
if (lowerPath.contains(token)
|
||||
&& CONTACT_ENDPOINT_ALLOWED_PREFIXES.stream().noneMatch(lowerPath::startsWith)) {
|
||||
violations.add(f.getFileName() + " → " + fullPath
|
||||
+ "(含 " + token + ")—— 联系类端点仅限受控联系授权前缀");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"联系类端点出现收敛前缀外落点(守卫②)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== ③ 到期语义完整契约 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("③ 到期语义: 双态惰性收敛(active/pending_consent→expired)+ disclose 走判定器 + 敏感审计")
|
||||
void expirySemanticsContractPresent() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// a) Mapper:expireOverdue 必须同时收敛 active 与 pending_consent 两个态
|
||||
String mapperSrc = stripComments(Files.readString(CONTACT_AUTH_MAPPER, StandardCharsets.UTF_8));
|
||||
int expireIdx = mapperSrc.indexOf("int expireOverdue()");
|
||||
assertTrue(expireIdx > 0, "expireOverdue 双态惰性收敛方法必须存在(守卫③非永真自检)");
|
||||
String expireSql = mapperSrc.substring(Math.max(0, expireIdx - 600), expireIdx);
|
||||
if (!(expireSql.contains("'active'") && expireSql.contains("'pending_consent'")
|
||||
&& expireSql.contains("'expired'"))) {
|
||||
violations.add("expireOverdue SQL 必须同时收敛 active 与 pending_consent 两态为 expired");
|
||||
}
|
||||
|
||||
// b) Service:disclose 必须走判定器(404 化拒绝)+ 每次落敏感访问审计
|
||||
String serviceSrc = stripComments(Files.readString(CONTACT_AUTH_SERVICE, StandardCharsets.UTF_8));
|
||||
if (!serviceSrc.contains("assertContactAllowed")) {
|
||||
violations.add("disclose 必须经 ContactAuthorizationPolicy.assertContactAllowed(404 化拒绝)");
|
||||
}
|
||||
if (!serviceSrc.contains("SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE")
|
||||
|| !serviceSrc.contains("sensitiveAccessLogMapper.insert")) {
|
||||
violations.add("disclose 必须每次落 SensitiveAccessLog(user_phone_disclosure)");
|
||||
}
|
||||
if (!serviceSrc.contains("config.isEnabled()")) {
|
||||
violations.add("disclose 必须含 enabled fail-safe 检查(配置缺失→机制关闭)");
|
||||
}
|
||||
|
||||
// c) 行为面交叉引用:404+审计的完整行为契约由 MatchmakerContactAuthorizationTest 锁定
|
||||
Path integrationTest = Path.of("src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java");
|
||||
if (!Files.exists(integrationTest)) {
|
||||
integrationTest = Path.of("backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java");
|
||||
}
|
||||
assertTrue(Files.exists(integrationTest),
|
||||
"行为面集成测试 MatchmakerContactAuthorizationTest 必须存在(到期语义行为契约承载)");
|
||||
|
||||
assertEquals(List.of(), violations,
|
||||
"受控联系授权到期/披露语义契约被破坏(守卫③)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== ④ consent 独立性(PIPL 单独同意) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("④ consent 独立性: 独立 POST 端点、无 @RequestBody、全仓无预同意参数")
|
||||
void consentMustBeIndependentAction() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
String src = stripComments(Files.readString(CONTACT_USER_CONTROLLER, StandardCharsets.UTF_8));
|
||||
if (!src.contains("@PostMapping(\"/{id}/consent\")")) {
|
||||
violations.add("consent 必须是独立 POST /{id}/consent 端点(不可捆绑到其他操作)");
|
||||
}
|
||||
if (!src.contains("@PostMapping(\"/{id}/decline\")")) {
|
||||
violations.add("decline 必须是独立 POST /{id}/decline 端点");
|
||||
}
|
||||
// consent/decline 不得有 @RequestBody(无预同意/勾选参数载体)
|
||||
Matcher methodBlocks = Pattern.compile(
|
||||
"@PostMapping\\(\"/\\{id\\}/(consent|decline)\"\\)[^}]*?\\)", Pattern.DOTALL).matcher(src);
|
||||
while (methodBlocks.find()) {
|
||||
if (methodBlocks.group().contains("@RequestBody")) {
|
||||
violations.add("consent/decline 端点不得携带 @RequestBody(杜绝预同意参数载体)");
|
||||
}
|
||||
}
|
||||
// 全仓不得出现预同意/预勾选参数命名(后端 main 源码 + miniapp 小程序 + admin-web 管理端
|
||||
// —— PIPL 单独同意的动作面横跨三端,任一端出现预同意参数即红)
|
||||
List<Path> scanRoots = new ArrayList<>();
|
||||
scanRoots.add(SRC_ROOT);
|
||||
Path repoRoot = null;
|
||||
// 从 SRC_ROOT 绝对路径逐级上溯,找到含 miniapp/ 的仓库根
|
||||
// (相对路径 "src/main/java" 上溯到 "src" 即 null,必须 toAbsolutePath 后再走)
|
||||
for (Path p = SRC_ROOT.toAbsolutePath().getParent(); p != null; p = p.getParent()) {
|
||||
if (Files.isDirectory(p.resolve("miniapp"))) {
|
||||
repoRoot = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (repoRoot != null) {
|
||||
Path miniapp = repoRoot.resolve("miniapp");
|
||||
Path adminWeb = repoRoot.resolve("admin-web/src");
|
||||
if (Files.isDirectory(miniapp)) scanRoots.add(miniapp);
|
||||
if (Files.isDirectory(adminWeb)) scanRoots.add(adminWeb);
|
||||
}
|
||||
for (Path scanRoot : scanRoots) {
|
||||
try (Stream<Path> files = Files.walk(scanRoot)) {
|
||||
for (Path f : files.filter(p -> {
|
||||
String n = p.toString();
|
||||
return n.endsWith(".java") || n.endsWith(".js") || n.endsWith(".ts") || n.endsWith(".vue");
|
||||
}).toList()) {
|
||||
String body = stripComments(Files.readString(f, StandardCharsets.UTF_8)).toLowerCase();
|
||||
for (String token : new String[]{"preconsent", "pre_consent", "preagree", "pre_agree",
|
||||
"autoconsent", "auto_consent"}) {
|
||||
if (body.contains(token)) {
|
||||
violations.add(f.getFileName() + " 出现预同意参数字样: " + token
|
||||
+ "(PIPL 单独同意不可被捆绑/预勾选)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"用户单独同意独立性被破坏(守卫④)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== ⑤ 保留不变:转介白名单 + 无批处理 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("⑤ 保留: ReferralService 16 键白名单+联系禁词 与 matchmaker/referral 无 @Scheduled")
|
||||
void referralWhitelistAndNoScheduledPreserved() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// a) ReferralService 响应键白名单(B1 立项后红娘侧转介仍零联系回显)
|
||||
String referralSrc = stripComments(Files.readString(REFERRAL_SERVICE, StandardCharsets.UTF_8));
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(referralSrc);
|
||||
while (put.find()) {
|
||||
keys.add(put.group(1));
|
||||
}
|
||||
assertTrue(keys.contains("needsUserConfirmation"), "must-confirm 语义键必须存在(守卫非永真自检)");
|
||||
for (String key : keys) {
|
||||
if (!RESPONSE_KEY_WHITELIST.contains(key)) {
|
||||
violations.add("ReferralService 非白名单响应键: " + key);
|
||||
}
|
||||
String lower = key.toLowerCase();
|
||||
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||
if (lower.contains(token)) {
|
||||
violations.add("ReferralService 响应键含联系方式 token: " + key + "(含 " + token + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// b) matchmaker/referral 包无 @Scheduled(无批处理代操作面)
|
||||
for (Path dir : PACKAGE_DIRS) {
|
||||
assertTrue(Files.isDirectory(dir), "包目录不存在: " + dir);
|
||||
try (Stream<Path> files = Files.walk(dir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String body = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
if (body.contains("@Scheduled")) {
|
||||
violations.add(f.getFileName() + " 含 @Scheduled 定时任务"
|
||||
+ "(禁止引入代替用户决定/代操作的自动化通道)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"既有 B1 契约(转介白名单/无批处理)被破坏(守卫⑤)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/** 提取类级 @RequestMapping 路径前缀(无则空串)。 */
|
||||
private String classMappingPrefix(String src) {
|
||||
Matcher classMapping = Pattern.compile("@RequestMapping\\(([^)]*)\\)").matcher(src);
|
||||
if (!classMapping.find()) {
|
||||
return "";
|
||||
}
|
||||
String args = classMapping.group(1).replaceAll("\"", "").trim();
|
||||
// 去掉 value=/path= 前缀写法,取第一段路径
|
||||
for (String part : args.split(",")) {
|
||||
String candidate = part.trim();
|
||||
if (candidate.startsWith("value=") || candidate.startsWith("path=")) {
|
||||
candidate = candidate.substring(candidate.indexOf('=') + 1).trim();
|
||||
}
|
||||
if (candidate.startsWith("/")) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 类级前缀 + 方法路径合成完整路径(Spring mapping 拼接语义)。 */
|
||||
private String compose(String classPrefix, String methodPath) {
|
||||
if (methodPath == null || methodPath.isEmpty() || methodPath.startsWith("(")) {
|
||||
return classPrefix;
|
||||
}
|
||||
if (classPrefix.isEmpty()) {
|
||||
return methodPath;
|
||||
}
|
||||
if (methodPath.startsWith("/")) {
|
||||
return classPrefix + methodPath;
|
||||
}
|
||||
return classPrefix + "/" + methodPath;
|
||||
}
|
||||
|
||||
private static String stripComments(String src) {
|
||||
return src.replaceAll("//[^\\n]*", "").replaceAll("/\\*.*?\\*/", "");
|
||||
}
|
||||
|
||||
/** 定位源码根目录(与 SecurityPathCoverageTest 同款双候选)。 */
|
||||
private static Path resolveSourceRoot() {
|
||||
for (String candidate : new String[]{"src/main/java", "backend/src/main/java"}) {
|
||||
Path path = Path.of(candidate);
|
||||
if (Files.isDirectory(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"无法定位源码根目录(尝试过 src/main/java 与 backend/src/main/java);当前工作目录="
|
||||
+ Path.of("").toAbsolutePath());
|
||||
}
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.referral.service.ReferralService;
|
||||
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
|
||||
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.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.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-005(统一数据范围与字段隐私)验收测试 —— 读侧纯化 + 防探测 404 + 字段白名单 + 组长统计。
|
||||
*
|
||||
* <p>覆盖:
|
||||
* <ul>
|
||||
* <li>MM-PRIVACY-001(负向×3):非持有人读详情 404+审计;查无 serviceId 响应与前者
|
||||
* 逐字段一致(防探测);撤销后原持有人再读 404;</li>
|
||||
* <li>MM-AUTH-001 读侧(正向):持分配红娘读完整详情、无 viewerIsOwner 双态残留;</li>
|
||||
* <li>MM-AUTH-003 读侧(负向):读他人对象 → 404+审计;</li>
|
||||
* <li>MM-LIFECYCLE-001 读侧:end/revoke/expired 后 404;</li>
|
||||
* <li>组长团队统计正例 + 普通红娘 403+审计(负向);</li>
|
||||
* <li>showOccupation 隐私开关:false 隐藏 occupationCode,true/未设置放行。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerDataScope005Test {
|
||||
|
||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||
@Autowired private OperatorMapper operatorMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private UserProfileMapper userProfileMapper;
|
||||
@Autowired private UserPrivacySettingsMapper userPrivacySettingsMapper;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
@Autowired private MatchmakerAssignmentController assignmentController;
|
||||
@Autowired private ReferralService referralService;
|
||||
|
||||
private String leaderA; // 组长,org X
|
||||
private String mmB; // 红娘,org X
|
||||
private String mmC; // 红娘,org Y
|
||||
private String mmD; // 红娘,org X
|
||||
private String user1;
|
||||
private String user2;
|
||||
private String user3;
|
||||
|
||||
private static final String ORG_X = "33333333-3333-3333-3333-333333333331";
|
||||
private static final String ORG_Y = "33333333-3333-3333-3333-333333333332";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
leaderA = createOperator("组长A-005", ORG_X);
|
||||
mmB = createOperator("红娘B-005", ORG_X);
|
||||
mmC = createOperator("红娘C-005", ORG_Y);
|
||||
mmD = createOperator("红娘D-005", ORG_X);
|
||||
user1 = createUserWithProfile("ds-user1");
|
||||
user2 = createUserWithProfile("ds-user2");
|
||||
user3 = createUserWithProfile("ds-user3");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ==================== 数据构造 ====================
|
||||
|
||||
private String createOperator(String name, String organizationId) {
|
||||
Operator op = new Operator();
|
||||
op.setName(name);
|
||||
op.setOrganizationId(organizationId);
|
||||
op.setUsername("ds_" + name + "_" + System.nanoTime());
|
||||
op.setPasswordHash("test-hash");
|
||||
op.setStatus("active");
|
||||
operatorMapper.insert(op);
|
||||
return op.getId();
|
||||
}
|
||||
|
||||
private String createUserWithProfile(String tag) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("ds_" + tag + "_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
userMapper.insert(u);
|
||||
|
||||
UserProfile profile = new UserProfile();
|
||||
profile.setUserId(u.getId());
|
||||
profile.setDisplayName(tag);
|
||||
profile.setGender("female");
|
||||
profile.setBirthDate(java.time.LocalDate.of(1995, 6, 15));
|
||||
profile.setRegionName("丹阳市云阳街道");
|
||||
profile.setEducationCode("bachelor");
|
||||
profile.setOccupationCode("teacher");
|
||||
profile.setMaritalStatus("never_married");
|
||||
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||
userProfileMapper.insert(profile);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
private String createService(String userId, String operatorId) {
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setDescription("希望找一位本地稳定的伴侣");
|
||||
// submitted:createAssignment 会联动把服务单置 assigned(A1 同事务联动语义)
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
svc.setAssignedOperatorId(operatorId);
|
||||
serviceMapper.insert(svc);
|
||||
return svc.getId();
|
||||
}
|
||||
|
||||
/** 平台授权路径创建分配(绕过组织范围校验以构造前置状态),返回 assignmentId */
|
||||
private String createAssignment(String operatorId, String userId, String serviceId) {
|
||||
MatchmakerAssignment a = assignmentService.create(leaderA, true, userId, operatorId,
|
||||
serviceId, "005 测试前置分配", null);
|
||||
return a.getId();
|
||||
}
|
||||
|
||||
private void authenticate(String operatorId, String... permissions) {
|
||||
Set<String> permSet = Set.of(permissions);
|
||||
var authorities = Stream.concat(
|
||||
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
|
||||
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
UsernamePasswordAuthenticationToken.authenticated(
|
||||
new OperatorPrincipal(operatorId, permSet), null, authorities));
|
||||
}
|
||||
|
||||
private long countAudits(String action, String operatorId) {
|
||||
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, action)
|
||||
.eq(operatorId != null, AuditLog::getOperatorId, operatorId));
|
||||
}
|
||||
|
||||
private long countDeniedAuditsForResource(String operatorId, String resourceId) {
|
||||
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||
.eq(AuditLog::getOperatorId, operatorId)
|
||||
.eq(AuditLog::getResourceId, resourceId));
|
||||
}
|
||||
|
||||
private BusinessException notFoundCall(String operatorId, String serviceId) {
|
||||
try {
|
||||
matchmakerServiceService.getServiceDetail(serviceId, operatorId);
|
||||
} catch (BusinessException e) {
|
||||
return e;
|
||||
}
|
||||
throw new AssertionError("预期抛出 BusinessException(NOT_FOUND),但正常返回了");
|
||||
}
|
||||
|
||||
// ==================== MM-PRIVACY-001(负向×3) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-PRIVACY-001a(负向): 非持有人读服务单 → 404 + DATA_SCOPE_DENIED 审计(语义403)")
|
||||
void mmPrivacy001a_nonHolderReadDetailNotFoundAndAudited() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
createAssignment(mmB, user1, serviceId);
|
||||
|
||||
long before = countDeniedAuditsForResource(mmC, serviceId);
|
||||
BusinessException e = notFoundCall(mmC, serviceId);
|
||||
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "非持有人响应必须是 404(防探测)");
|
||||
assertEquals("服务不存在", e.getMessage());
|
||||
assertEquals(before + 1, countDeniedAuditsForResource(mmC, serviceId),
|
||||
"越权读尝试必须先落 DATA_SCOPE_DENIED 审计(语义 403)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-PRIVACY-001b(负向): 查无 serviceId 的响应与非持有人 404 逐字段一致(防探测)")
|
||||
void mmPrivacy001b_notFoundResponseIndistinguishableFromDenied() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
createAssignment(mmB, user1, serviceId);
|
||||
|
||||
BusinessException denied = notFoundCall(mmC, serviceId); // 非持有人(真实存在)
|
||||
BusinessException missing = notFoundCall(mmC, "no-such-service-id"); // 查无
|
||||
|
||||
// 全局异常处理器把 BusinessException 映射为 ApiResponse.error(code, message),
|
||||
// code+message 一致 ⇒ 响应体逐字段一致(success/code/message),无法区分"无权"与"不存在"
|
||||
assertEquals(denied.getCode(), missing.getCode(), "HTTP/业务码必须一致");
|
||||
assertEquals(denied.getMessage(), missing.getMessage(), "响应 message 必须一致");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, missing.getCode());
|
||||
assertEquals("服务不存在", missing.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-PRIVACY-001c(负向): 撤销后原持有人再读详情 → 404(E1:无 assigned_operator_id 回退)")
|
||||
void mmPrivacy001c_revokedHolderReadDetailNotFound() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||
|
||||
// 撤销前持有人可读
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||
assertNotNull(detail);
|
||||
|
||||
assignmentService.revoke(assignmentId, leaderA, false, "005 撤销验证");
|
||||
|
||||
// E1 核心断言:撤销后服务单仍残留 assigned_operator_id=mmB,但回退分支已删 → 404
|
||||
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
|
||||
"前置确认:服务单 assigned_operator_id 残留(回退分支必须对此免疫)");
|
||||
long before = countDeniedAuditsForResource(mmB, serviceId);
|
||||
BusinessException e = notFoundCall(mmB, serviceId);
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||
assertEquals(before + 1, countDeniedAuditsForResource(mmB, serviceId),
|
||||
"原持有人失权后的读取也必须落 DATA_SCOPE_DENIED 审计");
|
||||
}
|
||||
|
||||
// ==================== MM-AUTH-001 读侧(正向) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-AUTH-001 读侧: 持分配红娘读详情 200 语义、完整字段、无 viewerIsOwner 双态残留")
|
||||
void mmAuth001_readSideHolderGetsFullDetailWithoutDualState() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
createAssignment(mmB, user1, serviceId);
|
||||
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||
|
||||
assertNotNull(detail);
|
||||
assertEquals(user1, detail.get("userId"));
|
||||
assertEquals("希望找一位本地稳定的伴侣", detail.get("description"));
|
||||
assertTrue(detail.containsKey("userProfile"), "持有人可见白名单用户资料");
|
||||
assertTrue(detail.containsKey("followups"), "持有人可见跟进正文");
|
||||
assertTrue(detail.containsKey("recommendations"), "持有人可见推荐列表");
|
||||
// E1:双态残留字段必须消失
|
||||
assertFalse(detail.containsKey("viewerIsOwner"), "viewerIsOwner 双态字段已删除");
|
||||
assertFalse(detail.containsKey("followupCount"), "脱敏分支的 followupCount 摘要已随 404 消失");
|
||||
// E3:userProfile 白名单键集合
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> userProfile = (Map<String, Object>) detail.get("userProfile");
|
||||
Set<String> whitelist = Set.of("displayName", "gender", "age", "regionName",
|
||||
"educationCode", "occupationCode", "maritalStatus");
|
||||
assertTrue(whitelist.containsAll(userProfile.keySet()),
|
||||
"userProfile 键必须落在白名单内,实际=" + userProfile.keySet());
|
||||
assertEquals("丹阳市云阳街道", userProfile.get("regionName"));
|
||||
}
|
||||
|
||||
// ==================== MM-AUTH-003 读侧(负向) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-AUTH-003 读侧(负向): B 读 D 的服务对象详情 → 404 + 审计")
|
||||
void mmAuth003_readSideCrossHolderAccessDeniedAndAudited() {
|
||||
String serviceId = createService(user3, mmD);
|
||||
createAssignment(mmD, user3, serviceId);
|
||||
|
||||
long before = countDeniedAuditsForResource(mmB, serviceId);
|
||||
BusinessException e = notFoundCall(mmB, serviceId);
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||
assertEquals(before + 1, countDeniedAuditsForResource(mmB, serviceId));
|
||||
}
|
||||
|
||||
// ==================== MM-LIFECYCLE-001 读侧 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-LIFECYCLE-001 读侧: end 后 404;expires_at 届满惰性置 expired 后 404")
|
||||
void mmLifecycle001_readSideTerminalStatesAllNotFound() {
|
||||
// end 后 404
|
||||
String serviceId1 = createService(user1, mmB);
|
||||
String a1 = createAssignment(mmB, user1, serviceId1);
|
||||
assignmentService.end(a1, leaderA, false, "005 服务完结");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND,
|
||||
notFoundCall(mmB, serviceId1).getCode(), "end 后读详情必须 404");
|
||||
|
||||
// expires_at 届满(固定时间注入:写入已过期的 expires_at)→ 惰性 expired → 404
|
||||
String serviceId2 = createService(user2, mmB);
|
||||
MatchmakerAssignment a2 = assignmentService.create(leaderA, true, user2, mmB,
|
||||
serviceId2, "短期分配", Instant.now().minusSeconds(3600));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND,
|
||||
notFoundCall(mmB, serviceId2).getCode(), "已过期分配读详情必须 404");
|
||||
assertEquals(Constants.ASSIGNMENT_STATUS_EXPIRED,
|
||||
assignmentMapper.findById(a2.getId()).orElseThrow().getStatus(),
|
||||
"惰性过期必须落库");
|
||||
}
|
||||
|
||||
// ==================== E1 边界:active 分配 × completed 服务单 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("E1 边界: active 分配 + completed 服务单 → 持有人可读详情(200);分配终态(end)后同单再读→404")
|
||||
void activeAssignmentWithCompletedServiceStillReadableThen404AfterEnd() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||
|
||||
// 推进服务单到 in_service 并完成。completeService 仅改服务单状态、不结束分配
|
||||
// (服务单与分配是两条独立生命周期)
|
||||
MatchmakerServiceRecord svc = serviceMapper.selectById(serviceId);
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||
serviceMapper.updateById(svc);
|
||||
matchmakerServiceService.completeService(serviceId, mmB);
|
||||
assertEquals(Constants.MATCHMAKER_STATUS_COMPLETED,
|
||||
serviceMapper.selectById(serviceId).getStatus());
|
||||
|
||||
// 分配仍处于 active → mmB 在该用户数据范围内,读 completed 单应放行(200,非 404)。
|
||||
// 说明:决策"completed 服务单 404"语义实为"分配已终态则 404"(代码 L348 注释"分配已终态"),
|
||||
// 本用例锁定"分配仍 active"时 completed 单仍属本人数据范围、可正常回看。
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||
assertNotNull(detail, "active 分配持有人读 completed 服务单应放行(200),不应 404");
|
||||
assertEquals(user1, detail.get("userId"));
|
||||
|
||||
// 对照:分配 end(终态)后,同一 completed 单再读 → 404(E1:防 assigned_operator_id 回退)
|
||||
assignmentService.end(assignmentId, leaderA, false, "005 边界:服务完结后结束分配");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, notFoundCall(mmB, serviceId).getCode(),
|
||||
"分配终态后读 completed 服务单详情必须 404");
|
||||
}
|
||||
|
||||
// ==================== 组长团队统计(T03) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("组长统计: 以组长 org 为边界(跨组织不计入);NULL 组长统计平台直属")
|
||||
void teamStatisticsScopedByLeaderOrganization() {
|
||||
createAssignment(mmB, user1, null);
|
||||
createAssignment(mmD, user3, null);
|
||||
createAssignment(mmC, user2, null); // org Y,不应计入 org X 组长的统计
|
||||
|
||||
Map<String, Object> stats = assignmentService.teamStatistics(leaderA);
|
||||
|
||||
assertEquals(ORG_X, stats.get("organizationId"));
|
||||
assertEquals(2L, stats.get("activeAssignmentCount"), "跨组织分配不得计入");
|
||||
assertEquals(2L, stats.get("activeMatchmakerCount"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> perMatchmaker = (List<Map<String, Object>>) stats.get("perMatchmakerCounts");
|
||||
assertEquals(2, perMatchmaker.size());
|
||||
assertTrue(perMatchmaker.stream().allMatch(row ->
|
||||
Set.of("operatorId", "operatorName", "activeCount").equals(row.keySet())
|
||||
|| row.keySet().containsAll(Set.of("operatorId", "operatorName", "activeCount"))),
|
||||
"分组行键必须为 operatorId/operatorName/activeCount(脱敏,无联系方式),实际=" + perMatchmaker);
|
||||
|
||||
// NULL 组长 → 平台直属边界(IS NOT DISTINCT FROM NULL)
|
||||
String platformLeader = createOperator("平台直属组长-005", null);
|
||||
String platformMm = createOperator("平台直属红娘-005", null);
|
||||
String platformUser = createUserWithProfile("ds-platform-user");
|
||||
assignmentService.create(platformLeader, true, platformUser, platformMm, null,
|
||||
"平台直属分配", null);
|
||||
Map<String, Object> platformStats = assignmentService.teamStatistics(platformLeader);
|
||||
assertNull(platformStats.get("organizationId"));
|
||||
assertEquals(1L, platformStats.get("activeAssignmentCount"),
|
||||
"NULL 组长统计平台直属(IS NOT DISTINCT FROM NULL)");
|
||||
// 该平台直属分配不得计入 orgX 组长的统计
|
||||
assertEquals(2L, assignmentService.teamStatistics(leaderA).get("activeAssignmentCount"),
|
||||
"平台直属分配不得计入有组织组长的统计");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("负向: 普通红娘(无 team.assign)调 team/statistics → 403 + AUTHORIZATION_DENIED 审计")
|
||||
void normalMatchmakerCannotReadTeamStatistics() {
|
||||
// 必须走 Spring 注入的代理 Bean(@PreAuthorize 方法级安全在代理上生效)
|
||||
authenticate(mmB, "matchmaker.view", "matchmaker.service");
|
||||
|
||||
long before = countAudits("AUTHORIZATION_DENIED", mmB);
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> assignmentController.teamStatistics(
|
||||
new OperatorPrincipal(mmB, Set.of("matchmaker.view", "matchmaker.service"))),
|
||||
"team/statistics 仅组长(team.assign)可读");
|
||||
assertEquals(before + 1, countAudits("AUTHORIZATION_DENIED", mmB),
|
||||
"授权拒绝必须落 AUTHORIZATION_DENIED 审计");
|
||||
}
|
||||
|
||||
// ==================== showOccupation 隐私开关(E3) ====================
|
||||
|
||||
// ==================== P1-1:推荐列表切判定器(读路径) ====================
|
||||
|
||||
private MatchmakerRecommendation createRecommendation(String serviceId, String userId,
|
||||
String targetUserId, String operatorId) {
|
||||
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||
rec.setId(UUID.randomUUID().toString());
|
||||
rec.setServiceId(serviceId);
|
||||
rec.setUserId(userId);
|
||||
rec.setTargetUserId(targetUserId);
|
||||
rec.setOperatorId(operatorId);
|
||||
rec.setReason("005 收口测试推荐");
|
||||
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
|
||||
recommendationMapper.insert(rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("P1-1: 推荐列表按生效分配过滤 —— revoke 后不再可见 assigned_operator_id 残留列放行的推荐")
|
||||
void recommendationsFollowActiveAssignmentsNotResidualColumn() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||
MatchmakerRecommendation rec = createRecommendation(serviceId, user1, user2, mmB);
|
||||
|
||||
// 正向:active 分配持有人可见本人分配对象名下服务的推荐
|
||||
List<Map<String, Object>> visible = matchmakerServiceService.getRecommendationsByOperator(mmB);
|
||||
assertEquals(1, visible.size(), "active 分配持有人应看到该推荐");
|
||||
assertEquals(rec.getId(), visible.get(0).get("id"));
|
||||
|
||||
// 横向:无分配的红娘不可见
|
||||
assertTrue(matchmakerServiceService.getRecommendationsByOperator(mmC).isEmpty(),
|
||||
"无生效分配的红娘不得看到他人对象的推荐");
|
||||
|
||||
// 负向:revoke 后服务单残留 assigned_operator_id=mmB(前置确认),推荐列表必须不再含该条
|
||||
assignmentService.revoke(assignmentId, leaderA, false, "005 P1-1 撤销验证");
|
||||
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
|
||||
"前置确认:残留列仍在(回退式过滤必须对此免疫)");
|
||||
assertTrue(matchmakerServiceService.getRecommendationsByOperator(mmB).isEmpty(),
|
||||
"撤销后推荐列表必须不再包含已失联对象的推荐");
|
||||
}
|
||||
|
||||
// ==================== P1-2:dashboard 三计数切判定器(读路径) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("P1-2: dashboard inProgress/today/completed 按生效分配口径 —— end 后全部归零(他人接手的单不再计入)")
|
||||
void dashboardCountsFollowActiveAssignments() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||
|
||||
Map<String, Object> d0 = matchmakerServiceService.getDashboard(mmB);
|
||||
assertEquals(1L, d0.get("todayCount"), "分配创建时服务单 assigned_at=今天 → today=1");
|
||||
assertEquals(0L, d0.get("inProgressCount"));
|
||||
assertEquals(0L, d0.get("completedCount"));
|
||||
|
||||
// 服务单推进到 in_service → 进行中 +1
|
||||
MatchmakerServiceRecord svc = serviceMapper.selectById(serviceId);
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||
serviceMapper.updateById(svc);
|
||||
assertEquals(1L, matchmakerServiceService.getDashboard(mmB).get("inProgressCount"));
|
||||
|
||||
// 完成 → inProgress 归零、completed +1
|
||||
matchmakerServiceService.completeService(serviceId, mmB);
|
||||
Map<String, Object> d1 = matchmakerServiceService.getDashboard(mmB);
|
||||
assertEquals(0L, d1.get("inProgressCount"));
|
||||
assertEquals(1L, d1.get("completedCount"), "分配仍生效时历史 completed 计入本人");
|
||||
|
||||
// 分配 end(终态)→ 三项计数全部归零(DEC-002 规则7:服务结束失去访问权)
|
||||
assignmentService.end(assignmentId, leaderA, false, "005 P1-2 结束验证");
|
||||
Map<String, Object> d2 = matchmakerServiceService.getDashboard(mmB);
|
||||
assertEquals(0L, d2.get("inProgressCount"));
|
||||
assertEquals(0L, d2.get("todayCount"), "分配 end 后该用户服务单不再计入本人今日数");
|
||||
assertEquals(0L, d2.get("completedCount"), "分配 end 后历史 completed 不再计入本人");
|
||||
}
|
||||
|
||||
// ==================== P1-3:转介授权切判定器(写路径) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("P1-3: 转介授权走判定器 —— 有效分配内正常转介(正向);revoke/无分配 → 404 化拒绝 + DATA_SCOPE_DENIED 审计(负向)")
|
||||
void referralAuthorizationFollowsActiveAssignments() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||
|
||||
// 正向:有效分配内红娘可转介
|
||||
Map<String, Object> ok = referralService.referToActivity(mmB, user1, "act-005-p13", "紧急联系人", "enc");
|
||||
assertNotNull(ok.get("registrationId"), "有效分配内转介必须成功落库");
|
||||
|
||||
// 负向①:无任何分配的用户 → 404 化拒绝(T04 防探测)+ 审计
|
||||
long before = countDeniedAuditsForResource(mmB, user2);
|
||||
BusinessException denied1 = assertThrows(BusinessException.class,
|
||||
() -> referralService.referToActivity(mmB, user2, "act-005-p14", "紧急联系人", "enc"),
|
||||
"无生效分配的红娘不得对任意用户发起转介");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, denied1.getCode(), "拒绝必须 404 化(防探测)");
|
||||
assertEquals("服务不存在", denied1.getMessage());
|
||||
assertEquals(before + 1, countDeniedAuditsForResource(mmB, user2),
|
||||
"越权转介必须先落 DATA_SCOPE_DENIED 审计");
|
||||
|
||||
// 负向②:revoke 后原对象转介 → 404 化拒绝 + 审计(残留 assigned_operator_id 不再构成转介凭据)
|
||||
assignmentService.revoke(assignmentId, leaderA, false, "005 P1-3 撤销验证");
|
||||
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
|
||||
"前置确认:服务单残留 assigned_operator_id");
|
||||
long beforeRevoked = countDeniedAuditsForResource(mmB, user1);
|
||||
BusinessException denied2 = assertThrows(BusinessException.class,
|
||||
() -> referralService.referToServiceStation(mmB, user1, "loc-005-p13",
|
||||
"counselor_consultation", null, "备注"),
|
||||
"撤销分配后红娘对原对象发起服务站转介必须拒绝");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, denied2.getCode());
|
||||
assertEquals(beforeRevoked + 1, countDeniedAuditsForResource(mmB, user1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("E3 showOccupation: false→无 occupationCode;true/未设置→有(缺省全放行)")
|
||||
void showOccupationPrivacySwitch() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
createAssignment(mmB, user1, serviceId);
|
||||
|
||||
// 未设置(无隐私记录)→ 放行
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> profileDefault = (Map<String, Object>) detail.get("userProfile");
|
||||
assertTrue(profileDefault.containsKey("occupationCode"), "未设置隐私记录时缺省放行");
|
||||
|
||||
// false → 隐藏 occupationCode,其余白名单字段不受影响
|
||||
UserPrivacySettings privacyOff = new UserPrivacySettings();
|
||||
privacyOff.setUserId(user1);
|
||||
privacyOff.setShowOccupation(false);
|
||||
userPrivacySettingsMapper.insert(privacyOff);
|
||||
Map<String, Object> detailOff = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> profileOff = (Map<String, Object>) detailOff.get("userProfile");
|
||||
assertFalse(profileOff.containsKey("occupationCode"), "showOccupation=false 必须隐藏职业");
|
||||
assertTrue(profileOff.containsKey("displayName") && profileOff.containsKey("educationCode"),
|
||||
"其他白名单字段不受影响");
|
||||
|
||||
// true → 放行
|
||||
privacyOff.setShowOccupation(true);
|
||||
userPrivacySettingsMapper.updateById(privacyOff);
|
||||
Map<String, Object> detailOn = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> profileOn = (Map<String, Object>) detailOn.get("userProfile");
|
||||
assertTrue(profileOn.containsKey("occupationCode"), "showOccupation=true 放行");
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-005 / E3+MM-PRIVACY-002 字段级隐私守卫(standalone 源码扫描,无需 Spring 上下文)。
|
||||
*
|
||||
* <p>锁定两条不变量:
|
||||
* <ol>
|
||||
* <li>红娘侧 userProfile 装配(MatchmakerServiceService#assembleMatchmakerUserProfile)
|
||||
* 的输出键集合 ⊆ §5.2 白名单 {displayName, gender, age, regionName, educationCode,
|
||||
* occupationCode, maritalStatus};方法体内不得出现 phone/mobile/wechat/idNumber/
|
||||
* birthDate/address/id_card 等禁放字段的 getter 调用(birthDate 仅允许作为
|
||||
* calculateAge 的输入存在,绝不能成为输出键);</li>
|
||||
* <li>MM-PRIVACY-002:matchmaker / referral 运营端 Controller 不存在
|
||||
* export / download / batch 类端点(锁定"不存在"——导出能力引入时守卫即红)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
class MatchmakerFieldPrivacyGuardTest {
|
||||
|
||||
private static final Path SERVICE_SOURCE =
|
||||
Paths.get("backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java");
|
||||
private static final Path CONTROLLER_DIRS = Paths.get("backend/src/main/java/com/danyang/match/platform");
|
||||
|
||||
/** §5.2 红娘侧用户资料白名单(与 assembleMatchmakerUserProfile 一一对照) */
|
||||
private static final Set<String> WHITELIST = Set.of(
|
||||
"displayName", "gender", "age", "regionName", "educationCode", "occupationCode", "maritalStatus");
|
||||
|
||||
/** §5.2 禁放字段(作为输出键或 getter 调用出现即违规) */
|
||||
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
|
||||
"phone", "mobile", "wechat", "idNumber", "birthDate", "address", "id_card");
|
||||
|
||||
private static final List<String> FORBIDDEN_GETTERS = List.of(
|
||||
"getPhone", "getMobile", "getWechat", "getIdNumber", "getAddress", "getIdCard");
|
||||
|
||||
// ==================== E3:userProfile 白名单 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("E3 守卫:userProfile 装配键 ⊆ §5.2 白名单,禁放字段不得出现")
|
||||
void userProfileAssemblyKeysAreWhitelisted() throws IOException {
|
||||
String body = extractMethodBody("assembleMatchmakerUserProfile");
|
||||
assertTrue(body != null && !body.isBlank(), "未能从源码提取 assembleMatchmakerUserProfile 方法体");
|
||||
|
||||
// 输出键白名单
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
Matcher put = Pattern.compile("userProfile\\.put\\(\"([^\"]+)\"").matcher(body);
|
||||
while (put.find()) {
|
||||
keys.add(put.group(1));
|
||||
}
|
||||
assertFalse(keys.isEmpty(), "装配方法必须显式输出字段(防止装配被搬空后守卫永真)");
|
||||
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (String key : keys) {
|
||||
if (!WHITELIST.contains(key)) {
|
||||
violations.add("非白名单输出键: " + key);
|
||||
}
|
||||
String lower = key.toLowerCase();
|
||||
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||
if (lower.contains(token.toLowerCase())) {
|
||||
violations.add("输出键含禁放字段 token: " + key + "(含 " + token + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 禁放字段的 getter 调用(birthDate 仅允许经 calculateAge 派生,无独立 getter 白名单问题)
|
||||
for (String getter : FORBIDDEN_GETTERS) {
|
||||
if (body.contains(getter + "(")) {
|
||||
violations.add("装配方法调用了禁放字段 getter: " + getter);
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(List.of(), violations,
|
||||
"userProfile 字段级隐私契约被破坏(§5.2):\n - " + String.join("\n - ", violations)
|
||||
+ "\n实际输出键=" + keys);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫自检:装配方法必须包含 showOccupation 开关逻辑(防装配被改回无隐私开关)")
|
||||
void assemblyMustKeepOccupationPrivacySwitch() throws IOException {
|
||||
String body = extractMethodBody("assembleMatchmakerUserProfile");
|
||||
assertTrue(body.contains("showOccupation"),
|
||||
"装配方法必须读取隐私开关 showOccupation(E3)");
|
||||
assertTrue(body.contains("Boolean.FALSE.equals"),
|
||||
"缺省=全放行的语义必须保留(仅显式 false 隐藏)");
|
||||
}
|
||||
|
||||
// ==================== MM-PRIVACY-002:无导出端点 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-PRIVACY-002: matchmaker/referral Controller 无 export/download/batch 端点")
|
||||
void noExportDownloadBatchEndpoints() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
List<Path> controllerDirs = List.of(
|
||||
CONTROLLER_DIRS.resolve("matchmaker/controller"),
|
||||
CONTROLLER_DIRS.resolve("referral/controller"));
|
||||
for (Path dir : controllerDirs) {
|
||||
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String src = Files.readString(f, StandardCharsets.UTF_8);
|
||||
Matcher mapping = Pattern.compile(
|
||||
"@(Get|Post|Put|Delete)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
|
||||
while (mapping.find()) {
|
||||
String args = mapping.group(2).toLowerCase();
|
||||
for (String banned : List.of("export", "download", "batch")) {
|
||||
if (args.contains(banned)) {
|
||||
violations.add(f.getFileName() + " → " + mapping.group(0)
|
||||
.replaceAll("\\s+", " ") + " 含禁用能力: " + banned);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"红娘/转介域引入导出/批量能力必须先过隐私评审(§5.2 + MM-PRIVACY-002)");
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/** 按花括号配对提取指定方法的方法体(去注释后匹配)。 */
|
||||
private String extractMethodBody(String methodName) throws IOException {
|
||||
String src = Files.readString(SERVICE_SOURCE, StandardCharsets.UTF_8)
|
||||
.replaceAll("//[^\\n]*", "")
|
||||
.replaceAll("/\\*.*?\\*/", "");
|
||||
int sig = src.indexOf(methodName);
|
||||
if (sig < 0) {
|
||||
return null;
|
||||
}
|
||||
int open = src.indexOf('{', sig);
|
||||
if (open < 0) {
|
||||
return null;
|
||||
}
|
||||
int depth = 0;
|
||||
for (int i = open; i < src.length(); i++) {
|
||||
char ch = src.charAt(i);
|
||||
if (ch == '{') depth++;
|
||||
else if (ch == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return src.substring(open + 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerManagementService;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerManagementReadTest {
|
||||
@Autowired MatchmakerManagementService service;
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
String manager, leader, otherLeader, user, serviceId;
|
||||
|
||||
@BeforeEach void seed() {
|
||||
manager = UUID.randomUUID().toString();
|
||||
leader = UUID.randomUUID().toString();
|
||||
otherLeader = UUID.randomUUID().toString();
|
||||
String org = UUID.randomUUID().toString();
|
||||
String mm = UUID.randomUUID().toString();
|
||||
user = UUID.randomUUID().toString();
|
||||
serviceId = UUID.randomUUID().toString();
|
||||
for (String id : new String[]{manager, leader, otherLeader, mm}) {
|
||||
jdbc.update("INSERT INTO operators(id,name,username,password_hash,organization_id) VALUES(?,?,?,?,?)",
|
||||
id, "验收人员", id, "test", id.equals(otherLeader) ? UUID.randomUUID().toString() : org);
|
||||
}
|
||||
jdbc.update("INSERT INTO users(id,wechat_openid,display_number) VALUES(?,?,?)", user, user, "YH2026101302");
|
||||
jdbc.update("INSERT INTO matchmaker_services(id,user_id,service_type,status,assigned_operator_id) VALUES(?,?,?,?,?)",
|
||||
serviceId, user, "other", "waiting_user", mm);
|
||||
jdbc.update("INSERT INTO matchmaker_followups(id,service_id,operator_id,content) VALUES(?,?,?,?)",
|
||||
UUID.randomUUID().toString(), serviceId, mm, "验收跟进");
|
||||
}
|
||||
|
||||
@Test void managerReadsPersistedFollowupAndAuditsAccess() {
|
||||
var principal = new OperatorPrincipal(manager, Set.of("matchmaker.assign"));
|
||||
var row = service.list(principal).stream().filter(r -> serviceId.equals(r.get("serviceId"))).findFirst().orElseThrow();
|
||||
assertEquals("验收跟进", row.get("latestFollowupContent"));
|
||||
assertEquals("YH2026101302", row.get("userDisplayNumber"));
|
||||
assertFalse(row.containsKey("phone"));
|
||||
var detail = service.detail(principal, serviceId);
|
||||
assertEquals(1, ((java.util.List<?>) detail.get("followups")).size());
|
||||
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM audit_logs WHERE resource_id=? AND action='MATCHMAKER_MANAGEMENT_VIEW'", Integer.class, serviceId));
|
||||
}
|
||||
|
||||
@Test void leaderCanOnlyReadOwnOrganization() {
|
||||
assertNotNull(service.detail(new OperatorPrincipal(leader, Set.of("matchmaker.team.assign")), serviceId));
|
||||
var other = new OperatorPrincipal(otherLeader, Set.of("matchmaker.team.assign"));
|
||||
assertTrue(service.list(other).isEmpty());
|
||||
assertThrows(BusinessException.class, () -> service.detail(other, serviceId));
|
||||
}
|
||||
|
||||
@Test void ordinaryMatchmakerCannotUseManagementRead() {
|
||||
var ordinary = new OperatorPrincipal(manager, Set.of("matchmaker.view", "matchmaker.service"));
|
||||
assertThrows(AccessDeniedException.class, () -> service.list(ordinary));
|
||||
assertThrows(AccessDeniedException.class, () -> service.detail(ordinary, serviceId));
|
||||
}
|
||||
}
|
||||
+38
-24
@@ -54,14 +54,15 @@ class MatchmakerReferralPhase13Test {
|
||||
@Autowired private com.danyang.match.platform.verification.mapper.UserIdentityMapper userIdentityMapper;
|
||||
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||
@Autowired private com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper matchmakerAssignmentMapper;
|
||||
@Autowired private MatchmakerService matchmakerService;
|
||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||
@Autowired private ReferralService referralService;
|
||||
@Autowired private ActivityRegistrationMapper activityRegistrationMapper;
|
||||
@Autowired private ServiceAppointmentMapper appointmentMapper;
|
||||
|
||||
private static final String MATCHMAKER_OWNER = "op-mm-owner";
|
||||
private static final String MATCHMAKER_OTHER = "op-mm-other";
|
||||
private static final String MATCHMAKER_OWNER = "00000000-0000-0000-0000-000000000104";
|
||||
private static final String MATCHMAKER_OTHER = "00000000-0000-0000-0000-000000000105";
|
||||
|
||||
private String user;
|
||||
private String target;
|
||||
@@ -73,6 +74,19 @@ class MatchmakerReferralPhase13Test {
|
||||
target = createUser("mm-target");
|
||||
// 一条归属 MATCHMAKER_OWNER 的红娘服务
|
||||
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||
// ZC-MM-005(E1):归属判定切纯判定器(无 assigned_operator_id 回退)后,
|
||||
// 服务层归属断言只认 matchmaker_assignments —— 前置补一条生效分配
|
||||
com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment assignment =
|
||||
new com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment();
|
||||
assignment.setUserId(user);
|
||||
assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER);
|
||||
assignment.setServiceId(serviceId);
|
||||
assignment.setAssignedBy(MATCHMAKER_OWNER);
|
||||
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
|
||||
assignment.setAssignmentReason("Phase13 前置分配");
|
||||
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||
assignment.setAssignedAt(java.time.Instant.now());
|
||||
matchmakerAssignmentMapper.insert(assignment);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,24 +303,29 @@ class MatchmakerReferralPhase13Test {
|
||||
@DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)")
|
||||
void otherMatchmakerCannotReferUser() {
|
||||
// MATCHMAKER_OTHER 与该用户之间没有任何服务关系
|
||||
assertThrows(IllegalStateException.class,
|
||||
// ZC-MM-005 收口(P1-3):转介授权切换为 ServiceAssignmentPolicy 判定器;
|
||||
// ZC-MM-006 / T04:转介为资源寻址写端点,拒绝 404 化(先 DATA_SCOPE_DENIED 审计,
|
||||
// 再抛 404「服务不存在」,与"查无"逐字段一致,防探测)
|
||||
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> referralService.referToActivity(
|
||||
MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"),
|
||||
"非该用户服务负责人的红娘不得发起转介");
|
||||
"非该用户服务负责人的红娘不得发起转介(404 化拒绝)");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> referralService.referToServiceStation(
|
||||
MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null),
|
||||
"非该用户服务负责人的红娘不得发起服务站转介");
|
||||
"非该用户服务负责人的红娘不得发起服务站转介(404 化拒绝)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)")
|
||||
void otherMatchmakerCannotRecommend() {
|
||||
assertThrows(IllegalStateException.class,
|
||||
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy;
|
||||
// ZC-MM-006 / T04:写接口拒绝 404 化(DATA_SCOPE_DENIED 审计 + BusinessException 404)
|
||||
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> matchmakerServiceService.recommendPartner(
|
||||
serviceId, MATCHMAKER_OTHER, target, "越权推荐"),
|
||||
"非服务负责人不得添加推荐");
|
||||
"非服务负责人不得添加推荐(404 化拒绝)");
|
||||
|
||||
assertTrue(recommendationMapper.findByService(serviceId).isEmpty(),
|
||||
"越权推荐失败后不得留下任何推荐记录");
|
||||
@@ -337,21 +356,15 @@ class MatchmakerReferralPhase13Test {
|
||||
// ==================== 计划项 3:红娘只读摘要,不读敏感正文 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项3/D4: 非负责人的 operator 读详情只得到脱敏视图(无 userProfile / 无 followup 正文)")
|
||||
void nonOwnerSeesSanitizedDetail() {
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER);
|
||||
|
||||
assertNotNull(detail);
|
||||
assertEquals(Boolean.FALSE, detail.get("viewerIsOwner"),
|
||||
"非负责人必须被标记为非 owner");
|
||||
assertFalse(detail.containsKey("userProfile"),
|
||||
"INV-013 同类口径:非负责人不得读取服务对象的用户资料");
|
||||
assertFalse(detail.containsKey("followups"),
|
||||
"非负责人不得读取跟进正文");
|
||||
assertFalse(detail.containsKey("userId"),
|
||||
"非负责人不需要知道申请人 userId");
|
||||
assertEquals(0, ((Number) detail.getOrDefault("followupCount", 0)).intValue(),
|
||||
"非负责人只能拿到跟进条数摘要");
|
||||
@DisplayName("计划项3/D4+ZC-MM-005 E2: 非持有人读详情 → 防探测 404(与「查无」一致,无脱敏双态)")
|
||||
void nonOwnerReadsDetailIsRejectedAsNotFound() {
|
||||
// ZC-MM-005(E1+E2):002 的"非负责人脱敏详情"双态已删除;
|
||||
// 非持有人读任意 serviceId 得到与"查无"逐字段一致的 404,防枚举探测。
|
||||
// 读侧不再走 403(写接口维持 403,见 assertServiceAccess 口径)。
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||
() -> matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER),
|
||||
"非持有人读详情必须被拒(防探测 404)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -360,7 +373,8 @@ class MatchmakerReferralPhase13Test {
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER);
|
||||
|
||||
assertNotNull(detail);
|
||||
assertEquals(Boolean.TRUE, detail.get("viewerIsOwner"));
|
||||
// ZC-MM-005(E1):viewerIsOwner 双态字段已删除,持有人语义由"能拿到完整视图"表达
|
||||
assertFalse(detail.containsKey("viewerIsOwner"), "双态字段已随读侧纯化删除");
|
||||
assertTrue(detail.containsKey("userId"), "负责人应能看到申请人 userId");
|
||||
assertTrue(detail.containsKey("followups"), "负责人应能看到跟进记录");
|
||||
}
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
|
||||
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.matchmaker.service.MatchmakerAssignmentService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
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.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-006 / T03 验收:SensitiveAccessLog 接入红娘服务对象详情。
|
||||
*
|
||||
* <p>语义(AGENTS.md #8:敏感数据默认拒绝,查看动作本身必须可溯源):
|
||||
* <ul>
|
||||
* <li>持有人读 GET /operator/matchmaker/services/{id}(getServiceDetail)→
|
||||
* sensitive_access_logs 落 success 行(dataType=matchmaker_service_detail);</li>
|
||||
* <li>拒绝路径维持 audit_logs DATA_SCOPE_DENIED(判定器写入),<b>不重复写</b>
|
||||
* SensitiveAccessLog —— 双记录互斥;</li>
|
||||
* <li>/my 与 /team/statistics 不接入敏感访问日志(无服务对象敏感明细)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>覆盖:正例 success 行;负例(非持有人)无 SensitiveAccessLog 行且 audit_logs 有
|
||||
* DATA_SCOPE_DENIED;连读 N 次落 N 行(幂等逐次记录,不聚合去重)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerSensitiveAccessAuditTest {
|
||||
|
||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||
@Autowired private OperatorMapper operatorMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
|
||||
private String holderId; // 分配持有人(红娘 B)
|
||||
private String outsiderId; // 无分配红娘 C
|
||||
private String userId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
holderId = createOperator("敏感审计持有人");
|
||||
outsiderId = createOperator("敏感审计外人");
|
||||
userId = createUser();
|
||||
}
|
||||
|
||||
// ==================== 数据构造(Phase13 范式) ====================
|
||||
|
||||
private String createOperator(String name) {
|
||||
Operator op = new Operator();
|
||||
op.setName(name);
|
||||
op.setUsername("sa_" + name + "_" + System.nanoTime());
|
||||
op.setPasswordHash("test-hash");
|
||||
op.setStatus("active");
|
||||
operatorMapper.insert(op);
|
||||
return op.getId();
|
||||
}
|
||||
|
||||
private String createUser() {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("sa_user_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
userMapper.insert(u);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
private String createServiceWithAssignment() {
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setDescription("敏感访问审计测试服务单");
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
svc.setAssignedOperatorId(holderId);
|
||||
serviceMapper.insert(svc);
|
||||
assignmentService.create(holderId, true, userId, holderId, svc.getId(),
|
||||
"T03 前置分配", null);
|
||||
return svc.getId();
|
||||
}
|
||||
|
||||
private List<SensitiveAccessLog> accessRows(String operatorId, String serviceId) {
|
||||
return sensitiveAccessLogMapper.selectList(new LambdaQueryWrapper<SensitiveAccessLog>()
|
||||
.eq(SensitiveAccessLog::getOperatorId, operatorId)
|
||||
.eq(SensitiveAccessLog::getDataType, Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL)
|
||||
.eq(SensitiveAccessLog::getAccessTarget, serviceId));
|
||||
}
|
||||
|
||||
// ==================== 正例:持有人读详情落 success 行 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("T03 正例: 持有人读服务详情 → sensitive_access_logs 落 success 行(dataType/target/reason 齐全)")
|
||||
void holderReadDetailWritesSensitiveAccessSuccessRow() {
|
||||
String serviceId = createServiceWithAssignment();
|
||||
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||
assertNotNull(detail);
|
||||
|
||||
List<SensitiveAccessLog> rows = accessRows(holderId, serviceId);
|
||||
assertEquals(1, rows.size(), "持有人读详情必须落恰好 1 行敏感访问日志");
|
||||
SensitiveAccessLog row = rows.get(0);
|
||||
assertEquals(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL, row.getDataType());
|
||||
assertEquals(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS, row.getAccessResult());
|
||||
assertEquals(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL, row.getReason());
|
||||
assertEquals(userId, row.getUserId());
|
||||
assertEquals(serviceId, row.getAccessTarget());
|
||||
}
|
||||
|
||||
// ==================== 负例:非持有人读 → 双记录互斥 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("T03 负例: 非持有人读详情 404 → 无 SensitiveAccessLog 行,audit_logs 有 DATA_SCOPE_DENIED")
|
||||
void outsiderReadDetailWritesNoSensitiveAccessRowButDeniedAudit() {
|
||||
String serviceId = createServiceWithAssignment();
|
||||
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> matchmakerServiceService.getServiceDetail(serviceId, outsiderId),
|
||||
"非持有人读详情必须 404(防探测口径)");
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||
|
||||
// 双记录互斥:拒绝路径只写 audit_logs,不写 sensitive_access_logs
|
||||
assertTrue(accessRows(outsiderId, serviceId).isEmpty(),
|
||||
"拒绝路径不得写 SensitiveAccessLog(与 DATA_SCOPE_DENIED 互斥)");
|
||||
Long deniedAudits = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||
.eq(AuditLog::getOperatorId, outsiderId)
|
||||
.eq(AuditLog::getResourceId, serviceId));
|
||||
assertEquals(1L, deniedAudits, "拒绝路径必须落 DATA_SCOPE_DENIED 审计");
|
||||
}
|
||||
|
||||
// ==================== 幂等逐次记录:连读 N 次落 N 行 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("T03 幂等: 持有人连读 3 次 → 落 3 行(逐次记录,不聚合去重)")
|
||||
void repeatedReadsWriteRowPerAccess() {
|
||||
String serviceId = createServiceWithAssignment();
|
||||
|
||||
matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||
matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||
matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||
|
||||
assertEquals(3, accessRows(holderId, serviceId).size(),
|
||||
"每次查看必须逐次记录(可溯源,不允许聚合去重)");
|
||||
}
|
||||
|
||||
// ==================== 边界:查无 serviceId 不写敏感日志 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("T03 边界: 查无 serviceId → 404,不落任何敏感访问行")
|
||||
void missingServiceWritesNoSensitiveAccessRow() {
|
||||
assertThrows(BusinessException.class,
|
||||
() -> matchmakerServiceService.getServiceDetail("sa-no-such-service", holderId));
|
||||
assertTrue(accessRows(holderId, "sa-no-such-service").isEmpty(),
|
||||
"查无资源不构成敏感访问,不得落行");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user