7 Commits
Author SHA1 Message Date
WorkBuddy d51c4c6a76 chore: ignore local temporary workspaces
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
2026-10-20 14:20:23 +08:00
WorkBuddy 69ebf33e18 release: freeze demo v1.1.0 2026-10-19
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
2026-10-19 20:36:22 +08:00
WorkBuddy 8b505d9133 feat: disable user messaging and retain公益 service guidance
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
2026-10-15 13:38:17 +08:00
WorkBuddy 3c2b967274 release: freeze 2026-10-15 validated release baseline
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
2026-10-15 09:47:31 +08:00
WorkBuddy 827c23db1d fix(audit): 拒绝审计改 REQUIRES_NEW 独立事务提交(运行时实测抓到拒绝审计被业务回滚吞掉的系统性缺陷)
- 根因:DataScopeGuard.deny/denyAsNotFound/denyUser 与 AuthorizationAuditListener 的审计 insert 与业务同事务——拒绝抛异常必然回滚,审计(已执行 Updates:1)随之消失 → 生产全量 DATA_SCOPE_DENIED 零落库;H2 测试类 @Transactional(测试事务内可见未提交行)结构性假绿
- 运行时取证:outsider consent → deny insert DEBUG 日志 Updates:1 → psql count=0(回滚吞掉)
- 修复:新增 AuditDenyWriter(@Transactional REQUIRES_NEW)——deny/denyAsNotFound/denyUser 与 AuthorizationAuditListener 全部改走独立提交;成功路径审计保持与业务同事务(业务回滚审计一并消失,语义正确)
- 测试判别性加固:新增 denyAuditSurvivesBusinessRollback(@Transactional NOT_SUPPORTED 挂起测试事务跑真事务,旧实现必红);两处 operator deny 断言前 clearCache(REQUIRES_NEW 走独立会话不清外层 MyBatis 一级缓存——调试实证行已落库而过滤查询命中陈旧缓存)
- 负向注入:AuditDenyWriter 退化同事务 → 判别用例精准判红 → 还原复绿
- 验证:定向 46/46(授权 14+disabled 2+守卫 5+迁移 13+监听 1+矩阵 1+预约 3+路径 2+覆盖 5)
2026-10-12 11:09:56 +08:00
WorkBuddy ef61d51f61 fix(audit,matchmaker): V057 audit_logs 增加 subject_user_id 主体维度(运行时实测抓到 C 端审计 FK 硬缺陷)
- 根因:audit_logs.operator_id FK→operators,C 端用户(consent/decline/越权)触发的审计把 userId 写 operator_id → FK violation:consent 成功路径 500 回滚(阻塞主链路)、deny 路径写失败被吞(C 端越权零审计,违反 DEC-006);H2 无 FK → 集成测试假绿(测试库缺约束类假绿第二例)
- V057:audit_logs + subject_user_id UUID FK users(可空)+ idx_audit_logs_subject_user;主体语义=operator_id 与 subject_user_id 恰有一者非空
- AuditLog 实体 +subjectUserId;DataScopeGuard.denyUser(subject 维度 deny,先审计后 403);AuditLogService.recordUserAction 备用入口
- ContactAuthorizationService:consent/decline 改 saveUserAudit(subject 维度);requireOwnedAuthorization 改 denyUser;operator 侧 6 处不变
- 测试:生命周期断言主体分流(consent 行 subject_user_id/operator_id=null/无 assignment_id);非本人 consent 负例补「越权 deny 审计必须落 subject 维度」断言;LATEST_MIGRATION 56→57
- 验证:定向 33/33(授权 13+disabled 2+守卫 5+迁移 13);负向注入 denyUser 写回 operator 维度 → 恰好新断言判红 → 还原复绿
- 发现渠道:本地栈运行时实测(mock 登录新用户 consent 403 → deny 审计 FK 失败日志溯源)
2026-10-12 10:15:46 +08:00
WorkBuddy 02bc01f9c9 fix(matchmaker): 受控联系审计 afterData 改 JSON 序列化(运行时实测抓到 PG jsonb 500)
- 根因:ContactAuthorizationService 7 处 saveAudit afterData 传 key=value 裸串,PG audit_logs.after_data 为 jsonb 严格解析 → request/disclose 全链路 500;H2 测试库该列 CLOB 不校验 → 集成测试假绿
- 修复:新增 jsonOf 辅助(Jackson writeValueAsString,成对 kv),7 调用点全部改写;purpose/reason 用户输入由 Jackson 转义防 JSON 注入
- 守卫加固:MatchmakerContactAuthorizationTest 审计断言补「afterData 必须可 JSON 解析且为对象」(readTree 真解析)——运行时事故进入自动化基线
- 同型排查:全仓 recordSensitiveAccess/saveAuditLog 其他调用点 afterData 干净,污染面仅本文件
- 验证:定向 20/20(授权 13+disabled 2+守卫 5);负向注入还原裸串 → 恰好 JSON 解析守卫判红(错误信息与生产一致 Unrecognized token 'channel')→ 还原复绿
- 发现渠道:本地栈运行时实测(V056 上线后首次真 PG 验证),非测试环境
2026-10-12 09:56:26 +08:00
328 changed files with 25837 additions and 614 deletions
+3
View File
@@ -61,6 +61,9 @@ tests/fixtures/*.json
.worktrees/ .worktrees/
.zcode/ .zcode/
.workbuddy/ .workbuddy/
.mimosa/
.trae/
.wb_tmp_bcrypt/
project.private.config.json project.private.config.json
/nul /nul
/infrastructure/nul /infrastructure/nul
+6 -2
View File
@@ -32,8 +32,12 @@ server {
} }
# API proxy to backend # API proxy to backend
# 用 resolver + 变量 proxy_pass:避免 nginx 启动时 backend DNS 还没就绪就 [emerg] 退出
# Docker Compose 默认网络的内置 DNS resolver 是 127.0.0.11
resolver 127.0.0.11 valid=30s ipv6=off;
set $backend_upstream "http://backend:8080";
location /api/ { location /api/ {
proxy_pass http://backend:8080; proxy_pass $backend_upstream;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -42,7 +46,7 @@ server {
# WebSocket proxy for chat # WebSocket proxy for chat
location /ws/ { location /ws/ {
proxy_pass http://backend:8080; proxy_pass $backend_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
+2 -2
View File
@@ -20,8 +20,8 @@
packages: packages:
- '.' - '.'
allowBuilds: allowBuilds:
esbuild: set this to true or false esbuild: true
vue-demi: set this to true or false vue-demi: true
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
- vue-demi - vue-demi
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 672 KiB

+363 -152
View File
@@ -1,26 +1,52 @@
<template> <template>
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }"> <div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息"> <header class="scene-header" aria-label="爱在丹阳公益服务品牌信息">
<!-- 背景装饰层:粉渐变兜底 + 丹阳剪影底图 + 爱心点缀 -->
<div class="header-bg" aria-hidden="true"></div>
<div class="header-hearts" aria-hidden="true">
<span>♡</span><span>♡</span><span>♡</span><span>♡</span>
</div>
<div class="scene-brand"> <div class="scene-brand">
<img :src="BRAND_LOGO" alt="善缘丹阳" /> <div class="brand-logo-wrap">
<img :src="BRAND_LOGO" alt="爱在丹阳" />
<span class="brand-dot" aria-hidden="true"></span>
</div>
<div class="scene-brand-copy"> <div class="scene-brand-copy">
<strong>善缘丹阳</strong> <div class="brand-name-row">
<span>公益婚恋服务平台</span> <strong>爱在丹阳</strong>
<em class="brand-badge">公益运营</em>
</div>
<span class="brand-tagline">公益婚恋服务平台</span>
<span class="brand-ornament" aria-hidden="true">❖ &nbsp;●&nbsp; ❖&nbsp; ●&nbsp; ❖</span>
</div> </div>
<div class="scene-title"> <div class="scene-title">
<h1>管理端(运营后台)</h1> <h1>管理端(运营后台)</h1>
<p>用专业的服务,帮助更多人遇见对的人</p> <p class="title-sub">
<span>公益婚恋服务平台</span>
<span class="sep">·</span>
<span>用爱连接美好姻缘</span>
<span class="sep">·</span>
<span>让更多人在丹阳遇见幸福</span>
</p>
<p class="title-meta">
<span class="meta-chip" title="当前权限范围内今日新增、分配、跟进或完成的红娘服务单,按北京时间去重">今日服务 {{ todayStats?.total ?? '—' }} 单</span>
<span class="meta-chip" title="当前权限范围内尚未完成的红娘服务单">待处理 {{ todayStats?.pending ?? '—' }} 件</span>
</p>
</div> </div>
</div> </div>
<div class="scene-slogan" aria-hidden="true"> <div class="scene-slogan" aria-hidden="true">
<span>善缘相牵</span> <span class="slogan-top">爱在丹阳</span>
<strong>爱在丹阳</strong> <strong class="slogan-main">原来是你</strong>
<em class="slogan-en">Love&nbsp;In&nbsp;Danyang</em>
</div> </div>
<div class="scene-benefits" aria-label="平台服务原则"> <div class="scene-benefits" aria-label="平台服务原则">
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span> <span><i class="benefit-icon"><el-icon><StarFilled /></el-icon></i><b>公益为本</b><small>不以营利为目的</small></span>
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span> <span><i class="benefit-icon"><el-icon><Lock /></el-icon></i><b>真实可靠</b><small>严格审核·多重保障</small></span>
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span> <span><i class="benefit-icon"><el-icon><LocationFilled /></el-icon></i><b>本地服务</b><small>深耕丹阳·服务家乡</small></span>
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span> <span><i class="benefit-icon"><el-icon><Opportunity /></el-icon></i><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
</div> </div>
</header> </header>
@@ -29,7 +55,7 @@
<div class="brand" :class="{ compact: isCollapse }"> <div class="brand" :class="{ compact: isCollapse }">
<img class="brand-mark" :src="BRAND_LOGO" alt="" /> <img class="brand-mark" :src="BRAND_LOGO" alt="" />
<div v-if="!isCollapse" class="brand-copy"> <div v-if="!isCollapse" class="brand-copy">
<strong>善缘丹阳</strong> <strong>爱在丹阳</strong>
<span>公益婚恋服务平台</span> <span>公益婚恋服务平台</span>
</div> </div>
</div> </div>
@@ -121,14 +147,16 @@
</el-container> </el-container>
<footer class="scene-footer"> <footer class="scene-footer">
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span> <strong>爱在丹阳</strong><span>公益婚恋服务平台</span>
<small>真实 · 真诚 · 公益 · 幸福</small> <small>真实 · 真诚 · 公益 · 幸福</small>
</footer> </footer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { get } from '@/api/client'
import { summarizeMatchmakerServices } from '@/services/dashboard'
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand' import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -199,6 +227,25 @@ const isCollapse = ref(false)
const globalSearch = ref('') const globalSearch = ref('')
const notificationCount = ref(0) const notificationCount = ref(0)
const todayStats = ref<{ total: number; pending: number } | null>(null)
watch(() => route.fullPath, async () => {
todayStats.value = null
const perms = authStore.permissions
if (authStore.token === 'ui-preview-only') return
try {
if (perms.includes('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(() => { const activeMenu = computed(() => {
if (route.path === '/users/detail') return '/users' if (route.path === '/users/detail') return '/users'
return route.path return route.path
@@ -258,137 +305,307 @@ function handleLogout() {
<style scoped> <style scoped>
.admin-scene { .admin-scene {
min-width: 1100px; min-width: 1100px;
min-height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
padding: 18px 34px 14px; padding: 18px 34px 14px;
overflow: hidden; overflow: hidden;
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀: /* 外层:纯色粉渐变兜底,再叠加背景图。
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。 原实现把 background 和 background-image 拆两行在某些浏览器会覆盖 —— 这里统一写一条,
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */ 并显式声明 position/size/repeat/attachment,确保 cover 全区域。 */
/* 先给纯色兜底,避免图未加载完成时闪白 */ background:
background: var(--admin-coral-lighter) center / cover fixed no-repeat; linear-gradient(180deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 40%, var(--admin-scene-bg-end) 100%),
background-image: url(v-bind('BRAND_BACKDROP')); url(v-bind('BRAND_BACKDROP')) center / cover fixed no-repeat;
} }
.scene-header { .scene-header {
position: relative;
overflow: hidden;
display: grid; display: grid;
max-width: var(--admin-shell-max-width); /* 100vw 撑满浏览器:用负 margin 抵消 admin-scene 的 padding */
height: 188px; width: 100vw;
margin: 0 auto; max-width: none;
grid-template-columns: minmax(640px, 1fr) auto; height: 105px;
grid-template-rows: 112px 76px; margin: -18px -34px 12px;
padding: 0 34px;
grid-template-columns: auto 1fr auto;
grid-template-rows: 1fr 36px;
grid-template-areas:
"brand title slogan"
"benefits benefits benefits";
align-items: center; align-items: center;
column-gap: var(--space-8); column-gap: 20px;
border-radius: 0;
} }
/* ===== 背景装饰层 ===== */
.header-bg {
position: absolute;
inset: 0;
background:
linear-gradient(135deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 55%, var(--admin-scene-bg-end) 100%),
url(v-bind('BRAND_BACKDROP')) center / cover no-repeat;
z-index: 0;
}
/* 左上角柔光晕 */
.header-bg::before {
content: '';
position: absolute;
top: -60px;
left: -40px;
width: 300px;
height: 180px;
background: radial-gradient(ellipse at center, var(--admin-scene-glow) 0%, transparent 70%);
pointer-events: none;
}
/* 右下角柔光晕 */
.header-bg::after {
content: '';
position: absolute;
right: 40px;
bottom: -30px;
width: 280px;
height: 140px;
background: radial-gradient(ellipse at center, var(--admin-scene-glow-soft) 0%, transparent 70%);
pointer-events: none;
}
/* 爱心点缀 */
.header-hearts {
position: absolute;
top: 18px;
right: 34px;
display: flex;
gap: 14px;
font-size: 16px;
color: var(--admin-coral-border);
z-index: 1;
pointer-events: none;
}
.header-hearts span {
animation: heartbeat 3s ease-in-out infinite;
}
.header-hearts span:nth-child(2) { animation-delay: .4s; }
.header-hearts span:nth-child(3) { animation-delay: .8s; }
.header-hearts span:nth-child(4) { animation-delay: 1.2s; }
@keyframes heartbeat {
0%, 100% { transform: scale(1); opacity: .5; }
50% { transform: scale(1.25); opacity: 1; }
}
/* ===== 所有 header 内容浮在背景层之上 ===== */
.scene-brand,
.scene-slogan,
.scene-benefits {
position: relative;
z-index: 2;
}
/* ===== 品牌区 ===== */
.scene-brand, .scene-brand,
.scene-benefits,
.scene-benefits span,
.scene-brand-copy { .scene-brand-copy {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.scene-brand > img { .scene-brand {
width: 64px; grid-area: brand;
height: 64px; gap: 10px;
object-fit: contain;
} }
.scene-slogan { /* Logo + 装饰圆点 */
.brand-logo-wrap {
position: relative;
width: 48px;
height: 48px;
flex: none;
display: grid;
place-items: center;
}
.brand-logo-wrap img {
width: 42px;
height: 42px;
object-fit: contain;
border-radius: 50%;
clip-path: circle(50%);
filter: drop-shadow(0 2px 6px rgba(244,95,104,0.25));
}
.brand-dot {
position: absolute;
top: -2px;
right: -2px;
width: 10px;
height: 10px;
background: var(--gradient-brand-solid);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(200, 74, 83, 0.45);
}
/* 品牌名 + 徽章 — tagline/ornament 在 105px 高度里塞不下,隐藏 */
.scene-brand-copy {
min-width: 0;
flex-direction: row;
align-items: center;
color: var(--admin-coral-strong);
gap: 8px;
}
.brand-name-row {
display: flex; display: flex;
padding-right: var(--space-6); align-items: center;
align-items: flex-end; gap: 8px;
}
.brand-name-row strong {
font-size: 24px;
font-weight: 700;
line-height: 1;
letter-spacing: 2px;
background: var(--gradient-brand-text);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: none;
}
.brand-badge {
display: inline-flex;
align-items: center;
padding: 1px 8px;
font-size: 12px;
font-style: normal;
font-weight: 500;
letter-spacing: 1px;
color: var(--color-text-inverse);
background: var(--gradient-brand-solid);
border-radius: 10px;
box-shadow: 0 2px 6px rgba(200, 74, 83, 0.3);
}
.brand-tagline,
.brand-ornament {
display: none;
}
/* 标题区 */
.scene-title {
min-width: 0;
margin-left: 20px;
padding-left: 16px;
border-left: 2px solid var(--admin-scene-divider);
}
.scene-title h1 {
font-size: 18px;
font-weight: 700;
line-height: 1.05;
letter-spacing: 1px;
color: var(--color-text-primary);
}
.title-sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
margin-top: 2px;
font-size: 12px;
color: var(--color-text-secondary);
letter-spacing: .2px;
}
.title-sub .sep {
margin: 0 5px;
color: var(--admin-coral-solid);
font-weight: 700;
}
.title-meta {
display: flex;
gap: 6px;
margin-top: 3px;
font-size: 12px;
}
.meta-chip {
display: inline-flex;
align-items: center;
padding: 0 8px;
height: 18px;
color: var(--admin-coral-strong);
background: var(--admin-scene-surface);
border: 1px solid var(--admin-coral-border);
border-radius: 9px;
font-weight: 500;
line-height: 18px;
}
/* ===== 书法 slogan ===== */
.scene-slogan {
grid-area: slogan;
display: flex;
padding-right: 0;
align-items: center;
flex-direction: column; flex-direction: column;
justify-content: center;
color: var(--admin-coral-strong); color: var(--admin-coral-strong);
font-family: var(--font-family-brand); font-family: var(--font-family-brand);
line-height: 1.05; line-height: 1;
transform: rotate(-7deg); text-align: center;
gap: 0;
} }
.slogan-top {
.scene-slogan span { font-size: 12px;
font-size: var(--font-size-lg); letter-spacing: 6px;
}
.scene-slogan strong {
margin-top: var(--space-1);
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-medium);
}
.scene-brand-copy {
min-width: 172px;
margin-left: 10px;
align-items: flex-start;
flex-direction: column;
color: var(--admin-coral-strong); color: var(--admin-coral-strong);
opacity: .8;
} }
.slogan-main {
.scene-brand-copy strong { font-size: 26px;
font-size: var(--font-size-4xl); font-weight: 500;
line-height: 1.15;
letter-spacing: 2px;
}
.scene-brand-copy span {
margin-top: 5px;
font-size: var(--font-size-xs);
letter-spacing: 4px; letter-spacing: 4px;
background: var(--gradient-brand-text);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.slogan-en {
display: none;
} }
.scene-title { /* ===== 公益原则行 ===== */
min-width: 370px;
margin-left: 28px;
padding-left: 28px;
border-left: 1px solid var(--admin-coral-border);
}
.scene-title h1 {
font-size: clamp(var(--font-size-3xl), 2.2vw, var(--font-size-display));
line-height: 1.1;
letter-spacing: 1px;
}
.scene-title p {
margin-top: 9px;
color: var(--color-text-secondary);
}
.scene-benefits { .scene-benefits {
grid-column: 1 / -1; grid-area: benefits;
align-self: start; align-self: center;
justify-content: flex-start; justify-content: center;
gap: clamp(38px, 5vw, 92px); flex-wrap: nowrap;
padding: var(--space-1) 0 0 50px; gap: clamp(10px, 1.6vw, 28px);
padding: 0;
overflow: hidden;
} }
.scene-benefits span { .scene-benefits span {
display: grid; flex: none;
grid-template-columns: 36px auto; display: inline-flex;
column-gap: var(--space-2); align-items: center;
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */ gap: 8px;
color: var(--admin-coral-strong); padding: 4px 14px 4px 8px;
}
.scene-benefits .el-icon {
grid-row: 1 / 3;
font-size: var(--font-size-4xl);
}
.scene-benefits b {
font-size: var(--font-size-sm);
}
.scene-benefits small {
margin-top: 2px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
white-space: nowrap; white-space: nowrap;
background: var(--admin-scene-surface);
border: 1px solid var(--admin-coral-border);
border-radius: 18px;
color: var(--admin-coral-strong);
backdrop-filter: blur(6px);
box-shadow: 0 1px 4px rgba(200, 74, 83, 0.06);
}
.scene-benefits b {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
line-height: 1.1;
}
.scene-benefits small {
color: var(--color-text-tertiary);
font-size: 12px;
font-size: 12px;
white-space: nowrap;
line-height: 1.1;
} }
.admin-shell { .admin-shell {
width: min(var(--admin-shell-max-width), 100%); width: min(var(--admin-shell-max-width), 100%);
height: calc(100vh - 238px); flex: 1;
min-height: 560px; min-height: 0;
margin: 0 auto; margin: 0 auto;
overflow: hidden; overflow: hidden;
background: var(--admin-shell-bg); background: var(--admin-shell-bg);
@@ -427,6 +644,8 @@ function handleLogout() {
height: 42px; height: 42px;
flex: none; flex: none;
object-fit: contain; object-fit: contain;
border-radius: 50%;
clip-path: circle(50%);
} }
.brand-copy { .brand-copy {
@@ -629,10 +848,15 @@ function handleLogout() {
} }
.admin-content { .admin-content {
height: 100%;
padding: 0; padding: 0;
overflow: auto; overflow: auto;
background: var(--color-bg-page); scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge 旧版 */
/* 暖色调背景,与整体品牌粉调一致;冷灰蓝 --color-bg-page 仅用于通用组件 */
background: var(--admin-scene-bg-end);
} }
.admin-content::-webkit-scrollbar { display: none; } /* Chrome/Safari/Edge */
.scene-footer { .scene-footer {
display: flex; display: flex;
@@ -656,62 +880,49 @@ function handleLogout() {
letter-spacing: 4px; letter-spacing: 4px;
} }
@media (max-width: 1450px), (max-height: 820px) { @media (max-width: 1280px), (max-height: 780px) {
.admin-scene { .admin-scene {
padding: 12px 24px 10px; padding: 12px 24px 10px;
} }
.scene-header { .scene-header {
display: grid; height: 160px;
height: 120px; grid-template-columns: minmax(480px, 1fr) auto;
grid-template-columns: minmax(520px, 1fr) auto; grid-template-rows: 92px 64px;
grid-template-rows: 66px 54px; padding-top: 14px;
column-gap: 18px;
} }
.scene-slogan { .brand-logo-wrap { width: 64px; height: 64px; }
padding-right: var(--space-3); .brand-logo-wrap img { width: 56px; height: 56px; }
} .brand-dot { display: none; }
.scene-slogan span { .brand-name-row strong { font-size: 30px; letter-spacing: 3px; }
font-size: var(--font-size-base); .brand-badge { display: none; }
} .brand-tagline { font-size: 12px; letter-spacing: 2px; }
.brand-ornament { display: none; }
.scene-slogan strong {
font-size: var(--font-size-2xl);
}
.scene-brand > img {
width: 52px;
height: 52px;
}
.scene-brand-copy strong {
font-size: var(--font-size-2xl);
}
.scene-title { .scene-title {
min-width: 310px; min-width: 280px;
margin-left: 18px; margin-left: 20px;
padding-left: 18px; padding-left: 20px;
} }
.scene-title h1 { font-size: 22px; letter-spacing: 1px; }
.title-sub { font-size: 12px; }
.title-meta { display: none; }
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */ .slogan-top { font-size: 13px; letter-spacing: 6px; }
.scene-title h1 { .slogan-main { font-size: 34px; letter-spacing: 4px; }
font-size: var(--font-size-3xl); .slogan-en { display: none; }
}
.scene-benefits { .benefit-icon { width: 34px; height: 34px; }
padding: 0 0 0 28px; .benefit-icon .el-icon { font-size: 16px; }
gap: clamp(22px, 4vw, 54px);
} .scene-benefits { gap: clamp(10px, 2vw, 28px); }
.scene-benefits b { font-size: 12px; }
.scene-benefits small { display: none; font-size: 12px; }
.admin-shell { .admin-shell {
height: calc(100vh - 170px); height: calc(100vh - 200px);
}
.scene-footer {
height: 28px;
} }
} }
@@ -49,9 +49,9 @@
<el-col :span="12"> <el-col :span="12">
<el-card shadow="never" class="inner-card"> <el-card shadow="never" class="inner-card">
<template #header><span>手工签到(特殊情形)</span></template> <template #header><span>手工签到(特殊情形)</span></template>
<el-input v-model="manualUserId" placeholder="输入用户 ID" /> <el-input v-model="manualUserNumber" placeholder="输入13位用户编号" />
<div class="btn-row"> <div class="btn-row">
<el-button type="primary" :loading="saving" :disabled="!manualUserId" <el-button type="primary" :loading="saving" :disabled="!manualUserNumber"
@click="manual">手工签到</el-button> @click="manual">手工签到</el-button>
</div> </div>
<div class="form-hint"> <div class="form-hint">
@@ -65,8 +65,8 @@
<el-card v-if="lastResult" shadow="never" class="inner-card result-card"> <el-card v-if="lastResult" shadow="never" class="inner-card result-card">
<template #header><span>最近一次签到</span></template> <template #header><span>最近一次签到</span></template>
<el-descriptions :column="2" border size="small"> <el-descriptions :column="2" border size="small">
<el-descriptions-item label="用户 ID">{{ lastResult.userId }}</el-descriptions-item> <el-descriptions-item label="用户编号">{{ lastResult.userDisplayNumber }}</el-descriptions-item>
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</el-descriptions-item> <el-descriptions-item label="活动编号">{{ lastResult.activityDisplayId }}</el-descriptions-item>
<el-descriptions-item label="本次是否为重复签到"> <el-descriptions-item label="本次是否为重复签到">
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small"> <el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }} {{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
@@ -96,7 +96,7 @@ import { get, post } from '@/api/client'
const activities = ref<any[]>([]) const activities = ref<any[]>([])
const activityId = ref('') const activityId = ref('')
const token = ref('') const token = ref('')
const manualUserId = ref('') const manualUserNumber = ref('')
const stats = ref<any>(null) const stats = ref<any>(null)
const lastResult = ref<any>(null) const lastResult = ref<any>(null)
const scanning = ref(false) const scanning = ref(false)
@@ -151,15 +151,15 @@ async function scan() {
} }
async function manual() { async function manual() {
if (!activityId.value || !manualUserId.value.trim()) return if (!activityId.value || !manualUserNumber.value.trim()) return
saving.value = true saving.value = true
try { try {
const res: any = await post( const res: any = await post(
`/activities/operator/${activityId.value}/checkin/manual`, `/activities/operator/${activityId.value}/checkin/manual`,
{ userId: manualUserId.value.trim() }, { userId: manualUserNumber.value.trim() },
authHeader()) authHeader())
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value } lastResult.value = { ...res.data, userDisplayNumber: res.data?.userDisplayNumber || manualUserNumber.value.trim(), activityDisplayId: activities.value.find((item: any) => item.id === activityId.value)?.displayId }
manualUserId.value = '' manualUserNumber.value = ''
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功') ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
await loadStats() await loadStats()
} finally { } finally {
@@ -4,13 +4,13 @@
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>活动互选管理</span> <span>活动互选管理</span>
<el-input v-model="searchActivityId" placeholder="活动ID搜索" clearable style="width:260px" /> <el-input v-model="searchActivityNumber" placeholder="活动编号搜索" clearable style="width:260px" />
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button> <el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
</div> </div>
</template> </template>
<el-table :data="tableData" v-loading="loading" border stripe> <el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="activityId" label="活动ID" width="160" /> <el-table-column prop="activityDisplayId" label="活动编号" width="160" />
<el-table-column prop="activityTitle" label="活动名称" min-width="180" /> <el-table-column prop="activityTitle" label="活动名称" min-width="180" />
<el-table-column label="双向互选对数" width="140"> <el-table-column label="双向互选对数" width="140">
<template #default="{row}">{{ row.mutualPairs }} 对</template> <template #default="{row}">{{ row.mutualPairs }} 对</template>
@@ -31,8 +31,8 @@
<div v-if="currentActivity"> <div v-if="currentActivity">
<h4>{{ currentActivity.title }} — 双向互选列表</h4> <h4>{{ currentActivity.title }} — 双向互选列表</h4>
<el-table :data="mutualList" border size="small" style="margin-top:16px"> <el-table :data="mutualList" border size="small" style="margin-top:16px">
<el-table-column prop="sourceUserId" label="表达者ID" min-width="180" /> <el-table-column prop="sourceUserDisplayNumber" label="表达者编号" min-width="180" />
<el-table-column prop="targetUserId" label="被表达者ID" min-width="180" /> <el-table-column prop="targetUserDisplayNumber" label="被表达者编号" min-width="180" />
<el-table-column prop="confirmedAt" label="确认时间" width="180" /> <el-table-column prop="confirmedAt" label="确认时间" width="180" />
</el-table> </el-table>
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" /> <el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
@@ -46,7 +46,7 @@ import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get } from '@/api/client' import { get } from '@/api/client'
const searchActivityId = ref('') const searchActivityNumber = ref('')
const loading = ref(false) const loading = ref(false)
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
const drawerVisible = ref(false) const drawerVisible = ref(false)
@@ -59,8 +59,8 @@ async function loadData() {
const token = getAccessToken() const token = getAccessToken()
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
const rows = res.data || [] const rows = res.data || []
tableData.value = searchActivityId.value tableData.value = searchActivityNumber.value
? rows.filter((row: any) => row.activityId.includes(searchActivityId.value.trim())) ? rows.filter((row: any) => (row.activityDisplayId || '').includes(searchActivityNumber.value.trim()))
: rows : rows
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -9,6 +9,7 @@
</template> </template>
<el-table :data="tableData" v-loading="loading" border stripe> <el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="displayId" label="活动编号" min-width="180" />
<el-table-column prop="title" label="活动名称" min-width="180" /> <el-table-column prop="title" label="活动名称" min-width="180" />
<el-table-column prop="activityType" label="活动类型" width="130"> <el-table-column prop="activityType" label="活动类型" width="130">
<template #default="{ row }"> <template #default="{ row }">
@@ -128,7 +129,6 @@
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px"> <el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
<h4>组织关系</h4> <h4>组织关系</h4>
<el-table :data="orgs" border size="small"> <el-table :data="orgs" border size="small">
<el-table-column prop="organizationId" label="组织ID" min-width="160" />
<el-table-column prop="name" label="名称" min-width="140" /> <el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="role" label="角色" width="120"> <el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">{{ roleText(row.role) }}</template> <template #default="{ row }">{{ roleText(row.role) }}</template>
+2 -2
View File
@@ -16,14 +16,14 @@
</template> </template>
<el-table :data="tableData" v-loading="loading" border stripe> <el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="operatorId" label="操作人ID" width="140" /> <el-table-column prop="operatorDisplayId" label="操作人编号" min-width="180" />
<el-table-column prop="action" label="操作" width="160"> <el-table-column prop="action" label="操作" width="160">
<template #default="{row}">{{ actionText(row.action) }}</template> <template #default="{row}">{{ actionText(row.action) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="resourceType" label="资源类型" width="120"> <el-table-column prop="resourceType" label="资源类型" width="120">
<template #default="{row}">{{ row.resourceType }}</template> <template #default="{row}">{{ row.resourceType }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="resourceId" label="资源ID" width="140" show-overflow-tooltip /> <el-table-column prop="resourceDisplayId" label="业务编号" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.resourceDisplayId || '未公开编号(已脱敏)' }}</template></el-table-column>
<el-table-column prop="ip" label="IP地址" width="140" /> <el-table-column prop="ip" label="IP地址" width="140" />
<el-table-column prop="createdAt" label="时间" width="180"> <el-table-column prop="createdAt" label="时间" width="180">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template> <template #default="{row}">{{ formatTime(row.createdAt) }}</template>
@@ -179,7 +179,7 @@ function isExternalLink(value: string) {
function previewTargetText(banner: any) { function previewTargetText(banner: any) {
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无' const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
if (!banner.targetValue) return dest if (!banner.targetValue) return dest
return `${dest} · ${banner.targetValue}` return `${dest} · 已配置站内目标`
} }
function assignForm(source: any) { function assignForm(source: any) {
form.value = { form.value = {
@@ -88,7 +88,7 @@
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px"> <el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
<el-form label-width="80px"> <el-form label-width="80px">
<el-form-item label="咨询师"> <el-form-item label="咨询师">
<el-input v-model="assignForm.counselorId" placeholder="请输入咨询师工号 / ID" /> <el-input v-model="assignForm.counselorNumber" placeholder="请输入13位工作人员编号" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -143,7 +143,7 @@ const assignedServices = ref<any[]>([])
const assignDialogVisible = ref(false) const assignDialogVisible = ref(false)
const followupDialogVisible = ref(false) const followupDialogVisible = ref(false)
const currentId = ref('') const currentId = ref('')
const assignForm = ref({ counselorId: '' }) const assignForm = ref({ counselorNumber: '' })
const followupForm = ref({ type: 'phone_call', content: '' }) const followupForm = ref({ type: 'phone_call', content: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = { const SERVICE_TYPE_LABEL: Record<string, string> = {
@@ -211,19 +211,19 @@ function onTabChange(name: string | number) {
function openAssign(row: any) { function openAssign(row: any) {
currentId.value = row.id currentId.value = row.id
assignForm.value.counselorId = '' assignForm.value.counselorNumber = ''
assignDialogVisible.value = true assignDialogVisible.value = true
} }
async function doAssign() { async function doAssign() {
if (!assignForm.value.counselorId) { if (!assignForm.value.counselorNumber) {
ElMessage.warning('请填写咨询师 ID') ElMessage.warning('请填写咨询师编号')
return return
} }
submitting.value = true submitting.value = true
try { try {
await post(`/counseling/services/operator/${currentId.value}/assign`, await post(`/counseling/services/operator/${currentId.value}/assign`,
{ counselorId: assignForm.value.counselorId }) { counselorId: assignForm.value.counselorNumber })
ElMessage.success('分配成功') ElMessage.success('分配成功')
assignDialogVisible.value = false assignDialogVisible.value = false
loadQueue() loadQueue()
@@ -2,11 +2,24 @@
<div class="matchmaker-management"> <div class="matchmaker-management">
<el-card> <el-card>
<template #header><div class="card-header"><span>公益红娘管理</span></div></template> <template #header><div class="card-header"><span>公益红娘管理</span></div></template>
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab" @tab-change="refresh">
<el-tab-pane 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) --> <!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services"> <el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
<el-table :data="services" stripe v-loading="loading"> <el-table :data="services" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" /> <el-table-column prop="userDisplayNumber" label="用户编号" width="190">
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
</el-table-column>
<el-table-column prop="serviceType" label="服务类型" width="160"> <el-table-column prop="serviceType" label="服务类型" width="160">
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template> <template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
</el-table-column> </el-table-column>
@@ -21,6 +34,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button> <el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button> <el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
<el-button v-if="row.status!=='submitted'" size="small" @click="openServiceDetail(row.serviceId || row.id)">查看服务</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -28,8 +42,8 @@
<el-tab-pane label="推荐列表" name="recommendations"> <el-tab-pane label="推荐列表" name="recommendations">
<el-table :data="recommendations" stripe v-loading="loading"> <el-table :data="recommendations" stripe v-loading="loading">
<el-table-column prop="userId" label="用户" width="110" /> <el-table-column prop="userDisplayNumber" label="用户" width="110" />
<el-table-column prop="targetUserId" label="推荐对象" width="110" /> <el-table-column prop="targetUserDisplayNumber" label="推荐对象" width="110" />
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip /> <el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="130"> <el-table-column prop="status" label="状态" width="130">
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template> <template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
@@ -43,7 +57,9 @@
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) --> <!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
<el-tab-pane label="服务对象分配" name="assignments"> <el-tab-pane label="服务对象分配" name="assignments">
<el-table :data="assignments" stripe v-loading="assignmentsLoading"> <el-table :data="assignments" stripe v-loading="assignmentsLoading">
<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="assignmentScope" label="范围" width="90"> <el-table-column prop="assignmentScope" label="范围" width="90">
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template> <template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
</el-table-column> </el-table-column>
@@ -59,6 +75,7 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="220"> <el-table-column label="操作" width="220">
<template #default="{ row }"> <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" type="primary" @click="openTransfer(row)">转分配</el-button>
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button> <el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button> <el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
@@ -69,8 +86,8 @@
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) --> <!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
<el-tab-pane label="联系授权审批" name="contactAuths"> <el-tab-pane label="联系授权审批" name="contactAuths">
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading"> <el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
<el-table-column prop="operatorId" label="红娘ID" width="120" /> <el-table-column prop="operatorDisplayId" label="红娘编号" width="120" />
<el-table-column prop="userId" label="用户ID" width="120" /> <el-table-column prop="userDisplayNumber" label="用户编号" width="120" />
<el-table-column prop="channel" label="渠道" width="110"> <el-table-column prop="channel" label="渠道" width="110">
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template> <template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
</el-table-column> </el-table-column>
@@ -151,8 +168,8 @@
<!-- 创建推荐弹窗 --> <!-- 创建推荐弹窗 -->
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px"> <el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
<el-form label-width="100px"> <el-form label-width="100px">
<el-form-item label="推荐给用户"><el-input v-model="recForm.userId" disabled /></el-form-item> <el-form-item label="推荐给用户"><el-input v-model="recForm.userDisplayNumber" disabled /></el-form-item>
<el-form-item label="推荐对象ID"><el-input v-model="recForm.targetUserId" placeholder="输入被推荐对象的用户ID" /></el-form-item> <el-form-item label="推荐对象编号"><el-input v-model="recForm.targetUserDisplayNumber" placeholder="输入13位用户编号" /></el-form-item>
<el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item> <el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -160,16 +177,67 @@
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button> <el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="serviceDetailVisible" title="服务详情" width="620px">
<el-descriptions v-if="serviceDetail" :column="2" border>
<el-descriptions-item label="服务编号">{{ serviceDetail.serviceDisplayId }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(serviceDetail.status) }}</el-descriptions-item>
<el-descriptions-item label="服务类型">{{ serviceTypeLabel(serviceDetail.serviceType) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(serviceDetail.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="服务需求" :span="2">{{ serviceDetail.description || '未填写' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">服务历史</el-divider>
<el-empty v-if="!serviceDetail?.followups?.length" description="暂无服务记录" />
<el-timeline v-else>
<el-timeline-item v-for="item in serviceDetail.followups" :key="item.id" :timestamp="formatDateTime(item.createdAt)">
{{ item.content }}
</el-timeline-item>
</el-timeline>
<template v-if="serviceDetail && !managementDetail">
<el-divider />
<el-button type="primary" @click="openFollowup">添加跟进记录</el-button>
<el-button
v-if="['completed', 'cancelled'].indexOf(serviceDetail.status) < 0"
type="success"
plain
@click="completeCurrentService"
>完成服务</el-button>
</template>
</el-dialog>
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="520px">
<el-form label-width="90px">
<el-form-item label="跟进方式">
<el-select v-model="followupForm.followupType" style="width: 100%">
<el-option label="电话沟通" value="phone_call" />
<el-option label="微信沟通" value="wechat" />
<el-option label="面对面" value="face_to_face" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="跟进内容" required>
<el-input v-model="followupForm.content" type="textarea" :rows="4" placeholder="记录本次服务进展" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="followupDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitFollowup">保存记录</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onActivated } from 'vue'
import { useRoute } from 'vue-router'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
const route = useRoute()
const managementDetail = ref(false)
const managedServices = ref<any[]>([])
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长) // ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
const canAssign = computed(() => { const canAssign = computed(() => {
const perms: string[] = authStore.permissions || [] const perms: string[] = authStore.permissions || []
@@ -186,6 +254,7 @@ const assignableMatchmakers = ref<any[]>([])
const assignDialogVisible = ref(false) const assignDialogVisible = ref(false)
const transferDialogVisible = ref(false) const transferDialogVisible = ref(false)
const terminateDialogVisible = ref(false) const terminateDialogVisible = ref(false)
const recDialogVisible = ref(false)
const currentServiceId = ref('') const currentServiceId = ref('')
const currentUserId = ref('') const currentUserId = ref('')
const currentAssignmentId = ref('') const currentAssignmentId = ref('')
@@ -193,7 +262,11 @@ const terminateAction = ref<'revoke' | 'end'>('revoke')
const assignForm = ref({ matchmakerOperatorId: '', reason: '' }) const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' }) const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
const terminateForm = ref({ reason: '' }) const terminateForm = ref({ reason: '' })
const recForm = ref({ userId: '', targetUserId: '', reason: '' }) const recForm = ref({ userDisplayNumber: '', targetUserDisplayNumber: '', reason: '' })
const serviceDetailVisible = ref(false)
const serviceDetail = ref<any>(null)
const followupDialogVisible = ref(false)
const followupForm = ref({ followupType: 'phone_call', content: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = { const SERVICE_TYPE_LABEL: Record<string, string> = {
recommendation: '推荐对象', referral_counseling: '转介咨询', recommendation: '推荐对象', referral_counseling: '转介咨询',
@@ -248,6 +321,49 @@ function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s } function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' } 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:受控联系授权 ===== // ===== ZC-MM-003 / B1:受控联系授权 =====
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置) // 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
const canApprove = computed(() => { const canApprove = computed(() => {
@@ -259,7 +375,13 @@ const contactAuthsLoading = ref(false)
async function loadContactAuths() { async function loadContactAuths() {
contactAuthsLoading.value = true contactAuthsLoading.value = true
try { const res: any = await get('/operator/matchmaker/contact-authorizations/my'); contactAuths.value = res?.data?.list || [] } finally { contactAuthsLoading.value = false } 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) { async function doContactApprove(row: any) {
try { try {
@@ -306,7 +428,12 @@ async function loadAssignableMatchmakers() {
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] } try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
} }
async function refresh() { async function refresh() {
if (activeTab.value === 'services') await loadServices() 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 === 'assignments') await loadAssignments()
else if (activeTab.value === 'contactAuths') await loadContactAuths() else if (activeTab.value === 'contactAuths') await loadContactAuths()
else await loadRecommendations() else await loadRecommendations()
@@ -373,21 +500,27 @@ async function doTerminate() {
function openRecommendation(row: any) { function openRecommendation(row: any) {
currentServiceId.value = row.id currentServiceId.value = row.id
recForm.value = { userId: row.userId, targetUserId: '', reason: '' } recForm.value = { userDisplayNumber: row.userDisplayNumber || '实名后生成', targetUserDisplayNumber: '', reason: '' }
recDialogVisible.value = true recDialogVisible.value = true
} }
async function doCreateRecommendation() { async function doCreateRecommendation() {
if (!recForm.value.targetUserId || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return } if (!recForm.value.targetUserDisplayNumber || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend // Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
try { try {
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, { await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
targetUserId: recForm.value.targetUserId, reason: recForm.value.reason targetUserId: recForm.value.targetUserDisplayNumber, reason: recForm.value.reason
}) })
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations() ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') } } catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
} }
onMounted(refresh) onActivated(async () => {
if (canAssign.value && typeof route.query.serviceId === 'string') {
activeTab.value = 'management'
await openServiceDetail(route.query.serviceId, true)
}
await refresh()
})
</script> </script>
<style scoped> <style scoped>
@@ -10,6 +10,7 @@
<el-table :data="tableData" v-loading="loading" border stripe> <el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="username" label="用户名" width="140" /> <el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="displayId" label="人员编号" width="150" />
<el-table-column prop="name" label="姓名" width="120" /> <el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="organizationId" label="所属机构" width="160"> <el-table-column prop="organizationId" label="所属机构" width="160">
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template> <template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
@@ -6,7 +6,7 @@
<el-table-column prop="type" label="类型" width="120"> <el-table-column prop="type" label="类型" width="120">
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template> <template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
</el-table-column> </el-table-column>
<el-table-column prop="id" label="关联ID" width="120" /> <el-table-column prop="referralDisplayId" label="转介编号" min-width="180" />
<el-table-column prop="status" label="状态" width="100"> <el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template> <template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
</el-table-column> </el-table-column>
+5 -3
View File
@@ -20,7 +20,8 @@
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending"> <el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId"> <el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip /> <el-table-column prop="displayId" label="任务编号" min-width="180" />
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="reviewType" label="审核类型" min-width="130"> <el-table-column prop="reviewType" label="审核类型" min-width="130">
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template> <template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
</el-table-column> </el-table-column>
@@ -42,7 +43,8 @@
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my"> <el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
<el-table :data="myTasks" v-loading="loading" row-key="taskId"> <el-table :data="myTasks" v-loading="loading" row-key="taskId">
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip /> <el-table-column prop="displayId" label="任务编号" min-width="180" />
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="reviewType" label="审核类型" min-width="130"> <el-table-column prop="reviewType" label="审核类型" min-width="130">
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template> <template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
</el-table-column> </el-table-column>
@@ -65,7 +67,7 @@
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div> <div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
<template v-else-if="currentTask"> <template v-else-if="currentTask">
<el-descriptions :column="1" border> <el-descriptions :column="1" border>
<el-descriptions-item label="用户编号">{{ currentTask.userId }}</el-descriptions-item> <el-descriptions-item label="用户编号">{{ currentTask.userDisplayNumber }}</el-descriptions-item>
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item> <el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item> <el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
<el-descriptions-item label="当前状态"> <el-descriptions-item label="当前状态">
@@ -25,7 +25,8 @@
<el-table :data="tableData" v-loading="loading" border stripe> <el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="reasonText" label="举报原因" width="140" /> <el-table-column prop="reasonText" label="举报原因" width="140" />
<el-table-column prop="reportedUserId" label="被举报人ID" width="140" /> <el-table-column prop="displayId" label="举报编号" min-width="180" />
<el-table-column prop="reportedUserDisplayNumber" label="被举报人编号" width="140" />
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip /> <el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100"> <el-table-column prop="status" label="状态" width="100">
<template #default="{row}"> <template #default="{row}">
@@ -11,8 +11,8 @@
</el-radio-group> </el-radio-group>
</div></template> </div></template>
<el-table :data="appointments" stripe v-loading="loading"> <el-table :data="appointments" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="100" /> <el-table-column prop="displayId" label="预约编号" min-width="180" />
<el-table-column prop="userId" label="用户ID" width="120" /> <el-table-column prop="userDisplayNumber" label="用户编号" min-width="180" />
<el-table-column prop="serviceType" label="服务类型" width="150" /> <el-table-column prop="serviceType" label="服务类型" width="150" />
<el-table-column prop="source" label="来源" width="140"> <el-table-column prop="source" label="来源" width="140">
<template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template> <template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template>
@@ -21,7 +21,7 @@
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template> <template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column> </el-table-column>
<el-table-column prop="preferredDate" label="预约日期" width="110" /> <el-table-column prop="preferredDate" label="预约日期" width="110" />
<el-table-column prop="assignedOperatorId" label="分配人员" width="100" /> <el-table-column prop="assignedOperatorDisplayId" label="分配人员" width="100" />
<el-table-column prop="createdAt" label="创建时间" width="160"> <el-table-column prop="createdAt" label="创建时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> <template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column> </el-table-column>
@@ -74,7 +74,7 @@
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item> <el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-table :data="members" stripe v-loading="memberLoading"> <el-table :data="members" stripe v-loading="memberLoading">
<el-table-column prop="userId" label="用户ID" width="200" show-overflow-tooltip /> <el-table-column prop="userDisplayNumber" label="用户编号" width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="110"> <el-table-column prop="status" label="状态" width="110">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag> <el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
@@ -19,7 +19,7 @@
<section v-else-if="user" class="surface-card detail-card"> <section v-else-if="user" class="surface-card detail-card">
<div class="identity-summary"> <div class="identity-summary">
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar> <el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div> <div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.displayNumber || '实名后生成' }}</span></div>
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" /> <el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
</div> </div>
<el-tabs v-model="activeTab" class="detail-tabs"> <el-tabs v-model="activeTab" class="detail-tabs">
@@ -42,7 +42,9 @@
</el-alert> </el-alert>
<el-table v-else :data="tableData" v-loading="loading" row-key="id"> <el-table v-else :data="tableData" v-loading="loading" row-key="id">
<el-table-column prop="id" label="用户编号" min-width="170" show-overflow-tooltip /> <el-table-column prop="displayNumber" label="用户编号" min-width="190" show-overflow-tooltip>
<template #default="{ row }">{{ row.displayNumber || '实名后生成' }}</template>
</el-table-column>
<el-table-column prop="displayName" label="显示名称" min-width="110"> <el-table-column prop="displayName" label="显示名称" min-width="110">
<template #default="{ row }">{{ row.displayName || '未设置' }}</template> <template #default="{ row }">{{ row.displayName || '未设置' }}</template>
</el-table-column> </el-table-column>
+52 -10
View File
@@ -18,6 +18,20 @@ interface ApiEnvelope<T> {
const REQUEST_TIMEOUT = 8000 const REQUEST_TIMEOUT = 8000
export function summarizeMatchmakerServices(rows: JsonRecord[]) {
const day = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' })
const today = day.format(new Date())
return {
pending: rows.filter(row => ['submitted', 'assigned', 'in_service', 'waiting_user'].includes(String(row.status))).length,
completed: rows.filter(row => row.status === 'completed').length,
total: rows.filter(row => ['createdAt', 'assignedAt', 'completedAt', 'latestFollowupAt'].some(key => {
if (!row[key]) return false
const date = new Date(String(row[key]))
return !Number.isNaN(date.getTime()) && day.format(date) === today
})).length,
}
}
const numberOf = (source: JsonRecord | undefined, key: string) => { const numberOf = (source: JsonRecord | undefined, key: string) => {
const value = source?.[key] const value = source?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : 0 return typeof value === 'number' && Number.isFinite(value) ? value : 0
@@ -45,9 +59,10 @@ const emptyMetric = (
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel { export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
return { return {
followups: [],
metrics: [ metrics: [
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'), emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'), emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/matchmaker'),
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'), emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'), emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'), emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
@@ -65,8 +80,11 @@ export function createEmptyDashboard(notice = '当前账号暂无可展示的工
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> { export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
const canViewAnalytics = permissions.includes('analytics.view') const canViewAnalytics = permissions.includes('analytics.view')
const canViewActivities = permissions.includes('activity.view') const canViewActivities = permissions.includes('activity.view')
const canViewMatchmaker = permissions.includes('matchmaker.view')
const canManageMatchmaker = permissions.includes('matchmaker.assign') || permissions.includes('matchmaker.team.assign')
const canViewReviews = permissions.includes('review.view')
const [coreResult, serviceResult, activityResult] = await Promise.allSettled([ const [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
canViewAnalytics canViewAnalytics
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT }) ? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null), : Promise.resolve(null),
@@ -76,14 +94,33 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
canViewActivities canViewActivities
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT }) ? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null), : Promise.resolve(null),
canViewMatchmaker
? client.get('/operator/matchmaker/dashboard', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null),
canViewReviews ? client.get('/operator/reviews/stats', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
canManageMatchmaker ? client.get('/operator/matchmaker/management/services', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
]) ])
const core = fulfilledData<JsonRecord>(coreResult) const core = fulfilledData<JsonRecord>(coreResult)
const service = fulfilledData<JsonRecord>(serviceResult) const service = fulfilledData<JsonRecord>(serviceResult)
const activityRows = fulfilledData<JsonRecord[]>(activityResult) const activityRows = fulfilledData<JsonRecord[]>(activityResult)
const matchmaker = fulfilledData<JsonRecord>(matchmakerResult)
const reviews = fulfilledData<JsonRecord>(reviewResult)
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
const managedStats = summarizeMatchmakerServices(managedServices || [])
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
const 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 analyticsAvailable = Boolean(core || service)
const eligibility = recordOf(service || undefined, 'localEligibility')
const counseling = recordOf(service || undefined, 'counseling') const counseling = recordOf(service || undefined, 'counseling')
const appointments = recordOf(service || undefined, 'serviceAppointments') const appointments = recordOf(service || undefined, 'serviceAppointments')
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections') const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
@@ -92,12 +129,13 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
const metrics: DashboardMetric[] = [ const metrics: DashboardMetric[] = [
{ {
key: 'pending_reviews', label: '待审核资料', value: numberOf(eligibility, 'pending'), key: 'pending_reviews', label: '待审核资料', value: numberOf(reviews || undefined, 'pending') + numberOf(reviews || undefined, 'processing'),
trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review', trendText: reviews ? '待领取与审核中资料' : '暂无授权数据', tone: 'rose', available: Boolean(reviews), route: '/review',
}, },
{ {
key: 'matchmaker_todos', label: '红娘服务待办', value: 0, key: 'matchmaker_todos', label: '红娘服务待办',
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral', value: matchmakerPending,
trendText: matchmakerAvailable ? (canManageMatchmaker ? '管理范围内待完成服务' : '本人服务对象待跟进') : '暂无授权数据', tone: 'amber', available: matchmakerAvailable, route: '/matchmaker',
}, },
{ {
key: 'counseling_todos', label: '咨询服务待办', key: 'counseling_todos', label: '咨询服务待办',
@@ -120,9 +158,10 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
] ]
const todos = ([ const todos = ([
{ key: 'review', title: '资料审核', description: '本地资格材料等待核验', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available }, { key: 'review', title: '资料审核', description: '待领取与审核中资料', count: 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: '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: '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) }, { key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
] as DashboardTodo[]).filter((item) => item.available) ] as DashboardTodo[]).filter((item) => item.available)
@@ -142,6 +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: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) },
{ key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) }, { key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) },
{ key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) }, { key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) },
{ key: 'matchmaker_completed', label: '红娘服务完成', value: matchmakerCompleted, compareText: canManageMatchmaker ? '管理范围内已完成服务' : '本人已完成服务', tone: 'amber', available: matchmakerAvailable },
] as DashboardOverviewItem[]).filter((item) => item.available) ] as DashboardOverviewItem[]).filter((item) => item.available)
const effectiveness = ([ const effectiveness = ([
@@ -152,12 +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) }, { key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
] as DashboardEffectivenessItem[]).filter((item) => item.available) ] as DashboardEffectivenessItem[]).filter((item) => item.available)
const rejectedCount = [coreResult, serviceResult, activityResult].filter((item) => item.status === 'rejected').length const rejectedCount = [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
const notice = !analyticsAvailable && !activityRows const anyData = analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
const notice = !anyData
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限') ? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '') : (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
return { return {
followups,
metrics, metrics,
todos, todos,
activities, activities,
+10
View File
@@ -37,8 +37,18 @@
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */ --admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */ --admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
--admin-coral-border: rgba(244, 95, 104, 0.22); --admin-coral-border: rgba(244, 95, 104, 0.22);
--admin-scene-bg-start: #FFEDEE;
--admin-scene-bg-mid: #FFF6F4;
--admin-scene-bg-end: #FBF5F4;
--admin-scene-glow: rgba(244, 95, 104, 0.28);
--admin-scene-glow-soft: rgba(255, 150, 170, 0.22);
--admin-scene-surface: rgba(255, 255, 255, 0.65);
--admin-scene-border: rgba(244, 95, 104, 0.18);
--admin-scene-divider: rgba(244, 95, 104, 0.30);
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */ /* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%); --gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
/* 文字用渐变(background-clip:text):比 solid 更鲜亮,用于「善缘丹阳」等大字号品牌标题 */
--gradient-brand-text: linear-gradient(135deg, #FF7D6E 0%, #E8475D 60%, #B8434A 100%);
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */ /* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%); --gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%); --gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
+1
View File
@@ -51,6 +51,7 @@ export interface DashboardEffectivenessItem {
} }
export interface DashboardModel { export interface DashboardModel {
followups: { serviceId: string; userDisplayNumber: string; content: string; createdAt: string }[]
metrics: DashboardMetric[] metrics: DashboardMetric[]
todos: DashboardTodo[] todos: DashboardTodo[]
activities: DashboardActivity[] activities: DashboardActivity[]
+30 -2
View File
@@ -106,6 +106,17 @@
</article> </article>
</section> </section>
<article v-if="dashboard.followups.length" class="dashboard-panel">
<header class="panel-header"><div><h2>最新红娘跟进记录</h2></div></header>
<div class="todo-list">
<button v-for="item in dashboard.followups" :key="item.serviceId" type="button" class="todo-row"
@click="goTo(`/matchmaker?serviceId=${encodeURIComponent(item.serviceId)}`)">
<span class="todo-copy"><strong>{{ item.userDisplayNumber }}</strong><small>{{ item.content }}</small></span>
<em>{{ formatDateTime(item.createdAt) }} · 查看服务</em>
</button>
</div>
</article>
<section class="dashboard-grid dashboard-grid-bottom"> <section class="dashboard-grid dashboard-grid-bottom">
<article class="dashboard-panel service-overview-panel"> <article class="dashboard-panel service-overview-panel">
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header> <header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
@@ -136,7 +147,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onActivated, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
@@ -277,15 +288,19 @@ async function retryLoad() {
} }
} }
onMounted(retryLoad) onActivated(retryLoad)
</script> </script>
<style scoped> <style scoped>
.dashboard-page { .dashboard-page {
display: flex;
flex-direction: column;
min-width: 0; min-width: 0;
min-height: 100%;
max-width: var(--admin-content-max-width); max-width: var(--admin-content-max-width);
margin: 0 auto; margin: 0 auto;
padding: var(--space-5) 26px 28px; padding: var(--space-5) 26px 28px;
box-sizing: border-box;
} }
.dashboard-welcome { .dashboard-welcome {
@@ -478,6 +493,19 @@ onMounted(retryLoad)
margin-top: 14px; margin-top: 14px;
} }
.dashboard-grid-top {
flex: 0 0 auto;
}
.dashboard-grid-bottom {
flex: 1;
min-height: 0;
}
.dashboard-grid-bottom .dashboard-panel {
height: 100%;
}
.dashboard-panel { .dashboard-panel {
min-width: 0; min-width: 0;
padding: 14px var(--space-4); padding: 14px var(--space-4);
+5 -3
View File
@@ -1,12 +1,12 @@
<template> <template>
<main class="login-shell"> <main class="login-shell">
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台"> <section class="brand-panel" aria-label="爱在丹阳公益婚恋服务平台">
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" /> <img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
<div class="brand-overlay"> <div class="brand-overlay">
<div class="brand-title"> <div class="brand-title">
<img :src="BRAND_LOGO" alt="" /> <img :src="BRAND_LOGO" alt="" />
<div> <div>
<strong>善缘丹阳</strong> <strong>爱在丹阳</strong>
<span>公益婚恋服务平台</span> <span>公益婚恋服务平台</span>
</div> </div>
</div> </div>
@@ -55,7 +55,7 @@
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span> <span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
</div> </div>
</div> </div>
<footer>善缘丹阳 · 公益婚恋服务平台</footer> <footer>爱在丹阳 · 公益婚恋服务平台</footer>
</section> </section>
</main> </main>
</template> </template>
@@ -135,6 +135,8 @@ async function handleLogin() {
.brand-title img { .brand-title img {
width: 54px; width: 54px;
height: 54px; height: 54px;
border-radius: 50%;
clip-path: circle(50%);
} }
.brand-title div { .brand-title div {
@@ -12,6 +12,8 @@ import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.risk.entity.RiskAction; import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper; import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig; import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,6 +48,7 @@ public class AppealService {
private final AppealMapper appealMapper; private final AppealMapper appealMapper;
private final RiskActionMapper riskActionMapper; private final RiskActionMapper riskActionMapper;
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final NotificationService notificationService; private final NotificationService notificationService;
@@ -281,6 +284,11 @@ public class AppealService {
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId()); log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
} }
// 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。
// 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
// users.account_status=banned/suspended 无人回滚。
restoreAccountStatusIfNoActiveRestriction(appeal.getUserId());
// 通知用户 // 通知用户
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT, notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
"申诉审核通过", "申诉审核通过",
@@ -371,6 +379,38 @@ public class AppealService {
return "limited"; return "limited";
} }
/**
* 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。
*
* <p>否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
* users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
*
* <p>仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。
*/
private void restoreAccountStatusIfNoActiveRestriction(String userId) {
boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream()
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())
|| Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
if (stillRestricted) {
return;
}
User user = userMapper.selectById(userId);
if (user == null) {
return;
}
String oldStatus = user.getAccountStatus();
if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus)
&& !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) {
return;
}
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。
saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}",
"{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}");
}
private void saveAuditLog(String operatorId, String action, String resourceType, private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) { String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog(); AuditLog auditLog = new AuditLog();
@@ -378,6 +418,10 @@ public class AppealService {
auditLog.setAction(action); auditLog.setAction(action);
auditLog.setResourceType(resourceType); auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId); auditLog.setResourceId(resourceId);
// 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意
// audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。
auditLog.setBeforeData(beforeData);
auditLog.setAfterData(afterData);
auditLogMapper.insert(auditLog); auditLogMapper.insert(auditLog);
} }
} }
@@ -30,4 +30,9 @@ public class AuditLog extends BaseEntity {
private String actorRoleSnapshot; private String actorRoleSnapshot;
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */ /** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
private String requestTraceId; 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);
}
}
@@ -103,4 +103,29 @@ public class AuditLogService {
} }
auditLogMapper.insert(auditLog); 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());
String ua = req.getHeader("User-Agent");
auditLog.setUserAgent(ua != null ? (ua.length() > 512 ? ua.substring(0, 512) : ua) : null);
}
auditLogMapper.insert(auditLog);
}
} }
@@ -29,6 +29,10 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
@Value("${wechat.app-id:dev-app-id}") @Value("${wechat.app-id:dev-app-id}")
private String appId; private String appId;
/** 演示环境可固定到预置用户;生产真实微信登录不使用此 Provider。 */
@Value("${wechat.mock.openid:}")
private String configuredOpenid;
// 模拟 openid 映射 // 模拟 openid 映射
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>(); private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
@@ -36,8 +40,21 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
public Map<String, String> getCodeSession(String code) { public Map<String, String> getCodeSession(String code) {
log.debug("[Mock] 微信登录 code: {}", code); log.debug("[Mock] 微信登录 code: {}", code);
// 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。
// 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。
if (code != null && code.startsWith("demo_openid_")) {
return Map.of(
"openid", code,
"unionid", "mock_unionid_" + code,
"session_key", "mock_session_key",
"appid", appId
);
}
// 模拟:相同 code 返回相同 openid // 模拟:相同 code 返回相同 openid
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8)); String openid = configuredOpenid != null && !configuredOpenid.isBlank()
? configuredOpenid
: codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
return Map.of( return Map.of(
"openid", openid, "openid", openid,
@@ -124,6 +124,7 @@ public class OperatorAuthService {
result.put("accessToken", accessToken); result.put("accessToken", accessToken);
result.put("refreshToken", refreshToken); result.put("refreshToken", refreshToken);
result.put("operatorId", operator.getId()); result.put("operatorId", operator.getId());
result.put("operatorDisplayId", operator.getDisplayId());
result.put("name", operator.getName()); result.put("name", operator.getName());
result.put("username", operator.getUsername()); result.put("username", operator.getUsername());
result.put("organizationId", operator.getOrganizationId()); result.put("organizationId", operator.getOrganizationId());
@@ -157,6 +158,7 @@ public class OperatorAuthService {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("operatorId", operator.getId()); result.put("operatorId", operator.getId());
result.put("operatorDisplayId", operator.getDisplayId());
result.put("name", operator.getName()); result.put("name", operator.getName());
result.put("username", operator.getUsername()); result.put("username", operator.getUsername());
result.put("username", operator.getUsername()); result.put("username", operator.getUsername());
@@ -204,6 +206,7 @@ public class OperatorAuthService {
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000); result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录 // 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
result.put("operatorId", operatorInfo.get("operatorId")); result.put("operatorId", operatorInfo.get("operatorId"));
result.put("operatorDisplayId", operatorInfo.get("operatorDisplayId"));
result.put("name", operatorInfo.get("name")); result.put("name", operatorInfo.get("name"));
result.put("username", operatorInfo.get("username")); result.put("username", operatorInfo.get("username"));
result.put("organizationId", operatorInfo.get("organizationId")); result.put("organizationId", operatorInfo.get("organizationId"));
@@ -0,0 +1,44 @@
package com.danyang.match.platform.chat.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 产品当前不提供用户之间的即时聊天。
*
* <p>在 HTTP 和 WebSocket 握手进入 Controller/Handler 前统一拒绝,
* 防止只隐藏小程序入口却留下可调用的通信通道。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ChatDisabledFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String context = request.getContextPath();
if (context != null && !context.isEmpty() && path.startsWith(context)) {
path = path.substring(context.length());
}
if (path.equals("/conversations") || path.startsWith("/conversations/")
|| path.equals("/ws/chat") || path.startsWith("/ws/chat/")) {
response.setStatus(HttpServletResponse.SC_GONE);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"success\":false,\"code\":\"CHAT_DISABLED\","
+ "\"message\":\"平台未提供用户之间的即时聊天功能\",\"data\":null}");
return;
}
filterChain.doFilter(request, response);
}
}
@@ -1,7 +1,5 @@
package com.danyang.match.platform.match.controller; package com.danyang.match.platform.match.controller;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.match.service.RelationService; import com.danyang.match.platform.match.service.RelationService;
import com.danyang.match.platform.match.service.RelationService.LikeResult; import com.danyang.match.platform.match.service.RelationService.LikeResult;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
@@ -11,7 +9,6 @@ import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.Data; import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -22,18 +19,16 @@ import org.springframework.web.bind.annotation.*;
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B * DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
* *
* 核心规则:INV-005 未 Mutual 不可聊天 * 核心规则:INV-005 未 Mutual 不可聊天
* 响应契约对齐 miniapp/types/index.d.ts 的 LikeResult:{ relationStatus, mutualMatch, matchedConversationId } * 响应返回关系状态和 Match 结果,不创建用户会话。
* *
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。 * 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
*/ */
@Slf4j
@RestController @RestController
@RequestMapping("/likes") @RequestMapping("/likes")
@RequiredArgsConstructor @RequiredArgsConstructor
public class LikeController { public class LikeController {
private final RelationService relationService; private final RelationService relationService;
private final ChatService chatService;
@Idempotent @Idempotent
@PostMapping @PostMapping
@@ -44,7 +39,7 @@ public class LikeController {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空"); return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
} }
LikeResult result = relationService.like(principal.id(), req.targetUserId); LikeResult result = relationService.like(principal.id(), req.targetUserId);
return ApiResponse.ok(LikeResponse.from(result, ensureConversation(result, principal.id(), req.targetUserId))); return ApiResponse.ok(LikeResponse.from(result));
} }
/** /**
@@ -62,20 +57,8 @@ public class LikeController {
/** /**
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。 * Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
* *
* - getOrCreateConversation 幂等,不会重复建会话
* - 建会话失败不应让 Like 整体失败(Like 已落库),故降级为 null,前端仅提示不跳转
*/ */
private String ensureConversation(LikeResult result, String userId, String targetUserId) { // 当前产品不提供用户间会话;Match 成功后仅展示结果和公益服务入口。
if (!result.mutual || result.match == null) return null;
try {
Conversation conv = chatService.getOrCreateConversation(result.match.getId(), userId, targetUserId);
return conv.getId();
} catch (RuntimeException e) {
log.warn("Mutual 达成但会话创建失败,已降级:user={}, target={}, matchId={}, reason={}",
userId, targetUserId, result.match.getId(), e.getMessage());
return null;
}
}
// ==================== DTO ==================== // ==================== DTO ====================
@@ -89,16 +72,14 @@ public class LikeController {
public static class LikeResponse { public static class LikeResponse {
private String relationStatus; private String relationStatus;
private boolean mutualMatch; private boolean mutualMatch;
private String matchedConversationId;
private String matchId; private String matchId;
public static LikeResponse from(LikeResult r, String matchedConversationId) { public static LikeResponse from(LikeResult r) {
LikeResponse resp = new LikeResponse(); LikeResponse resp = new LikeResponse();
resp.relationStatus = r.mutual resp.relationStatus = r.mutual
? Constants.RELATION_STATUS_MUTUAL_MATCH ? Constants.RELATION_STATUS_MUTUAL_MATCH
: Constants.RELATION_STATUS_LIKED; : Constants.RELATION_STATUS_LIKED;
resp.mutualMatch = r.mutual; resp.mutualMatch = r.mutual;
resp.matchedConversationId = matchedConversationId;
resp.matchId = r.match != null ? r.match.getId() : null; resp.matchId = r.match != null ? r.match.getId() : null;
return resp; return resp;
} }
@@ -51,7 +51,9 @@ public class MatchController {
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) { public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized(); if (principal == null) return ApiResponse.unauthorized();
List<Match> matches = matchMapper.findActiveByUser(principal.id()); List<Match> matches = matchMapper.findActiveByUser(principal.id());
List<MatchResponse> items = matches.stream().map(MatchResponse::from).collect(Collectors.toList()); List<MatchResponse> items = matches.stream()
.map(match -> MatchResponse.from(match, principal.id()))
.collect(Collectors.toList());
// 与全平台列表接口统一信封 { items, nextCursor };当前一次性返回全部,nextCursor 恒为 null // 与全平台列表接口统一信封 { items, nextCursor };当前一次性返回全部,nextCursor 恒为 null
return ApiResponse.ok(CursorPage.all(items)); return ApiResponse.ok(CursorPage.all(items));
} }
@@ -67,7 +69,7 @@ public class MatchController {
if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) { if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) {
return ApiResponse.forbidden(); return ApiResponse.forbidden();
} }
return ApiResponse.ok(MatchResponse.from(m)); return ApiResponse.ok(MatchResponse.from(m, principal.id()));
} }
@Idempotent @Idempotent
@@ -166,13 +168,13 @@ public class MatchController {
private java.time.Instant lastActivityAt; private java.time.Instant lastActivityAt;
private Integer episode; private Integer episode;
public static MatchResponse from(Match m) { public static MatchResponse from(Match m, String currentUserId) {
MatchResponse r = new MatchResponse(); MatchResponse r = new MatchResponse();
r.id = m.getId(); r.id = m.getId();
r.source = m.getSource(); r.source = m.getSource();
r.sourceId = m.getSourceId(); r.sourceId = m.getSourceId();
r.status = m.getStatus(); r.status = m.getStatus();
r.peerUserId = m.getUserAId(); // 客户端需要自己判断对方 r.peerUserId = currentUserId.equals(m.getUserAId()) ? m.getUserBId() : m.getUserAId();
r.matchedAt = m.getMatchedAt(); r.matchedAt = m.getMatchedAt();
r.lastActivityAt = m.getLastActivityAt(); r.lastActivityAt = m.getLastActivityAt();
r.episode = m.getEpisode(); r.episode = m.getEpisode();
@@ -310,7 +310,11 @@ public class RecommendationEngineService {
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY); record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setScore(java.math.BigDecimal.valueOf(score)); record.setScore(java.math.BigDecimal.valueOf(score));
record.setLevel(toLevel(score)); record.setLevel(toLevel(score));
record.setReasons(String.join(";", reasons)); // recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。
// 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会
// 触发 PostgreSQL「invalid input syntax for type json」,导致整个
// /home 聚合接口 500。改为序列化为 JSON 数组。
record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now()); record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion()); record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本) // MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -342,6 +346,16 @@ public class RecommendationEngineService {
} }
} }
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
private String writeReasonsJson(List<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/** /**
* 内部评分结果 * 内部评分结果
*/ */
@@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.service.UserProfileService; import com.danyang.match.platform.profile.service.UserProfileService;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
public class RecommendationService { public class RecommendationService {
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class); private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final MatchEligibilityService eligibilityService; private final MatchEligibilityService eligibilityService;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
@@ -61,7 +63,9 @@ public class RecommendationService {
record.setTargetUserId(targetUserId); record.setTargetUserId(targetUserId);
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY); record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setLevel(level); record.setLevel(level);
record.setReasons(new java.util.ArrayList<>(reasons).toString()); // recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON
// (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。
record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now()); record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion()); record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本) // MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -70,6 +74,16 @@ public class RecommendationService {
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level); log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
} }
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
private String writeReasonsJson(List<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/** /**
* 记录用户对推荐的响应(Like/Pass/Block) * 记录用户对推荐的响应(Like/Pass/Block)
*/ */
@@ -4,6 +4,8 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService; import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; 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 lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -31,6 +33,8 @@ import java.util.Map;
public class MatchmakerAssignmentController { public class MatchmakerAssignmentController {
private final MatchmakerAssignmentService assignmentService; private final MatchmakerAssignmentService assignmentService;
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
/** /**
* 创建分配。 * 创建分配。
@@ -132,9 +136,16 @@ public class MatchmakerAssignmentController {
} }
private Map<String, Object> assignmentView(MatchmakerAssignment a) { 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( return Map.ofEntries(
Map.entry("id", a.getId()), Map.entry("id", a.getId()),
Map.entry("userId", a.getUserId()), 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("matchmakerOperatorId", a.getMatchmakerOperatorId()),
Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()), Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()),
Map.entry("assignmentScope", a.getAssignmentScope()), Map.entry("assignmentScope", a.getAssignmentScope()),
@@ -75,6 +75,14 @@ public class MatchmakerContactAuthorizationController {
return ApiResponse.ok(contactAuthorizationService.myAuthorizations(principal.id())); 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 必填)。 */ /** 申请人或审批人撤销(reason 必填)。 */
@PostMapping("/{id}/revoke") @PostMapping("/{id}/revoke")
public ApiResponse<MatchmakerContactAuthorization> revoke( public ApiResponse<MatchmakerContactAuthorization> revoke(
@@ -54,7 +54,8 @@ public class MatchmakerController {
String serviceType = (req.serviceType == null || req.serviceType.isBlank()) String serviceType = (req.serviceType == null || req.serviceType.isBlank())
? "recommendation" : req.serviceType; ? "recommendation" : req.serviceType;
String description = req.description != null ? req.description : req.requirement; String description = req.description != null ? req.description : req.requirement;
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description)); return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description,
req.targetUserId, req.authorizationAccepted));
} }
@GetMapping("/services") @GetMapping("/services")
@@ -64,6 +65,23 @@ public class MatchmakerController {
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit)); return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
} }
@Idempotent
@PostMapping("/services/{id}/confirm")
public ApiResponse<MatchmakerServiceRecord> confirmService(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
}
@Idempotent
@PostMapping("/services/{id}/withdraw")
public ApiResponse<Void> withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
return ApiResponse.ok();
}
@GetMapping("/recommendations") @GetMapping("/recommendations")
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) { public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized(); if (principal == null) return ApiResponse.unauthorized();
@@ -103,5 +121,10 @@ public class MatchmakerController {
private String description; private String description;
/** 兼容字段:小程序约定的需求描述(requirement -> description) */ /** 兼容字段:小程序约定的需求描述(requirement -> description) */
private String requirement; private String requirement;
private String targetUserId;
private Boolean authorizationAccepted;
} }
@Data
public static class WithdrawRequest { private String reason; }
} }
@@ -26,6 +26,20 @@ import java.util.Map;
public class MatchmakerOperatorController { public class MatchmakerOperatorController {
private final MatchmakerServiceService matchmakerServiceService; private final MatchmakerServiceService matchmakerServiceService;
private final com.danyang.match.platform.matchmaker.service.MatchmakerManagementService managementService;
@GetMapping("/management/services")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> managementServices(@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(managementService.list(principal));
}
@GetMapping("/management/services/{id}")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<Map<String, Object>> managementDetail(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(managementService.detail(principal, id));
}
@GetMapping("/dashboard") @GetMapping("/dashboard")
public ApiResponse<Map<String, Object>> getDashboard( public ApiResponse<Map<String, Object>> getDashboard(
@@ -25,6 +25,10 @@ public class MatchmakerServiceRecord extends BaseEntity {
private String userId; private String userId;
private String serviceType; private String serviceType;
private String description; private String description;
private String targetUserId;
private java.time.Instant authorizationAt;
private java.time.Instant withdrawnAt;
private String withdrawReason;
private String status; private String status;
private String organizationId; private String organizationId;
private String assignedOperatorId; private String assignedOperatorId;
@@ -28,6 +28,11 @@ public interface MatchmakerContactAuthorizationMapper extends BaseMapper<Matchma
"AND deleted_at IS NULL ORDER BY created_at DESC") "AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerContactAuthorization> findByOperator(@Param("operatorId") String operatorId); 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 端同意页数据源) */ /** 用户待同意队列(pending_consent,C 端同意页数据源) */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " + @Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
"AND status = 'pending_consent' AND expires_at > NOW() AND deleted_at IS NULL " + "AND status = 'pending_consent' AND expires_at > NOW() AND deleted_at IS NULL " +
@@ -5,6 +5,7 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -12,6 +13,23 @@ import java.util.Optional;
@Mapper @Mapper
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> { public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
@Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " +
"s.description, s.target_user_id AS \"targetUserId\", s.authorization_at AS \"authorizationAt\", " +
"s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
"u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " +
"tu.display_number AS \"targetUserDisplayNumber\", " +
"(SELECT f.content FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupContent\", " +
"(SELECT f.created_at FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupAt\" " +
"FROM matchmaker_services s LEFT JOIN users u ON u.id=s.user_id " +
"LEFT JOIN users tu ON tu.id=s.target_user_id " +
"LEFT JOIN operators o ON o.id=s.assigned_operator_id AND o.deleted_at IS NULL " +
"WHERE s.deleted_at IS NULL <if test='!global'>AND o.id IS NOT NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}</if> ORDER BY s.created_at DESC</script>")
List<java.util.Map<String, Object>> findManagementServices(@Param("global") boolean global,
@Param("organizationId") String organizationId);
@Select("SELECT * FROM matchmaker_services WHERE id = #{id} AND deleted_at IS NULL LIMIT 1") @Select("SELECT * FROM matchmaker_services WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerServiceRecord> findById(@Param("id") String id); Optional<MatchmakerServiceRecord> findById(@Param("id") String id);
@@ -68,6 +86,19 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}") @Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit); List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id=#{userId} AND target_user_id=#{targetUserId} " +
"AND service_type='recommendation_assistance' AND status NOT IN ('completed','cancelled') AND deleted_at IS NULL")
long countActiveAssistance(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
@Update("UPDATE matchmaker_services SET status='cancelled', withdrawn_at=NOW(), withdraw_reason=#{reason}, updated_at=NOW() " +
"WHERE id=#{serviceId} AND user_id=#{userId} AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
int withdraw(@Param("serviceId") String serviceId, @Param("userId") String userId, @Param("reason") String reason);
/** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */
@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):该用户仍处于非终态的红娘服务数。 * 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
* 状态口径(submitted/assigned/in_service/waiting_user)。 * 状态口径(submitted/assigned/in_service/waiting_user)。
@@ -1,5 +1,7 @@
package com.danyang.match.platform.matchmaker.service; 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.AuditLog;
import com.danyang.match.platform.audit.entity.SensitiveAccessLog; import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.audit.mapper.AuditLogMapper;
@@ -14,6 +16,8 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
import com.danyang.match.platform.shared.security.DataScopeGuard; import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.danyang.match.platform.user.entity.User; import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper; import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC; import org.slf4j.MDC;
@@ -23,6 +27,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -75,6 +80,10 @@ public class ContactAuthorizationService {
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final SensitiveAccessLogMapper sensitiveAccessLogMapper; private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final OperatorMapper operatorMapper;
/** afterData JSON 序列化(静态初始化,不参与构造注入,避免测试桩连带改动)。 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// ==================== ① request:红娘发起申请 ==================== // ==================== ① request:红娘发起申请 ====================
@@ -122,7 +131,7 @@ public class ContactAuthorizationService {
auth.getId(), operatorId, userId, channel); auth.getId(), operatorId, userId, channel);
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(), saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(),
assignment != null ? assignment.getId() : null, assignment != null ? assignment.getId() : null,
"matchmaker.contact.request", "channel=" + channel + ";purpose=" + auth.getPurpose()); "matchmaker.contact.request", jsonOf("channel", channel, "purpose", auth.getPurpose()));
return auth; return auth;
} }
@@ -207,7 +216,7 @@ public class ContactAuthorizationService {
log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId); log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId);
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId, saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.approve", activeAssignmentIdOf(auth), "matchmaker.contact.approve",
"result=approved;channel=" + auth.getChannel()); jsonOf("result", "approved", "channel", auth.getChannel()));
return auth; return auth;
} }
@@ -230,7 +239,7 @@ public class ContactAuthorizationService {
log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId); log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId);
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId, saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.approve", activeAssignmentIdOf(auth), "matchmaker.contact.approve",
"result=rejected;channel=" + auth.getChannel()); jsonOf("result", "rejected", "channel", auth.getChannel()));
return auth; return auth;
} }
@@ -249,9 +258,8 @@ public class ContactAuthorizationService {
authorizationMapper.updateById(auth); authorizationMapper.updateById(auth);
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId); log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId, saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.consent", "matchmaker.contact.consent", jsonOf("channel", auth.getChannel()));
"channel=" + auth.getChannel());
return auth; return auth;
} }
@@ -266,9 +274,8 @@ public class ContactAuthorizationService {
authorizationMapper.updateById(auth); authorizationMapper.updateById(auth);
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId); log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId, saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.decline", "matchmaker.contact.decline", jsonOf("channel", auth.getChannel()));
"channel=" + auth.getChannel());
return auth; return auth;
} }
@@ -301,7 +308,7 @@ public class ContactAuthorizationService {
log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId); log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId);
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId, saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.revoke", activeAssignmentIdOf(auth), "matchmaker.contact.revoke",
"reason=" + auth.getRevokeReason()); jsonOf("reason", auth.getRevokeReason()));
return auth; return auth;
} }
@@ -347,7 +354,7 @@ public class ContactAuthorizationService {
auth.getId(), operatorId, userId, auth.getChannel()); auth.getId(), operatorId, userId, auth.getChannel());
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(), saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(),
assignment != null ? assignment.getId() : null, assignment != null ? assignment.getId() : null,
"matchmaker.contact.disclose", "channel=" + auth.getChannel()); "matchmaker.contact.disclose", jsonOf("channel", auth.getChannel()));
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("phone", phone); result.put("phone", phone);
@@ -368,6 +375,24 @@ public class ContactAuthorizationService {
return result; 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 端:我的授权列表 + 待同意队列。 */ /** C 端:我的授权列表 + 待同意队列。 */
public Map<String, Object> myAuthorizationsForUser(String userId) { public Map<String, Object> myAuthorizationsForUser(String userId) {
List<Map<String, Object>> items = authorizationMapper.findByUser(userId).stream() List<Map<String, Object>> items = authorizationMapper.findByUser(userId).stream()
@@ -418,7 +443,9 @@ public class ContactAuthorizationService {
private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) { private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) {
MatchmakerContactAuthorization auth = requireAuthorization(authId); MatchmakerContactAuthorization auth = requireAuthorization(authId);
if (!auth.getUserId().equals(userId)) { if (!auth.getUserId().equals(userId)) {
dataScopeGuard.deny(userId, RESOURCE_TYPE, authId, "仅本人可处理该授权请求"); // 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; return auth;
} }
@@ -447,4 +474,43 @@ public class ContactAuthorizationService {
auditLog.setRequestTraceId(MDC.get("requestId")); auditLog.setRequestTraceId(MDC.get("requestId"));
auditLogMapper.insert(auditLog); 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);
}
}
} }
@@ -12,6 +12,8 @@ import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard; import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
@@ -55,6 +57,7 @@ import java.util.stream.Collectors;
public class MatchmakerAssignmentService { public class MatchmakerAssignmentService {
private static final String RESOURCE_TYPE = "matchmaker_assignment"; private static final String RESOURCE_TYPE = "matchmaker_assignment";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final MatchmakerAssignmentMapper assignmentMapper; private final MatchmakerAssignmentMapper assignmentMapper;
private final MatchmakerServiceMapper serviceMapper; private final MatchmakerServiceMapper serviceMapper;
@@ -87,6 +90,20 @@ public class MatchmakerAssignmentService {
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false); "组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> { assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
// 历史服务已完成/取消时,旧分配只是残留状态,不应阻塞用户再次申请服务。
// 仍在进行中的服务继续按唯一主责约束返回 409。
MatchmakerServiceRecord linked = existing.getServiceId() == null
? null : serviceMapper.selectById(existing.getServiceId());
if (linked != null && (Constants.MATCHMAKER_STATUS_COMPLETED.equals(linked.getStatus())
|| Constants.MATCHMAKER_STATUS_CANCELLED.equals(linked.getStatus()))) {
existing.setStatus(Constants.ASSIGNMENT_STATUS_ENDED);
existing.setEndedAt(Instant.now());
existing.setEndReason("关联服务已完成或取消,自动结束旧分配");
assignmentMapper.updateById(existing);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_END, existing.getId(),
auditSummary(existing.getMatchmakerOperatorId(), existing.getAssignmentScope(), existing.getEndReason()));
return;
}
throw new BusinessException(ApiResponseCode.CONFLICT, throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配,请先转分配或撤销"); "该用户已存在生效中的主责分配,请先转分配或撤销");
}); });
@@ -388,10 +405,15 @@ public class MatchmakerAssignmentService {
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */ /** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
private String auditSummary(String matchmakerOperatorId, String scope, String reason) { private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
String summary = "matchmakerOperatorId=" + matchmakerOperatorId Map<String, String> summary = Map.of(
+ ";scope=" + scope "matchmakerOperatorId", matchmakerOperatorId == null ? "" : matchmakerOperatorId,
+ ";reason=" + (reason == null ? "" : reason); "scope", scope == null ? "" : scope,
return summary.length() > 500 ? summary.substring(0, 500) : summary; "reason", reason == null ? "" : reason);
try {
return OBJECT_MAPPER.writeValueAsString(summary);
} catch (JsonProcessingException e) {
throw new IllegalStateException("audit afterData JSON serialization failed", e);
}
} }
private void saveAudit(String operatorId, String action, String resourceId, String afterData) { private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
@@ -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;
}
}
@@ -48,6 +48,8 @@ public class MatchmakerService {
private final RelationService relationService; private final RelationService relationService;
private final MatchMapper matchMapper; private final MatchMapper matchMapper;
private final NotificationService notificationService; private final NotificationService notificationService;
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper followupMapper;
private final com.danyang.match.platform.shared.id.PublicIdMapper publicIdMapper;
// ==================== 用户端:提交红娘服务请求 ==================== // ==================== 用户端:提交红娘服务请求 ====================
@@ -62,22 +64,57 @@ public class MatchmakerService {
*/ */
@Transactional @Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) { public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
return submitServiceRequest(userId, serviceType, description, null, null);
}
@Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description,
String targetUserId, Boolean authorizationAccepted) {
boolean assistance = "recommendation_assistance".equals(serviceType);
if (assistance) {
if (targetUserId == null || targetUserId.isBlank()) throw new IllegalArgumentException("请选择需要协助的对象");
if (!Boolean.TRUE.equals(authorizationAccepted)) throw new IllegalArgumentException("请先确认授权红娘查看必要公开资料");
if (userId.equals(targetUserId)) throw new IllegalArgumentException("不能申请协助了解自己");
if (!relationService.canInteract(userId, targetUserId)) throw new IllegalStateException("该对象当前不可申请红娘协助");
if (serviceMapper.countActiveAssistance(userId, targetUserId) > 0) throw new IllegalStateException("已提交过该对象的红娘协助申请");
}
MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setId(UUID.randomUUID().toString()); svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId); svc.setUserId(userId);
svc.setServiceType(serviceType); svc.setServiceType(serviceType);
svc.setDescription(description); svc.setDescription(description);
svc.setTargetUserId(targetUserId);
if (assistance) svc.setAuthorizationAt(Instant.now());
svc.setStatus("submitted"); svc.setStatus("submitted");
serviceMapper.insert(svc); serviceMapper.insert(svc);
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType); log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
return svc; return svc;
} }
@Transactional
public void withdrawService(String serviceId, String userId, String reason) {
if (serviceMapper.withdraw(serviceId, userId, reason) != 1) throw new IllegalStateException("服务不存在或当前状态不可撤回");
}
/** 查询我的红娘服务历史 */ /** 查询我的红娘服务历史 */
public List<MatchmakerServiceRecord> myServices(String userId, int limit) { public List<MatchmakerServiceRecord> myServices(String userId, int limit) {
return serviceMapper.findByUser(userId, Math.min(limit, 50)); return serviceMapper.findByUser(userId, Math.min(limit, 50));
} }
/** 用户本人确认红娘方案,服务恢复为进行中。 */
@Transactional
public MatchmakerServiceRecord confirmService(String serviceId, String userId) {
if (serviceId == null || userId == null) {
throw new IllegalArgumentException("服务和用户不能为空");
}
int updated = serviceMapper.confirmWaitingForUser(serviceId, userId);
if (updated != 1) {
throw new IllegalStateException("服务不存在或当前状态无需确认");
}
return serviceMapper.findById(serviceId)
.orElseThrow(() -> new IllegalStateException("服务不存在"));
}
// ==================== 操作人端:分配 + 推荐 + 转介 ==================== // ==================== 操作人端:分配 + 推荐 + 转介 ====================
/** /**
@@ -157,9 +194,7 @@ public class MatchmakerService {
throw new IllegalStateException("您不是此服务的负责人"); throw new IllegalStateException("您不是此服务的负责人");
} }
// referral 记录写进 MatchmakerServiceRecord.description 的扩展 JSON // 转介建议复用跟进记录持久化,独立 ZF 编号不替用户创建申请。
// + 单独一条 Followup 记录(DB V009 已有 matchmaker_followups)
// 这里用 Map 简单记录,不引入新实体
ReferralRecord record = new ReferralRecord( ReferralRecord record = new ReferralRecord(
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
serviceId, serviceId,
@@ -171,11 +206,22 @@ public class MatchmakerService {
Instant.now() Instant.now()
); );
var followup = new com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup();
followup.setId(record.id());
followup.setServiceId(serviceId);
followup.setOperatorId(operatorId);
followup.setFollowupType("system");
followup.setContent("转介建议:" + referralLabel(referralType) + (note == null ? "" : "," + note));
followup.setCreatedAt(record.createdAt());
followupMapper.insert(followup);
String referralNumber = publicIdMapper.nextReferral(record.createdAt());
if (publicIdMapper.saveReferral(record.id(), referralNumber) != 1) throw new IllegalStateException("转介编号写入失败");
// 通知用户被转介了 // 通知用户被转介了
notificationService.send(userId, "matchmaker_referral", notificationService.send(userId, "matchmaker_referral",
"红娘为你转介了一个" + referralLabel(referralType), "红娘为你转介了一个" + referralLabel(referralType),
"公益红娘为你转介了" + referralLabel(referralType) + "公益红娘为你转介了" + referralLabel(referralType) +
"(" + (referralTarget != null ? referralTarget : "") + "),你可以在" + "(转介编号:" + referralNumber + "),你可以在" +
serviceLabel(referralType) + "页面申请。", serviceLabel(referralType) + "页面申请。",
"matchmaker_service", serviceId); "matchmaker_service", serviceId);
@@ -44,6 +44,7 @@ public class OperatorManagementService {
List<Map<String, Object>> items = operators.stream().map(op -> { List<Map<String, Object>> items = operators.stream().map(op -> {
Map<String, Object> m = new HashMap<>(); Map<String, Object> m = new HashMap<>();
m.put("id", op.getId()); m.put("id", op.getId());
m.put("displayId", op.getDisplayId());
m.put("name", op.getName()); m.put("name", op.getName());
m.put("username", op.getUsername()); m.put("username", op.getUsername());
m.put("status", op.getStatus()); m.put("status", op.getStatus());
@@ -62,6 +63,7 @@ public class OperatorManagementService {
@Transactional @Transactional
public Operator createOperator(String name, String username, String password, String organizationId) { public Operator createOperator(String name, String username, String password, String organizationId) {
Operator operator = new Operator(); Operator operator = new Operator();
operator.setId(UUID.randomUUID().toString());
operator.setName(name); operator.setName(name);
operator.setUsername(username); operator.setUsername(username);
operator.setPasswordHash(passwordEncoder.encode(password)); operator.setPasswordHash(passwordEncoder.encode(password));
@@ -54,6 +54,7 @@ public class OperatorService {
}); });
Operator operator = new Operator(); Operator operator = new Operator();
operator.setId(java.util.UUID.randomUUID().toString());
operator.setName(name); operator.setName(name);
operator.setUsername(username); operator.setUsername(username);
operator.setPasswordHash(passwordEncoder.encode(password)); operator.setPasswordHash(passwordEncoder.encode(password));
@@ -15,6 +15,8 @@ public class Operator extends BaseEntity {
private String phoneEnc; private String phoneEnc;
private String phoneHash; private String phoneHash;
private String username; private String username;
/** 对外展示编号,内部 id 仍为 UUID。 */
private String displayId;
private String passwordHash; private String passwordHash;
private String status; private String status;
private java.time.Instant lastLoginAt; private java.time.Instant lastLoginAt;
@@ -1,13 +1,15 @@
package com.danyang.match.platform.profile.entity; package com.danyang.match.platform.profile.entity;
import com.baomidou.mybatisplus.annotation.TableName; 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 com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@TableName("user_preferences") @TableName(value = "user_preferences", autoResultMap = true)
public class UserPreference extends BaseEntity { public class UserPreference extends BaseEntity {
private String userId; private String userId;
@@ -17,8 +19,10 @@ public class UserPreference extends BaseEntity {
private Integer minHeightCm; private Integer minHeightCm;
private Integer maxHeightCm; private Integer maxHeightCm;
private String heightPriority; private String heightPriority;
@TableField(typeHandler = JsonNodeTypeHandler.class)
private com.fasterxml.jackson.databind.JsonNode regionScope; private com.fasterxml.jackson.databind.JsonNode regionScope;
private String regionPriority; private String regionPriority;
@TableField(typeHandler = JsonNodeTypeHandler.class)
private com.fasterxml.jackson.databind.JsonNode maritalStatuses; private com.fasterxml.jackson.databind.JsonNode maritalStatuses;
private String maritalPriority; private String maritalPriority;
private String minEducationCode; private String minEducationCode;
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.profile.entity.UserPreference; import com.danyang.match.platform.profile.entity.UserPreference;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.ResultMap;
import java.util.Optional; import java.util.Optional;
@@ -20,5 +21,6 @@ public interface UserPreferenceMapper extends BaseMapper<UserPreference> {
* <p>{@code deleted_at IS NULL}:V037 已为 user_preferences 补上软删列。 * <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") @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); Optional<UserPreference> findByUserId(String userId);
} }
@@ -227,9 +227,10 @@ public class ReferralService {
private String getOperatorName(String operatorId) { private String getOperatorName(String operatorId) {
try { try {
var operator = operatorMapper.selectById(operatorId); var operator = operatorMapper.selectById(operatorId);
return operator != null ? operator.getName() : operatorId; return operator != null ? operator.getName() : "公益红娘";
} catch (Exception e) { } catch (Exception e) {
return operatorId; log.warn("转介操作员名称查询失败", e);
return "公益红娘";
} }
} }
@@ -251,16 +251,7 @@ public class RelationshipService {
matchMapper.insert(match); matchMapper.insert(match);
} }
// V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理 // 当前产品不提供用户间聊天;Match 仅用于结果判定和公益服务引导。
if (autoCreateConversation) {
try {
chatService.getOrCreateConversation(match.getId(), userId, targetUserId);
log.info("Match后自动创建会话: matchId={}, users={}", match.getId(), userId + "," + targetUserId);
} catch (Exception e) {
log.warn("创建会话失败,Match已创建但聊天可能不可用: matchId={}, error={}",
match.getId(), e.toString(), e);
}
}
return match; return match;
} }
@@ -286,7 +277,6 @@ public class RelationshipService {
result.put("matchReused", true); result.put("matchReused", true);
result.put("matchId", existingMatch.get().getId()); result.put("matchId", existingMatch.get().getId());
result.put("status", existingMatch.get().getStatus()); result.put("status", existingMatch.get().getStatus());
result.put("conversationAutoCreated", true);
log.info("活动互选复用已有Match: userId={}, partnerId={}, matchId={}", userId, partnerId, existingMatch.get().getId()); log.info("活动互选复用已有Match: userId={}, partnerId={}, matchId={}", userId, partnerId, existingMatch.get().getId());
return result; return result;
} }
@@ -299,20 +289,10 @@ public class RelationshipService {
// 3. 创建新 Match Episode(source=activity_mutual,不自动创建 Conversation) // 3. 创建新 Match Episode(source=activity_mutual,不自动创建 Conversation)
Match match = createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false); Match match = createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false);
// V1.1 Phase 16 修复(Gate:互选确认后双方可沟通):双确认后必须开启会话。
// 此前此处未建会话,仅靠首条消息惰性创建,导致「双方可沟通」的入口不可见。
// 现显式开启,保证互选成功后聊天入口立即可见。
try {
chatService.getOrCreateConversation(match.getId(), match.getUserAId(), match.getUserBId());
} catch (Exception e) {
log.warn("活动互选开启会话失败(Match 已创建): matchId={}, error={}", match.getId(), e.toString(), e);
}
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("matchCreated", true); result.put("matchCreated", true);
result.put("matchId", match.getId()); result.put("matchId", match.getId());
result.put("priorUnmatch", priorUnmatchExists); result.put("priorUnmatch", priorUnmatchExists);
result.put("conversationAutoCreated", true);
log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}, matchId={}", log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}, matchId={}",
userId, partnerId, priorUnmatchExists, match.getId()); userId, partnerId, priorUnmatchExists, match.getId());
return result; return result;
@@ -45,5 +45,26 @@ public class ReportController {
return ApiResponse.ok(reportService.getMyReports(userId)); return ApiResponse.ok(reportService.getMyReports(userId));
} }
/**
* 获取我的举报详情(仅举报人本人可见,含处理结果与证据签名 URL)
* GET /api/v1/risk/reports/{reportId}
*/
@GetMapping("/{reportId}")
public ApiResponse<Map<String, Object>> getMyReportDetail(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String reportId) {
return ApiResponse.ok(reportService.getMyReportDetail(principal.id(), reportId));
}
/**
* 获取举报证据直传凭证(客户端直传对象存储后,将 fileKey 随提交举报上报)
* POST /api/v1/risk/reports/evidence/upload-token
*/
@PostMapping("/evidence/upload-token")
public ApiResponse<Map<String, Object>> getEvidenceUploadToken(
@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(reportService.getEvidenceUploadToken(userId));
}
} }
@@ -3,9 +3,12 @@ package com.danyang.match.platform.risk.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog; import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditLogService;
import com.danyang.match.platform.notification.service.NotificationService; import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.risk.dto.ReportItemDTO; import com.danyang.match.platform.risk.dto.ReportItemDTO;
import com.danyang.match.platform.risk.dto.ReportProcessDTO; import com.danyang.match.platform.risk.dto.ReportProcessDTO;
import com.danyang.match.platform.risk.dto.ReportSubmitDTO; import com.danyang.match.platform.risk.dto.ReportSubmitDTO;
@@ -16,9 +19,16 @@ import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper; import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.risk.mapper.RiskEventMapper; import com.danyang.match.platform.risk.mapper.RiskEventMapper;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor; import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
@@ -30,9 +40,11 @@ import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.List; import java.util.List;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
/** /**
* 举报与风控服务 * 举报与风控服务
@@ -43,17 +55,72 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
* - 封禁需要人工复核,不能自动永久封禁 * - 封禁需要人工复核,不能自动永久封禁
*/ */
@Service @Service
@RequiredArgsConstructor
public class ReportService { public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class); private static final Logger log = LoggerFactory.getLogger(ReportService.class);
/** 补充说明字数上限(与前端 maxlength 对齐)。 */
private static final int MAX_DESCRIPTION_LENGTH = 500;
/** 证据截图上限的配置默认值(未获产品正式确认,按「DEFAULT + CONFIG」可配置,见 docs/governance/config-register)。 */
private static final int DEFAULT_MAX_EVIDENCE = 9;
/** 运营端按状态列表查询的单页上限。 */
private static final int STATUS_LIST_LIMIT = 200;
/** 高风险举报原因:必须补充文字说明。 */
private static final Set<String> HIGH_RISK_REASONS = Set.of(
Constants.REPORT_REASON_FRAUD,
Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION,
Constants.REPORT_REASON_FAKE_MARITAL_STATUS
);
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final RiskEventMapper riskEventMapper; private final RiskEventMapper riskEventMapper;
private final RiskActionMapper riskActionMapper; private final RiskActionMapper riskActionMapper;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final UserMapper userMapper;
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final AuditLogService auditLogService;
private final NotificationService notificationService; private final NotificationService notificationService;
private final ObjectMapper objectMapper;
/** 证据对象的归属/存在性校验走内网客户端。 */
private final AmazonS3 amazonS3;
/** 给客户端签发证据直传与查看签名 URL 的客户端(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
@Value("${object-storage.bucket-private}")
private String privateBucket;
@Value("${report.evidence.max-count:" + DEFAULT_MAX_EVIDENCE + "}")
private int maxEvidenceCount;
@Autowired
public ReportService(
ReportMapper reportMapper,
RiskEventMapper riskEventMapper,
RiskActionMapper riskActionMapper,
UserProfileMapper userProfileMapper,
UserMapper userMapper,
AuditLogMapper auditLogMapper,
AuditLogService auditLogService,
NotificationService notificationService,
ObjectMapper objectMapper,
@Qualifier("amazonS3") AmazonS3 amazonS3,
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
this.reportMapper = reportMapper;
this.riskEventMapper = riskEventMapper;
this.riskActionMapper = riskActionMapper;
this.userProfileMapper = userProfileMapper;
this.userMapper = userMapper;
this.auditLogMapper = auditLogMapper;
this.auditLogService = auditLogService;
this.notificationService = notificationService;
this.objectMapper = objectMapper;
this.amazonS3 = amazonS3;
this.amazonS3Signer = amazonS3Signer;
}
// 风险评分权重 // 风险评分权重
private static final Map<String, Integer> REASON_SCORES = Map.of( private static final Map<String, Integer> REASON_SCORES = Map.of(
@@ -75,10 +142,30 @@ public class ReportService {
*/ */
@Transactional @Transactional
public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) { public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) {
// 举报对象校验
if (dto.getReportedUserId() == null || dto.getReportedUserId().isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少举报对象");
}
// 不允许举报自己 // 不允许举报自己
if (reporterUserId.equals(dto.getReportedUserId())) { if (reporterUserId.equals(dto.getReportedUserId())) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己"); throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己");
} }
// 举报原因白名单(REASON_SCORES 的 key 即合法枚举)
String reasonType = dto.getReasonType();
if (reasonType == null || !REASON_SCORES.containsKey(reasonType)) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "举报原因不合法");
}
// 补充说明长度 + 高风险必填
String description = dto.getDescription() == null ? "" : dto.getDescription().trim();
if (description.length() > MAX_DESCRIPTION_LENGTH) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"补充说明过长(上限 " + MAX_DESCRIPTION_LENGTH + " 字)");
}
if (HIGH_RISK_REASONS.contains(reasonType) && description.isEmpty()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "该举报原因需要补充说明");
}
// 证据截图:校验归属与存在性(避免任意 fileKey 注入或引用未完成上传的孤儿对象)
List<String> evidenceFileIds = validateEvidence(reporterUserId, dto.getEvidenceFileIds());
// 检查是否已被封禁 // 检查是否已被封禁
List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(reporterUserId); List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(reporterUserId);
@@ -91,34 +178,35 @@ public class ReportService {
Report report = new Report(); Report report = new Report();
report.setReporterUserId(reporterUserId); report.setReporterUserId(reporterUserId);
report.setReportedUserId(dto.getReportedUserId()); report.setReportedUserId(dto.getReportedUserId());
report.setReasonType(dto.getReasonType()); report.setReasonType(reasonType);
report.setDescription(dto.getDescription()); report.setDescription(description.isEmpty() ? null : description);
if (dto.getEvidenceFileIds() != null) { // evidence_file_ids 列(TEXT)存 JSON 数组:必须序列化为合法 JSON,不能用 List.toString()
report.setEvidenceFileIds(java.util.List.of(dto.getEvidenceFileIds()).toString()); report.setEvidenceFileIds(writeEvidenceJson(evidenceFileIds));
}
report.setStatus(Constants.REPORT_STATUS_PENDING); report.setStatus(Constants.REPORT_STATUS_PENDING);
reportMapper.insert(report); reportMapper.insert(report);
// 触发风险事件:被举报用户得分增加 // 触发风险事件:被举报用户得分增加
int scoreAdd = REASON_SCORES.getOrDefault(dto.getReasonType(), 5); int scoreAdd = REASON_SCORES.getOrDefault(reasonType, 5);
RiskEvent riskEvent = new RiskEvent(); RiskEvent riskEvent = new RiskEvent();
riskEvent.setUserId(dto.getReportedUserId()); riskEvent.setUserId(dto.getReportedUserId());
riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT); riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT);
riskEvent.setScore(scoreAdd); riskEvent.setScore(scoreAdd);
riskEvent.setSource("report"); riskEvent.setSource("report");
riskEvent.setSourceId(report.getId()); riskEvent.setSourceId(report.getId());
riskEvent.setNote("举报触发,原因: " + dto.getReasonType()); riskEvent.setNote("举报触发,原因: " + reasonType);
riskEventMapper.insert(riskEvent); riskEventMapper.insert(riskEvent);
// 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充) // 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充)
if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT, if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION).contains(dto.getReasonType())) { Constants.REPORT_REASON_IMPERSONATION).contains(reasonType)) {
report.setStatus(Constants.REPORT_STATUS_PROCESSING); report.setStatus(Constants.REPORT_STATUS_PROCESSING);
reportMapper.updateById(report); reportMapper.updateById(report);
} }
// 审计 // 审计(C 端用户主体:写 subject_user_id 且 operator_id=null)
saveAuditLog(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null); // 修复:原 saveAuditLog(operatorId=userId) 会让 userId 撞 audit_logs_operator_id_fkey(→operators)
// 触发 FK violation,导致举报提交必然 500、举报→管理端举报中心链路整体不可用。
auditLogService.recordUserAction(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("reportId", report.getId()); result.put("reportId", report.getId());
@@ -135,6 +223,45 @@ public class ReportService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 用户端举报详情(仅举报人本人可见):返回处理结果与证据签名查看 URL。
* 非本人访问一律按「不存在」处理,避免泄露他人举报存在性。
*/
public Map<String, Object> getMyReportDetail(String userId, String reportId) {
Report report = reportMapper.selectById(reportId);
if (report == null || !userId.equals(report.getReporterUserId())) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
return detail;
}
/**
* 签发举报证据直传凭证(客户端直传私有桶,服务端不落库)。
* fileKey 固定以 {@code reports/{userId}/evidence/} 开头,提交举报时按此前缀校验归属。
*/
public Map<String, Object> getEvidenceUploadToken(String userId) {
String fileKey = String.format("reports/%s/evidence/%s.jpg",
userId, UUID.randomUUID().toString().replace("-", ""));
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.PUT)
.withExpiration(expiration);
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey);
token.put("uploadUrl", uploadUrl);
token.put("expiresIn", 3600);
return token;
}
// ==================== 管理端举报处理 ==================== // ==================== 管理端举报处理 ====================
/** /**
@@ -157,6 +284,13 @@ public class ReportService {
Map<String, Object> detail = new HashMap<>(); Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report)); detail.put("report", toDTO(report));
// 证据截图:向运营端返回文件标识与短时签名查看 URL(查看动作的敏感访问审计由调用方记录)
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
// 被举报人风险记录 // 被举报人风险记录
String reportedUserId = report.getReportedUserId(); String reportedUserId = report.getReportedUserId();
if (reportedUserId != null) { if (reportedUserId != null) {
@@ -215,7 +349,10 @@ public class ReportService {
} }
// 通知被举报用户 // 通知被举报用户
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote()); sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote(), reportId);
// 举报链路闭环:处置完成后通知举报人(仅告知结果,不泄露被举报人处置细节)
sendReporterResultNotification(report.getReporterUserId(), result, reportId);
// 高风险举报(婚姻状态欺诈)额外记录风险事件 // 高风险举报(婚姻状态欺诈)额外记录风险事件
if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) { if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) {
@@ -257,8 +394,20 @@ public class ReportService {
return m; return m;
}).collect(java.util.stream.Collectors.toList()); }).collect(java.util.stream.Collectors.toList());
} }
// For other statuses, return empty or implement full query // 按状态真实查询(此前固定返回空列表,导致运营端「处理中 / 已处理」页签恒为空)
return List.of(); return reportMapper.findByStatusPaged(status, STATUS_LIST_LIMIT, 0).stream().map(report -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("id", report.getId());
m.put("reasonType", report.getReasonType());
m.put("reasonText", getReasonText(report.getReasonType()));
m.put("reportedUserId", report.getReportedUserId());
m.put("status", report.getStatus());
m.put("description", report.getDescription());
m.put("createdAt", report.getCreatedAt());
m.put("processResult", report.getProcessResult());
m.put("processNote", report.getProcessNote());
return m;
}).collect(java.util.stream.Collectors.toList());
} }
public Map<String, Object> getReportStats() { public Map<String, Object> getReportStats() {
@@ -298,6 +447,83 @@ public class ReportService {
// ==================== 私有辅助方法 ==================== // ==================== 私有辅助方法 ====================
/** 校验证据文件标识:数量上限 + 归属前缀(reports/{userId}/)+ 对象确实已上传。 */
private List<String> validateEvidence(String reporterUserId, List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return List.of();
}
if (evidenceFileIds.size() > maxEvidenceCount) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"证据截图最多 " + maxEvidenceCount + " 张");
}
String prefix = "reports/" + reporterUserId + "/";
for (String key : evidenceFileIds) {
if (key == null || !key.startsWith(prefix)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的证据文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, key)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "证据文件尚未上传完成");
}
}
return evidenceFileIds;
}
/** 将证据 fileKey 列表序列化为 JSON 数组,写入 evidence_file_ids(TEXT 列)。 */
private String writeEvidenceJson(List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return "[]";
}
try {
return objectMapper.writeValueAsString(evidenceFileIds);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new ServerErrorException("证据序列化失败", e);
}
}
/** 解析 evidence_file_ids 的 JSON 数组;历史脏数据(非 JSON 数组)按空列表处理。 */
private List<String> parseEvidence(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("解析举报证据 JSON 失败,按空列表处理: {}", json);
return List.of();
}
}
/** 为证据文件签发短时(10 分钟)只读 URL,供运营端查看。 */
private String signEvidenceUrl(String fileKey) {
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10));
return amazonS3Signer.generatePresignedUrl(
new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.GET)
.withExpiration(expiration)).toString();
}
/** 取用户展示名(无资料时回退 null,前端可回退为「用户」)。 */
private String displayNameOf(String userId) {
if (userId == null) return null;
return userProfileMapper.findByUserId(userId).map(UserProfile::getDisplayName).orElse(null);
}
/** 处置完成后通知举报人(不通知被举报人处置细节,避免二次伤害/踢皮球)。 */
private void sendReporterResultNotification(String reporterUserId, String result, String reportId) {
if (reporterUserId == null) return;
String title = "举报处理结果";
String content = Constants.PROCESS_RESULT_NO_ACTION.equals(result)
? "您提交的举报经核实未发现问题,感谢您的监督。"
: "您提交的举报已处理完成,感谢您对社区安全的贡献。";
try {
// business_id 列为 UUID:用举报 id 作幂等键(不可传非 UUID 字符串)
notificationService.send(reporterUserId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"report", reportId);
} catch (Exception e) {
log.warn("举报结果通知发送失败: reporter={}", reporterUserId, e);
}
}
private void applyRiskAction(String userId, String result, String note, String operatorId) { private void applyRiskAction(String userId, String result, String note, String operatorId) {
RiskAction action = new RiskAction(); RiskAction action = new RiskAction();
action.setUserId(userId); action.setUserId(userId);
@@ -329,6 +555,34 @@ public class ReportService {
action.setEffectiveAt(Instant.now()); action.setEffectiveAt(Instant.now());
action.setReasonDetail(note); action.setReasonDetail(note);
riskActionMapper.insert(action); riskActionMapper.insert(action);
// 同步账号状态:封禁 / 暂停必须落到 users.account_status,否则处置不真正生效 ——
// 登录门禁(AuthService.checkAccountStatus)、推荐引擎、匹配资格、能力判定
// 全部以 users.account_status 为准,只写 risk_actions 会让封禁沦为"一条记录"。
String newAccountStatus = switch (result) {
case Constants.PROCESS_RESULT_BAN -> Constants.ACCOUNT_STATUS_BANNED;
case Constants.PROCESS_RESULT_TEMPORARY_SUSPEND -> Constants.ACCOUNT_STATUS_SUSPENDED;
default -> null; // warning / feature_limit 不改变账号状态
};
if (newAccountStatus != null) {
syncAccountStatus(userId, newAccountStatus, operatorId);
}
}
/**
* 把处置结果写入 users.account_status 并留痕。
* 注销(closed)态不属风控处置范围,不可被覆盖;相同状态不重复写。
*/
private void syncAccountStatus(String userId, String newStatus, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) return;
String oldStatus = user.getAccountStatus();
if (newStatus.equals(oldStatus) || Constants.ACCOUNT_STATUS_CLOSED.equals(oldStatus)) return;
user.setAccountStatus(newStatus);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串(如 normal)会被 PG 拒绝。
saveAuditLog(operatorId, "ACCOUNT_STATUS_CHANGE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}", "{\"accountStatus\":\"" + newStatus + "\"}");
} }
private String calculateRiskLevel(int score) { private String calculateRiskLevel(int score) {
@@ -351,6 +605,8 @@ public class ReportService {
dto.setProcessNote(report.getProcessNote()); dto.setProcessNote(report.getProcessNote());
// 填充显示名称 // 填充显示名称
dto.setReasonText(getReasonText(report.getReasonType())); dto.setReasonText(getReasonText(report.getReasonType()));
dto.setReporterName(displayNameOf(report.getReporterUserId()));
dto.setReportedName(displayNameOf(report.getReportedUserId()));
return dto; return dto;
} }
@@ -368,7 +624,7 @@ public class ReportService {
}; };
} }
private void sendProcessNotification(String userId, String result, String note) { private void sendProcessNotification(String userId, String result, String note, String businessId) {
if (userId == null) return; if (userId == null) return;
String title = "账号处置通知"; String title = "账号处置通知";
String content = switch (result) { String content = switch (result) {
@@ -378,8 +634,9 @@ public class ReportService {
case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。"; case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。";
default -> "您的账号相关处置已完成。"; default -> "您的账号相关处置已完成。";
}; };
// business_id 列为 UUID:用被处置举报 id 作幂等键(历史 defect:曾传非 UUID 字符串导致处置 500,事务回滚)
notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content, notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"risk_action", null, "risk_action:" + userId + ":" + System.currentTimeMillis()); "risk_action", businessId);
} }
private void saveAuditLog(String operatorId, String action, String resourceType, private void saveAuditLog(String operatorId, String action, String resourceType,
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
*/ */
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
@org.springframework.core.annotation.Order(0)
public class ApiResponseStatusAdvice implements ResponseBodyAdvice<ApiResponse> { public class ApiResponseStatusAdvice implements ResponseBodyAdvice<ApiResponse> {
private static final Map<String, ApiResponseCode> BY_CODE = private static final Map<String, ApiResponseCode> BY_CODE =
@@ -65,7 +65,8 @@ public class SecurityConfig {
.accessDeniedHandler(forbiddenHandler(objectMapper))) .accessDeniedHandler(forbiddenHandler(objectMapper)))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// 公开接口(order=0 最高优先级) // 公开接口(order=0 最高优先级)
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login").permitAll() .requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login")
.permitAll()
.requestMatchers("/health/**").permitAll() .requestMatchers("/health/**").permitAll()
.requestMatchers("/version").permitAll() .requestMatchers("/version").permitAll()
.requestMatchers("/app/config").permitAll() .requestMatchers("/app/config").permitAll()
@@ -100,7 +101,9 @@ public class SecurityConfig {
"/activities/operator/**", "/activities/operator/**",
"/service-appointments/operator/**", "/service-appointments/operator/**",
"/service-programs/operator/**", "/service-programs/operator/**",
"/referrals/operator/**") "/referrals/operator/**",
"/referrals/service-station",
"/referrals/activity")
.hasRole("OPERATOR") .hasRole("OPERATOR")
/** /**
* 其余接口必须持用户身份(ROLE_USER),不再只是"已认证"。 * 其余接口必须持用户身份(ROLE_USER),不再只是"已认证"。
@@ -39,11 +39,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private static final List<String> PERMITTED_PATHS = List.of( private static final List<String> PERMITTED_PATHS = List.of(
"/api/v1/auth/**", "/api/v1/auth/**",
"/auth/**",
"/api/v1/health/**", "/api/v1/health/**",
"/health/**",
"/api/v1/version", "/api/v1/version",
"/version",
"/api/v1/operator/auth/**", "/api/v1/operator/auth/**",
"/api/v1/app/**", "/api/v1/app/**",
"/api/v1/activities" "/app/**",
"/api/v1/activities",
"/activities"
); );
@Override @Override
@@ -51,11 +56,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
FilterChain chain) throws ServletException, IOException { FilterChain chain) throws ServletException, IOException {
String contextPath = request.getContextPath(); String contextPath = request.getContextPath();
String servletPath = request.getServletPath(); String servletPath = request.getServletPath();
if (servletPath == null || servletPath.isEmpty()) {
servletPath = request.getRequestURI();
}
String effectivePath = (contextPath != null && !contextPath.isEmpty()) String effectivePath = (contextPath != null && !contextPath.isEmpty())
? contextPath + servletPath : servletPath; ? contextPath + servletPath : servletPath;
String method = request.getMethod(); String method = request.getMethod();
if ("OPTIONS".equals(method) || isPermittedPath(effectivePath)) { if ("OPTIONS".equals(method)
|| isPermittedPath(effectivePath)
|| isPermittedPath(request.getRequestURI())) {
chain.doFilter(request, response); chain.doFilter(request, response);
return; return;
} }
@@ -78,7 +88,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
} }
} }
if (token == null) { if (token == null) {
chain.doFilter(request, response); sendUnauthorized(response);
return; return;
} }
if (!jwtTokenService.validateToken(token)) { if (!jwtTokenService.validateToken(token)) {
@@ -136,7 +146,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
} }
private boolean isPermittedPath(String path) { private boolean isPermittedPath(String path) {
String normalizedPath = path.startsWith("/api/v1")
? path
: "/api/v1" + (path.startsWith("/") ? path : "/" + path);
return PERMITTED_PATHS.stream() return PERMITTED_PATHS.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path)); .anyMatch(pattern -> pathMatcher.match(pattern, path)
|| pathMatcher.match(pattern, normalizedPath));
} }
} }
@@ -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());
}
}
}
@@ -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;
}
}
@@ -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.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; 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 com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -21,6 +22,7 @@ import java.util.UUID;
public class AuthorizationAuditListener { public class AuthorizationAuditListener {
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final AuditDenyWriter auditDenyWriter;
@EventListener @EventListener
public void onAuthorizationDenied(AuthorizationDeniedEvent<?> event) { public void onAuthorizationDenied(AuthorizationDeniedEvent<?> event) {
@@ -46,7 +48,8 @@ public class AuthorizationAuditListener {
} }
try { try {
auditLogMapper.insert(audit); // 拒绝审计独立事务提交:@PreAuthorize 拒绝若发生在业务事务内,同事务写入会被回滚吞掉
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.error("写入授权拒绝审计失败: operatorId={}", principal.id(), exception); log.error("写入授权拒绝审计失败: operatorId={}", principal.id(), exception);
} }
@@ -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.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; 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.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -22,6 +23,7 @@ import org.slf4j.MDC;
public class DataScopeGuard { public class DataScopeGuard {
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final AuditDenyWriter auditDenyWriter;
public void deny(String operatorId, String resourceType, String resourceId, String message) { public void deny(String operatorId, String resourceType, String resourceId, String message) {
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写 // ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写
@@ -48,7 +50,8 @@ public class DataScopeGuard {
audit.setRequestTraceId(MDC.get("requestId")); audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now()); audit.setCreatedAt(Instant.now());
try { try {
auditLogMapper.insert(audit); // 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}", log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception); operatorId, resourceType, resourceId, exception);
@@ -81,7 +84,8 @@ public class DataScopeGuard {
audit.setRequestTraceId(MDC.get("requestId")); audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now()); audit.setCreatedAt(Instant.now());
try { try {
auditLogMapper.insert(audit); // 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}", log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception); operatorId, resourceType, resourceId, exception);
@@ -89,6 +93,31 @@ public class DataScopeGuard {
throw new BusinessException(ApiResponseCode.NOT_FOUND, message); 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} 同口径): * 当前操作者权限快照(与 {@link AuthorizationAuditListener} 同口径):
* OperatorPrincipal.permissions() 逗号拼接,截断 128。 * OperatorPrincipal.permissions() 逗号拼接,截断 128。
@@ -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 { public class UserDetailDTO {
private String id; private String id;
private String displayNumber;
private String displayName; private String displayName;
private String gender; private String gender;
private Integer age; private Integer age;
@@ -34,6 +35,8 @@ public class UserDetailDTO {
public String getId() { return id; } public String getId() { return id; }
public void setId(String id) { this.id = 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 String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; } public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getGender() { return gender; } public String getGender() { return gender; }
@@ -5,6 +5,7 @@ package com.danyang.match.platform.user.dto;
*/ */
public class UserListItemDTO { public class UserListItemDTO {
private String id; private String id;
private String displayNumber;
private String displayName; private String displayName;
private String gender; private String gender;
private Integer age; private Integer age;
@@ -19,6 +20,8 @@ public class UserListItemDTO {
public String getId() { return id; } public String getId() { return id; }
public void setId(String id) { this.id = 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 String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; } public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getGender() { return gender; } public String getGender() { return gender; }
@@ -19,6 +19,8 @@ public class User extends BaseEntity {
@TableField("phone") @TableField("phone")
private String phone; private String phone;
@TableField("display_number")
private String displayNumber;
@TableField("account_status") @TableField("account_status")
private String accountStatus; private String accountStatus;
@@ -55,6 +55,7 @@ public class UserService {
if (keyword != null && !keyword.isBlank()) { if (keyword != null && !keyword.isBlank()) {
query.and(q -> q.like(User::getId, keyword) query.and(q -> q.like(User::getId, keyword)
.or().like(User::getDisplayNumber, keyword)
.or().like(User::getWechatOpenid, keyword)); .or().like(User::getWechatOpenid, keyword));
} }
if (accountStatus != null && !accountStatus.isBlank()) { if (accountStatus != null && !accountStatus.isBlank()) {
@@ -71,7 +72,7 @@ public class UserService {
} }
LambdaQueryWrapper<User> countQuery = new LambdaQueryWrapper<User>(); 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 (accountStatus != null && !accountStatus.isBlank()) countQuery.eq(User::getAccountStatus, accountStatus);
if (profileStatus != null && !profileStatus.isBlank()) { if (profileStatus != null && !profileStatus.isBlank()) {
List<String> userIds = userProfileMapper.selectList( List<String> userIds = userProfileMapper.selectList(
@@ -155,6 +156,7 @@ public class UserService {
private UserListItemDTO toListItemDTO(User user) { private UserListItemDTO toListItemDTO(User user) {
UserListItemDTO dto = new UserListItemDTO(); UserListItemDTO dto = new UserListItemDTO();
dto.setId(user.getId()); dto.setId(user.getId());
dto.setDisplayNumber(user.getDisplayNumber());
dto.setAccountStatus(user.getAccountStatus()); dto.setAccountStatus(user.getAccountStatus());
dto.setRegisteredAt(user.getCreatedAt()); dto.setRegisteredAt(user.getCreatedAt());
dto.setRelationshipStatus(user.getRelationshipStatus()); dto.setRelationshipStatus(user.getRelationshipStatus());
@@ -189,6 +191,7 @@ public class UserService {
int reportCount, boolean hasSensitiveView) { int reportCount, boolean hasSensitiveView) {
UserDetailDTO dto = new UserDetailDTO(); UserDetailDTO dto = new UserDetailDTO();
dto.setId(user.getId()); dto.setId(user.getId());
dto.setDisplayNumber(user.getDisplayNumber());
dto.setAccountStatus(user.getAccountStatus()); dto.setAccountStatus(user.getAccountStatus());
dto.setRelationshipStatus(user.getRelationshipStatus()); dto.setRelationshipStatus(user.getRelationshipStatus());
dto.setRegisteredAt(user.getCreatedAt()); dto.setRegisteredAt(user.getCreatedAt());
@@ -7,10 +7,22 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.util.Optional; import java.util.Optional;
import java.time.Instant;
@Mapper @Mapper
public interface UserIdentityMapper extends BaseMapper<UserIdentity> { 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") @Select("SELECT * FROM user_identities WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
Optional<UserIdentity> findByUserId(@Param("userId") String userId); Optional<UserIdentity> findByUserId(@Param("userId") String userId);
@@ -119,6 +119,8 @@ public class IdentityVerificationService {
identity.setVerifiedAt(java.time.Instant.now()); identity.setVerifiedAt(java.time.Instant.now());
userIdentityMapper.insert(identity); userIdentityMapper.insert(identity);
// V060 在实名记录写入事务中通过数据库原子计数器生成展示编号。
// 11. 更新用户表的认证状态标记 // 11. 更新用户表的认证状态标记
saveAuditLog(userId, null, "IDENTITY_VERIFY", "user", userId, null, null); saveAuditLog(userId, null, "IDENTITY_VERIFY", "user", userId, null, null);
log.info("用户实名认证成功: userId={}, age={}, gender={}", userId, age, gender); log.info("用户实名认证成功: userId={}, age={}, gender={}", userId, age, gender);
@@ -16,6 +16,8 @@ spring:
wechat: wechat:
app-id: ${WECHAT_APP_ID:} app-id: ${WECHAT_APP_ID:}
app-secret: ${WECHAT_APP_SECRET:} app-secret: ${WECHAT_APP_SECRET:}
mock:
openid: ${WECHAT_MOCK_OPENID:}
encryption: encryption:
key: ${ENCRYPTION_KEY} key: ${ENCRYPTION_KEY}
@@ -2,7 +2,6 @@
-- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL) -- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL)
-- ============================================================ -- ============================================================
BEGIN; BEGIN;
SET session_replication_role = 'replica';
-- 1. 组织 -- 1. 组织
INSERT INTO organizations (id, name, type, contact_name, status) VALUES INSERT INTO organizations (id, name, type, contact_name, status) VALUES
@@ -11,27 +10,27 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
(gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active'); (gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active');
-- 2. 工作人员(V1.0原有 + V1.1新增) -- 2. 工作人员(V1.0原有 + V1.1新增)
INSERT INTO operators (id, organization_id, name, username, password_hash, status) INSERT INTO operators (id, organization_id, display_id, name, username, password_hash, status)
SELECT gen_random_uuid(), id, '陈主任', 'admin', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019001', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation' FROM organizations WHERE type='women_federation'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '红娘小王', 'wanghongniang', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019002', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation' FROM organizations WHERE type='women_federation'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '审核员小张', 'reviewer01', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019003', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center' FROM organizations WHERE type='charity_center'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '活动管理员赵', 'activitymgr','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019004', '活动管理员赵', 'activitymgr','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='trade_union' FROM organizations WHERE type='trade_union'
-- V1.1 新增演示操作员 -- V1.1 新增演示操作员
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '服务站小李', 'stationop', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019005', '服务站小李', 'stationop', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation' FROM organizations WHERE type='women_federation'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '咨询师小周', 'counselor', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019006', '咨询师小周', 'counselor', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center' FROM organizations WHERE type='charity_center'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '内容编辑小吴', 'contenteditor','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019007', '内容编辑小吴', 'contenteditor','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'; FROM organizations WHERE type='women_federation';
-- 操作员角色绑定(V1.0原有 + V1.1新增) -- 操作员角色绑定(V1.0原有 + V1.1新增)
@@ -211,10 +210,11 @@ SELECT gen_random_uuid(), id, 'photos/demo/'||alias||'/lifestyle1.jpg', 'lifesty
-- ============================================================ -- ============================================================
-- 4. 审核任务 -- 4. 审核任务
-- ============================================================ -- ============================================================
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version) INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version, display_id)
SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved', SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved',
(SELECT id FROM operators WHERE username='reviewer01'), (SELECT id FROM operators WHERE username='reviewer01'),
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1 NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1,
'RW'||LPAD(ROW_NUMBER() OVER (ORDER BY ud.alias)::text, 11, '0')
FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05'); FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05');
-- ============================================================ -- ============================================================
@@ -331,17 +331,17 @@ VALUES
WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1), WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1),
trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1), trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1),
mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1) mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1)
INSERT INTO activities (id, title, description, organizer_org_id, coorganizer_text, INSERT INTO activities (id, title, display_id, description, organizer_org_id, coorganizer_text,
start_at, end_at, location_name, location_address, start_at, end_at, location_name, location_address,
male_limit, female_limit, total_limit, min_age, max_age, male_limit, female_limit, total_limit, min_age, max_age,
registration_start_at, registration_end_at, need_review, status, created_by) registration_start_at, registration_end_at, need_review, status, created_by)
VALUES VALUES
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', (gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', 'HD20261019001',
'面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。', '面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。',
(SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会', (SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会',
'2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号', '2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号',
15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)), 15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)),
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你', (gen_random_uuid(), '七夕特别活动·丹阳缘来是你', 'HD20261019002',
'七夕节特别策划,浪漫主题相亲活动。', '七夕节特别策划,浪漫主题相亲活动。',
(SELECT id FROM trade_org), '丹阳市新时代文明实践中心', (SELECT id FROM trade_org), '丹阳市新时代文明实践中心',
'2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号', '2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号',
@@ -442,7 +442,6 @@ VALUES
('manual_review_needed', '需人工复核', 'quality', true, 7) ('manual_review_needed', '需人工复核', 'quality', true, 7)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
SET session_replication_role = 'origin';
DROP TABLE IF EXISTS _conv_result; DROP TABLE IF EXISTS _conv_result;
-- ============================================================ -- ============================================================
@@ -0,0 +1,487 @@
-- ============================================================
-- 丹阳公益婚恋服务平台 — V1.1 增量演示数据(单事务,纯 SQL)
-- ============================================================
-- 目标:为 V1.1 内容/服务域补齐演示数据,使 H5 演示端以下页面不再空白:
-- home_banners(首页轮播)、content_categories / content_articles(婚恋课堂+反诈)、
-- service_locations(善缘服务站)、service_programs(专项公益服务项目)、
-- counseling_services(婚恋咨询 + 婚前辅导)、user_local_eligibilities(本地资格),
-- 以及首页「今日推荐」所需的双向 Required 兼容候选对象(见第 10 节)。
--
-- 约束(与任务一致):
-- 1. 不改任何 Flyway 迁移 / Java / 前端源码;本文件位于 db/ 而非 db/migration/。
-- 2. 可重复执行(幂等):固定 UUID 字面量 + ON CONFLICT (id) DO NOTHING,
-- 唯一约束冲突处用 WHERE NOT EXISTS 守卫。
-- 3. 不使用 SET session_replication_role;所有 NOT NULL 列显式赋值。
-- 4. 外键用子查询解析既有演示用户 / 操作员 / 组织。
-- ============================================================
BEGIN;
-- ============================================================
-- 1. 首页轮播 home_banners(追加 3 条;现有 3 条保持不动)
-- target_type 仅限内部跳转,禁止外部 URL(AGENTS 第 16 条)
-- created_by 引用既有操作员 contenteditor
-- ============================================================
INSERT INTO home_banners
(id, title, subtitle, button_text, target_type, target_value,
audience_scope, sort_order, start_at, end_at, status, created_by)
VALUES
('a1111111-1111-1111-1111-111111110001'::uuid,
'婚前辅导公益开放报名', '专业婚前辅导,帮助你们更从容地走向未来', '了解婚前辅导',
'premarital', NULL, 'all', 4, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110002'::uuid,
'婚恋反诈 · 守护你的真心', '识别常见情感骗局,远离财产与隐私陷阱', '查看反诈指南',
'article', 'a3333333-3333-3333-3333-333333330004', 'all', 5,
NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110003'::uuid,
'婚恋咨询可以预约啦', '一对一的公益婚恋咨询服务,为你答疑解惑', '预约咨询',
'counseling', NULL, 'all', 6, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 2. 婚恋课堂分类 content_categories(4 个)
-- 生产已由 V027 播过同名 code(marriage_class / anti_fraud / premarital_edu / custom_culture),
-- 此处在缺失时补种;用 WHERE NOT EXISTS 守卫保证幂等,不覆盖既有行。
-- ============================================================
INSERT INTO content_categories (id, name, code, description, sort_order, status)
SELECT v.id, v.name, v.code, v.description, v.sort_order, 'active'
FROM (VALUES
('a2222222-2222-2222-2222-222222220001'::uuid, '婚恋课堂', 'marriage_class',
'恋爱沟通、关系经营与择偶知识', 1),
('a2222222-2222-2222-2222-222222220002'::uuid, '婚恋反诈', 'anti_fraud',
'婚恋诈骗识别与防范知识', 2),
('a2222222-2222-2222-2222-222222220003'::uuid, '婚前辅导', 'premarital_edu',
'婚前准备与关系建立指导', 3),
('a2222222-2222-2222-2222-222222220004'::uuid, '婚俗新风', 'custom_culture',
'传统婚俗与文明新风尚', 4)
) AS v(id, name, code, description, sort_order)
WHERE NOT EXISTS (SELECT 1 FROM content_categories c WHERE c.code = v.code);
-- ============================================================
-- 3. 婚恋课堂文章 content_articles(7 篇,含 2 篇反诈主题)
-- status = published 且 publish_at <= NOW()(公开查询 findPublishedAll 的条件)
-- category_id 按 code 子查询解析;author_operator_id 引用 contenteditor
-- ============================================================
INSERT INTO content_articles
(id, category_id, title, summary, content, author_operator_id, status, publish_at, view_count, sort_order)
VALUES
('a3333333-3333-3333-3333-333333330001'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'初次见面聊什么?五个不冷场的话题',
'第一次见面不必紧张,从兴趣、家乡、工作日常聊起,自然又真诚。',
'很多人在第一次见面时最怕冷场。其实不必刻意寻找“金句”,从彼此的兴趣、家乡、工作日常聊起,就能自然展开。'
|| E'\n\n' || '一、聊聊兴趣爱好:最近在看的书、在练的运动、在追的剧,都能引出一段轻松对话。'
|| E'\n' || '二、聊聊家乡与成长:丹阳本地人可以从熟悉的地方小吃、老街区聊起。'
|| E'\n' || '三、聊聊工作日常:关注感受而非收入数字,避免变成互相盘问。'
|| E'\n' || '四、聊聊对未来的期待:让对方看到你的真诚与计划。'
|| E'\n' || '五、聊聊最近的开心事:好情绪能迅速拉近距离。'
|| E'\n\n' || '记住:一次见面能愉快结束,就已经是一次成功的相识。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-01T10:00:00Z', 186, 1),
('a3333333-3333-3333-3333-333333330002'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'如何判断两个人是否合适',
'合适的两个人,不是条件清单全打勾,而是价值观相似、沟通舒服、节奏一致。',
'判断两个人是否合适,可以从三个维度观察。'
|| E'\n\n' || '一、价值观是否相似:对家庭、金钱、事业、生活方式的根本看法越接近,长期相处越省力。'
|| E'\n' || '二、沟通是否舒服:有分歧时能不能把话说完、愿不愿意换位理解,比从不吵架更重要。'
|| E'\n' || '三、节奏是否一致:对关系推进速度、见面频率的期待相差太远,容易积累失望。'
|| E'\n\n' || '合适的感情会让人感到安心,而不是长期紧绷。如果相处总让你自我怀疑,就要认真审视了。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-12T10:00:00Z', 154, 2),
('a3333333-3333-3333-3333-333333330003'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'从喜欢到结婚:健康关系的发展节奏',
'好的感情有其自然节奏:相识、了解、确定关系、见家长、谈婚论嫁,每一步都值得认真对待。',
'从喜欢到结婚,往往要经历几个自然阶段。'
|| E'\n\n' || '相识期:通过平台、活动或朋友认识,先建立基本的信任。'
|| E'\n' || '了解期:在多次见面和沟通中,观察对方真实的生活状态与处事方式。'
|| E'\n' || '确定期:双方对关系的定位达成一致,明确彼此是唯一的选择。'
|| E'\n' || '成长期:一起面对家庭、工作与现实问题,检验是否适合长期共同生活。'
|| E'\n' || '决策期:讨论婚礼、住房、育儿等议题,走向婚姻。'
|| E'\n\n' || '不要因为年龄焦虑而跳过了解期,也不要因为害怕而迟迟不肯确认。健康的节奏,是双方都舒服又都在向前走。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-05T10:00:00Z', 97, 3),
('a3333333-3333-3333-3333-333333330004'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'婚恋反诈指南:识别常见情感骗局',
'刚认识就谈投资、频繁借钱、索取隐私资料,都是危险信号。保护好自己的真心和钱包。',
'婚恋交友中,以下情况需要高度警惕。'
|| E'\n\n' || '一、短期内的经济诉求:以投资理财、博彩平台、工程项目为由,拉你一起“赚钱”。'
|| E'\n' || '二、反复借款:以生病、周转、被冻结为由不断借钱,甚至教你如何贷款。'
|| E'\n' || '三、索取隐私资料:催你脱离平台联系,索要银行卡、验证码、身份证信息。'
|| E'\n' || '四、冒充平台人员:谎称缴纳认证金、保证金、解冻费。'
|| E'\n\n' || '一旦遇到上述情形:立即停止资金操作,保存聊天与转账证据,及时通过平台举报,紧急情况拨打 110。'
|| E'\n\n' || '请记住:正规公益平台不会以任何名义向你收取费用,实名认证和人工审核也不会要求你转账。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-10T10:00:00Z', 260, 1),
('a3333333-3333-3333-3333-333333330005'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'「杀猪盘」套路全解析',
'温柔人设、投资话术、逐步加码——「杀猪盘」的每一步都有迹可循。',
'「杀猪盘」是婚恋诈骗中常见的一类,套路通常分四步。'
|| E'\n\n' || '第一步:建立人设。对方往往形象、职业、谈吐都很完美,且对你的生活格外“上心”。'
|| E'\n' || '第二步:迅速升温。短时间内表达强烈的爱与依赖,推动关系快速确定。'
|| E'\n' || '第三步:引入投资。以“内部消息”“稳赚不赔”为诱饵,让你注册陌生平台并小额获利尝甜头。'
|| E'\n' || '第四步:加大投入后收割。诱导你投入更多资金,随后平台无法提现、对方失联。'
|| E'\n\n' || '防范要点:爱情里没有人会急着带你“赚钱”;凡是引导转账、下载陌生 App 的,立即停止并举报。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-20T10:00:00Z', 213, 2),
('a3333333-3333-3333-3333-333333330006'::uuid,
(SELECT id FROM content_categories WHERE code = 'premarital_edu'),
'婚前一定要聊清楚的十个问题',
'在走进婚姻之前,把金钱、家务、父母、育儿等关键问题谈清楚,是对彼此负责。',
'走进婚姻前,建议和对方认真聊清以下问题。'
|| E'\n\n' || '一、金钱观:收入如何管理?大额支出怎么商量?'
|| E'\n' || '二、婚后居住:和父母同住还是独立居住?'
|| E'\n' || '三、家务分工:如何共同承担家庭事务?'
|| E'\n' || '四、生育计划:要不要孩子、何时要、如何教育?'
|| E'\n' || '五、双方父母:如何平衡对各自家庭的照顾?'
|| E'\n' || '六、职业规划:是否支持对方的职业发展?'
|| E'\n' || '七、冲突处理:吵架时如何避免伤害对方?'
|| E'\n' || '八、双方底线:哪些事情无法接受?'
|| E'\n' || '九、家庭应急:突发状况如何共同应对?'
|| E'\n' || '十、对婚姻的期待:各自理想中的婚后生活是什么样的?'
|| E'\n\n' || '这些问题没有标准答案,但提前沟通能减少未来的误解与争执。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-15T10:00:00Z', 88, 1),
('a3333333-3333-3333-3333-333333330007'::uuid,
(SELECT id FROM content_categories WHERE code = 'custom_culture'),
'婚事新办,文明婚俗新风尚',
'低彩礼、简仪式、重情义,让婚礼回归幸福本身。',
'近年来,婚事新办、喜事简办逐渐成为新风尚。'
|| E'\n\n' || '抵制高价彩礼:彩礼不应成为家庭的沉重负担,更不该成为衡量感情的标尺。'
|| E'\n' || '简化仪式流程:把精力放在真诚的相处上,而不是铺张的排场。'
|| E'\n' || '注重情义内涵:一场由亲友见证、温馨朴素的婚礼,同样值得珍藏。'
|| E'\n\n' || '善缘丹阳倡导文明健康的婚恋观,愿每一对新人都能轻装上阵,从容开启新生活。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-18T10:00:00Z', 64, 1)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 4. 善缘服务站 service_locations(追加 2 条;现有 1 条保持不动)
-- ============================================================
INSERT INTO service_locations
(id, name, short_name, address, latitude, longitude,
contact_phone_display, opening_hours_text, service_description,
primary_organization_id, status)
VALUES
('a4444444-4444-4444-4444-444444440001'::uuid,
'丹阳市开发区善缘服务站', '开发区站', '丹阳市开发区凤凰路18号',
31.9210000, 119.6090000, '0511-86666888',
'周一至周五 9:00-17:00,周六 9:00-12:00(法定节假日除外)',
'提供公益红娘服务、婚恋咨询预约、婚前辅导与本地资格核验',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), 'active'),
('a4444444-4444-4444-4444-444444440002'::uuid,
'丹阳市延陵镇善缘服务站', '延陵镇站', '丹阳市延陵镇人民路36号',
31.8600000, 119.4900000, '0511-86688866',
'周一至周五 9:00-16:30(法定节假日除外)',
'面向延陵镇及周边乡镇提供公益婚恋登记咨询、活动报名与反诈宣传',
(SELECT id FROM organizations WHERE type = 'trade_union' LIMIT 1), 'active')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 5. 专项公益服务项目 service_programs(3 条:2 个已激活 + 1 个草稿)
-- 用户端公开列表只展示 status = active
-- ============================================================
INSERT INTO service_programs
(id, name, description, organizer_org_id, start_at, end_at, status, created_by)
VALUES
('a5555555-5555-5555-5555-555555550001'::uuid,
'公益红娘一对一服务计划',
'由公益红娘为长期未遇合适对象的单身人士提供一对一牵线与情感陪跑服务。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() - INTERVAL '30 days', NOW() + INTERVAL '150 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550002'::uuid,
'婚恋反诈宣传进社区',
'联合社区开展婚恋反诈宣讲,帮助居民识别情感诈骗与网络交友陷阱。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
NOW() - INTERVAL '15 days', NOW() + INTERVAL '120 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550003'::uuid,
'婚前辅导公益课堂(筹备中)',
'面向即将步入婚姻的情侣,开展婚前沟通、家庭经营等公益课程。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() + INTERVAL '20 days', NOW() + INTERVAL '200 days', 'draft',
(SELECT id FROM operators WHERE username = 'admin'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 6. 婚恋咨询 counseling_services(2 条)
-- service_type / status 取值对齐生产 CHECK 约束
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
preferred_service_mode, scheduled_start_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
'relationship_guidance', 'user_self',
'离异后重新开始,希望了解如何建立健康的新关系。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed', 'offline', NOW() - INTERVAL '20 days', NOW() - INTERVAL '19 days'),
('a6666666-6666-6666-6666-666666660002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m02'),
'emotional_support', 'user_self',
'工作压力较大,希望有人倾听并给出情绪调节建议。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'assigned', 'online', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 7. 婚前辅导 counseling_services(service_type = premarital_guidance,2 条)
-- 婚前辅导复用 counseling_services 表,无独立表
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
companion_user_id, scheduled_start_at, user_confirmed_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
'premarital_guidance', 'user_self',
'计划明年结婚,希望接受婚前辅导,了解婚后相处与家庭经营。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
NOW() - INTERVAL '12 days', NOW() - INTERVAL '12 days', NOW() - INTERVAL '11 days'),
('a6666666-6666-6666-6666-666666660004'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
'premarital_guidance', 'user_self',
'希望和未婚妻一起参加婚前辅导课堂,为婚后生活做准备。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'scheduled',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
NOW() + INTERVAL '5 days', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 8. 本地资格 user_local_eligibilities(3 条)
-- eligibility_type / verification_status 对齐生产 CHECK 约束
-- 生产有 UNIQUE(user_id),用固定 id + ON CONFLICT (id) DO NOTHING 保证幂等
-- ============================================================
INSERT INTO user_local_eligibilities
(id, user_id, eligibility_type, declared_region_code, verification_status, verified_at, verified_by)
VALUES
('a7777777-7777-7777-7777-777777770001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f01'),
'household_registration', '321181', 'verified',
NOW() - INTERVAL '40 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01'),
'employment', '321181', 'verified',
NOW() - INTERVAL '35 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03'),
'residence', '321100', 'pending_review', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 9. 本地资格佐证 local_eligibility_evidences(2 条)
-- ============================================================
INSERT INTO local_eligibility_evidences
(id, local_eligibility_id, file_key, evidence_type, status)
VALUES
('a8888888-8888-8888-8888-888888880001'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03')),
'eligibility/demo/f03/residence_proof.jpg', 'residence_proof', 'reviewing'),
('a8888888-8888-8888-8888-888888880002'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01')),
'eligibility/demo/m01/employment_proof.jpg', 'employment_proof', 'approved')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 10. 首页「今日推荐」候选对象(新增 3 位男性演示用户 m07/m08/m09)
-- ------------------------------------------------------------
-- 根因:演示登录账号「晓燕」(demo_openid_f02) 为离异,其 required 婚姻状况为
-- ["never_married"](要求对方未婚);而既有演示男性 m01~m06 的 required 婚姻
-- 状况同样是 ["never_married"],对「晓燕」的 divorced 不满足 → 双向 Required
-- 互斥,RecommendationEngineService 对 f02 恒返回空,「今日推荐」恒空白。
--
-- 这 3 位候选的设计(同时满足 f02 的 required 与自身的 required 反向):
-- * 本人资料:未婚 (never_married)、年龄落在 f02 的 28-40、地区落在
-- ["321181","321100"]、身高落在 f02 偏好的 155-172、学历 >= college;
-- * 自身择偶条件:marital_statuses = ["never_married","divorced"](接受离异),
-- age 范围覆盖 f02 的 33 岁、region 覆盖 321181,故反向 Required 亦满足。
-- * 账号满足 RecommendationEngineService.isParticipantEligible:
-- account_status=normal / relationship_status=searching / recommendation_enabled=true /
-- visible_in_recommendations=true(本地资格未开启强制,无需核验)。
-- ============================================================
INSERT INTO users (id, wechat_openid, account_status, relationship_status, recommendation_enabled, created_at)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 'demo_openid_m07', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000008'::uuid, 'demo_openid_m08', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000009'::uuid, 'demo_openid_m09', 'normal', 'searching', true, NOW() - INTERVAL '20 days')
ON CONFLICT DO NOTHING;
INSERT INTO user_profiles (user_id, display_name, gender, birth_date, height_cm,
region_code, region_name, marital_status, children_status,
education_code, occupation_code, smoking_status, drinking_status,
long_term_danyang, future_danyang, relationship_goal, profile_completion, profile_status)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, '陈志远', 'male', '1991-03-18', 172,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'engineer', 'never', 'sometimes',
'是', '是', '结婚', 93, 'approved'),
('a9900000-0000-0000-0000-000000000008'::uuid, '吴凯', 'male', '1994-07-09', 170,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'teacher', 'never', 'sometimes',
'是', '是', '结婚', 91, 'approved'),
('a9900000-0000-0000-0000-000000000009'::uuid, '郑海', 'male', '1989-11-22', 168,
'321100', '镇江', 'never_married', 'none', 'master', 'architect', 'never', 'sometimes',
'是', '是', '结婚', 94, 'approved')
ON CONFLICT (user_id) DO NOTHING;
-- 择偶条件:marital 接受 never_married + divorced(关键差异点),其余维度与 f02 互容
INSERT INTO user_preferences
(user_id, min_age, max_age, age_priority, min_height_cm, max_height_cm, height_priority,
region_scope, region_priority, marital_statuses, marital_priority, min_education_code, education_priority)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000008'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000009'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred')
ON CONFLICT (user_id) DO NOTHING;
-- 隐私:必须允许出现在推荐里,否则 isParticipantEligible 直接排除
INSERT INTO user_privacy_settings
(user_id, visible_in_recommendations, visible_in_search, show_occupation,
show_lifestyle_photos, receive_activity_recommendations, accept_matchmaker_recommendations)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000008'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000009'::uuid, true, true, true, true, true, true)
ON CONFLICT (user_id) DO NOTHING;
-- 实名认证(mock,与既有演示用户一致,用于演示端「已实名」标识)
INSERT INTO user_identities
(id, user_id, real_name_enc, id_number_enc, id_number_hash, gender, birth_date, verification_status, provider, verified_at)
VALUES
('a9922222-2222-2222-2222-222222220007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid,
encode('demo_enc_m07'::bytea, 'escape'), encode('demo_id_m07'::bytea, 'escape'), 'demo_sha256_m07',
'male', '1991-03-18', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid,
encode('demo_enc_m08'::bytea, 'escape'), encode('demo_id_m08'::bytea, 'escape'), 'demo_sha256_m08',
'male', '1994-07-09', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid,
encode('demo_enc_m09'::bytea, 'escape'), encode('demo_id_m09'::bytea, 'escape'), 'demo_sha256_m09',
'male', '1989-11-22', 'verified', 'mock', NOW() - INTERVAL '1 day')
ON CONFLICT DO NOTHING;
-- 照片(头像 + 生活照,与既有演示用户一致)
INSERT INTO user_photos (id, user_id, file_key, photo_type, sort_order, review_status)
VALUES
('a9933333-3333-3333-3333-333333330007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330017'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330018'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330019'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/lifestyle1.jpg', 'lifestyle', 1, 'approved')
ON CONFLICT DO NOTHING;
-- ============================================================
-- 11. 红娘工作台「我的服务对象分配」(matchmaker_assignments)
-- ------------------------------------------------------------
-- 根因:V053 的存量回填发生在演示 seed 之前,故演示服务单的分配记录在
-- matchmaker_assignments 中为空 → GET /operator/matchmaker/assignments/my
-- 返回 [],红娘端工作台既无服务对象,也无法发起「联系授权申请 / 转介」。
-- 这里为既有演示服务单补建分配记录,与 matchmaker_services.assigned_operator_id
-- 保持一致(均指向演示红娘 wanghongniang)。
-- ============================================================
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)
VALUES
-- 晓燕(f02) → 红娘小王,服务单 FW20261018001(in_service),生效中
('a9944444-4444-4444-4444-444444440001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018001'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
-- 丧偶用户(f06) → 红娘小王,服务单 FW20261018002(waiting_user),生效中
('a9944444-4444-4444-4444-444444440002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f06'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018002'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days'),
-- 已完结服务(f04) → 分配已结束(终态,便于演示历史分配)
('a9944444-4444-4444-4444-444444440003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f04'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018003'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:服务已完结', 'ended',
NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 12. 服务站运营人员数据范围(service_location_operators)
-- ------------------------------------------------------------
-- 根因:该表为空 → 管理端「服务预约」(GET /service-appointments/operator/list)
-- 对服务站运营人员 stationop 恒返回空,红娘转介产生的预约(source =
-- matchmaker_referral)在管理端不可见,红娘端↔管理端的转介联动无法演示。
-- 为演示服务站运营人员 stationop 显式授予 3 个演示服务站的运营范围
-- (组织关系不自动授予数据权限,必须显式站点分配,见 AGENTS 第 24 条 / INV-018)。
-- ============================================================
INSERT INTO service_location_operators (service_location_id, operator_id, created_by, created_at)
VALUES
((SELECT id FROM service_locations WHERE name = '万善公园婚姻登记处善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市开发区善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市延陵镇善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW())
ON CONFLICT (service_location_id, operator_id) DO NOTHING;
-- ============================================================
-- 验证
-- ============================================================
DO $$
DECLARE
b_cnt INTEGER; cc_cnt INTEGER; ca_cnt INTEGER; sl_cnt INTEGER;
sp_cnt INTEGER; cs_cnt INTEGER; pm_cnt INTEGER; le_cnt INTEGER; ev_cnt INTEGER;
rec_cnt INTEGER; rec_pref INTEGER; asg_cnt INTEGER; slo_cnt INTEGER;
BEGIN
SELECT COUNT(*) INTO b_cnt FROM home_banners WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cc_cnt FROM content_categories WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ca_cnt FROM content_articles WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sl_cnt FROM service_locations WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sp_cnt FROM service_programs WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cs_cnt FROM counseling_services WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO pm_cnt FROM counseling_services
WHERE service_type = 'premarital_guidance' AND deleted_at IS NULL;
SELECT COUNT(*) INTO le_cnt FROM user_local_eligibilities WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ev_cnt FROM local_eligibility_evidences WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO rec_cnt FROM users
WHERE wechat_openid IN ('demo_openid_m07','demo_openid_m08','demo_openid_m09')
AND deleted_at IS NULL;
SELECT COUNT(*) INTO rec_pref FROM user_preferences p
WHERE p.user_id IN ('a9900000-0000-0000-0000-000000000007'::uuid,
'a9900000-0000-0000-0000-000000000008'::uuid,
'a9900000-0000-0000-0000-000000000009'::uuid);
SELECT COUNT(*) INTO asg_cnt FROM matchmaker_assignments WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO slo_cnt FROM service_location_operators;
RAISE NOTICE '========================================';
RAISE NOTICE 'V1.1 增量演示数据填充完成!';
RAISE NOTICE '========================================';
RAISE NOTICE ' Banner: % 条 | 内容分类: % 个 | 文章: % 篇', b_cnt, cc_cnt, ca_cnt;
RAISE NOTICE ' 服务站: % 个 | 专项服务项目: % 个', sl_cnt, sp_cnt;
RAISE NOTICE ' 咨询服务: % 条(其中婚前辅导 % 条)', cs_cnt, pm_cnt;
RAISE NOTICE ' 本地资格: % 条 | 资格佐证: % 条', le_cnt, ev_cnt;
RAISE NOTICE ' 今日推荐候选(m07/m08/m09): % 位 | 择偶条件: % 条', rec_cnt, rec_pref;
RAISE NOTICE ' 红娘服务对象分配: % 条', asg_cnt;
RAISE NOTICE ' 服务站运营人员范围(slo): % 条', slo_cnt;
RAISE NOTICE '========================================';
END $$;
COMMIT;
@@ -14,13 +14,13 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), ('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), ('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), ('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'); ('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active');
INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001'); INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001');
@@ -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;
@@ -0,0 +1,12 @@
-- 指定对象的公益红娘协助申请(不交换联系方式)
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS target_user_id UUID;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS authorization_at TIMESTAMPTZ;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdraw_reason VARCHAR(500);
ALTER TABLE matchmaker_services DROP CONSTRAINT IF EXISTS chk_matchmaker_service_type;
ALTER TABLE matchmaker_services ADD CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN (
'recommend_partner', 'review_preference', 'contact_intention', 'join_activity',
'no_match_help', 'chat_help', 'other', 'recommendation',
'recommendation_assistance', 'referral_counseling', 'referral_activity', 'referral_station'
));
CREATE INDEX IF NOT EXISTS idx_matchmaker_target_user ON matchmaker_services(target_user_id, created_at DESC);
@@ -73,7 +73,7 @@ class ActivityMutualConsentServiceTest {
@Test @Test
@DisplayName("Gate: 双确认后形成 Match 并开启 Conversation(此前只打日志,Gate 失败)") @DisplayName("Gate: 双确认后形成 Match 并开启 Conversation(此前只打日志,Gate 失败)")
void doubleConfirmCreatesMatchAndConversation() { void doubleConfirmCreatesMatchWithoutConversation() {
// 第一阶段:建立互选同意记录(二阶段同意) // 第一阶段:建立互选同意记录(二阶段同意)
consentService.createMutualConsent(activityId, userA, userB); consentService.createMutualConsent(activityId, userA, userB);
@@ -95,8 +95,8 @@ class ActivityMutualConsentServiceTest {
assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, match.getSource()); assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, match.getSource());
// 会话必须已开启(Gate:互选确认后双方可沟通) // 会话必须已开启(Gate:互选确认后双方可沟通)
assertTrue(conversationMapper.findByMatchId(match.getId()).isPresent(), assertTrue(conversationMapper.findByMatchId(match.getId()).isEmpty(),
"双确认后必须已开启 Conversation"); "双确认后不得创建用户聊天 Conversation");
// 溯源字段:consent 记录应绑定 match_id // 溯源字段:consent 记录应绑定 match_id
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userA, userB).orElseThrow(); ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userA, userB).orElseThrow();
@@ -15,6 +15,8 @@ import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper; import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig; import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import com.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.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -68,6 +70,8 @@ class AppealPhase18IntegrationTest {
@Autowired @Autowired
private NotificationMapper notificationMapper; private NotificationMapper notificationMapper;
@Autowired @Autowired
private UserMapper userMapper;
@Autowired
private FeatureFlagConfig featureFlagConfig; private FeatureFlagConfig featureFlagConfig;
private boolean originalAppealEnabled; private boolean originalAppealEnabled;
@@ -201,6 +205,44 @@ class AppealPhase18IntegrationTest {
assertEquals("suspended", status.get("accountStatus")); assertEquals("suspended", status.get("accountStatus"));
} }
// ==================== 申诉通过回滚 users.account_status(避免"通过仍锁死") ====================
private User insertUser(String accountStatus) {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setWechatOpenid("openid-" + UUID.randomUUID());
user.setAccountStatus(accountStatus);
userMapper.insert(user);
return user;
}
@Test
void approveRestoresAccountStatusToNormal() {
User user = insertUser(Constants.ACCOUNT_STATUS_BANNED);
RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "approve");
assertEquals(Constants.ACCOUNT_STATUS_NORMAL,
userMapper.selectById(user.getId()).getAccountStatus(),
"申诉通过且该用户已无其他生效封禁/暂停时,必须把 users.account_status 恢复为 normal");
}
@Test
void approveKeepsAccountStatusWhenOtherRestrictionRemains() {
User user = insertUser(Constants.ACCOUNT_STATUS_BANNED);
RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
insertAction(user.getId(), Constants.RISK_ACTION_TEMPORARY_SUSPEND, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "approve");
assertEquals(Constants.ACCOUNT_STATUS_BANNED,
userMapper.selectById(user.getId()).getAccountStatus(),
"仍有其他生效处置时,账号状态不得被回滚为 normal");
}
// ==================== Gate B:拒绝后原处置保持有效 ==================== // ==================== Gate B:拒绝后原处置保持有效 ====================
@Test @Test
@@ -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"));
}
}
@@ -71,6 +71,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest @SpringBootTest
@ActiveProfiles("test") @ActiveProfiles("test")
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Disabled("用户端即时聊天已按产品方案下线")
class ChatPhase09IntegrationTest { class ChatPhase09IntegrationTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@@ -493,7 +493,7 @@ class EntityMigrationParityTest {
for (Path f : entityFiles) { for (Path f : entityFiles) {
String src = Files.readString(f); 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()) { if (!tm.find()) {
continue; continue;
} }
@@ -38,7 +38,10 @@ class MigrationValidationTest {
// actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等) // actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等)
// ZC-MM-003(B1 例外通道立项):V056 matchmaker_contact_authorizations 受控联系授权表 // ZC-MM-003(B1 例外通道立项):V056 matchmaker_contact_authorizations 受控联系授权表
// (六态状态机 + active 部分唯一索引) // (六态状态机 + active 部分唯一索引)
private static final int LATEST_MIGRATION = 56; // 用户展示编号:V058 users.display_number 及历史数据回填。
// 公共身份展示编号:V059 operators.display_id 及历史数据回填。
// 红娘协助对象与授权字段:V061 matchmaker_services 扩展。
private static final int LATEST_MIGRATION = 61;
private static final String MIGRATION_DIR = private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/"; "backend/src/main/resources/db/migration/";
@@ -94,6 +94,7 @@ class P0FixVerificationTest {
// ==================== P0-1: Block不变量验证 ==================== // ==================== P0-1: Block不变量验证 ====================
@Test @Test
@Disabled("用户端即时聊天已按产品方案下线")
@DisplayName("INV-003: Block后A不可向B发送消息") @DisplayName("INV-003: Block后A不可向B发送消息")
void testBlockPreventsChat() { void testBlockPreventsChat() {
relationshipService.like(userA, userB); relationshipService.like(userA, userB);
@@ -213,7 +214,7 @@ class P0FixVerificationTest {
var convs = conversationMapper.selectList( var convs = conversationMapper.selectList(
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>() new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
.eq(com.danyang.match.platform.chat.entity.Conversation::getMatchId, match.getId())); .eq(com.danyang.match.platform.chat.entity.Conversation::getMatchId, match.getId()));
assertFalse(convs.isEmpty(), "Match后应自动创建Conversation"); assertTrue(convs.isEmpty(), "Match 后不得自动创建 Conversation");
} }
// ==================== P1-1: Required全字段过滤验证 ==================== // ==================== P1-1: Required全字段过滤验证 ====================
@@ -95,7 +95,7 @@ class MatchmakerActionGuardTest {
@Autowired private MatchmakerServiceService matchmakerServiceService; @Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private ReferralService referralService; @Autowired private ReferralService referralService;
private static final String MATCHMAKER_OWNER = "op-ag-owner"; private static final String MATCHMAKER_OWNER = "00000000-0000-0000-0000-000000000103";
private String user; private String user;
private String target; private String target;
@@ -258,12 +258,12 @@ class MatchmakerActionGuardTest {
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ==================== // ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
@Test @Test
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)") @DisplayName("MM-ACTION-002(负向): operator token 打已下线会话端点 → 410")
void operatorCannotOpenConversationFromMatch() throws Exception { void operatorCannotOpenConversationFromMatch() throws Exception {
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在 // 用户间会话已全局下线,先于角色鉴权返回 CHAT_DISABLED。
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match") mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
.header("Authorization", "Bearer " + operatorToken)) .header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden()); .andExpect(status().isGone());
} }
// ==================== MM-ACTION-003:must-confirm 语义锁定 ==================== // ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
@@ -202,7 +202,8 @@ class MatchmakerAssignmentA1Test {
// standalone MockMvc:仅注册真实控制器路由,验证 /claim 已不在映射中(D3 决策) // standalone MockMvc:仅注册真实控制器路由,验证 /claim 已不在映射中(D3 决策)
MatchmakerServiceService stubService = Mockito.mock(MatchmakerServiceService.class); MatchmakerServiceService stubService = Mockito.mock(MatchmakerServiceService.class);
MockMvc mockMvc = MockMvcBuilders MockMvc mockMvc = MockMvcBuilders
.standaloneSetup(new MatchmakerOperatorController(stubService)) .standaloneSetup(new MatchmakerOperatorController(stubService,
org.mockito.Mockito.mock(com.danyang.match.platform.matchmaker.service.MatchmakerManagementService.class)))
.build(); .build();
mockMvc.perform(post("/operator/matchmaker/services/some-id/claim")) mockMvc.perform(post("/operator/matchmaker/services/some-id/claim"))
@@ -259,11 +260,11 @@ class MatchmakerAssignmentA1Test {
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count()); Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count());
assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())), assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())),
"resourceType 必须是 matchmaker_assignment"); "resourceType 必须是 matchmaker_assignment");
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason // afterData 摘要口径:只含 matchmakerOperatorId/scope/reason,且必须是合法 JSON
assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null
&& a.getAfterData().contains("matchmakerOperatorId=") && a.getAfterData().contains("\"matchmakerOperatorId\"")
&& a.getAfterData().contains("scope=") && a.getAfterData().contains("\"scope\"")
&& a.getAfterData().contains("reason=")), && a.getAfterData().contains("\"reason\"")),
"afterData 必须记录 operatorId/scope/reason 摘要"); "afterData 必须记录 operatorId/scope/reason 摘要");
} }
@@ -67,6 +67,7 @@ class MatchmakerContactAuthorizationTest {
@Autowired private UserMapper userMapper; @Autowired private UserMapper userMapper;
@Autowired private AuditLogMapper auditLogMapper; @Autowired private AuditLogMapper auditLogMapper;
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper; @Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
@Autowired private org.mybatis.spring.SqlSessionTemplate sqlSessionTemplate;
private String mmOwner; // 分配持有人(红娘,org X) private String mmOwner; // 分配持有人(红娘,org X)
private String leaderA; // 组长,org X(team.assign 审批岗) private String leaderA; // 组长,org X(team.assign 审批岗)
@@ -232,12 +233,79 @@ class MatchmakerContactAuthorizationTest {
AuditLog audit = latestContactAudit(pair[0], auth.getId()); AuditLog audit = latestContactAudit(pair[0], auth.getId());
assertNotNull(audit, pair[0] + " 审计行必须存在"); assertNotNull(audit, pair[0] + " 审计行必须存在");
assertEquals(pair[1], audit.getPurposeCode(), pair[0] + " purpose_code 必须落库"); assertEquals(pair[1], audit.getPurposeCode(), pair[0] + " purpose_code 必须落库");
assertEquals(assignmentId, audit.getServiceAssignmentId(),
pair[0] + " 必须关联 service_assignment_id");
assertEquals("contacttest0001", audit.getRequestTraceId(), assertEquals("contacttest0001", audit.getRequestTraceId(),
pair[0] + " 必须携带请求追踪 ID"); pair[0] + " 必须携带请求追踪 ID");
assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone), assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone),
pair[0] + " 审计内容不得含 phone"); 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);
} }
} }
@@ -265,6 +333,7 @@ class MatchmakerContactAuthorizationTest {
assertThrows(AccessDeniedException.class, assertThrows(AccessDeniedException.class,
() -> contactAuthorizationService.approve(auth.getId(), leaderA, Set.of("matchmaker.view")), () -> contactAuthorizationService.approve(auth.getId(), leaderA, Set.of("matchmaker.view")),
"非审批岗不得 approve"); "非审批岗不得 approve");
sqlSessionTemplate.clearCache();
assertEquals(before + 1, deniedAudits(leaderA, auth.getId())); assertEquals(before + 1, deniedAudits(leaderA, auth.getId()));
} }
@@ -277,6 +346,7 @@ class MatchmakerContactAuthorizationTest {
assertThrows(AccessDeniedException.class, assertThrows(AccessDeniedException.class,
() -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER), () -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER),
"跨组织组长(仅主审岗)不得审批"); "跨组织组长(仅主审岗)不得审批");
sqlSessionTemplate.clearCache();
assertEquals(before + 1, deniedAudits(leaderY, auth.getId()), assertEquals(before + 1, deniedAudits(leaderY, auth.getId()),
"跨组织组长审批拒绝必须落 DATA_SCOPE_DENIED 审计"); "跨组织组长审批拒绝必须落 DATA_SCOPE_DENIED 审计");
} }
@@ -410,6 +480,16 @@ class MatchmakerContactAuthorizationTest {
assertThrows(AccessDeniedException.class, assertThrows(AccessDeniedException.class,
() -> contactAuthorizationService.consent(auth.getId(), otherUser), () -> 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 ==================== // ==================== 状态机非法转换 CONFLICT ====================
@@ -40,8 +40,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
@Transactional @Transactional
class MatchmakerContactDisabledTest { class MatchmakerContactDisabledTest {
private static final String OPERATOR_ID = "disabled-test-operator-0001"; private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000101";
private static final String USER_ID = "disabled-test-user-0001"; private static final String USER_ID = "00000000-0000-0000-0000-000000000102";
@Autowired private ContactAuthorizationService contactAuthorizationService; @Autowired private ContactAuthorizationService contactAuthorizationService;
@Autowired private MatchmakerContactAuthorizationMapper authorizationMapper; @Autowired private MatchmakerContactAuthorizationMapper authorizationMapper;
@@ -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));
}
}
@@ -61,8 +61,8 @@ class MatchmakerReferralPhase13Test {
@Autowired private ActivityRegistrationMapper activityRegistrationMapper; @Autowired private ActivityRegistrationMapper activityRegistrationMapper;
@Autowired private ServiceAppointmentMapper appointmentMapper; @Autowired private ServiceAppointmentMapper appointmentMapper;
private static final String MATCHMAKER_OWNER = "op-mm-owner"; private static final String MATCHMAKER_OWNER = "00000000-0000-0000-0000-000000000104";
private static final String MATCHMAKER_OTHER = "op-mm-other"; private static final String MATCHMAKER_OTHER = "00000000-0000-0000-0000-000000000105";
private String user; private String user;
private String target; private String target;
@@ -39,7 +39,8 @@ class NotificationPhase11IntegrationTest {
@Autowired @Autowired
private PlatformTransactionManager txManager; private PlatformTransactionManager txManager;
private static final String USER = "u-notif-p11"; // 与生产 users.id 类型保持一致,避免该通知夹具污染共享 H2 库后触发 UUID 转换告警。
private static final String USER = "22222222-2222-4222-8222-222222222222";
private List<Notification> allFor(String userId, String type) { private List<Notification> allFor(String userId, String type) {
return notificationMapper.selectList( return notificationMapper.selectList(
@@ -38,7 +38,8 @@ class NotificationTemplateTypeGuardTest {
@Autowired @Autowired
private NotificationMapper notificationMapper; private NotificationMapper notificationMapper;
private static final String USER = "u-notif-guard"; // 必须使用与生产 users.id 一致的 UUID;否则该夹具写入 H2 后,后续 UUID 条件查询会触发类型转换告警。
private static final String USER = "11111111-1111-4111-8111-111111111111";
private void assertPersisted(String type, String businessId, String label) { private void assertPersisted(String type, String businessId, String label) {
List<Notification> rows = notificationMapper.selectList( List<Notification> rows = notificationMapper.selectList(
@@ -52,7 +52,7 @@ class OrganizationServiceTest {
@Autowired private AuditLogMapper auditLogMapper; @Autowired private AuditLogMapper auditLogMapper;
/** 审计归属断言用的操作人 id(模拟已认证的运营主体) */ /** 审计归属断言用的操作人 id(模拟已认证的运营主体) */
private static final String OPERATOR_ID = "op-audit-e2e"; private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000106";
private static Map<String, Object> params(String name, String type) { private static Map<String, Object> params(String name, String type) {
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
@@ -76,9 +76,12 @@ class OrganizationServiceTest {
void createOrganizationRecordsOperatorInAuditLog() { void createOrganizationRecordsOperatorInAuditLog() {
Organization org = createAndReload("审计归属机构-" + System.nanoTime(), "other"); Organization org = createAndReload("审计归属机构-" + System.nanoTime(), "other");
// resource_id 同时承载授权拒绝事件的方法签名,H2 会按 UUID 参数比较时拒绝混合值;
// 先按稳定动作取行,再在 Java 侧核对资源,保持测试语义且不改变生产字段。
List<AuditLog> logs = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>() List<AuditLog> logs = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getResourceId, org.getId()) .eq(AuditLog::getAction, "CREATE_ORGANIZATION")).stream()
.eq(AuditLog::getAction, "CREATE_ORGANIZATION")); .filter(log -> org.getId().equals(log.getResourceId()))
.toList();
assertEquals(1, logs.size(), "创建机构应恰好留下 1 条 CREATE_ORGANIZATION 审计"); assertEquals(1, logs.size(), "创建机构应恰好留下 1 条 CREATE_ORGANIZATION 审计");
assertEquals(OPERATOR_ID, logs.get(0).getOperatorId(), assertEquals(OPERATOR_ID, logs.get(0).getOperatorId(),
@@ -103,7 +106,7 @@ class OrganizationServiceTest {
void disableOrganizationPersistsInactiveStatus() { void disableOrganizationPersistsInactiveStatus() {
Organization org = createAndReload("待停用机构-" + System.nanoTime(), "charity_center"); Organization org = createAndReload("待停用机构-" + System.nanoTime(), "charity_center");
organizationService.disableOrganization(org.getId(), "op-1"); organizationService.disableOrganization(org.getId(), OPERATOR_ID);
Organization after = organizationMapper.selectById(org.getId()); Organization after = organizationMapper.selectById(org.getId());
assertEquals(Constants.ORG_STATUS_INACTIVE, after.getStatus(), assertEquals(Constants.ORG_STATUS_INACTIVE, after.getStatus(),
@@ -114,9 +117,9 @@ class OrganizationServiceTest {
@DisplayName("启用机构:落库状态回到 active(原写 'normal' —— 不在白名单)") @DisplayName("启用机构:落库状态回到 active(原写 'normal' —— 不在白名单)")
void enableOrganizationPersistsActiveStatus() { void enableOrganizationPersistsActiveStatus() {
Organization org = createAndReload("待启用机构-" + System.nanoTime(), "volunteer_team"); Organization org = createAndReload("待启用机构-" + System.nanoTime(), "volunteer_team");
organizationService.disableOrganization(org.getId(), "op-1"); organizationService.disableOrganization(org.getId(), OPERATOR_ID);
organizationService.enableOrganization(org.getId(), "op-1"); organizationService.enableOrganization(org.getId(), OPERATOR_ID);
Organization after = organizationMapper.selectById(org.getId()); Organization after = organizationMapper.selectById(org.getId());
assertEquals(Constants.ORG_STATUS_ACTIVE, after.getStatus(), assertEquals(Constants.ORG_STATUS_ACTIVE, after.getStatus(),
@@ -128,8 +131,8 @@ class OrganizationServiceTest {
void disableThenEnableRoundTripKeepsRecordReadable() { void disableThenEnableRoundTripKeepsRecordReadable() {
Organization org = createAndReload("往返机构-" + System.nanoTime(), "trade_union"); Organization org = createAndReload("往返机构-" + System.nanoTime(), "trade_union");
organizationService.disableOrganization(org.getId(), "op-1"); organizationService.disableOrganization(org.getId(), OPERATOR_ID);
organizationService.enableOrganization(org.getId(), "op-1"); organizationService.enableOrganization(org.getId(), OPERATOR_ID);
assertEquals(1, organizationService.listOrganizations().stream() assertEquals(1, organizationService.listOrganizations().stream()
.filter(o -> org.getId().equals(o.getId())).count(), .filter(o -> org.getId().equals(o.getId())).count(),
@@ -196,8 +199,8 @@ class OrganizationServiceTest {
@DisplayName("机构不存在时:停用 / 启用 / 更新返回 NOT_FOUND 业务码,而非 500") @DisplayName("机构不存在时:停用 / 启用 / 更新返回 NOT_FOUND 业务码,而非 500")
void missingOrganizationYieldsNotFoundCode() { void missingOrganizationYieldsNotFoundCode() {
for (Runnable call : java.util.List.<Runnable>of( for (Runnable call : java.util.List.<Runnable>of(
() -> organizationService.disableOrganization("no-such-org-id", "op-1"), () -> organizationService.disableOrganization("no-such-org-id", OPERATOR_ID),
() -> organizationService.enableOrganization("no-such-org-id", "op-1"), () -> organizationService.enableOrganization("no-such-org-id", OPERATOR_ID),
() -> organizationService.updateOrganization("no-such-org-id", () -> organizationService.updateOrganization("no-such-org-id",
params("新名称", "other")))) { params("新名称", "other")))) {
BusinessException ex = assertThrows(BusinessException.class, call::run); BusinessException ex = assertThrows(BusinessException.class, call::run);
@@ -237,7 +237,7 @@ class RelationshipProgressPhase12Test {
// 旧 Match 有会话 // 旧 Match 有会话
Long oldConvCount = conversationMapper.selectCount( Long oldConvCount = conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())); new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId()));
assertEquals(1L, oldConvCount, "首个 Match 应有 1 个会话"); assertEquals(0L, oldConvCount, "首个 Match 不应有用户聊天会话");
// Unmatch // Unmatch
Match m1 = matchMapper.findById(first.getId()).orElseThrow(); Match m1 = matchMapper.findById(first.getId()).orElseThrow();
@@ -255,10 +255,10 @@ class RelationshipProgressPhase12Test {
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, second.getId())), new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, second.getId())),
"新 Match 不应自动继承旧会话(旧聊天权限不恢复)"); "新 Match 不应自动继承旧会话(旧聊天权限不恢复)");
// 且旧会话仍挂在旧 Match 上,不会被新 Match 复用 // 历史数据为空:本产品不再创建用户聊天会话
assertEquals(1L, conversationMapper.selectCount( assertEquals(0L, conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())), new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())),
"旧会话应仍属于旧 Match"); "旧 Match 也不应存在用户聊天会话");
} }
// ==================== 拒绝 / 恢复 / 退出 ==================== // ==================== 拒绝 / 恢复 / 退出 ====================
@@ -0,0 +1,37 @@
package com.danyang.match.platform.review;
import com.danyang.match.platform.review.service.ReviewService;
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.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class ReviewDashboardCountTest {
@Autowired ReviewService service;
@Autowired JdbcTemplate jdbc;
@Test void approvingTaskReducesOutstandingCountAndRemovesItFromQueue() {
String task = UUID.randomUUID().toString();
String operator = UUID.randomUUID().toString();
String user = UUID.randomUUID().toString();
jdbc.update("INSERT INTO operators(id,name,username,password_hash) VALUES(?,?,?,?)", operator, "测试审核员", operator, "test");
jdbc.update("INSERT INTO users(id,wechat_openid) VALUES(?,?)", user, user);
jdbc.update("INSERT INTO review_tasks(id,user_id,review_type,status,created_at) VALUES(?,?,?,'pending',NOW())", task, user, "profile");
var before = service.getPendingStats();
assertTrue(service.claimTask(operator, task).isPresent());
var claimed = service.getPendingStats();
assertEquals(before.get("pending") - 1, claimed.get("pending"));
assertEquals(before.get("processing") + 1, claimed.get("processing"));
service.approve(task, operator, "验收通过");
var after = service.getPendingStats();
assertEquals(before.get("pending") + before.get("processing") - 1, after.get("pending") + after.get("processing"));
assertFalse(service.listPendingTasks(0, 1000).stream().anyMatch(row -> task.equals(row.getId())));
}
}

Some files were not shown because too many files have changed in this diff Show More