Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4248e9574e | ||
|
|
6c2601950b | ||
|
|
1be0d98ebd | ||
|
|
5e484d0e74 | ||
|
|
3caf5f8816 | ||
|
|
861c78efb5 | ||
|
|
6187ec69df | ||
|
|
2d60f7899f | ||
|
|
f76bda1efa | ||
|
|
4edb7619bc | ||
|
|
dfb74f3386 | ||
|
|
9fc3385d6c | ||
|
|
c1c75edc55 | ||
|
|
47cccc9ca5 | ||
|
|
5c97c57c76 | ||
|
|
ca0db57f24 | ||
|
|
8335ef780c | ||
|
|
1ad9793ea2 | ||
|
|
45e84722e1 | ||
|
|
f4e00b4d95 | ||
|
|
e740cbf403 | ||
|
|
44f75acebe | ||
|
|
0a100548a9 | ||
|
|
c0298c27a5 | ||
|
|
1fc34f1bcd | ||
|
|
2c325136ab |
@@ -0,0 +1,37 @@
|
|||||||
|
# CODEOWNERS — 善缘丹阳公益婚恋平台 审查角色分配(草案)
|
||||||
|
#
|
||||||
|
# 作用:开 PR 时 GitHub 自动向对应路径的 owner 请求审查;配合分支保护与
|
||||||
|
# docs/governance/CODE_REVIEW_STANDARDS.md 的 T0/T1/T2 分级与双审规则使用。
|
||||||
|
# 语法:https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
|
||||||
|
#
|
||||||
|
# 当前为一人团队,默认全部归技术负责人 @g1307092382。
|
||||||
|
# 待扩员时,把各端拆给对应熟悉者即可;T2 高危改动按审查标准须 2 人复核(其中 1 人为该端 owner)。
|
||||||
|
# 说明:CODEOWNERS 只负责「请求谁审」,不强制「必须不同人」;双审由审查流程纪律保证。
|
||||||
|
# 排序:子路径必须放在父路径之后(GitHub 取最后一个匹配的规则)。
|
||||||
|
|
||||||
|
# —— 默认兜底 ——
|
||||||
|
* @g1307092382
|
||||||
|
|
||||||
|
# —— 后端(Spring Boot / Java)——
|
||||||
|
/backend/ @g1307092382
|
||||||
|
|
||||||
|
# 数据库迁移(Flyway):schema 变更属 T2,须重点复核(放在 /backend/ 之后)
|
||||||
|
/backend/src/main/resources/db/migration/ @g1307092382
|
||||||
|
|
||||||
|
# —— 微信小程序(原生 WXML/WXSS/TS)——
|
||||||
|
/miniapp/ @g1307092382
|
||||||
|
|
||||||
|
# —— 管理端(Vue3 / TS / pnpm)——
|
||||||
|
/admin-web/ @g1307092382
|
||||||
|
|
||||||
|
# —— 测试与契约守卫(跨端契约,需熟悉各端)——
|
||||||
|
/tests/ @g1307092382
|
||||||
|
/ui-tests/ @g1307092382
|
||||||
|
/scripts/run-ui-contracts.mjs @g1307092382
|
||||||
|
|
||||||
|
# —— 基础设施 / 部署(Compose / 镜像 / 密钥)——
|
||||||
|
/infrastructure/ @g1307092382
|
||||||
|
|
||||||
|
# —— 治理与审查机制文档(变更须经技术负责人确认)——
|
||||||
|
/docs/governance/ @g1307092382
|
||||||
|
/.github/ @g1307092382
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!--
|
||||||
|
提 PR 前请先读 docs/governance/CODE_REVIEW_STANDARDS.md。
|
||||||
|
本模板是合并准入的硬性门槛:逐项打勾 = 已实际验证(不是"应该没问题")。
|
||||||
|
证据优先:描述里的"✅已完成"不是证据,请贴命令+输出/代码 diff/实测结论。
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
<!-- 改了什么、为什么、影响面 -->
|
||||||
|
|
||||||
|
## 风险分级(见审查标准 §2)
|
||||||
|
- [ ] T0 琐碎(纯文案/文档/格式化/资源重命名)
|
||||||
|
- [ ] T1 常规(单端功能/Bug/UI/单 API)
|
||||||
|
- [ ] T2 高危(鉴权/支付/隐私/跨端契约/DB schema/并发幂等/已知阻塞项)— 需 2 人复核
|
||||||
|
|
||||||
|
## 验证方式(必填,证据优先)
|
||||||
|
<!-- 贴实际跑过的命令与关键输出;不要只写"已自测通过" -->
|
||||||
|
- 本地测试/契约守卫命令与结果:
|
||||||
|
- 运行时实测(如涉及渲染/接口,贴 mock/路由/关键响应):
|
||||||
|
- 负向注入(新增或修改测试时必填:故意改坏后测试是否转红):
|
||||||
|
|
||||||
|
## 技术栈审查清单(按本 PR 涉及项打勾)
|
||||||
|
### 后端 Java / Spring Boot
|
||||||
|
- [ ] 业务拒绝用 `BusinessException`+`ApiResponseCode`(HTTP 400≤code<500),无裸 `RuntimeException`
|
||||||
|
- [ ] 测试对业务拒绝断言 `400 ≤ status < 500`(非仅 `!= 200`)
|
||||||
|
- [ ] 核心不变量(INV-014/015、Required/Mutual/Block 等)未被绕过
|
||||||
|
- [ ] 主键/唯一键/CHECK 约束与 Java 枚举一致,无静默 no-op 写
|
||||||
|
- [ ] 无新增死表/死配置(`system_configs`/`match_rule_configs` 类)
|
||||||
|
|
||||||
|
### 小程序 miniapp
|
||||||
|
- [ ] `app.json`/`pages.json` 路径与磁盘文件一致、无前导 `/`
|
||||||
|
- [ ] `ignoreDevUnusedFiles`/`ignoreUploadUnusedFiles` = false(资源不被吞)
|
||||||
|
- [ ] 跨端字段名与后端规范名一致(tsc 不保护 JS 层字段名)
|
||||||
|
- [ ] WXSS 无通配符/属性选择器/嵌套注释;`<image>` 显式高度
|
||||||
|
- [ ] 胶囊安全区让出 `--nav-right`
|
||||||
|
|
||||||
|
### 管理端 admin-web
|
||||||
|
- [ ] 渲染相关状态走 `ref`/`reactive`,已补渲染态实测
|
||||||
|
- [ ] 颜色/字号收敛到 `src/theme/` token,EP 主色阶补全 `-light-N`/`-dark-2`/`-rgb`
|
||||||
|
- [ ] 表单校验类型安全、出错保留输入
|
||||||
|
- [ ] 触摸目标 ≥44px,尊重 `prefers-reduced-motion`
|
||||||
|
|
||||||
|
### 数据库迁移 SQL
|
||||||
|
- [ ] `V<序号>__<描述>.sql` 命名,版本连续唯一
|
||||||
|
- [ ] 向后兼容,NOT NULL 新列有默认值/迁移
|
||||||
|
- [ ] CHECK 约束与枚举一致,破坏性变更有回滚方案
|
||||||
|
|
||||||
|
### 测试 / 契约守卫
|
||||||
|
- [ ] 无空壳 `skip`/`pass` 测试
|
||||||
|
- [ ] 已做负向注入证明判别力
|
||||||
|
- [ ] `scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 23) 同步(增删守卫必改)
|
||||||
|
|
||||||
|
### 脚本 / 基础设施
|
||||||
|
- [ ] 脚本真实依赖已在 CI 对应 job 安装(非仅本地能跑)
|
||||||
|
- [ ] 密钥仅运行期、不落盘;本地栈固定 `-p danyang`
|
||||||
|
|
||||||
|
## 判据纪律自查(见审查标准 §5,对应改动必查)
|
||||||
|
- [ ] 非空数据验证(无空数据假绿,如推荐/列表返回空也能过)
|
||||||
|
- [ ] 无直连绕过(关键路径至少一个经 HTTP 层断言)
|
||||||
|
- [ ] 约束相关改动在带约束环境验证(无测试库缺约束假绿)
|
||||||
|
- [ ] 小程序改动不走 `Mini Program Compile Check` 的"仅 tsc"假绿,已真编译/查资源
|
||||||
|
- [ ] 合并后回读 `git rev-parse HEAD` + 轮询 CI 确认本次 head 真 success
|
||||||
|
|
||||||
|
## 已知阻塞 / 决策关联
|
||||||
|
- [ ] 本 PR 是否触碰上线阻塞项(微信真实登录 / 照片直传落库)?若是:✅本次解决 / ⚠️未解决已登记 known-risks
|
||||||
|
- [ ] 是否与 `decision-register` 的 LOCKED 决策冲突?若是:已回决策层确认(附结论)
|
||||||
|
- [ ] 是否产生新债务?若是:已登记 `known-risks.md`(编号 KR-xx)
|
||||||
|
|
||||||
|
## 关联
|
||||||
|
<!-- 关联 Issue / 决策 / 风险编号 -->
|
||||||
@@ -25,6 +25,13 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: mvn verify -B
|
run: mvn verify -B
|
||||||
|
|
||||||
|
# 后端契约门禁(与 scripts/run-ui-contracts.mjs 同级):
|
||||||
|
# 固化关键后端回归/契约测试(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底
|
||||||
|
# + 核心不变量),先校验这些测试源文件存在性下限(防改名/删除后门禁假绿),
|
||||||
|
# 再用 `mvn test -Dtest=<列表>` 真跑。复用本 job 已编译产物与依赖缓存,开销极小。
|
||||||
|
- name: 后端契约门禁(curated + 下限校验)
|
||||||
|
run: node scripts/run-backend-contracts.mjs
|
||||||
|
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org/>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "danyang-match-admin",
|
"name": "danyang-match-admin",
|
||||||
"version": "1.0.0-rc.2",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
# 后者生成 Vue 版本桥接),允许它们执行。
|
# 后者生成 Vue 版本桥接),允许它们执行。
|
||||||
packages:
|
packages:
|
||||||
- '.'
|
- '.'
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: set this to true or false
|
||||||
|
vue-demi: set this to true or false
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- esbuild
|
- esbuild
|
||||||
- vue-demi
|
- vue-demi
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: '/api/v1',
|
baseURL: '/api/v1',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 防止 401 时并发触发多次刷新
|
||||||
|
let refreshing = false
|
||||||
|
|
||||||
client.interceptors.request.use((config) => {
|
client.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
|
|||||||
|
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(res) => res.data,
|
(res) => res.data,
|
||||||
(err) => {
|
async (err) => {
|
||||||
if (err.response?.status === 401) {
|
const status = err.response?.status
|
||||||
localStorage.removeItem('accessToken')
|
const original = err.config
|
||||||
router.push('/login')
|
const isAuthEndpoint = !!original?.url?.includes('/auth/operator/')
|
||||||
|
|
||||||
|
// access token 过期:尝试用 HttpOnly refresh Cookie 静默刷新一次后重试原请求
|
||||||
|
// __retried 防止刷新后重试仍 401 时再次触发刷新,形成循环
|
||||||
|
if (status === 401 && !refreshing && !isAuthEndpoint && !(original as any)?.__retried) {
|
||||||
|
refreshing = true
|
||||||
|
try {
|
||||||
|
const resp: any = await client.post('/auth/operator/refresh', {})
|
||||||
|
if (resp?.success) {
|
||||||
|
const newToken = resp.data.accessToken
|
||||||
|
setAccessToken(newToken)
|
||||||
|
refreshing = false
|
||||||
|
;(original as any).__retried = true
|
||||||
|
const headers = (original.headers || {}) as Record<string, string>
|
||||||
|
headers.Authorization = `Bearer ${newToken}`
|
||||||
|
original.headers = headers as any
|
||||||
|
return client(original)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 刷新失败:清除并跳登录
|
||||||
|
} finally {
|
||||||
|
refreshing = false
|
||||||
|
}
|
||||||
|
clearAccessToken()
|
||||||
ElMessage.error('登录已过期,请重新登录')
|
ElMessage.error('登录已过期,请重新登录')
|
||||||
|
router.push('/login')
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新/登录等鉴权端点自身 401:交由调用方处理文案,不全局弹窗跳转
|
||||||
|
if (status === 401) {
|
||||||
|
if (!isAuthEndpoint) {
|
||||||
|
clearAccessToken()
|
||||||
|
ElMessage.error('登录已过期,请重新登录')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
} else if (err.response?.data?.message) {
|
} else if (err.response?.data?.message) {
|
||||||
ElMessage.error(err.response.data.message)
|
ElMessage.error(err.response.data.message)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ const navigation: NavigationItem[] = [
|
|||||||
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
|
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
|
||||||
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
|
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
|
||||||
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
|
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
|
||||||
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
|
{ label: '公益红娘', path: '/referral', permissions: ['matchmaker.service'], icon: Connection },
|
||||||
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection },
|
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.view'], icon: Connection },
|
||||||
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
|
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
|
||||||
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
|
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
|
||||||
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
|
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
|
||||||
@@ -217,7 +217,7 @@ const currentRoleName = computed(() => {
|
|||||||
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
||||||
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
||||||
if (perms.includes('review.view')) return '资料审核人员'
|
if (perms.includes('review.view')) return '资料审核人员'
|
||||||
if (perms.includes('matchmaker.service.view')) return '公益红娘'
|
if (perms.includes('matchmaker.view')) return '公益红娘'
|
||||||
return '运营人员'
|
return '运营人员'
|
||||||
})
|
})
|
||||||
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
|
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getAccessToken } from '@/utils/token'
|
||||||
/**
|
/**
|
||||||
* U32 活动签到工作台(Phase 15)
|
* U32 活动签到工作台(Phase 15)
|
||||||
*
|
*
|
||||||
@@ -111,7 +112,7 @@ const TYPE_TEXT: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
|
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
|
||||||
|
|
||||||
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` })
|
const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
|
||||||
|
|
||||||
const formatTime = (v: any) => {
|
const formatTime = (v: any) => {
|
||||||
if (!v) return '-'
|
if (!v) return '-'
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ const mutualList = ref<any[]>([])
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
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 = searchActivityId.value
|
||||||
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
|
|||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
currentActivity.value = tableData.value.find(row => row.activityId === activityId)
|
currentActivity.value = tableData.value.find(row => row.activityId === activityId)
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
|
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
|
||||||
mutualList.value = mutualRes.data || []
|
mutualList.value = mutualRes.data || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -168,6 +168,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getAccessToken } from '@/utils/token'
|
||||||
/**
|
/**
|
||||||
* 活动管理(V1.1 Phase 14)
|
* 活动管理(V1.1 Phase 14)
|
||||||
*
|
*
|
||||||
@@ -259,7 +260,7 @@ function emptyForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authHeader() {
|
function authHeader() {
|
||||||
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
|
return { Authorization: `Bearer ${getAccessToken()}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
|
|||||||
@@ -213,6 +213,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getAccessToken } from '@/utils/token'
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { get } from '@/api/client'
|
import { get } from '@/api/client'
|
||||||
import { token } from '@/theme/tokens'
|
import { token } from '@/theme/tokens'
|
||||||
@@ -302,7 +303,7 @@ async function loadFunnel() {
|
|||||||
try {
|
try {
|
||||||
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
|
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
|
||||||
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
|
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
|
||||||
const authToken = localStorage.getItem('accessToken')
|
const authToken = getAccessToken()
|
||||||
if (dateRange.value[0] && dateRange.value[1]) {
|
if (dateRange.value[0] && dateRange.value[1]) {
|
||||||
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
|
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
|
||||||
get('/operator/analytics/user-funnel', {
|
get('/operator/analytics/user-funnel', {
|
||||||
@@ -328,7 +329,7 @@ async function loadFunnel() {
|
|||||||
|
|
||||||
async function loadDailySummary() {
|
async function loadDailySummary() {
|
||||||
try {
|
try {
|
||||||
const authToken = localStorage.getItem('accessToken')
|
const authToken = getAccessToken()
|
||||||
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
|
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
|
||||||
dailySummary.value = res.data || []
|
dailySummary.value = res.data || []
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getAccessToken } from '@/utils/token'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get, post } from '@/api/client'
|
import { get, post } from '@/api/client'
|
||||||
import { token as tokenColor } from '@/theme/tokens'
|
import { token as tokenColor } from '@/theme/tokens'
|
||||||
@@ -109,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
|
||||||
tableData.value = res.data || []
|
tableData.value = res.data || []
|
||||||
loadStats()
|
loadStats()
|
||||||
@@ -119,7 +120,7 @@ async function loadData() {
|
|||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
|
||||||
const data = res.data || {}
|
const data = res.data || {}
|
||||||
stats.value = [
|
stats.value = [
|
||||||
@@ -138,7 +139,7 @@ async function openDetail(id: string) {
|
|||||||
decision.value = 'approve'
|
decision.value = 'approve'
|
||||||
decisionNote.value = ''
|
decisionNote.value = ''
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
|
||||||
detail.value = res.data
|
detail.value = res.data
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
@@ -148,7 +149,7 @@ async function handleProcess() {
|
|||||||
if (!decision.value) return
|
if (!decision.value) return
|
||||||
processing.value = true
|
processing.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
await post(`/operator/appeals/${currentId.value}/process`,
|
await post(`/operator/appeals/${currentId.value}/process`,
|
||||||
{ decision: decision.value, decisionNote: decisionNote.value },
|
{ decision: decision.value, decisionNote: decisionNote.value },
|
||||||
{ Authorization: `Bearer ${token}` })
|
{ Authorization: `Bearer ${token}` })
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/audit/logs', {
|
const res: any = await get('/operator/audit/logs', {
|
||||||
resourceType: resourceFilter.value || undefined,
|
resourceType: resourceFilter.value || undefined,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<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">
|
||||||
<el-tab-pane label="服务请求队列" name="services">
|
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
|
||||||
|
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
|
||||||
<el-table :data="services" stripe v-loading="loading">
|
<el-table :data="services" stripe v-loading="loading">
|
||||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||||
<el-table-column prop="serviceType" label="服务类型" width="160">
|
<el-table-column prop="serviceType" label="服务类型" width="160">
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
<el-table-column label="操作" width="200">
|
<el-table-column label="操作" width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
|
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
|
||||||
<el-button v-if="row.status==='assigned' || row.status==='processing'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -38,16 +39,47 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
|
||||||
|
<el-tab-pane label="服务对象分配" name="assignments">
|
||||||
|
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
|
||||||
|
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||||
|
<el-table-column prop="assignmentScope" label="范围" width="90">
|
||||||
|
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="assignmentReason" label="分配理由" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="status" label="状态" width="90">
|
||||||
|
<template #default="{ row }"><el-tag :type="assignmentStatusTag(row.status)">{{ assignmentStatusLabel(row.status) }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="assignedAt" label="分配时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.assignedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="expiresAt" label="有效期至" width="170">
|
||||||
|
<template #default="{ row }">{{ row.expiresAt ? formatDateTime(row.expiresAt) : '长期' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
|
||||||
|
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
|
||||||
|
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 分配弹窗 -->
|
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
|
||||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
|
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
|
||||||
<el-form label-width="80px">
|
<el-form label-width="90px">
|
||||||
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
|
<el-form-item label="红娘">
|
||||||
<el-option label="王红娘" value="operator_wang" />
|
<el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
|
||||||
<el-option label="陈红娘" value="operator_chen" />
|
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||||
</el-select></el-form-item>
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分配理由" required>
|
||||||
|
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||||
@@ -55,6 +87,37 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
|
||||||
|
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="新红娘">
|
||||||
|
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
|
||||||
|
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="转分配理由" required>
|
||||||
|
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="transferDialogVisible=false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
|
||||||
|
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
|
||||||
|
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="terminateDialogVisible=false">取消</el-button>
|
||||||
|
<el-button type="danger" @click="doTerminate">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 创建推荐弹窗 -->
|
<!-- 创建推荐弹窗 -->
|
||||||
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
|
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
@@ -71,18 +134,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { get, post } from '@/api/client'
|
import { get, post } from '@/api/client'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const activeTab = ref('services')
|
const authStore = useAuthStore()
|
||||||
|
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
|
||||||
|
const canAssign = computed(() => {
|
||||||
|
const perms: string[] = authStore.permissions || []
|
||||||
|
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const services = ref<any[]>([])
|
const services = ref<any[]>([])
|
||||||
const recommendations = ref<any[]>([])
|
const recommendations = ref<any[]>([])
|
||||||
|
const assignments = ref<any[]>([])
|
||||||
|
const assignmentsLoading = ref(false)
|
||||||
|
const assignableMatchmakers = ref<any[]>([])
|
||||||
const assignDialogVisible = ref(false)
|
const assignDialogVisible = ref(false)
|
||||||
const recDialogVisible = ref(false)
|
const transferDialogVisible = ref(false)
|
||||||
|
const terminateDialogVisible = ref(false)
|
||||||
const currentServiceId = ref('')
|
const currentServiceId = ref('')
|
||||||
const assignForm = ref({ operatorId: '' })
|
const currentUserId = ref('')
|
||||||
|
const currentAssignmentId = ref('')
|
||||||
|
const terminateAction = ref<'revoke' | 'end'>('revoke')
|
||||||
|
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
|
||||||
|
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
|
||||||
|
const terminateForm = ref({ reason: '' })
|
||||||
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
||||||
|
|
||||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||||
@@ -90,10 +170,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
|
|||||||
referral_activity: '转介活动', referral_station: '转介服务站'
|
referral_activity: '转介活动', referral_station: '转介服务站'
|
||||||
}
|
}
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
|
submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
|
||||||
|
processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||||
}
|
}
|
||||||
const STATUS_TAG: Record<string, string> = {
|
const STATUS_TAG: Record<string, string> = {
|
||||||
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger'
|
submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
|
||||||
|
processing: '', completed: 'success', cancelled: 'danger'
|
||||||
}
|
}
|
||||||
const REC_STATUS_LABEL: Record<string, string> = {
|
const REC_STATUS_LABEL: Record<string, string> = {
|
||||||
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
|
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
|
||||||
@@ -103,18 +185,27 @@ const REC_STATUS_TAG: Record<string, string> = {
|
|||||||
pending_view: 'warning', viewed: 'info', liked: 'primary',
|
pending_view: 'warning', viewed: 'info', liked: 'primary',
|
||||||
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
|
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
|
||||||
}
|
}
|
||||||
|
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
|
||||||
|
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
|
||||||
|
}
|
||||||
|
const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
|
||||||
|
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
|
||||||
|
}
|
||||||
|
|
||||||
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
|
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
|
||||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||||
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
|
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
|
||||||
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
|
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
|
||||||
|
function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || s }
|
||||||
|
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
|
||||||
|
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
|
||||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||||
|
|
||||||
async function loadServices() {
|
async function loadServices() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
|
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
|
||||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
|
// 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
|
||||||
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
|
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
async function loadRecommendations() {
|
async function loadRecommendations() {
|
||||||
@@ -123,19 +214,80 @@ async function loadRecommendations() {
|
|||||||
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
|
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
|
||||||
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
|
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
async function loadAssignments() {
|
||||||
|
assignmentsLoading.value = true
|
||||||
|
// ZC-MM-002:本人名下生效中的服务对象分配
|
||||||
|
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
|
||||||
|
}
|
||||||
|
async function loadAssignableMatchmakers() {
|
||||||
|
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
|
||||||
|
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
|
||||||
|
}
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
|
if (activeTab.value === 'services') await loadServices()
|
||||||
|
else if (activeTab.value === 'assignments') await loadAssignments()
|
||||||
|
else await loadRecommendations()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
|
async function openAssign(row: any) {
|
||||||
|
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
|
||||||
|
currentServiceId.value = row.serviceId || row.id
|
||||||
|
currentUserId.value = row.userId
|
||||||
|
assignForm.value = { matchmakerOperatorId: '', reason: '' }
|
||||||
|
await loadAssignableMatchmakers()
|
||||||
|
assignDialogVisible.value = true
|
||||||
|
}
|
||||||
async function doAssign() {
|
async function doAssign() {
|
||||||
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
|
if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
|
||||||
|
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
|
||||||
|
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
|
||||||
try {
|
try {
|
||||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
|
await post('/operator/matchmaker/assignments', {
|
||||||
|
userId: currentUserId.value,
|
||||||
|
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
|
||||||
|
serviceId: currentServiceId.value,
|
||||||
|
reason: assignForm.value.reason.trim()
|
||||||
|
})
|
||||||
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
|
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
|
||||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openTransfer(row: any) {
|
||||||
|
currentAssignmentId.value = row.id
|
||||||
|
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
|
||||||
|
await loadAssignableMatchmakers()
|
||||||
|
transferDialogVisible.value = true
|
||||||
|
}
|
||||||
|
async function doTransfer() {
|
||||||
|
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
|
||||||
|
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
|
||||||
|
try {
|
||||||
|
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
|
||||||
|
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
|
||||||
|
reason: transferForm.value.reason.trim()
|
||||||
|
})
|
||||||
|
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTerminate(row: any, action: 'revoke' | 'end') {
|
||||||
|
currentAssignmentId.value = row.id
|
||||||
|
terminateAction.value = action
|
||||||
|
terminateForm.value = { reason: '' }
|
||||||
|
terminateDialogVisible.value = true
|
||||||
|
}
|
||||||
|
async function doTerminate() {
|
||||||
|
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
|
||||||
|
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
|
||||||
|
reason: terminateForm.value.reason.trim()
|
||||||
|
})
|
||||||
|
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
function openRecommendation(row: any) {
|
function openRecommendation(row: any) {
|
||||||
currentServiceId.value = row.id
|
currentServiceId.value = row.id
|
||||||
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
|
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getAccessToken } from '@/utils/token'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get, post, put } from '@/api/client'
|
import { get, post, put } from '@/api/client'
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ const orgMap = ref<Record<string, string>>({})
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
||||||
tableData.value = res.data?.list || []
|
tableData.value = res.data?.list || []
|
||||||
total.value = res.data?.total || 0
|
total.value = res.data?.total || 0
|
||||||
@@ -89,7 +90,7 @@ async function loadData() {
|
|||||||
|
|
||||||
async function loadOrgs() {
|
async function loadOrgs() {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||||
orgList.value = res.data || []
|
orgList.value = res.data || []
|
||||||
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
|
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
|
||||||
@@ -105,7 +106,7 @@ async function save() {
|
|||||||
if (!form.value.username || !form.value.password) return
|
if (!form.value.username || !form.value.password) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
||||||
formVisible.value = false
|
formVisible.value = false
|
||||||
loadData()
|
loadData()
|
||||||
@@ -115,7 +116,7 @@ async function save() {
|
|||||||
|
|
||||||
async function toggleStatus(row: any) {
|
async function toggleStatus(row: any) {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||||
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
|
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
|
||||||
loadData()
|
loadData()
|
||||||
@@ -126,7 +127,7 @@ async function resetPwd(id: string) {
|
|||||||
const pwd = prompt('请输入新密码(至少8位)')
|
const pwd = prompt('请输入新密码(至少8位)')
|
||||||
if (!pwd || pwd.length < 8) return
|
if (!pwd || pwd.length < 8) return
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
|
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getAccessToken } from '@/utils/token'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get, post, put } from '@/api/client'
|
import { get, post, put } from '@/api/client'
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ const ORG_TYPE_TEXT: Record<string, string> = {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||||
tableData.value = res.data || []
|
tableData.value = res.data || []
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
@@ -101,7 +102,7 @@ async function save() {
|
|||||||
if (!form.value.name) return
|
if (!form.value.name) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
if (editId.value) {
|
if (editId.value) {
|
||||||
await put(`/operator/organizations/${editId.value}`, form.value, {
|
await put(`/operator/organizations/${editId.value}`, form.value, {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
@@ -119,7 +120,7 @@ async function save() {
|
|||||||
|
|
||||||
async function toggleStatus(row: any) {
|
async function toggleStatus(row: any) {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||||
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getAccessToken } from '@/utils/token'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get, post } from '@/api/client'
|
import { get, post } from '@/api/client'
|
||||||
import { token as tokenColor } from '@/theme/tokens'
|
import { token as tokenColor } from '@/theme/tokens'
|
||||||
@@ -124,7 +125,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
|
||||||
tableData.value = res.data || []
|
tableData.value = res.data || []
|
||||||
loadStats()
|
loadStats()
|
||||||
@@ -134,7 +135,7 @@ async function loadData() {
|
|||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
|
||||||
const data = res.data || {}
|
const data = res.data || {}
|
||||||
stats.value = [
|
stats.value = [
|
||||||
@@ -152,7 +153,7 @@ async function openDetail(reportId: string) {
|
|||||||
processResult.value = 'no_action'
|
processResult.value = 'no_action'
|
||||||
processNote.value = ''
|
processNote.value = ''
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
|
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
|
||||||
detail.value = res.data
|
detail.value = res.data
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
@@ -162,7 +163,7 @@ async function handleProcess() {
|
|||||||
if (!processResult.value) return
|
if (!processResult.value) return
|
||||||
processing.value = true
|
processing.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const token = getAccessToken()
|
||||||
await post(`/operator/risk/reports/${currentReportId.value}/process`,
|
await post(`/operator/risk/reports/${currentReportId.value}/process`,
|
||||||
{ processResult: processResult.value, processNote: processNote.value },
|
{ processResult: processResult.value, processNote: processNote.value },
|
||||||
{ Authorization: `Bearer ${token}` })
|
{ Authorization: `Bearer ${token}` })
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
|
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
|
||||||
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
|
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
|
||||||
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
|
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
|
||||||
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
|
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['matchmaker.service'] } },
|
||||||
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } },
|
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.view'] } },
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -48,10 +48,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
|
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
|
||||||
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
|
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach(async (to) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const requiredPerms = (to.meta.permissions as string[]) || []
|
const requiredPerms = (to.meta.permissions as string[]) || []
|
||||||
|
|
||||||
|
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
|
||||||
|
await authStore.initializeSession()
|
||||||
|
|
||||||
// 1. 登录检查
|
// 1. 登录检查
|
||||||
if (to.path !== '/login' && !authStore.isLoggedIn) {
|
if (to.path !== '/login' && !authStore.isLoggedIn) {
|
||||||
return { path: '/login', query: { redirect: to.fullPath } }
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { post } from '@/api/client'
|
import { post } from '@/api/client'
|
||||||
|
import { setAccessToken, clearAccessToken } from '@/utils/token'
|
||||||
|
|
||||||
interface OperatorInfo {
|
interface OperatorInfo {
|
||||||
operatorId: string
|
operatorId: string
|
||||||
@@ -12,9 +13,9 @@ interface OperatorInfo {
|
|||||||
|
|
||||||
const previewRolePermissions: Record<string, string[]> = {
|
const previewRolePermissions: Record<string, string[]> = {
|
||||||
reviewer: ['review.view', 'user.view'],
|
reviewer: ['review.view', 'user.view'],
|
||||||
counselor: ['counseling.service.view', 'referral.manage'],
|
counselor: ['counseling.service.view', 'matchmaker.service'],
|
||||||
station: ['service_location.view', 'service_appointment.view'],
|
station: ['service_location.view', 'service_appointment.view'],
|
||||||
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'referral.manage'],
|
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'matchmaker.view', 'matchmaker.service'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
@@ -23,38 +24,70 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
||||||
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
|
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
|
||||||
: null
|
: null
|
||||||
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
|
|
||||||
const operator = ref<OperatorInfo | null>(
|
|
||||||
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
|
|
||||||
)
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
|
||||||
|
const operator = ref<OperatorInfo | null>(previewOperator)
|
||||||
|
let sessionInitialized = false
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value && !!operator.value)
|
||||||
const permissions = computed(() => operator.value?.permissions || [])
|
const permissions = computed(() => operator.value?.permissions || [])
|
||||||
|
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
|
||||||
|
const hasPermission = (code: string) => permissions.value.includes(code)
|
||||||
|
|
||||||
|
function applyAuthData(data: any) {
|
||||||
|
token.value = data.accessToken || ''
|
||||||
|
operator.value = {
|
||||||
|
operatorId: data.operatorId,
|
||||||
|
name: data.name,
|
||||||
|
username: data.username,
|
||||||
|
permissions: data.permissions || [],
|
||||||
|
organizationId: data.organizationId ?? null,
|
||||||
|
}
|
||||||
|
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
||||||
|
setAccessToken(token.value)
|
||||||
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string) {
|
async function login(username: string, password: string) {
|
||||||
const res: any = await post('/auth/operator/login', { username, password })
|
const res: any = await post('/auth/operator/login', { username, password })
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
token.value = res.data.accessToken
|
applyAuthData(res.data)
|
||||||
operator.value = {
|
|
||||||
operatorId: res.data.operatorId,
|
|
||||||
name: res.data.name,
|
|
||||||
username: res.data.username,
|
|
||||||
permissions: res.data.permissions || [],
|
|
||||||
organizationId: res.data.organizationId ?? null,
|
|
||||||
}
|
|
||||||
localStorage.setItem('accessToken', res.data.accessToken)
|
|
||||||
localStorage.setItem('operator', JSON.stringify(operator.value))
|
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
throw new Error(res.message || '登录失败')
|
throw new Error(res.message || '登录失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
/**
|
||||||
token.value = ''
|
* 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
|
||||||
operator.value = null
|
* 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
|
||||||
localStorage.removeItem('accessToken')
|
* 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
|
||||||
localStorage.removeItem('operator')
|
*/
|
||||||
|
async function initializeSession(): Promise<boolean> {
|
||||||
|
if (sessionInitialized) return isLoggedIn.value
|
||||||
|
sessionInitialized = true
|
||||||
|
if (token.value) return true
|
||||||
|
try {
|
||||||
|
const res: any = await post('/auth/operator/refresh', {})
|
||||||
|
if (res.success) {
|
||||||
|
applyAuthData(res.data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 未登录或 refresh 失效,交由路由守卫跳转登录页
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, operator, isLoggedIn, permissions, login, logout }
|
function logout() {
|
||||||
|
// 尽力通知后端注销并清除 Cookie(best-effort)
|
||||||
|
try {
|
||||||
|
post('/auth/operator/logout', {}).catch(() => {})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
token.value = ''
|
||||||
|
operator.value = null
|
||||||
|
clearAccessToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// 内存态访问令牌。
|
||||||
|
//
|
||||||
|
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
|
||||||
|
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
|
||||||
|
// Authorization 头承载。两者并存,互不冲突。
|
||||||
|
|
||||||
|
let currentToken = ''
|
||||||
|
|
||||||
|
export function getAccessToken(): string {
|
||||||
|
return currentToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAccessToken(token: string): void {
|
||||||
|
currentToken = token || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAccessToken(): void {
|
||||||
|
currentToken = ''
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 187 KiB |
@@ -13,9 +13,9 @@
|
|||||||
|
|
||||||
<groupId>com.danyang.match</groupId>
|
<groupId>com.danyang.match</groupId>
|
||||||
<artifactId>danyang-match-platform</artifactId>
|
<artifactId>danyang-match-platform</artifactId>
|
||||||
<version>1.0.0-RC.1</version>
|
<version>1.2.0</version>
|
||||||
<name>Danyang Match Platform</name>
|
<name>Danyang Match Platform</name>
|
||||||
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
|
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import com.danyang.match.platform.activity.entity.ActivityInterest;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
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 com.danyang.match.platform.relationship.service.RelationshipService;
|
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
|
||||||
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 lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -30,7 +30,8 @@ import java.util.Map;
|
|||||||
public class ActivityInterestController {
|
public class ActivityInterestController {
|
||||||
|
|
||||||
private final ActivityInterestService interestService;
|
private final ActivityInterestService interestService;
|
||||||
private final RelationshipService relationshipService;
|
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
|
||||||
|
private final ActivityMutualConsentService consentService;
|
||||||
private final ActivityMapper activityMapper;
|
private final ActivityMapper activityMapper;
|
||||||
|
|
||||||
@GetMapping("/operator/activity-interest-summary")
|
@GetMapping("/operator/activity-interest-summary")
|
||||||
@@ -105,7 +106,12 @@ public class ActivityInterestController {
|
|||||||
/**
|
/**
|
||||||
* 确认开启线上沟通(V1.1 Phase 08)
|
* 确认开启线上沟通(V1.1 Phase 08)
|
||||||
* POST /api/v1/activities/{id}/interests/confirm
|
* POST /api/v1/activities/{id}/interests/confirm
|
||||||
* 双方确认后创建 Match Episode,不自动创建 Conversation
|
*
|
||||||
|
* <p><b>2026-09-23 UAT 走查修复(INV-014/015)</b>:此前本端点仅校验「双向互选」便直调
|
||||||
|
* {@code RelationshipService.confirmActivityInterest} —— 那是「建 Match」的底层原语
|
||||||
|
* (不做任何同意校验),于是**单方调用即可建 Match/会话**,绕过「双方各自显式确认」
|
||||||
|
* 的二阶段同意。现改为与 {@code /activities/{id}/mutual/*} 走**同一条**二阶段链路:
|
||||||
|
* 本端点只登记「我方确认」,只有双方都 accepted 时才建 Match/会话。
|
||||||
*/
|
*/
|
||||||
@Idempotent
|
@Idempotent
|
||||||
@PostMapping("/{id}/interests/confirm")
|
@PostMapping("/{id}/interests/confirm")
|
||||||
@@ -120,13 +126,16 @@ public class ActivityInterestController {
|
|||||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||||
"partnerId不能为空");
|
"partnerId不能为空");
|
||||||
}
|
}
|
||||||
// 验证互选状态
|
// 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
|
||||||
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
|
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
|
||||||
return ApiResponse.error(
|
return ApiResponse.error(
|
||||||
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
|
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
|
||||||
"您与该用户未形成活动双向互选");
|
"您与该用户未形成活动双向互选");
|
||||||
}
|
}
|
||||||
return ApiResponse.ok(relationshipService.confirmActivityInterest(userId, partnerId));
|
// 二阶段同意:先幂等确保同意记录存在,再仅提交「我方确认」。
|
||||||
|
// 单方调用不再可能创建 Match(INV-014/015)。
|
||||||
|
consentService.createMutualConsent(id, userId, partnerId);
|
||||||
|
return ApiResponse.ok(consentService.confirmConnection(userId, id, partnerId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import java.util.*;
|
|||||||
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.GlobalExceptionHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 活动互选服务
|
* 活动互选服务
|
||||||
@@ -69,7 +70,7 @@ public class ActivityInterestService {
|
|||||||
|
|
||||||
// 2. 验证用户已报名且通过审核
|
// 2. 验证用户已报名且通过审核
|
||||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||||
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
|
||||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import java.util.*;
|
|||||||
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.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -407,7 +408,7 @@ public class ActivityService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public ActivityRegistration cancelRegistration(String userId, String activityId) {
|
public ActivityRegistration cancelRegistration(String userId, String activityId) {
|
||||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
|
||||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
|
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
|
||||||
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
|
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
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;
|
import com.danyang.match.platform.shared.exception.ServerErrorException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 签到服务
|
* 签到服务
|
||||||
@@ -64,7 +65,7 @@ public class CheckinService {
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
|
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
|
||||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||||
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
|
||||||
|
|
||||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -67,7 +69,7 @@ public class ActivityMutualConsentService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
|
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
|
||||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
|
||||||
|
|
||||||
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
|
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
|
||||||
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
|
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
|
||||||
@@ -76,7 +78,7 @@ public class ActivityMutualConsentService {
|
|||||||
|
|
||||||
// 回读权威状态(可能受并发另一方提交影响)
|
// 回读权威状态(可能受并发另一方提交影响)
|
||||||
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||||
.orElseThrow(() -> new RuntimeException("互选记录已不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "互选记录已不存在"));
|
||||||
|
|
||||||
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
|
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
|
||||||
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
|
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
|
||||||
@@ -120,7 +122,7 @@ public class ActivityMutualConsentService {
|
|||||||
|
|
||||||
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
|
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
|
||||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
|
||||||
if (userId.equals(consent.getUserAId())) {
|
if (userId.equals(consent.getUserAId())) {
|
||||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,4 +19,15 @@ public class AuditLog extends BaseEntity {
|
|||||||
private String afterData;
|
private String afterData;
|
||||||
private String ip;
|
private String ip;
|
||||||
private String userAgent;
|
private String userAgent;
|
||||||
|
|
||||||
|
// ==================== ZC-MM-006 / T01 扩展审计字段(V055,全可空) ====================
|
||||||
|
|
||||||
|
/** 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view。 */
|
||||||
|
private String purposeCode;
|
||||||
|
/** 关联的 matchmaker_assignments.id(非分配场景为 null)。 */
|
||||||
|
private String serviceAssignmentId;
|
||||||
|
/** 落库时操作者权限快照(permissions 逗号拼接,截断 128)。 */
|
||||||
|
private String actorRoleSnapshot;
|
||||||
|
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
|
||||||
|
private String requestTraceId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ public class AuditLogService {
|
|||||||
m.put("ip", log.getIp());
|
m.put("ip", log.getIp());
|
||||||
m.put("userAgent", log.getUserAgent());
|
m.put("userAgent", log.getUserAgent());
|
||||||
m.put("createdAt", log.getCreatedAt());
|
m.put("createdAt", log.getCreatedAt());
|
||||||
|
// ZC-MM-006 / T01:扩展审计字段输出(可空键,存量行输出 null,读取兼容)
|
||||||
|
m.put("purposeCode", log.getPurposeCode());
|
||||||
|
m.put("serviceAssignmentId", log.getServiceAssignmentId());
|
||||||
|
m.put("actorRoleSnapshot", log.getActorRoleSnapshot());
|
||||||
|
m.put("requestTraceId", log.getRequestTraceId());
|
||||||
return m;
|
return m;
|
||||||
}).collect(java.util.stream.Collectors.toList());
|
}).collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
@@ -73,11 +78,23 @@ public class AuditLogService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
|
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
|
||||||
|
recordSensitiveAccess(operatorId, resourceType, resourceId, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T01 扩展版敏感访问记录:附加目的码与关联分配 ID。
|
||||||
|
* 旧 3 参签名委托本方法(purposeCode/serviceAssignmentId 为 null),存量调用兼容。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId,
|
||||||
|
String purposeCode, String serviceAssignmentId) {
|
||||||
AuditLog auditLog = new AuditLog();
|
AuditLog auditLog = new AuditLog();
|
||||||
auditLog.setOperatorId(operatorId);
|
auditLog.setOperatorId(operatorId);
|
||||||
auditLog.setAction("SENSITIVE_ACCESS");
|
auditLog.setAction("SENSITIVE_ACCESS");
|
||||||
auditLog.setResourceType(resourceType);
|
auditLog.setResourceType(resourceType);
|
||||||
auditLog.setResourceId(resourceId);
|
auditLog.setResourceId(resourceId);
|
||||||
|
auditLog.setPurposeCode(purposeCode);
|
||||||
|
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||||
HttpServletRequest req = attrs.getRequest();
|
HttpServletRequest req = attrs.getRequest();
|
||||||
auditLog.setIp(req.getRemoteAddr());
|
auditLog.setIp(req.getRemoteAddr());
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.danyang.match.platform.auth.controller;
|
package com.danyang.match.platform.auth.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
|
||||||
import com.danyang.match.platform.auth.service.OperatorAuthService;
|
import com.danyang.match.platform.auth.service.OperatorAuthService;
|
||||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -17,14 +23,80 @@ import java.util.Map;
|
|||||||
public class OperatorAuthController {
|
public class OperatorAuthController {
|
||||||
|
|
||||||
private final OperatorAuthService operatorAuthService;
|
private final OperatorAuthService operatorAuthService;
|
||||||
|
private final OperatorCookieWriter cookieWriter;
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body) {
|
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
|
||||||
|
HttpServletRequest request, HttpServletResponse response) {
|
||||||
String username = body.get("username");
|
String username = body.get("username");
|
||||||
String password = body.get("password");
|
String password = body.get("password");
|
||||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
|
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
|
||||||
}
|
}
|
||||||
return ApiResponse.ok(operatorAuthService.login(username, password));
|
Map<String, Object> result = operatorAuthService.login(username, password);
|
||||||
|
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌。
|
||||||
|
// Cookie max-age 由 OperatorCookieWriter 按 JWT 有效期计算,控制器不引用 JWT 工具
|
||||||
|
// (控制器禁止解析/引用 JWT 工具,见 PrincipalInjectionContractTest 契约)。
|
||||||
|
cookieWriter.writeLoginCookies(response,
|
||||||
|
(String) result.get("accessToken"), (String) result.get("refreshToken"));
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 Operator Token。
|
||||||
|
*
|
||||||
|
* <p>refresh token 优先取请求体(兼容旧前端/跨端口开发代理),缺省时回退到 HttpOnly Cookie
|
||||||
|
* {@code dy_op_rt}(生产同源场景,前端不持有 refresh token,抗 XSS)。
|
||||||
|
* 响应同时下发新的 access / refresh Cookie,实现刷新即轮换。
|
||||||
|
*/
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ApiResponse<Map<String, Object>> refresh(@RequestBody(required = false) Map<String, String> body,
|
||||||
|
HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
String refreshToken = (body != null) ? body.get("refreshToken") : null;
|
||||||
|
if (refreshToken == null || refreshToken.isBlank()) {
|
||||||
|
refreshToken = readCookie(request, OperatorCookieWriter.REFRESH_COOKIE);
|
||||||
|
}
|
||||||
|
if (refreshToken == null || refreshToken.isBlank()) {
|
||||||
|
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "缺少 Refresh Token");
|
||||||
|
}
|
||||||
|
String operatorId = (body != null) ? body.get("operatorId") : null;
|
||||||
|
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
|
||||||
|
cookieWriter.writeLoginCookies(response,
|
||||||
|
(String) result.get("accessToken"), (String) result.get("refreshToken"));
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
|
||||||
|
* operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入),
|
||||||
|
* 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie,
|
||||||
|
* 仅用于黑名单登记。
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
String token = readBearer(request);
|
||||||
|
if (token != null && principal != null) {
|
||||||
|
operatorAuthService.logout(principal.id(), token);
|
||||||
|
}
|
||||||
|
cookieWriter.clearCookies(response);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readCookie(HttpServletRequest request, String name) {
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies == null) return null;
|
||||||
|
for (Cookie c : cookies) {
|
||||||
|
if (name.equals(c.getName())) return c.getValue();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readBearer(HttpServletRequest request) {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
|
return header.substring(7);
|
||||||
|
}
|
||||||
|
return readCookie(request, OperatorCookieWriter.ACCESS_COOKIE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.danyang.match.platform.auth.cookie;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端鉴权 Cookie 写入工具。
|
||||||
|
*
|
||||||
|
* <p>登录 / 刷新成功时下发 HttpOnly + SameSite=Lax 的 access / refresh Cookie;登出时清除。
|
||||||
|
* Cookie 为 HttpOnly,JS 无法读取,从根本上消除 XSS 读取令牌并外泄的风险(此前令牌存于
|
||||||
|
* 前端 localStorage,XSS 可读写并伪造权限)。
|
||||||
|
*
|
||||||
|
* <p>{@code Secure} 标志由 {@code app.auth-cookie-secure} 控制:生产(https)为 true;
|
||||||
|
* dev / docker(http)为 false,否则浏览器拒绝写入。
|
||||||
|
*
|
||||||
|
* <p>路径固定为 {@code /api/v1}(即后端 servlet context-path),确保只随同源 API 请求携带。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OperatorCookieWriter {
|
||||||
|
|
||||||
|
public static final String ACCESS_COOKIE = "dy_op_at";
|
||||||
|
public static final String REFRESH_COOKIE = "dy_op_rt";
|
||||||
|
|
||||||
|
@Value("${app.auth-cookie-secure:true}")
|
||||||
|
private boolean secure;
|
||||||
|
|
||||||
|
// Cookie 生命周期与 JWT 自身有效期对齐(access 2h / refresh 14d),由配置驱动、不写死。
|
||||||
|
// 由本组件持有而非控制器持有:控制器不得直接引用 JwtTokenService
|
||||||
|
// (PrincipalInjectionContractTest 契约禁止控制器解析/引用 JWT 工具)。
|
||||||
|
@Value("${jwt.access-token-expiration-ms:7200000}")
|
||||||
|
private long accessExpirationMs;
|
||||||
|
|
||||||
|
@Value("${jwt.refresh-token-expiration-ms:1209600000}")
|
||||||
|
private long refreshExpirationMs;
|
||||||
|
|
||||||
|
/** 登录/刷新下发 Cookie,max-age 自动取 JWT 有效期(秒)。 */
|
||||||
|
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken) {
|
||||||
|
writeLoginCookies(response, accessToken, refreshToken, accessExpirationMs / 1000, refreshExpirationMs / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
|
||||||
|
long accessMaxAgeSec, long refreshMaxAgeSec) {
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, refreshToken, refreshMaxAgeSec).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearCookies(HttpServletResponse response) {
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, "", 0).toString());
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, "", 0).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseCookie build(String name, String value, long maxAgeSec) {
|
||||||
|
return ResponseCookie.from(name, value)
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(secure)
|
||||||
|
.sameSite("Lax")
|
||||||
|
.path("/api/v1")
|
||||||
|
.maxAge(maxAgeSec)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,15 @@ import java.util.UUID;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock 微信登录 Provider(开发/测试环境使用)
|
* Mock 微信登录 Provider(开发/测试环境使用)。
|
||||||
|
*
|
||||||
|
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code wechat.login.provider},
|
||||||
|
* 避免与实名核验 Provider 共用一把开关导致生产误启 Mock。
|
||||||
|
* 保留 {@code matchIfMissing = true}:未显式配置时本地/测试默认走 Mock;
|
||||||
|
* 生产必须在 {@code application-production.yml} 显式置 {@code wechat.login.provider=real}。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
|
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
|
||||||
public class MockWechatLoginProvider implements WechatLoginProvider {
|
public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
|
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.danyang.match.platform.auth.provider;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实微信登录 Provider(生产环境使用)。
|
||||||
|
*
|
||||||
|
* <p>通过 {@code code2session} 用登录临时凭证 {@code code} 换取 {@code openid / session_key / unionid}。
|
||||||
|
* 由配置 {@code wechat.login.provider=real} 激活;未显式配置时默认是 Mock(见 {@link MockWechatLoginProvider}),
|
||||||
|
* 因此生产环境必须在 {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
|
||||||
|
*
|
||||||
|
* <p>安全要点:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code appId / appSecret} 必须来自环境变量({@code WECHAT_APP_ID / WECHAT_APP_SECRET}),不得硬编码;
|
||||||
|
* 缺失即 fail-fast,避免用占位值放行任意 code。</li>
|
||||||
|
* <li>code 必须进 URL 前编码;响应必须校验 {@code errcode},微信对非法/过期 code 会返回 errcode 而非抛网络异常。</li>
|
||||||
|
* <li>绝不把 appSecret / session_key 回传给前端。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "real")
|
||||||
|
public class RealWechatLoginProvider implements WechatLoginProvider {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RealWechatLoginProvider.class);
|
||||||
|
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
|
||||||
|
|
||||||
|
@Value("${wechat.app-id:}")
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
@Value("${wechat.app-secret:}")
|
||||||
|
private String appSecret;
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String> getCodeSession(String code) {
|
||||||
|
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
|
||||||
|
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意 code
|
||||||
|
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录未配置 appId/appSecret");
|
||||||
|
}
|
||||||
|
if (code == null || code.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:缺少 code");
|
||||||
|
}
|
||||||
|
|
||||||
|
String url = CODE2SESSION_URL
|
||||||
|
+ "?appid=" + appId
|
||||||
|
+ "&secret=" + appSecret
|
||||||
|
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
|
||||||
|
+ "&grant_type=authorization_code";
|
||||||
|
|
||||||
|
try {
|
||||||
|
String resp = restTemplate.getForObject(url, String.class);
|
||||||
|
JsonNode node = objectMapper.readTree(resp);
|
||||||
|
if (node.has("errcode") && node.get("errcode").asInt(0) != 0) {
|
||||||
|
log.warn("[Wechat] code2session 失败: errcode={}, errmsg={}",
|
||||||
|
node.get("errcode").asText(), node.has("errmsg") ? node.get("errmsg").asText() : "");
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败,请重试");
|
||||||
|
}
|
||||||
|
String openid = node.has("openid") ? node.get("openid").asText() : null;
|
||||||
|
if (openid == null || openid.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
|
||||||
|
}
|
||||||
|
String sessionKey = node.has("session_key") ? node.get("session_key").asText() : null;
|
||||||
|
String unionid = node.has("unionid") ? node.get("unionid").asText() : null;
|
||||||
|
|
||||||
|
// 用 HashMap(允许 unionid 为 null),不要 Map.of(禁止 null 值)
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
result.put("openid", openid);
|
||||||
|
result.put("unionid", unionid);
|
||||||
|
result.put("session_key", sessionKey);
|
||||||
|
result.put("appid", appId);
|
||||||
|
return result;
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[Wechat] code2session 调用异常", e);
|
||||||
|
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录服务异常");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isMock() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import java.time.Duration;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -90,7 +91,9 @@ public class AuthService {
|
|||||||
|
|
||||||
// 将 refreshToken 存入 Redis(用于撤销)
|
// 将 refreshToken 存入 Redis(用于撤销)
|
||||||
String refreshTokenKey = "auth:refresh_token:" + user.getId();
|
String refreshTokenKey = "auth:refresh_token:" + user.getId();
|
||||||
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
|
// 刷新令牌 Redis 登记的 TTL 必须与 refresh JWT 自身有效期一致(getRefreshExpirationMs,
|
||||||
|
// 默认 14 天),否则 4h 后记录消失、刷新即失效,用户被迫频繁重新登录。
|
||||||
|
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||||
|
|
||||||
// 记录审计日志
|
// 记录审计日志
|
||||||
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
|
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
|
||||||
@@ -111,7 +114,7 @@ public class AuthService {
|
|||||||
public void bindPhone(String userId, String phoneCode) {
|
public void bindPhone(String userId, String phoneCode) {
|
||||||
// TODO: Phase 03 对接真实短信验证码
|
// TODO: Phase 03 对接真实短信验证码
|
||||||
User user = userMapper.findById(userId)
|
User user = userMapper.findById(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
|
||||||
// 检查手机号是否已被其他用户占用
|
// 检查手机号是否已被其他用户占用
|
||||||
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
|
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
|
||||||
@@ -172,9 +175,9 @@ public class AuthService {
|
|||||||
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
|
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
|
||||||
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
|
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
|
||||||
|
|
||||||
// 更新 Redis
|
// 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
|
||||||
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
|
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
|
||||||
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
|
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||||
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("accessToken", newAccessToken);
|
result.put("accessToken", newAccessToken);
|
||||||
@@ -200,7 +203,7 @@ public class AuthService {
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> getMeStatus(String userId) {
|
public Map<String, Object> getMeStatus(String userId) {
|
||||||
User user = userMapper.findById(userId)
|
User user = userMapper.findById(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
|
||||||
Map<String, Object> status = new HashMap<>();
|
Map<String, Object> status = new HashMap<>();
|
||||||
status.put("userId", user.getId());
|
status.put("userId", user.getId());
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -100,6 +101,9 @@ public class OperatorAuthService {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// 签发 Token
|
// 签发 Token
|
||||||
|
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
|
||||||
|
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
|
||||||
|
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
|
||||||
Map<String, Object> claims = new HashMap<>();
|
Map<String, Object> claims = new HashMap<>();
|
||||||
claims.put("operatorId", operator.getId());
|
claims.put("operatorId", operator.getId());
|
||||||
claims.put("organizationId", operator.getOrganizationId());
|
claims.put("organizationId", operator.getOrganizationId());
|
||||||
@@ -144,7 +148,7 @@ public class OperatorAuthService {
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> getMe(String operatorId) {
|
public Map<String, Object> getMe(String operatorId) {
|
||||||
Operator operator = operatorMapper.findById(operatorId)
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
|
||||||
|
|
||||||
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
|
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
|
||||||
List<String> permissionCodes = permissions.stream()
|
List<String> permissionCodes = permissions.stream()
|
||||||
@@ -176,6 +180,11 @@ public class OperatorAuthService {
|
|||||||
throw new UnauthorizedException("Refresh Token 无效");
|
throw new UnauthorizedException("Refresh Token 无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
|
||||||
|
if (operatorId == null || operatorId.isBlank()) {
|
||||||
|
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
|
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
|
||||||
if (storedToken == null || !storedToken.equals(refreshToken)) {
|
if (storedToken == null || !storedToken.equals(refreshToken)) {
|
||||||
throw new UnauthorizedException("Refresh Token 无效");
|
throw new UnauthorizedException("Refresh Token 无效");
|
||||||
@@ -193,6 +202,12 @@ public class OperatorAuthService {
|
|||||||
result.put("accessToken", accessToken);
|
result.put("accessToken", accessToken);
|
||||||
result.put("refreshToken", newRefreshToken);
|
result.put("refreshToken", newRefreshToken);
|
||||||
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||||
|
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
|
||||||
|
result.put("operatorId", operatorInfo.get("operatorId"));
|
||||||
|
result.put("name", operatorInfo.get("name"));
|
||||||
|
result.put("username", operatorInfo.get("username"));
|
||||||
|
result.put("organizationId", operatorInfo.get("organizationId"));
|
||||||
|
result.put("permissions", operatorInfo.get("permissions"));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,9 +215,21 @@ public class JwtTokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Token 过期时间
|
* 获取 Access Token 过期时间(毫秒)
|
||||||
*/
|
*/
|
||||||
public long getExpirationMs() {
|
public long getExpirationMs() {
|
||||||
return accessExpirationMs;
|
return accessExpirationMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Refresh Token 过期时间(毫秒)。
|
||||||
|
*
|
||||||
|
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
|
||||||
|
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
|
||||||
|
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
|
||||||
|
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
|
||||||
|
*/
|
||||||
|
public long getRefreshExpirationMs() {
|
||||||
|
return refreshExpirationMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
|
|||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
private final LocalEligibilityMapper localEligibilityMapper;
|
private final LocalEligibilityMapper localEligibilityMapper;
|
||||||
|
/** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
|
||||||
|
private final MatchScoringService matchScoringService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取每日推荐列表
|
* 获取每日推荐列表
|
||||||
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 双向 Required 检查
|
// 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
|
||||||
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
|
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
|
||||||
if (targetPref == null) {
|
if (targetPref == null) {
|
||||||
return null; // 目标用户未设置择偶条件,无法双向验证
|
return null; // 目标用户未设置择偶条件,无法双向验证
|
||||||
}
|
}
|
||||||
|
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||||
// A → B Required 检查
|
UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
|
||||||
if (!checkBidirectionalRequired(myPref, targetPref)) {
|
if (myProfile == null || targetProfile == null) {
|
||||||
|
return null; // 资料缺失则无法核验 Required,不假定满足
|
||||||
|
}
|
||||||
|
if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,36 +180,18 @@ public class MatchEligibilityService {
|
|||||||
return "verified".equals(el.getVerificationStatus());
|
return "verified".equals(el.getVerificationStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
|
||||||
* 双向 Required 检查
|
// (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
|
||||||
* Required 是 Hard Constraint,不可自动放宽
|
|
||||||
*/
|
|
||||||
private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
|
|
||||||
// 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
|
|
||||||
// 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
|
|
||||||
// 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 方向性评分(A对B的适配度)
|
* 方向性评分(A对B的适配度)
|
||||||
* 基于 Preferred 维度计算 0-100 分
|
* 基于 Preferred 维度计算 0-100 分
|
||||||
*/
|
*/
|
||||||
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
|
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
|
||||||
double score = 0;
|
// 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
|
||||||
double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() +
|
return userProfileMapper.findByUserId(toUserId)
|
||||||
pref.getMaritalWeight() + pref.getChildrenWeight() +
|
.map(p -> matchScoringService.scoreDirectionally(pref, p))
|
||||||
pref.getEducationWeight() + pref.getHeightWeight() +
|
.orElse(0.0);
|
||||||
pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
|
|
||||||
pref.getLifestyleWeight() + pref.getInterestWeight();
|
|
||||||
|
|
||||||
// TODO: 实现各维度评分逻辑
|
|
||||||
// 年龄匹配:在范围内得满分,超出范围按比例扣分
|
|
||||||
// 地区匹配:同地区得满分,不同地区按比例扣分
|
|
||||||
// ...
|
|
||||||
|
|
||||||
if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
|
|
||||||
return (int)(Math.round(score / totalWeight * 100));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,20 +199,21 @@ public class MatchEligibilityService {
|
|||||||
*/
|
*/
|
||||||
private List<String> generateReasons(String userId, String targetUserId,
|
private List<String> generateReasons(String userId, String targetUserId,
|
||||||
UserPreference myPref, UserPreference targetPref) {
|
UserPreference myPref, UserPreference targetPref) {
|
||||||
List<String> reasons = new ArrayList<>();
|
// 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
|
||||||
// TODO: 根据实际资料生成真实推荐理由
|
return userProfileMapper.findByUserId(targetUserId)
|
||||||
reasons.add("年龄符合双方择偶要求");
|
.map(p -> matchScoringService.reasons(myPref, p))
|
||||||
reasons.add("都长期生活在丹阳");
|
.orElseGet(ArrayList::new);
|
||||||
reasons.add("婚姻计划接近");
|
|
||||||
return reasons;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取已Pass的用户ID集合(冷却期内)
|
* 获取已Pass的用户ID集合(冷却期内)
|
||||||
*/
|
*/
|
||||||
private Set<String> getPassedUserIds(String userId, Instant since) {
|
private Set<String> getPassedUserIds(String userId, Instant since) {
|
||||||
// TODO: 查询 user_relations 表中 passed 关系的 target_user_id
|
// 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
|
||||||
return Collections.emptySet();
|
return recommendationRecordMapper.findRecentByUserId(userId, since).stream()
|
||||||
|
.filter(r -> "passed".equals(r.getAction()))
|
||||||
|
.map(RecommendationRecord::getTargetUserId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -240,14 +228,28 @@ public class MatchEligibilityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建候选池(简化版)
|
* 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
|
||||||
* TODO: Phase 06 后续接入数据库查询可用用户
|
* (2026-09-23:替换原 `return List.of()` 空实现。)
|
||||||
*/
|
*/
|
||||||
private List<String> buildCandidatePool(String userId, UserPreference myPref,
|
private List<String> buildCandidatePool(String userId, UserPreference myPref,
|
||||||
Set<String> excludedIds, Set<String> alreadyRecommended) {
|
Set<String> excludedIds, Set<String> alreadyRecommended) {
|
||||||
// TODO: 从数据库查询符合条件的用户
|
// 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
|
||||||
// 当前返回空列表,等待后续阶段完善
|
// 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
|
||||||
return List.of();
|
UserProfile me = userProfileMapper.findByUserId(userId).orElse(null);
|
||||||
|
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserProfile> wrapper =
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
|
||||||
|
wrapper.isNotNull(UserProfile::getGender)
|
||||||
|
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
|
||||||
|
.isNull(UserProfile::getDeletedAt);
|
||||||
|
if (me != null && me.getGender() != null && !me.getGender().isBlank()) {
|
||||||
|
wrapper.ne(UserProfile::getGender, me.getGender());
|
||||||
|
}
|
||||||
|
return userProfileMapper.selectList(wrapper).stream()
|
||||||
|
.map(UserProfile::getUserId)
|
||||||
|
.filter(id -> !id.equals(userId))
|
||||||
|
.filter(id -> !excludedIds.contains(id))
|
||||||
|
.filter(id -> !alreadyRecommended.contains(id))
|
||||||
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) {
|
private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) {
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package com.danyang.match.platform.match.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.profile.entity.UserPreference;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.Period;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配评分 —— **全仓唯一事实源**。
|
||||||
|
*
|
||||||
|
* <p>背景:`RecommendationEngineService.calculateScore` 与
|
||||||
|
* `MatchEligibilityService.directionalScore` 曾各自是**桩**(前者 `return 50.0`、
|
||||||
|
* 后者 `score` 恒 0),而阈值 `match.default-score-threshold` 默认 60 →
|
||||||
|
* `mutualScore < 60` 恒成立 → **推荐列表恒为空**(2026-09-23 UAT 走查实测确认)。
|
||||||
|
* 且两处评分逻辑重复,是典型的「双实现漂移」隐患。
|
||||||
|
*
|
||||||
|
* <p>本类把「择偶条件 × 候选人资料」的方向性评分收敛到一处,供两条推荐路径共用。
|
||||||
|
*
|
||||||
|
* <p><b>规则(不新造常量)</b>:直接使用 {@link UserPreference} 上<b>已存在</b>的
|
||||||
|
* 十个维度权重;只为「用户实际设置了的条件」计分(未设置 = 不构成约束,不计入分母)。
|
||||||
|
* 某维度上候选人缺少可用于判定的资料时,该维度计 0 分(「无法核验即不假定满足」,
|
||||||
|
* 与 Required「不可自动放宽」一致)。若用户未设置任何条件,视为无约束 → 返回 100。
|
||||||
|
*
|
||||||
|
* <p>返回值域 0–100;调用方按 `match.default-score-threshold`(技术默认 60,OQ-MATCH-001)过滤。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MatchScoringService {
|
||||||
|
|
||||||
|
/** 学历高低序列,取索引差做比较(与 chk_profile_edu 顺序一致)。 */
|
||||||
|
private static final List<String> EDUCATION_ORDER = List.of(
|
||||||
|
"primary", "junior_high", "senior_high", "vocational", "college", "bachelor", "master", "doctor");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方向性评分:seeker 的择偶条件对 candidate 资料的适配度(0–100)。
|
||||||
|
*
|
||||||
|
* @param pref seeker 的择偶条件(可为 null → 无约束)
|
||||||
|
* @param candidate 候选人资料
|
||||||
|
*/
|
||||||
|
public double scoreDirectionally(UserPreference pref, UserProfile candidate) {
|
||||||
|
if (candidate == null) return 0;
|
||||||
|
if (pref == null) return 100;
|
||||||
|
|
||||||
|
double acc = 0; // 加权得分
|
||||||
|
double denom = 0; // 已设置条件的权重合计
|
||||||
|
|
||||||
|
// —— 年龄 ——(设置了 min/maxAge 才构成约束)
|
||||||
|
if (pref.getMinAge() != null && pref.getMaxAge() != null) {
|
||||||
|
double w = w(pref.getAgeWeight());
|
||||||
|
denom += w;
|
||||||
|
acc += w * rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge());
|
||||||
|
}
|
||||||
|
// —— 身高 ——
|
||||||
|
if (pref.getMinHeightCm() != null && pref.getMaxHeightCm() != null) {
|
||||||
|
double w = w(pref.getHeightWeight());
|
||||||
|
denom += w;
|
||||||
|
Integer h = candidate.getHeightCm();
|
||||||
|
acc += w * (h == null ? 0 : rangeRatio(h, pref.getMinHeightCm(), pref.getMaxHeightCm()));
|
||||||
|
}
|
||||||
|
// —— 地区(regionScope 为地区码数组)——
|
||||||
|
if (hasJsonArray(pref.getRegionScope())) {
|
||||||
|
double w = w(pref.getRegionWeight());
|
||||||
|
denom += w;
|
||||||
|
acc += w * (inJsonArray(pref.getRegionScope(), candidate.getRegionCode()) ? 1 : 0);
|
||||||
|
}
|
||||||
|
// —— 婚姻状况(maritalStatuses 数组)——
|
||||||
|
if (hasJsonArray(pref.getMaritalStatuses())) {
|
||||||
|
double w = w(pref.getMaritalWeight());
|
||||||
|
denom += w;
|
||||||
|
acc += w * (inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus()) ? 1 : 0);
|
||||||
|
}
|
||||||
|
// —— 子女(childrenAcceptance:any/accept/reject,兼容中文)——
|
||||||
|
if (notBlank(pref.getChildrenAcceptance())) {
|
||||||
|
double w = w(pref.getChildrenWeight());
|
||||||
|
denom += w;
|
||||||
|
acc += w * (childrenAcceptable(pref.getChildrenAcceptance(), candidate.getChildrenStatus()) ? 1 : 0);
|
||||||
|
}
|
||||||
|
// —— 学历(minEducationCode 为下限)——
|
||||||
|
if (notBlank(pref.getMinEducationCode())) {
|
||||||
|
double w = w(pref.getEducationWeight());
|
||||||
|
denom += w;
|
||||||
|
acc += w * (educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode()) ? 1 : 0);
|
||||||
|
}
|
||||||
|
// —— 婚恋规划(marriagePlanPreference 与 candidate.marriagePlan 一致)——
|
||||||
|
if (notBlank(pref.getMarriagePlanPreference())) {
|
||||||
|
double w = w(pref.getMarriagePlanWeight());
|
||||||
|
denom += w;
|
||||||
|
acc += w * (sameValue(pref.getMarriagePlanPreference(), candidate.getMarriagePlan()) ? 1 : 0);
|
||||||
|
}
|
||||||
|
// —— 定居意愿(settlementPreference 对照 candidate 长期/未来丹阳意向)——
|
||||||
|
if (notBlank(pref.getSettlementPreference())) {
|
||||||
|
double w = w(pref.getSettlementWeight());
|
||||||
|
denom += w;
|
||||||
|
boolean ok = sameValue(pref.getSettlementPreference(), candidate.getLongTermDanyang())
|
||||||
|
|| sameValue(pref.getSettlementPreference(), candidate.getFutureDanyang());
|
||||||
|
acc += w * (ok ? 1 : 0);
|
||||||
|
}
|
||||||
|
// —— 生活方式(吸烟 / 饮酒,取已设置者的均值)——
|
||||||
|
double lifeW = w(pref.getLifestyleWeight());
|
||||||
|
int lifeDims = 0;
|
||||||
|
double lifeHit = 0;
|
||||||
|
if (notBlank(pref.getSmokingPreference())) {
|
||||||
|
lifeDims++;
|
||||||
|
if (sameValue(pref.getSmokingPreference(), candidate.getSmokingStatus())) lifeHit++;
|
||||||
|
}
|
||||||
|
if (notBlank(pref.getDrinkingPreference())) {
|
||||||
|
lifeDims++;
|
||||||
|
if (sameValue(pref.getDrinkingPreference(), candidate.getDrinkingStatus())) lifeHit++;
|
||||||
|
}
|
||||||
|
if (lifeDims > 0) {
|
||||||
|
denom += lifeW;
|
||||||
|
acc += lifeW * (lifeHit / lifeDims);
|
||||||
|
}
|
||||||
|
// interestWeight:当前无对应择偶条件字段,不参与计分(保留权重以备扩展)。
|
||||||
|
|
||||||
|
if (denom == 0) return 100; // 用户未设置任何条件 → 无约束
|
||||||
|
return clamp(acc / denom * 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成中文推荐理由(基于**实际达成**的维度,不再是无条件套话)。
|
||||||
|
* 未达成任何具体维度时回退为「资料符合基本要求」。
|
||||||
|
*/
|
||||||
|
public List<String> reasons(UserPreference pref, UserProfile candidate) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (pref == null || candidate == null) return out;
|
||||||
|
if (pref.getMinAge() != null && pref.getMaxAge() != null
|
||||||
|
&& rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge()) > 0) {
|
||||||
|
out.add("年龄在您的要求范围内");
|
||||||
|
}
|
||||||
|
if (hasJsonArray(pref.getRegionScope()) && inJsonArray(pref.getRegionScope(), candidate.getRegionCode())) {
|
||||||
|
out.add("同在您期望的地区");
|
||||||
|
}
|
||||||
|
if (hasJsonArray(pref.getMaritalStatuses())
|
||||||
|
&& inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus())) {
|
||||||
|
out.add("婚姻状况符合您的期望");
|
||||||
|
}
|
||||||
|
if (notBlank(pref.getMinEducationCode())
|
||||||
|
&& educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode())) {
|
||||||
|
out.add("学历符合您的要求");
|
||||||
|
}
|
||||||
|
if (notBlank(pref.getAgePriority()) && "required".equals(pref.getAgePriority())) {
|
||||||
|
out.add("年龄为您设定的必要条件");
|
||||||
|
}
|
||||||
|
if (out.isEmpty()) out.add("资料符合基本要求");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——唯一事实源。
|
||||||
|
*
|
||||||
|
* <p>任一方把某维度设为 `required`,则对方该维度<b>必须</b>落在要求内,否则整条候选被否。
|
||||||
|
* 覆盖年龄 / 身高 / 地区 / 婚姻状况四类(当前产品定义的 Required 维度)。
|
||||||
|
*
|
||||||
|
* @return true 表示双向 Required 全部满足
|
||||||
|
*/
|
||||||
|
public boolean bidirectionalRequiredSatisfied(UserPreference myPref, UserProfile myProfile,
|
||||||
|
UserPreference targetPref, UserProfile targetProfile) {
|
||||||
|
if (myPref == null || myProfile == null || targetPref == null || targetProfile == null) return false;
|
||||||
|
|
||||||
|
// 年龄
|
||||||
|
if (isRequired(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
|
||||||
|
Integer candAge = age(targetProfile.getBirthDate());
|
||||||
|
if (candAge == null || candAge < myPref.getMinAge() || candAge > myPref.getMaxAge()) return false;
|
||||||
|
}
|
||||||
|
if (isRequired(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
|
||||||
|
Integer myAge = age(myProfile.getBirthDate());
|
||||||
|
if (myAge == null || myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) return false;
|
||||||
|
}
|
||||||
|
// 身高
|
||||||
|
if (isRequired(myPref.getHeightPriority()) && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
|
||||||
|
Integer h = targetProfile.getHeightCm();
|
||||||
|
if (h == null || h < myPref.getMinHeightCm() || h > myPref.getMaxHeightCm()) return false;
|
||||||
|
}
|
||||||
|
if (isRequired(targetPref.getHeightPriority()) && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
|
||||||
|
Integer h = myProfile.getHeightCm();
|
||||||
|
if (h == null || h < targetPref.getMinHeightCm() || h > targetPref.getMaxHeightCm()) return false;
|
||||||
|
}
|
||||||
|
// 地区(Required 用 isJsonArray:空数组=自相矛盾的硬约束 → 拒绝;不可放宽为「无约束」)
|
||||||
|
if (isRequired(myPref.getRegionPriority()) && isJsonArray(myPref.getRegionScope())
|
||||||
|
&& !inJsonArray(myPref.getRegionScope(), targetProfile.getRegionCode())) return false;
|
||||||
|
if (isRequired(targetPref.getRegionPriority()) && isJsonArray(targetPref.getRegionScope())
|
||||||
|
&& !inJsonArray(targetPref.getRegionScope(), myProfile.getRegionCode())) return false;
|
||||||
|
// 婚姻状况
|
||||||
|
if (isRequired(myPref.getMaritalPriority()) && isJsonArray(myPref.getMaritalStatuses())
|
||||||
|
&& !inJsonArray(myPref.getMaritalStatuses(), targetProfile.getMaritalStatus())) return false;
|
||||||
|
if (isRequired(targetPref.getMaritalPriority()) && isJsonArray(targetPref.getMaritalStatuses())
|
||||||
|
&& !inJsonArray(targetPref.getMaritalStatuses(), myProfile.getMaritalStatus())) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isRequired(String priority) { return "required".equals(priority); }
|
||||||
|
|
||||||
|
// ==================== 私有工具 ====================
|
||||||
|
|
||||||
|
private static double w(Double v) { return v == null ? 0 : Math.max(0, v); }
|
||||||
|
|
||||||
|
private static boolean notBlank(String s) { return s != null && !s.isBlank(); }
|
||||||
|
|
||||||
|
private static Integer age(LocalDate birthDate) {
|
||||||
|
if (birthDate == null) return null;
|
||||||
|
return Period.between(birthDate, LocalDate.now()).getYears();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在 [min,max] 内 → 1;否则按越界距离线性衰减(跨度越大衰减越慢),最低 0。 */
|
||||||
|
private static double rangeRatio(Integer value, int min, int max) {
|
||||||
|
if (value == null) return 0;
|
||||||
|
if (min > max) { int t = min; min = max; max = t; }
|
||||||
|
if (value >= min && value <= max) return 1.0;
|
||||||
|
int span = Math.max(1, max - min);
|
||||||
|
int distance = value < min ? (min - value) : (value - max);
|
||||||
|
return Math.max(0.0, 1.0 - (double) distance / span);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 评分用:仅当数组**非空**才算「用户表达了该条件」。空数组 = 未设置,不计入分母。 */
|
||||||
|
private static boolean hasJsonArray(JsonNode node) {
|
||||||
|
return node != null && node.isArray() && node.size() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required 校验用:只要节点是数组即「用户声明了该硬约束」。
|
||||||
|
* 空数组是自相矛盾的声明(要求必填却无任何可接受值)→ 交由调用方判为不满足,
|
||||||
|
* 与「Required 不可自动放宽」(INV-001)一致;不可退化成「无约束」。
|
||||||
|
*/
|
||||||
|
private static boolean isJsonArray(JsonNode node) {
|
||||||
|
return node != null && node.isArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean inJsonArray(JsonNode node, String value) {
|
||||||
|
if (node == null || !node.isArray() || value == null) return false;
|
||||||
|
for (int i = 0; i < node.size(); i++) {
|
||||||
|
if (value.equals(node.get(i).asText())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子女接受度:any/不限 → 恒满足;accept/可接受 → 有孩与否皆可;reject/不接受 → 仅无孩。 */
|
||||||
|
private static boolean childrenAcceptable(String acceptance, String childrenStatus) {
|
||||||
|
String a = acceptance == null ? "" : acceptance.trim();
|
||||||
|
boolean candidateHasChildren = "yes".equals(childrenStatus) || "has".equals(childrenStatus)
|
||||||
|
|| "有".equals(childrenStatus);
|
||||||
|
return switch (a) {
|
||||||
|
case "any", "不限" -> true;
|
||||||
|
case "accept", "可接受" -> true; // 接受带孩(且同样接受无孩)
|
||||||
|
case "reject", "不接受" -> !candidateHasChildren;
|
||||||
|
default -> true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean educationAtLeast(String candidateCode, String minCode) {
|
||||||
|
int ci = EDUCATION_ORDER.indexOf(candidateCode);
|
||||||
|
int mi = EDUCATION_ORDER.indexOf(minCode);
|
||||||
|
if (mi < 0) return true; // 下限码不在白名单 → 不做约束
|
||||||
|
if (ci < 0) return false; // 候选人学历缺失/未知 → 无法核验,不假定满足
|
||||||
|
return ci >= mi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宽松相等:去空白、忽略大小写;两边都非空才比较。 */
|
||||||
|
private static boolean sameValue(String a, String b) {
|
||||||
|
if (!notBlank(a) || !notBlank(b)) return false;
|
||||||
|
return a.trim().equalsIgnoreCase(b.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double clamp(double v) {
|
||||||
|
if (v < 0) return 0;
|
||||||
|
if (v > 100) return 100;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 便于单测/文档:常量键说明。 */
|
||||||
|
public static Map<String, String> weightKeys() {
|
||||||
|
return Map.of(
|
||||||
|
"age", "ageWeight", "region", "regionWeight", "marital", "maritalWeight",
|
||||||
|
"children", "childrenWeight", "education", "educationWeight", "height", "heightWeight",
|
||||||
|
"marriagePlan", "marriagePlanWeight", "lifestyle", "lifestyleWeight",
|
||||||
|
"settlement", "settlementWeight", "interest", "interestWeight");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.Period;
|
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
|
|||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final UserPrivacySettingsMapper privacySettingsMapper;
|
private final UserPrivacySettingsMapper privacySettingsMapper;
|
||||||
private final LocalEligibilityMapper localEligibilityMapper;
|
private final LocalEligibilityMapper localEligibilityMapper;
|
||||||
|
/** 评分唯一事实源(2026-09-23:替换原 `return 50.0` 桩,修复「推荐恒空」)。 */
|
||||||
|
private final MatchScoringService matchScoringService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取每日推荐列表
|
* 获取每日推荐列表
|
||||||
@@ -241,86 +242,16 @@ public class RecommendationEngineService {
|
|||||||
return userProfileMapper.findByUserId(userId).orElse(null);
|
return userProfileMapper.findByUserId(userId).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——委托
|
||||||
|
* {@link MatchScoringService#bidirectionalRequiredSatisfied} 唯一事实源。
|
||||||
|
*
|
||||||
|
* <p>此前本方法自带一份等价实现,与事实源构成「双实现漂移」隐患;2026-09-23 收敛为单点。
|
||||||
|
* 参数对映:本方法的 {@code candidate} 即事实源签名中的 {@code targetProfile}。
|
||||||
|
*/
|
||||||
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
|
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
|
||||||
UserPreference targetPref, UserProfile myProfile) {
|
UserPreference targetPref, UserProfile myProfile) {
|
||||||
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
|
return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
|
||||||
// 1. 年龄 Required 检查
|
|
||||||
if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
|
|
||||||
int candidateAge = calculateAge(candidate.getBirthDate());
|
|
||||||
if (candidateAge < myPref.getMinAge() || candidateAge > myPref.getMaxAge()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ("required".equals(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
|
|
||||||
int myAge = calculateAge(myProfile.getBirthDate());
|
|
||||||
if (myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 身高 Required 检查
|
|
||||||
if ("required".equals(myPref.getHeightPriority())
|
|
||||||
&& myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
|
|
||||||
if (candidate.getHeightCm() == null
|
|
||||||
|| candidate.getHeightCm() < myPref.getMinHeightCm()
|
|
||||||
|| candidate.getHeightCm() > myPref.getMaxHeightCm()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ("required".equals(targetPref.getHeightPriority())
|
|
||||||
&& targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
|
|
||||||
if (myProfile.getHeightCm() == null
|
|
||||||
|| myProfile.getHeightCm() < targetPref.getMinHeightCm()
|
|
||||||
|| myProfile.getHeightCm() > targetPref.getMaxHeightCm()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 地区 Required 检查
|
|
||||||
if ("required".equals(myPref.getRegionPriority()) && myPref.getRegionScope() != null && myPref.getRegionScope().isArray()) {
|
|
||||||
boolean inScope = false;
|
|
||||||
for (int i = 0; i < myPref.getRegionScope().size(); i++) {
|
|
||||||
if (myPref.getRegionScope().get(i).asText().equals(candidate.getRegionCode())) {
|
|
||||||
inScope = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!inScope) return false;
|
|
||||||
}
|
|
||||||
if ("required".equals(targetPref.getRegionPriority()) && targetPref.getRegionScope() != null && targetPref.getRegionScope().isArray()) {
|
|
||||||
boolean inScope = false;
|
|
||||||
for (int i = 0; i < targetPref.getRegionScope().size(); i++) {
|
|
||||||
if (targetPref.getRegionScope().get(i).asText().equals(myProfile.getRegionCode())) {
|
|
||||||
inScope = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!inScope) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 婚姻状况 Required 检查
|
|
||||||
if ("required".equals(myPref.getMaritalPriority()) && myPref.getMaritalStatuses() != null && myPref.getMaritalStatuses().isArray()) {
|
|
||||||
boolean accepted = false;
|
|
||||||
for (int i = 0; i < myPref.getMaritalStatuses().size(); i++) {
|
|
||||||
if (myPref.getMaritalStatuses().get(i).asText().equals(candidate.getMaritalStatus())) {
|
|
||||||
accepted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!accepted) return false;
|
|
||||||
}
|
|
||||||
if ("required".equals(targetPref.getMaritalPriority()) && targetPref.getMaritalStatuses() != null && targetPref.getMaritalStatuses().isArray()) {
|
|
||||||
boolean accepted = false;
|
|
||||||
for (int i = 0; i < targetPref.getMaritalStatuses().size(); i++) {
|
|
||||||
if (targetPref.getMaritalStatuses().get(i).asText().equals(myProfile.getMaritalStatus())) {
|
|
||||||
accepted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!accepted) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isParticipantEligible(String userId) {
|
private boolean isParticipantEligible(String userId) {
|
||||||
@@ -341,23 +272,19 @@ public class RecommendationEngineService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方向性评分:委托 {@link MatchScoringService}(唯一事实源)。
|
||||||
|
*
|
||||||
|
* <p>此前为桩 `return 50.0`,而阈值默认 60 → `mutualScore < 60` 恒成立,
|
||||||
|
* 导致 `GET /recommendations` **恒返回空数组**(2026-09-23 UAT 走查实测)。
|
||||||
|
*/
|
||||||
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
|
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
|
||||||
// TODO: Phase 07 后续完善评分逻辑
|
return matchScoringService.scoreDirectionally(pref, target);
|
||||||
return 50.0; // V1 简化:默认中间分
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> generateReasons(String userId, UserProfile candidate,
|
private List<String> generateReasons(String userId, UserProfile candidate,
|
||||||
UserPreference myPref, UserPreference targetPref) {
|
UserPreference myPref, UserPreference targetPref) {
|
||||||
List<String> reasons = new ArrayList<>();
|
return matchScoringService.reasons(myPref, candidate);
|
||||||
// TODO: 基于真实资料生成推荐理由
|
|
||||||
reasons.add("年龄符合双方择偶要求");
|
|
||||||
reasons.add("都希望长期在丹阳生活");
|
|
||||||
return reasons;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int calculateAge(LocalDate birthDate) {
|
|
||||||
if (birthDate == null) return 0;
|
|
||||||
return Period.between(birthDate, LocalDate.now()).getYears();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Set<String> getPassedUserIds(String userId, Instant since) {
|
private Set<String> getPassedUserIds(String userId, Instant since) {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||||
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务对象分配接口(运营端,ZC-MM-002 / A1)。
|
||||||
|
*
|
||||||
|
* <p>权限模型(D2):
|
||||||
|
* <ul>
|
||||||
|
* <li>写操作(创建/转分配/撤销/结束/可分配列表):
|
||||||
|
* {@code matchmaker.assign}(平台授权人员,不限组织)
|
||||||
|
* 或 {@code matchmaker.team.assign}(红娘组长,同组织范围);</li>
|
||||||
|
* <li>读自己名下分配(/my):{@code matchmaker.service}(普通红娘即可)。</li>
|
||||||
|
* </ul>
|
||||||
|
* operatorId 一律取当前认证主体(principal),不信任请求参数。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/operator/matchmaker/assignments")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerAssignmentController {
|
||||||
|
|
||||||
|
private final MatchmakerAssignmentService assignmentService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分配。
|
||||||
|
* Body: userId / matchmakerOperatorId / serviceId? / reason(必填)/ expiresAt?(ISO-8601)
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<?> create(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
MatchmakerAssignment created = assignmentService.create(
|
||||||
|
principal.id(),
|
||||||
|
principal.hasPermission("matchmaker.assign"),
|
||||||
|
body.get("userId"),
|
||||||
|
body.get("matchmakerOperatorId"),
|
||||||
|
body.getOrDefault("serviceId", null),
|
||||||
|
body.get("reason"),
|
||||||
|
parseInstant(body.get("expiresAt")));
|
||||||
|
return ApiResponse.ok(assignmentView(created));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转分配:旧记录置 transferred,同事务新建 active。reason 必填。 */
|
||||||
|
@PostMapping("/{id}/transfer")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<?> transfer(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
MatchmakerAssignment created = assignmentService.transfer(
|
||||||
|
id,
|
||||||
|
principal.id(),
|
||||||
|
principal.hasPermission("matchmaker.assign"),
|
||||||
|
body.get("newMatchmakerOperatorId"),
|
||||||
|
body.get("reason"));
|
||||||
|
return ApiResponse.ok(assignmentView(created));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销分配。reason 必填。 */
|
||||||
|
@PostMapping("/{id}/revoke")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<?> revoke(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
return ApiResponse.ok(assignmentView(assignmentService.revoke(
|
||||||
|
id, principal.id(), principal.hasPermission("matchmaker.assign"),
|
||||||
|
body.get("reason"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结束分配。reason 必填。 */
|
||||||
|
@PostMapping("/{id}/end")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<?> end(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
return ApiResponse.ok(assignmentView(assignmentService.end(
|
||||||
|
id, principal.id(), principal.hasPermission("matchmaker.assign"),
|
||||||
|
body.get("reason"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我名下生效中的服务对象分配(普通红娘工作台)。 */
|
||||||
|
@GetMapping("/my")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> my(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
List<Map<String, Object>> list = assignmentService.listMy(principal.id()).stream()
|
||||||
|
.map(this::assignmentView)
|
||||||
|
.toList();
|
||||||
|
return ApiResponse.ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可分配红娘列表(创建/转分配弹窗数据源)。
|
||||||
|
* 返回 id/name/organizationId,不含联系方式(字段级隐私,ZC-MM-005 范围)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/assignable-matchmakers")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> assignable(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(assignmentService.listAssignableMatchmakers(
|
||||||
|
principal.id(), principal.hasPermission("matchmaker.assign")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组长团队统计(ZC-MM-005 / E4 新增)。仅 matchmaker.team.assign 持有者可调;
|
||||||
|
* 以组长本人 organization_id 为边界(NULL 组长统计平台直属)。
|
||||||
|
* 返回纯脱敏计数:activeAssignmentCount / perMatchmakerCounts / activeMatchmakerCount,
|
||||||
|
* 不含任何用户身份或联系方式。
|
||||||
|
*/
|
||||||
|
@GetMapping("/team/statistics")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.team.assign')")
|
||||||
|
public ApiResponse<Map<String, Object>> teamStatistics(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(assignmentService.teamStatistics(principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant parseInstant(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Instant.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> assignmentView(MatchmakerAssignment a) {
|
||||||
|
return Map.ofEntries(
|
||||||
|
Map.entry("id", a.getId()),
|
||||||
|
Map.entry("userId", a.getUserId()),
|
||||||
|
Map.entry("matchmakerOperatorId", a.getMatchmakerOperatorId()),
|
||||||
|
Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()),
|
||||||
|
Map.entry("assignmentScope", a.getAssignmentScope()),
|
||||||
|
Map.entry("status", a.getStatus()),
|
||||||
|
Map.entry("assignmentReason", a.getAssignmentReason()),
|
||||||
|
Map.entry("assignedAt", a.getAssignedAt() == null ? "" : a.getAssignedAt().toString()),
|
||||||
|
Map.entry("expiresAt", a.getExpiresAt() == null ? "" : a.getExpiresAt().toString()),
|
||||||
|
Map.entry("endedAt", a.getEndedAt() == null ? "" : a.getEndedAt().toString()),
|
||||||
|
Map.entry("endReason", a.getEndReason() == null ? "" : a.getEndReason())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 红娘服务端点
|
* 红娘服务端点(用户端)
|
||||||
*
|
*
|
||||||
* 用户端:
|
* 用户端:
|
||||||
* POST /api/v1/matchmaker/services — 提交红娘服务请求
|
* POST /api/v1/matchmaker/services — 提交红娘服务请求
|
||||||
@@ -26,10 +26,15 @@ import java.util.Map;
|
|||||||
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
|
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
|
||||||
* POST /api/v1/matchmaker/recommendations/{id}/pass
|
* POST /api/v1/matchmaker/recommendations/{id}/pass
|
||||||
*
|
*
|
||||||
* 操作人端(Matchmaker/Operator role):
|
* 操作人端(Matchmaker/Operator)在 {@link MatchmakerOperatorController},基路径为
|
||||||
* PUT /api/v1/matchmaker/services/{id}/assign
|
* <b>{@code /api/v1/operator/matchmaker}</b>(如 {@code POST /services/{id}/assign}、
|
||||||
* POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象
|
* {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
|
||||||
* POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务
|
* {@code POST /services/{id}/complete})。
|
||||||
|
*
|
||||||
|
* <p><b>2026-09-23 修正</b>:本注释此前把上述 operator 端点写成
|
||||||
|
* {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
|
||||||
|
* ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
|
||||||
|
* 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/matchmaker")
|
@RequestMapping("/matchmaker")
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import java.util.Map;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 红娘工作台控制器(Operator调用)
|
* 红娘工作台控制器(Operator调用)
|
||||||
|
*
|
||||||
|
* <p>ZC-MM-002(A1)状态机变更:原 {@code POST /services/{id}/claim}(红娘接单)端点已删除。
|
||||||
|
* 服务对象归属统一由 matchmaker_assignments 建模(D3 决策:不保留组长认领),
|
||||||
|
* 分配入口收敛为 {@code MatchmakerAssignmentController}(POST /operator/matchmaker/assignments),
|
||||||
|
* 服务单状态由分配动作同事务联动(submitted → assigned)。
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/operator/matchmaker")
|
@RequestMapping("/operator/matchmaker")
|
||||||
@@ -29,42 +34,57 @@ public class MatchmakerOperatorController {
|
|||||||
return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId));
|
return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待分配队列。ZC-MM-002(D4):方法级权限收敛 —— 仅分配权持有者
|
||||||
|
* (matchmaker.assign 平台授权 / matchmaker.team.assign 组长)可看全局待分配队列,
|
||||||
|
* 普通红娘(仅 matchmaker.view/service)403。
|
||||||
|
*
|
||||||
|
* <p>组长视角说明(ZC-MM-005 / E4,治理登记 007):matchmaker_services 表无组织归属字段
|
||||||
|
* (organization_id 恒 null 的已知限制),因此待分配队列无法按组长组织过滤,
|
||||||
|
* 权衡后对分配权持有者全局可读 —— 待分配服务单尚无负责人,队列不含敏感分配关系。
|
||||||
|
* 组织归属字段的补齐登记至 ZC-MM-007。
|
||||||
|
*/
|
||||||
@GetMapping("/pending")
|
@GetMapping("/pending")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||||
public ApiResponse<List<Map<String, Object>>> getPending(
|
public ApiResponse<List<Map<String, Object>>> getPending(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
return ApiResponse.ok(matchmakerServiceService.getPendingServices());
|
return ApiResponse.ok(matchmakerServiceService.getPendingServices());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配服务给红娘(兼容入口)。ZC-MM-002(D4):
|
||||||
|
* 同事务联动「创建 matchmaker_assignments 分配 + 服务单置 assigned」。
|
||||||
|
*/
|
||||||
@PostMapping("/services/{id}/assign")
|
@PostMapping("/services/{id}/assign")
|
||||||
@PreAuthorize("hasAuthority('matchmaker.assign')")
|
@PreAuthorize("hasAuthority('matchmaker.assign')")
|
||||||
public ApiResponse<?> assignService(
|
public ApiResponse<?> assignService(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
String operatorId = principal.id();
|
String assignerOperatorId = principal.id();
|
||||||
String assignOperatorId = body.get("operatorId");
|
String targetOperatorId = body.get("operatorId");
|
||||||
return ApiResponse.ok(matchmakerServiceService.assignService(id, assignOperatorId, null));
|
String reason = body.getOrDefault("reason", "红娘服务单分配");
|
||||||
|
return ApiResponse.ok(matchmakerServiceService.assignService(
|
||||||
|
id, assignerOperatorId, targetOperatorId, reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/services/{id}/claim")
|
// GET /my-services 已删除(ZC-MM-005 / E4 死端点清理):
|
||||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
// 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空),
|
||||||
public ApiResponse<?> claimService(
|
// 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径:
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
// GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。
|
||||||
@PathVariable String id) {
|
// getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。
|
||||||
String operatorId = principal.id();
|
|
||||||
return ApiResponse.ok(matchmakerServiceService.claimService(operatorId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/my-services")
|
|
||||||
public ApiResponse<?> getMyServices(
|
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
|
||||||
String operatorId = principal.id();
|
|
||||||
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
|
* 红娘名下服务的全部推荐列表(运营端工作台用)。
|
||||||
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
|
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
|
||||||
|
*
|
||||||
|
* <p>口径核查(ZC-MM-005 收口 / P1-1):范围事实源是 {@code matchmaker_assignments}
|
||||||
|
* 中本人<b>生效中的分配</b>(active 且未过有效期)→ 该分配对象(user)名下
|
||||||
|
* 服务单上的推荐,经 matchmaker_services 桥接;既不按
|
||||||
|
* matchmaker_services.assigned_operator_id 残留列过滤,也<b>不</b>按
|
||||||
|
* recommendation.operator_id 过滤(推荐条目只含 targetUserId/reason/status
|
||||||
|
* 摘要,不涉及服务对象字段级隐私)。分配 revoke/end 后不再可见
|
||||||
|
* (DEC-002 规则7:撤销即失权)。
|
||||||
*/
|
*/
|
||||||
@GetMapping("/recommendations")
|
@GetMapping("/recommendations")
|
||||||
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
||||||
@@ -72,16 +92,19 @@ public class MatchmakerOperatorController {
|
|||||||
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
|
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务详情(ZC-MM-005 / E2 防探测 404)。
|
||||||
|
*
|
||||||
|
* <p>语义变更:非持有人与查无 serviceId 抛出完全相同的
|
||||||
|
* BusinessException(NOT_FOUND, "服务不存在") —— 响应体逐字段一致
|
||||||
|
* (success=false / code=NOT_FOUND / message=服务不存在),无法区分"无权"与"不存在"。
|
||||||
|
* 此前 002 的 viewerIsOwner 双态(持有人完整 / 非负责人脱敏)已删除。
|
||||||
|
*/
|
||||||
@GetMapping("/services/{id}")
|
@GetMapping("/services/{id}")
|
||||||
public ApiResponse<Map<String, Object>> getServiceDetail(
|
public ApiResponse<Map<String, Object>> getServiceDetail(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
|
return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id()));
|
||||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id, principal.id());
|
|
||||||
if (detail == null) {
|
|
||||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
return ApiResponse.ok(detail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/services/{id}/recommend")
|
@PostMapping("/services/{id}/recommend")
|
||||||
@@ -114,7 +137,8 @@ public class MatchmakerOperatorController {
|
|||||||
public ApiResponse<?> completeService(
|
public ApiResponse<?> completeService(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
return ApiResponse.ok(matchmakerServiceService.completeService(id));
|
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy(服务层完成)
|
||||||
|
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务对象分配(ZC-MM-002 / A1)
|
||||||
|
* 对应 DB: matchmaker_assignments(V053)
|
||||||
|
*
|
||||||
|
* <p>建模说明:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code matchmakerOperatorId}:持有该用户服务对象的红娘(operators 主体,
|
||||||
|
* 沿用 matchmaker_services.assigned_operator_id 惯例)。</li>
|
||||||
|
* <li>{@code assignedBy}:做出本次分配的操作者(组长 / 平台授权人员)。</li>
|
||||||
|
* <li>状态机(D5):active → transferred / revoked / ended;
|
||||||
|
* expires_at 届满由 {@code ServiceAssignmentPolicy} 惰性置 expired(无定时任务);
|
||||||
|
* 终态一律拒绝再转换。</li>
|
||||||
|
* <li>scope:primary(主责,同用户仅一条生效中,DB 部分唯一索引兜底)/
|
||||||
|
* collaborating(协同)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("matchmaker_assignments")
|
||||||
|
public class MatchmakerAssignment extends BaseEntity {
|
||||||
|
|
||||||
|
/** 被服务用户 */
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
/** 持有服务对象的红娘(operators.id) */
|
||||||
|
private String matchmakerOperatorId;
|
||||||
|
|
||||||
|
/** 关联的服务单(可空:允许脱离服务单的用户级分配) */
|
||||||
|
private String serviceId;
|
||||||
|
|
||||||
|
/** 做出本次分配的操作者(operators.id) */
|
||||||
|
private String assignedBy;
|
||||||
|
|
||||||
|
/** 分配范围:primary / collaborating(与 V053 chk_mm_assignment_scope 白名单一致) */
|
||||||
|
private String assignmentScope;
|
||||||
|
|
||||||
|
/** 分配理由(必填) */
|
||||||
|
private String assignmentReason;
|
||||||
|
|
||||||
|
/** 状态:active/ended/revoked/transferred/expired(与 V053 chk_mm_assignment_status 白名单一致) */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private Instant assignedAt;
|
||||||
|
|
||||||
|
/** 有效期届满时间;null 表示长期有效 */
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
/** 终态时间(ended/revoked/transferred/expired 时落库) */
|
||||||
|
private Instant endedAt;
|
||||||
|
|
||||||
|
/** 终态理由(revoked/ended 必填;transferred 记录转分配理由;expired 为固定文案) */
|
||||||
|
private String endReason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务对象分配 Mapper(ZC-MM-002 / A1)
|
||||||
|
*
|
||||||
|
* <p>「生效中」的统一口径:status = 'active' 且(expires_at IS NULL OR expires_at > NOW())。
|
||||||
|
* 惰性过期由 {@link #expireOverdue} 负责(读前触发,无定时任务),
|
||||||
|
* 这里的 expires 条件是双保险:即使惰性过期尚未执行,已届满的行也不会被当作生效中。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface MatchmakerAssignmentMapper extends BaseMapper<MatchmakerAssignment> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM matchmaker_assignments WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<MatchmakerAssignment> findById(@Param("id") String id);
|
||||||
|
|
||||||
|
/** 某红娘名下全部生效中的分配(GET /my、GET /assignable 的工作台口径) */
|
||||||
|
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||||
|
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
|
||||||
|
List<MatchmakerAssignment> findActiveByOperator(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
|
/** 某红娘对某用户是否持有生效中的分配(数据范围判定核心查询) */
|
||||||
|
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND user_id = #{userId} AND status = 'active' " +
|
||||||
|
"AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||||
|
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
|
||||||
|
Optional<MatchmakerAssignment> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
|
||||||
|
@Param("userId") String userId);
|
||||||
|
|
||||||
|
/** 某用户当前生效中的主责分配(创建/转分配前的唯一性预检) */
|
||||||
|
@Select("SELECT * FROM matchmaker_assignments WHERE user_id = #{userId} " +
|
||||||
|
"AND status = 'active' AND assignment_scope = 'primary' " +
|
||||||
|
"AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||||
|
"AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<MatchmakerAssignment> findActivePrimaryByUser(@Param("userId") String userId);
|
||||||
|
|
||||||
|
/** 某服务单上生效中的分配(服务单维度的归属判定) */
|
||||||
|
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
|
||||||
|
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||||
|
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
|
||||||
|
Optional<MatchmakerAssignment> findActiveByService(@Param("serviceId") String serviceId);
|
||||||
|
|
||||||
|
/** 某服务单的全部分配历史(追溯用) */
|
||||||
|
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
|
||||||
|
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
|
||||||
|
List<MatchmakerAssignment> findByService(@Param("serviceId") String serviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 惰性过期:把 expires_at 已届满的 active 行置为 expired 并落库。
|
||||||
|
* 由 {@code ServiceAssignmentPolicy} 在读取路径前触发,无定时任务。
|
||||||
|
*
|
||||||
|
* @return 本次被置为 expired 的行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE matchmaker_assignments SET status = 'expired', " +
|
||||||
|
"ended_at = NOW(), end_reason = '有效期届满自动失效', updated_at = NOW() " +
|
||||||
|
"WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() " +
|
||||||
|
"AND deleted_at IS NULL")
|
||||||
|
int expireOverdue();
|
||||||
|
|
||||||
|
// ==================== ZC-MM-005 / E4:组长团队统计(纯脱敏计数) ====================
|
||||||
|
// 组织边界:organization_id IS NOT DISTINCT FROM #{organizationId} ——
|
||||||
|
// NULL=NULL 平台直属规则在 SQL 层等价复用(有组织组长统计本组织;NULL 组长统计平台直属)。
|
||||||
|
|
||||||
|
/** 某组织范围内生效中的分配总数 */
|
||||||
|
@Select("SELECT COUNT(*) FROM matchmaker_assignments ma " +
|
||||||
|
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||||
|
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||||
|
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||||
|
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
|
||||||
|
long countActiveByOrganization(@Param("organizationId") String organizationId);
|
||||||
|
|
||||||
|
/** 某组织范围内各红娘的生效分配数(operatorId/name/activeCount;别名加引号保持驼峰键名) */
|
||||||
|
@Select("SELECT o.id AS \"operatorId\", o.name AS \"operatorName\", COUNT(*) AS \"activeCount\" " +
|
||||||
|
"FROM matchmaker_assignments ma " +
|
||||||
|
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||||
|
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||||
|
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||||
|
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId} " +
|
||||||
|
"GROUP BY o.id, o.name ORDER BY \"activeCount\" DESC")
|
||||||
|
List<Map<String, Object>> countActiveGroupByOperator(@Param("organizationId") String organizationId);
|
||||||
|
|
||||||
|
/** 某组织范围内持有生效分配的红娘数(去重) */
|
||||||
|
@Select("SELECT COUNT(DISTINCT ma.matchmaker_operator_id) FROM matchmaker_assignments ma " +
|
||||||
|
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||||
|
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||||
|
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||||
|
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
|
||||||
|
long countActiveOperators(@Param("organizationId") String organizationId);
|
||||||
|
}
|
||||||
@@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
|
|||||||
int markViewed(@Param("id") String id);
|
int markViewed(@Param("id") String id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
|
* 某红娘推荐列表(运营端工作台用)。
|
||||||
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
|
*
|
||||||
* 避免运营端越权看到他人推荐。
|
* <p>ZC-MM-005 收口(P1-1):范围事实源由 matchmaker_services.assigned_operator_id
|
||||||
|
* 残留列切换为 matchmaker_assignments 生效中分配(active 且未过有效期)。
|
||||||
|
* 语义 = 「本人当前有效分配对象(user)名下服务单上的推荐」—— 分配 revoke/end 后
|
||||||
|
* 残留列不再构成可见凭据(DEC-002 规则7:撤销即失权)。
|
||||||
*/
|
*/
|
||||||
@Select("SELECT r.* FROM matchmaker_recommendations r " +
|
@Select("SELECT r.* FROM matchmaker_recommendations r " +
|
||||||
"JOIN matchmaker_services s ON s.id = r.service_id " +
|
"JOIN matchmaker_services s ON s.id = r.service_id AND s.deleted_at IS NULL " +
|
||||||
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
|
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||||
|
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||||
|
"AND a.deleted_at IS NULL " +
|
||||||
"ORDER BY r.created_at DESC")
|
"ORDER BY r.created_at DESC")
|
||||||
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
|
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,35 +26,51 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
|
|||||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
|
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
|
||||||
long countSubmitted();
|
long countSubmitted();
|
||||||
|
|
||||||
/** 某红娘进行中的服务数(工作台统计) */
|
/**
|
||||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
* 某红娘进行中的服务数(工作台统计)。
|
||||||
"AND status = 'processing' AND deleted_at IS NULL")
|
* ZC-MM-005 收口(P1-2):范围事实源由 assigned_operator_id 残留列切换为
|
||||||
|
* matchmaker_assignments 生效中分配(active 且未过有效期)——「本人名下进行中」
|
||||||
|
* = 本人 active 分配关联 user 的服务单中 status='in_service'。
|
||||||
|
* 分配 end/revoke 后,该用户的服务单不再计入本人(DEC-002 规则7:服务结束失去访问权)。
|
||||||
|
* 状态口径沿用 ZC-MM-002(D4)修正:'in_service'('processing' 不在 V009 白名单)。
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||||
|
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||||
|
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||||
|
"AND a.deleted_at IS NULL " +
|
||||||
|
"WHERE s.status = 'in_service' AND s.deleted_at IS NULL")
|
||||||
long countInProgressByOperator(@Param("operatorId") String operatorId);
|
long countInProgressByOperator(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
|
/** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-005 收口 / P1-2:按生效分配 join) */
|
||||||
|
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||||
|
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||||
|
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||||
|
"AND a.deleted_at IS NULL " +
|
||||||
|
"WHERE CAST(s.assigned_at AS DATE) = CURRENT_DATE AND s.deleted_at IS NULL")
|
||||||
|
long countTodayByOperator(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-005 收口 / P1-2)。
|
||||||
|
* 口径说明(与 E1「服务结束失去访问权」一致):按当前生效分配统计 ——
|
||||||
|
* 分配 end/revoke 后,该用户的历史 completed 服务单<b>不再计入</b>本人完成数;
|
||||||
|
* 仅当分配仍生效时,其名下 completed 单才计入。
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||||
|
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||||
|
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||||
|
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||||
|
"AND a.deleted_at IS NULL " +
|
||||||
|
"WHERE s.status = 'completed' AND s.deleted_at IS NULL")
|
||||||
|
long countCompletedByOperator(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
|
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
|
||||||
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||||
|
|
||||||
/** 某红娘名下全部服务(含历史)—— 红娘工作台 */
|
|
||||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
|
||||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
|
||||||
List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId);
|
|
||||||
|
|
||||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
|
|
||||||
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
|
|
||||||
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
|
|
||||||
*/
|
|
||||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
|
||||||
"AND user_id = #{userId} AND status IN ('assigned','in_service','waiting_user','submitted') " +
|
|
||||||
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
|
||||||
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
|
|
||||||
@Param("userId") String userId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
|
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
|
||||||
* 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。
|
* 状态口径(submitted/assigned/in_service/waiting_user)。
|
||||||
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
|
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
|
||||||
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
|
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务对象分配服务(ZC-MM-002 / A1,架构师设计 D5)。
|
||||||
|
*
|
||||||
|
* <p>状态机矩阵(status ∈ active/ended/revoked/transferred/expired):
|
||||||
|
* <pre>
|
||||||
|
* active → transferred(transfer:旧记录置 transferred + end_reason,同事务新建 active)
|
||||||
|
* active → revoked (撤销,end_reason 必填)
|
||||||
|
* active → ended (结束,end_reason 必填)
|
||||||
|
* 终态(ended/revoked/transferred/expired)一律拒绝再转换 → CONFLICT
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>权限模型(D2):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code matchmaker.assign}:平台授权人员,不限组织;</li>
|
||||||
|
* <li>{@code matchmaker.team.assign}:红娘组长,团队边界 = 同 organization_id。</li>
|
||||||
|
* </ul>
|
||||||
|
* 组织边界规则(技术默认,治理登记后续补充):
|
||||||
|
* <b>NULL=NULL 视为平台直属</b> —— 平台直属组长只能分配平台直属红娘;
|
||||||
|
* 有组织组长不能分配 org=NULL 红娘;org=NULL 普通红娘只能由 matchmaker.assign 持有者分配。
|
||||||
|
* 范围违规统一走 {@link DataScopeGuard#deny}(自动落 DATA_SCOPE_DENIED 审计,403)。
|
||||||
|
*
|
||||||
|
* <p>唯一性:同 user_id + primary scope 同时最多一条 active(DB 部分唯一索引兜底),
|
||||||
|
* 业务层预检 + 捕获 DuplicateKeyException 双保险,均映射 CONFLICT。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerAssignmentService {
|
||||||
|
|
||||||
|
private static final String RESOURCE_TYPE = "matchmaker_assignment";
|
||||||
|
|
||||||
|
private final MatchmakerAssignmentMapper assignmentMapper;
|
||||||
|
private final MatchmakerServiceMapper serviceMapper;
|
||||||
|
private final OperatorMapper operatorMapper;
|
||||||
|
private final DataScopeGuard dataScopeGuard;
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
/** 读路径复用判定器(含惰性过期);Policy 不反向依赖本 Service,无环。 */
|
||||||
|
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分配(primary)。含可选服务单联动:serviceId 非空时同事务把服务单置为 assigned。
|
||||||
|
*
|
||||||
|
* @param assignerOperatorId 当前操作者(分配人)
|
||||||
|
* @param hasGlobalAssign 操作者是否持有 matchmaker.assign(true=不限组织)
|
||||||
|
* @param userId 被服务用户
|
||||||
|
* @param matchmakerOperatorId 目标红娘
|
||||||
|
* @param serviceId 关联服务单(可空)
|
||||||
|
* @param reason 分配理由(必填)
|
||||||
|
* @param expiresAt 有效期(可空=长期)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerAssignment create(String assignerOperatorId, boolean hasGlobalAssign,
|
||||||
|
String userId, String matchmakerOperatorId,
|
||||||
|
String serviceId, String reason, Instant expiresAt) {
|
||||||
|
requireReason(reason, "分配理由必填");
|
||||||
|
|
||||||
|
Operator target = requireActiveOperator(matchmakerOperatorId, "目标红娘不存在");
|
||||||
|
// create/assign 类无资源寻址端点维持 403(ZC-MM-006 / T04 口径)
|
||||||
|
assertAssignScope(assignerOperatorId, hasGlobalAssign, matchmakerOperatorId,
|
||||||
|
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
|
||||||
|
|
||||||
|
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"该用户已存在生效中的主责分配,请先转分配或撤销");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (serviceId != null && !serviceId.isBlank()) {
|
||||||
|
linkServiceRow(serviceId, matchmakerOperatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchmakerAssignment assignment = new MatchmakerAssignment();
|
||||||
|
assignment.setUserId(userId);
|
||||||
|
assignment.setMatchmakerOperatorId(matchmakerOperatorId);
|
||||||
|
assignment.setServiceId(serviceId);
|
||||||
|
assignment.setAssignedBy(assignerOperatorId);
|
||||||
|
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
|
||||||
|
assignment.setAssignmentReason(reason);
|
||||||
|
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||||
|
assignment.setAssignedAt(Instant.now());
|
||||||
|
assignment.setExpiresAt(expiresAt);
|
||||||
|
try {
|
||||||
|
assignmentMapper.insert(assignment);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
// 并发竞态兜底:uq_mm_assignment_active_primary 部分唯一索引拒绝第二条 active+primary
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"该用户已存在生效中的主责分配(并发创建被唯一索引拒绝)");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("服务对象分配创建: assignmentId={}, userId={}, matchmakerOperatorId={}, by={}",
|
||||||
|
assignment.getId(), userId, matchmakerOperatorId, assignerOperatorId);
|
||||||
|
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, assignment.getId(),
|
||||||
|
auditSummary(matchmakerOperatorId, assignment.getAssignmentScope(), reason));
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转分配(active → transferred + 同事务新建 active)。
|
||||||
|
* 旧记录置 transferred + end_reason,新记录 assigned_by = 当前操作者。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerAssignment transfer(String assignmentId, String assignerOperatorId,
|
||||||
|
boolean hasGlobalAssign, String newMatchmakerOperatorId,
|
||||||
|
String reason) {
|
||||||
|
requireReason(reason, "转分配理由必填");
|
||||||
|
|
||||||
|
MatchmakerAssignment old = requireAssignment(assignmentId);
|
||||||
|
assertActiveForTransition(old, "转分配");
|
||||||
|
|
||||||
|
// 防御纵深(QA P2-1):不仅新目标红娘要在操作者范围内,
|
||||||
|
// 被转记录的**原持有人**也必须在范围内(与 terminate 路径同口径;
|
||||||
|
// 否则 org=X 组长可把 org=Y 红娘持有的分配"顺手"转走)。
|
||||||
|
// ZC-MM-006 / T04:transfer 为资源寻址写端点,范围违规 404 化(防探测)。
|
||||||
|
assertAssignScope(assignerOperatorId, hasGlobalAssign, old.getMatchmakerOperatorId(),
|
||||||
|
"组长只能处置同组织红娘持有的分配(NULL=NULL 平台直属)", assignmentId, true);
|
||||||
|
|
||||||
|
requireActiveOperator(newMatchmakerOperatorId, "目标红娘不存在");
|
||||||
|
assertAssignScope(assignerOperatorId, hasGlobalAssign, newMatchmakerOperatorId,
|
||||||
|
"组长只能转分配给同组织的红娘(NULL=NULL 平台直属)", assignmentId, true);
|
||||||
|
|
||||||
|
// 1) 旧记录置 transferred
|
||||||
|
old.setStatus(Constants.ASSIGNMENT_STATUS_TRANSFERRED);
|
||||||
|
old.setEndReason(reason);
|
||||||
|
old.setEndedAt(Instant.now());
|
||||||
|
assignmentMapper.updateById(old);
|
||||||
|
|
||||||
|
// 2) 同事务新建 active 分配
|
||||||
|
MatchmakerAssignment created = new MatchmakerAssignment();
|
||||||
|
created.setUserId(old.getUserId());
|
||||||
|
created.setMatchmakerOperatorId(newMatchmakerOperatorId);
|
||||||
|
created.setServiceId(old.getServiceId());
|
||||||
|
created.setAssignedBy(assignerOperatorId);
|
||||||
|
created.setAssignmentScope(old.getAssignmentScope());
|
||||||
|
created.setAssignmentReason(reason);
|
||||||
|
created.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||||
|
created.setAssignedAt(Instant.now());
|
||||||
|
created.setExpiresAt(old.getExpiresAt());
|
||||||
|
try {
|
||||||
|
assignmentMapper.insert(created);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
// 事务内旧记录已非 active,正常不应触发;仍兜底映射 CONFLICT(整个事务回滚)
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"该用户已存在生效中的主责分配(并发转分配被唯一索引拒绝)");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("服务对象转分配: oldId={}, newId={}, fromUser={}, toOperator={}, by={}",
|
||||||
|
old.getId(), created.getId(), old.getUserId(), newMatchmakerOperatorId, assignerOperatorId);
|
||||||
|
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER, assignmentId,
|
||||||
|
auditSummary(newMatchmakerOperatorId, created.getAssignmentScope(), reason));
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销分配(active → revoked,end_reason 必填)。 */
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerAssignment revoke(String assignmentId, String operatorId,
|
||||||
|
boolean hasGlobalAssign, String reason) {
|
||||||
|
return terminate(assignmentId, operatorId, hasGlobalAssign, reason,
|
||||||
|
Constants.ASSIGNMENT_STATUS_REVOKED, Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE, "撤销");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结束分配(active → ended,end_reason 必填)。 */
|
||||||
|
@Transactional
|
||||||
|
public MatchmakerAssignment end(String assignmentId, String operatorId,
|
||||||
|
boolean hasGlobalAssign, String reason) {
|
||||||
|
return terminate(assignmentId, operatorId, hasGlobalAssign, reason,
|
||||||
|
Constants.ASSIGNMENT_STATUS_ENDED, Constants.AUDIT_ACTION_ASSIGNMENT_END, "结束");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我(当前红娘)名下生效中的服务对象分配。读前惰性过期。 */
|
||||||
|
public List<MatchmakerAssignment> listMy(String operatorId) {
|
||||||
|
return serviceAssignmentPolicy.findActiveByOperator(operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可分配红娘列表(创建分配弹窗数据源)。
|
||||||
|
* operators.status='active';组长按组织过滤、平台授权人员(matchmaker.assign)返回全部。
|
||||||
|
* 返回 id/name/organizationId,<b>不含联系方式</b>(字段级隐私,ZC-MM-005 范围)。
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> listAssignableMatchmakers(String assignerOperatorId,
|
||||||
|
boolean hasGlobalAssign) {
|
||||||
|
String assignerOrg = null;
|
||||||
|
if (!hasGlobalAssign) {
|
||||||
|
Operator assigner = operatorMapper.findById(assignerOperatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
|
||||||
|
assignerOrg = assigner.getOrganizationId();
|
||||||
|
}
|
||||||
|
String orgFilter = assignerOrg;
|
||||||
|
return operatorMapper.selectList(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Operator>()
|
||||||
|
.eq(Operator::getStatus, "active")
|
||||||
|
.isNull(Operator::getDeletedAt))
|
||||||
|
.stream()
|
||||||
|
.filter(op -> hasGlobalAssign || Objects.equals(orgFilter, op.getOrganizationId()))
|
||||||
|
.map(op -> {
|
||||||
|
Map<String, Object> item = new HashMap<>();
|
||||||
|
item.put("id", op.getId());
|
||||||
|
item.put("name", op.getName());
|
||||||
|
item.put("organizationId", op.getOrganizationId());
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组长团队统计(ZC-MM-005 / E4 新增):以组长本人 organization_id 为边界
|
||||||
|
* (NULL 组长统计平台直属,SQL 侧 IS NOT DISTINCT FROM 复用 NULL=NULL 规则)。
|
||||||
|
* 返回纯脱敏计数,不含任何用户身份或联系方式:
|
||||||
|
* <ul>
|
||||||
|
* <li>activeAssignmentCount —— 组织内生效中的分配总数;</li>
|
||||||
|
* <li>perMatchmakerCounts —— 各红娘的生效分配数(operatorId/name/activeCount);</li>
|
||||||
|
* <li>activeMatchmakerCount —— 持有生效分配的红娘数(去重)。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p><b>字段脱敏决策(P2-4,对照《决策》§5.2 字段分类 + 功能权限矩阵)</b>:
|
||||||
|
* perMatchmakerCounts 中的 <b>operatorName(红娘姓名)保留、不做脱敏</b>。依据:
|
||||||
|
* <ol>
|
||||||
|
* <li>红娘姓名属于「本人/团队范围内的工作人员标识」,<b>不是用户 PII</b>
|
||||||
|
* (§5.2 隐私清单的禁放字段是 phone/wechat/idNumber 等用户敏感数据);</li>
|
||||||
|
* <li>组长统计本团队工作量属其管理职责(DEC-MATCHMAKER-002 红娘组长:
|
||||||
|
* 团队内进度与工作量管理),「查看脱敏服务统计:组长=团队范围」是既有授权口径;</li>
|
||||||
|
* <li>组长已可经 GET /assignable-matchmakers 拿到同批红娘姓名 —— 此字段
|
||||||
|
* 非新增泄露面,管理范围 ≠ 用户敏感数据。</li>
|
||||||
|
* </ol>
|
||||||
|
* 决策依据同时登记于 docs/governance/open-questions.md(OQ-MATCHMAKER-002),
|
||||||
|
* 避免后人误判"统计含姓名=越权"。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> teamStatistics(String leaderOperatorId) {
|
||||||
|
Operator leader = operatorMapper.findById(leaderOperatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
|
||||||
|
String orgId = leader.getOrganizationId();
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("organizationId", orgId);
|
||||||
|
result.put("activeAssignmentCount", assignmentMapper.countActiveByOrganization(orgId));
|
||||||
|
result.put("perMatchmakerCounts", assignmentMapper.countActiveGroupByOperator(orgId));
|
||||||
|
result.put("activeMatchmakerCount", assignmentMapper.countActiveOperators(orgId));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内部实现 ====================
|
||||||
|
|
||||||
|
/** revoke / end 共用的终态迁移(仅允许 active → 终态)。 */
|
||||||
|
private MatchmakerAssignment terminate(String assignmentId, String operatorId,
|
||||||
|
boolean hasGlobalAssign, String reason,
|
||||||
|
String targetStatus, String auditAction,
|
||||||
|
String actionLabel) {
|
||||||
|
requireReason(reason, actionLabel + "理由必填");
|
||||||
|
|
||||||
|
MatchmakerAssignment assignment = requireAssignment(assignmentId);
|
||||||
|
assertActiveForTransition(assignment, actionLabel);
|
||||||
|
|
||||||
|
// 范围校验:操作者必须有权处置该红娘的分配(assign 不限组织 / team.assign 同组织)
|
||||||
|
// ZC-MM-006 / T04:revoke/end 为资源寻址写端点,范围违规 404 化(防探测)
|
||||||
|
assertAssignScope(operatorId, hasGlobalAssign, assignment.getMatchmakerOperatorId(),
|
||||||
|
"组长只能处置同组织红娘的分配(NULL=NULL 平台直属)", assignmentId, true);
|
||||||
|
|
||||||
|
assignment.setStatus(targetStatus);
|
||||||
|
assignment.setEndReason(reason);
|
||||||
|
assignment.setEndedAt(Instant.now());
|
||||||
|
assignmentMapper.updateById(assignment);
|
||||||
|
|
||||||
|
log.info("服务对象分配{}: assignmentId={}, operatorId={}, by={}",
|
||||||
|
actionLabel, assignmentId, assignment.getMatchmakerOperatorId(), operatorId);
|
||||||
|
saveAudit(operatorId, auditAction, assignmentId,
|
||||||
|
auditSummary(assignment.getMatchmakerOperatorId(), assignment.getAssignmentScope(), reason));
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组织范围判定(D2):hasGlobalAssign(matchmaker.assign)不限组织;
|
||||||
|
* team.assign 时校验操作者与目标红娘 organization_id 一致。
|
||||||
|
* <b>NULL=NULL 视为平台直属</b>:平台直属组长只能分配平台直属红娘;
|
||||||
|
* 有组织组长不能分配 org=NULL 红娘。违规统一先落 DATA_SCOPE_DENIED 审计。
|
||||||
|
*
|
||||||
|
* <p>拒绝口径(ZC-MM-006 / T04):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code notFoundOnDeny=false}(create/assign 类,无资源寻址)→ 403 AccessDeniedException;</li>
|
||||||
|
* <li>{@code notFoundOnDeny=true}(transfer/revoke/end,资源寻址写端点)→ 404 化
|
||||||
|
* {@link DataScopeGuard#denyAsNotFound}(响应与"资源不存在"逐字段一致,防探测)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private void assertAssignScope(String assignerOperatorId, boolean hasGlobalAssign,
|
||||||
|
String targetOperatorId, String denyMessage,
|
||||||
|
String auditResourceId, boolean notFoundOnDeny) {
|
||||||
|
if (hasGlobalAssign) {
|
||||||
|
return; // 平台授权人员(matchmaker.assign),不限组织
|
||||||
|
}
|
||||||
|
Operator assigner = operatorMapper.findById(assignerOperatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
|
||||||
|
Operator target = operatorMapper.findById(targetOperatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "目标红娘不存在"));
|
||||||
|
// Objects.equals 的 NULL=NULL → true 语义即「平台直属对平台直属」规则
|
||||||
|
if (Objects.equals(assigner.getOrganizationId(), target.getOrganizationId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (notFoundOnDeny) {
|
||||||
|
dataScopeGuard.denyAsNotFound(assignerOperatorId, RESOURCE_TYPE, auditResourceId,
|
||||||
|
"服务不存在", null, null);
|
||||||
|
} else {
|
||||||
|
dataScopeGuard.deny(assignerOperatorId, RESOURCE_TYPE, auditResourceId, denyMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务单联动(同事务):仅 submitted 状态允许关联分配并置为 assigned。 */
|
||||||
|
private void linkServiceRow(String serviceId, String matchmakerOperatorId) {
|
||||||
|
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||||
|
if (service == null) {
|
||||||
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联服务单不存在");
|
||||||
|
}
|
||||||
|
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "服务单当前状态不允许关联分配");
|
||||||
|
}
|
||||||
|
service.setAssignedOperatorId(matchmakerOperatorId);
|
||||||
|
service.setStatus(Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||||
|
service.setAssignedAt(Instant.now());
|
||||||
|
serviceMapper.updateById(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MatchmakerAssignment requireAssignment(String assignmentId) {
|
||||||
|
return assignmentMapper.findById(assignmentId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "分配记录不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Operator requireActiveOperator(String operatorId, String notFoundMessage) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, notFoundMessage));
|
||||||
|
if (!"active".equals(operator.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "目标红娘账号非 active 状态");
|
||||||
|
}
|
||||||
|
return operator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 终态一律拒绝再转换(D5 状态机矩阵)。 */
|
||||||
|
private void assertActiveForTransition(MatchmakerAssignment assignment, String actionLabel) {
|
||||||
|
if (!Constants.ASSIGNMENT_STATUS_ACTIVE.equals(assignment.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||||
|
"分配已处于终态(" + assignment.getStatus() + "),不允许" + actionLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireReason(String reason, String message) {
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
|
||||||
|
private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
|
||||||
|
String summary = "matchmakerOperatorId=" + matchmakerOperatorId
|
||||||
|
+ ";scope=" + scope
|
||||||
|
+ ";reason=" + (reason == null ? "" : reason);
|
||||||
|
return summary.length() > 500 ? summary.substring(0, 500) : summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
|
||||||
|
AuditLog auditLog = new AuditLog();
|
||||||
|
auditLog.setOperatorId(operatorId);
|
||||||
|
auditLog.setAction(action);
|
||||||
|
auditLog.setResourceType(RESOURCE_TYPE);
|
||||||
|
auditLog.setResourceId(resourceId);
|
||||||
|
auditLog.setAfterData(afterData);
|
||||||
|
auditLogMapper.insert(auditLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.danyang.match.platform.matchmaker.service;
|
package com.danyang.match.platform.matchmaker.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
||||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
|
||||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
|
||||||
import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO;
|
import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
||||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||||
@@ -11,7 +13,9 @@ import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper;
|
|||||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
import com.danyang.match.platform.notification.service.NotificationService;
|
import com.danyang.match.platform.notification.service.NotificationService;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
|
||||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
|
||||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -27,6 +31,31 @@ import java.util.stream.Collectors;
|
|||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红娘服务服务层。
|
||||||
|
*
|
||||||
|
* <p>ZC-MM-002(A1)变更说明:
|
||||||
|
* <ul>
|
||||||
|
* <li>claimService(红娘接单)已删除(D3:不保留组长认领),服务对象归属统一由
|
||||||
|
* matchmaker_assignments 建模;</li>
|
||||||
|
* <li>写接口(recommend/followup/complete)归属判定切换到
|
||||||
|
* {@link ServiceAssignmentPolicy}(D4),拒绝统一走 DataScopeGuard(审计 + 403);
|
||||||
|
* 存量兼容:assignment 表上线前的服务单仍按 assigned_operator_id 判定(005 阶段收敛);</li>
|
||||||
|
* <li>assignService 同事务联动「创建分配 + 服务单置 assigned」。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>ZC-MM-005(E1 双轨切纯判定器)变更说明:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>删除 assigned_operator_id 回退分支</b>:002 的 "assignment 优先 +
|
||||||
|
* assigned_operator_id 回退" 有真漏洞 —— 分配 revoke/end 后服务单残留
|
||||||
|
* assigned_operator_id,回退分支会放行已失去访问权的红娘(违反 DEC-002 规则7)。
|
||||||
|
* V053 已回填全部存量进行中服务单,数据一致性已建立;终态服务单不回填是有意设计
|
||||||
|
* (服务结束即失去访问权)。{@link ServiceAssignmentPolicy} 成为唯一事实源;</li>
|
||||||
|
* <li>读详情(getServiceDetail)改用 E2 防探测 404:非持有人响应与"不存在"逐字段一致,
|
||||||
|
* 删除 viewerIsOwner 双态与"非负责人脱敏详情"整段;</li>
|
||||||
|
* <li>行为变化:红娘读自己历史 completed 服务单详情将 404(符合 DEC-002 决策)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MatchmakerServiceService {
|
public class MatchmakerServiceService {
|
||||||
@@ -37,8 +66,12 @@ public class MatchmakerServiceService {
|
|||||||
private final MatchmakerRecommendationMapper recommendationMapper;
|
private final MatchmakerRecommendationMapper recommendationMapper;
|
||||||
private final MatchmakerFollowupMapper followupMapper;
|
private final MatchmakerFollowupMapper followupMapper;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
|
private final UserPrivacySettingsMapper userPrivacySettingsMapper;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final AuditLogMapper auditLogMapper;
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||||
|
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||||
|
private final MatchmakerAssignmentService matchmakerAssignmentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户申请红娘服务
|
* 用户申请红娘服务
|
||||||
@@ -82,43 +115,46 @@ public class MatchmakerServiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分配服务给红娘
|
* 分配服务给红娘(ZC-MM-002 / D4 联动版)。
|
||||||
|
*
|
||||||
|
* <p>同事务完成:① 创建 matchmaker_assignments 主责分配(含组织范围校验、
|
||||||
|
* 唯一性约束、CREATE 审计,由 {@link MatchmakerAssignmentService#create} 承担);
|
||||||
|
* ② 服务单 submitted → assigned、写回 assigned_operator_id。
|
||||||
|
* 任何一步失败整体回滚。
|
||||||
|
*
|
||||||
|
* @param serviceId 服务单 ID
|
||||||
|
* @param assignerOperatorId 当前操作者(分配人,principal)
|
||||||
|
* @param targetOperatorId 目标红娘
|
||||||
|
* @param reason 分配理由(默认文案由 Controller 传入)
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public MatchmakerServiceRecord assignService(String serviceId, String operatorId, String organizationId) {
|
public MatchmakerServiceRecord assignService(String serviceId, String assignerOperatorId,
|
||||||
|
String targetOperatorId, String reason) {
|
||||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||||
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||||
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
|
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
|
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
|
||||||
}
|
}
|
||||||
|
if (targetOperatorId == null || targetOperatorId.isBlank()) {
|
||||||
service.setAssignedOperatorId(operatorId);
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "目标红娘必填");
|
||||||
service.setOrganizationId(organizationId);
|
|
||||||
service.setStatus(Constants.MATCHMAKER_STATUS_ASSIGNED);
|
|
||||||
service.setAssignedAt(Instant.now());
|
|
||||||
serviceMapper.updateById(service);
|
|
||||||
|
|
||||||
log.info("分配红娘服务: serviceId={}, operatorId={}", serviceId, operatorId);
|
|
||||||
saveAuditLog(operatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null);
|
|
||||||
return service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 红娘接单
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public MatchmakerServiceRecord claimService(String operatorId, String serviceId) {
|
|
||||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
|
||||||
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
|
||||||
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
|
|
||||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许接单");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
// ① 创建分配(matchmaker.assign 入口为平台授权,hasGlobalAssign=true)
|
||||||
serviceMapper.updateById(service);
|
MatchmakerAssignment createdAssignment = matchmakerAssignmentService.create(assignerOperatorId, true,
|
||||||
|
service.getUserId(), targetOperatorId, serviceId, reason, null);
|
||||||
|
|
||||||
log.info("红娘接单: operatorId={}, serviceId={}", operatorId, serviceId);
|
// ② 服务单联动(create 内已对 submitted 服务单置 assigned;此处再校验一次确保联动生效)
|
||||||
return service;
|
MatchmakerServiceRecord after = serviceMapper.selectById(serviceId);
|
||||||
|
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(after.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "服务单分配联动失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("分配红娘服务: serviceId={}, targetOperatorId={}, by={}",
|
||||||
|
serviceId, targetOperatorId, assignerOperatorId);
|
||||||
|
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||||
|
saveAuditLog(assignerOperatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null,
|
||||||
|
"matchmaker.service.assign", createdAssignment.getId());
|
||||||
|
return after;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,10 +168,10 @@ public class MatchmakerServiceService {
|
|||||||
/**
|
/**
|
||||||
* 红娘推荐候选人给用户
|
* 红娘推荐候选人给用户
|
||||||
*
|
*
|
||||||
* Phase 13 复验修复(D3):补 assigned_operator 归属校验。
|
* Phase 13 复验修复(D3):补归属校验。
|
||||||
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator
|
* ZC-MM-002(D4):归属判定切换到 {@link ServiceAssignmentPolicy}(matchmaker_assignments
|
||||||
* 都能对**任意** serviceId 创建推荐(越权)。与
|
* 是服务对象归属的唯一事实源,无任何 assigned_operator_id 回退);拒绝统一走
|
||||||
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。
|
* DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403),不再抛裸 IllegalStateException。
|
||||||
*
|
*
|
||||||
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
|
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
|
||||||
*/
|
*/
|
||||||
@@ -145,10 +181,8 @@ public class MatchmakerServiceService {
|
|||||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||||
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||||
|
|
||||||
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐
|
// D4:归属校验 —— 只有该服务对象的负责人(分配持有人)才能推荐
|
||||||
if (!operatorId.equals(service.getAssignedOperatorId())) {
|
MatchmakerAssignment assignment = assertServiceAccess(operatorId, service);
|
||||||
throw new IllegalStateException("您不是此服务的负责人");
|
|
||||||
}
|
|
||||||
// 不能推荐给自己(服务申请人)
|
// 不能推荐给自己(服务申请人)
|
||||||
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
|
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
|
||||||
throw new IllegalArgumentException("不能推荐用户给自己");
|
throw new IllegalArgumentException("不能推荐用户给自己");
|
||||||
@@ -171,7 +205,9 @@ public class MatchmakerServiceService {
|
|||||||
"matchmaker_recommendation", rec.getId().toString());
|
"matchmaker_recommendation", rec.getId().toString());
|
||||||
|
|
||||||
log.info("红娘推荐: serviceId={}, operatorId={}, targetUserId={}", serviceId, operatorId, targetUserId);
|
log.info("红娘推荐: serviceId={}, operatorId={}, targetUserId={}", serviceId, operatorId, targetUserId);
|
||||||
saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null);
|
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||||
|
saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null,
|
||||||
|
"matchmaker.service.recommend", assignment != null ? assignment.getId() : null);
|
||||||
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("recommendationId", rec.getId());
|
result.put("recommendationId", rec.getId());
|
||||||
@@ -182,11 +218,18 @@ public class MatchmakerServiceService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 红娘添加跟进记录
|
* 红娘添加跟进记录
|
||||||
|
*
|
||||||
|
* ZC-MM-002(D4):补归属校验(此前无任何校验,任何持 matchmaker.service 权限的
|
||||||
|
* operator 可对任意 serviceId 写跟进 → 越权写)。判定口径同 recommendPartner。
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public MatchmakerFollowup addFollowup(String serviceId, String operatorId,
|
public MatchmakerFollowup addFollowup(String serviceId, String operatorId,
|
||||||
String followupType, String content,
|
String followupType, String content,
|
||||||
String nextFollowupAt) {
|
String nextFollowupAt) {
|
||||||
|
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||||
|
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||||
|
MatchmakerAssignment followupAssignment = assertServiceAccess(operatorId, service);
|
||||||
|
|
||||||
MatchmakerFollowup followup = new MatchmakerFollowup();
|
MatchmakerFollowup followup = new MatchmakerFollowup();
|
||||||
followup.setServiceId(serviceId);
|
followup.setServiceId(serviceId);
|
||||||
followup.setOperatorId(operatorId);
|
followup.setOperatorId(operatorId);
|
||||||
@@ -198,17 +241,23 @@ public class MatchmakerServiceService {
|
|||||||
followupMapper.insert(followup);
|
followupMapper.insert(followup);
|
||||||
|
|
||||||
log.info("红娘跟进: serviceId={}, operatorId={}, type={}", serviceId, operatorId, followupType);
|
log.info("红娘跟进: serviceId={}, operatorId={}, type={}", serviceId, operatorId, followupType);
|
||||||
saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null);
|
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||||
|
saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null,
|
||||||
|
"matchmaker.service.followup", followupAssignment != null ? followupAssignment.getId() : null);
|
||||||
return followup;
|
return followup;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 完成服务
|
* 完成服务
|
||||||
|
*
|
||||||
|
* ZC-MM-002(D4):补归属校验(判定口径同 recommendPartner);
|
||||||
|
* operatorId 由 Controller 传入 principal,不信任请求参数。
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public MatchmakerServiceRecord completeService(String serviceId) {
|
public MatchmakerServiceRecord completeService(String serviceId, String operatorId) {
|
||||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||||
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||||
|
MatchmakerAssignment completeAssignment = assertServiceAccess(operatorId, service);
|
||||||
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
|
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
|
||||||
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
|
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
|
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
|
||||||
@@ -219,7 +268,9 @@ public class MatchmakerServiceService {
|
|||||||
serviceMapper.updateById(service);
|
serviceMapper.updateById(service);
|
||||||
|
|
||||||
log.info("红娘服务完成: serviceId={}", serviceId);
|
log.info("红娘服务完成: serviceId={}", serviceId);
|
||||||
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null);
|
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||||
|
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null,
|
||||||
|
"matchmaker.service.complete", completeAssignment != null ? completeAssignment.getId() : null);
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +308,24 @@ public class MatchmakerServiceService {
|
|||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取红娘工作台数据。
|
||||||
|
*
|
||||||
|
* ZC-MM-002(D4):today / completed 改为本人口径(该红娘名下的服务),
|
||||||
|
* pendingCount 保留全局口径(待分配队列是分配权持有者的全局视图);
|
||||||
|
* inProgress 统计的状态口径由错误的 'processing' 修正为 'in_service'
|
||||||
|
* ('processing' 不在 V009 chk_matchmaker_status 白名单,恒为 0)。
|
||||||
|
*
|
||||||
|
* <p>ZC-MM-005 收口(P1-2):inProgress / today / completed 三项计数统一切换为
|
||||||
|
* <b>matchmaker_assignments 生效中分配</b>口径(SQL 侧 join assignments,见
|
||||||
|
* MatchmakerServiceMapper 计数查询)。口径说明:
|
||||||
|
* <ul>
|
||||||
|
* <li>三项均只统计「本人当前生效分配对象名下」的服务单;</li>
|
||||||
|
* <li>分配 end / revoke 后(含他人接手后原红娘),该用户的服务单
|
||||||
|
* <b>不再计入</b>原红娘的 dashboard 计数(DEC-002 规则7:服务结束失去访问权);</li>
|
||||||
|
* <li>completed 计数同理:历史 completed 单仅在分配仍生效时计入本人。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
public Map<String, Object> getDashboard(String operatorId) {
|
public Map<String, Object> getDashboard(String operatorId) {
|
||||||
long pendingCount = serviceMapper.countSubmitted();
|
long pendingCount = serviceMapper.countSubmitted();
|
||||||
long inProgressCount = serviceMapper.countInProgressByOperator(operatorId);
|
long inProgressCount = serviceMapper.countInProgressByOperator(operatorId);
|
||||||
@@ -264,57 +333,69 @@ public class MatchmakerServiceService {
|
|||||||
Map<String, Object> dashboard = new HashMap<>();
|
Map<String, Object> dashboard = new HashMap<>();
|
||||||
dashboard.put("pendingCount", pendingCount);
|
dashboard.put("pendingCount", pendingCount);
|
||||||
dashboard.put("inProgressCount", inProgressCount);
|
dashboard.put("inProgressCount", inProgressCount);
|
||||||
long todayCount = serviceMapper.selectCount(
|
// 本人口径:今天新流转到该红娘名下(按 assigned_at)的服务数
|
||||||
new LambdaQueryWrapper<MatchmakerServiceRecord>()
|
long todayCount = serviceMapper.countTodayByOperator(operatorId);
|
||||||
.eq(MatchmakerServiceRecord::getStatus, "submitted"));
|
|
||||||
dashboard.put("todayCount", todayCount);
|
dashboard.put("todayCount", todayCount);
|
||||||
long completedCount = serviceMapper.selectCount(
|
// 本人口径:该红娘名下已完成的服务数
|
||||||
new LambdaQueryWrapper<MatchmakerServiceRecord>()
|
long completedCount = serviceMapper.countCompletedByOperator(operatorId);
|
||||||
.eq(MatchmakerServiceRecord::getStatus, "completed"));
|
|
||||||
dashboard.put("completedCount", completedCount);
|
dashboard.put("completedCount", completedCount);
|
||||||
return dashboard;
|
return dashboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取我的服务列表(红娘工作台)
|
* 获取服务详情(ZC-MM-005 / E1+E2 重构)。
|
||||||
*/
|
|
||||||
public List<MatchmakerServiceRecord> getOperatorServices(String operatorId) {
|
|
||||||
return serviceMapper.findByOperatorId(operatorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取服务详情
|
|
||||||
*
|
*
|
||||||
* Phase 13 复验修复(D4):改为按调用者授权范围返回。
|
* <p>此前(002 双轨)语义:负责人 → 完整详情;其他 operator → 脱敏详情(含 viewerIsOwner
|
||||||
* 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取
|
* 双态)。该设计有两个问题:① assigned_operator_id 回退分支在分配 revoke/end 后仍放行
|
||||||
* **任意** serviceId 的详情,其中包含该用户的完整资料
|
* (服务单残留该列);② 脱敏详情响应可被用于按 serviceId 枚举探测资源存在性。
|
||||||
* (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。
|
|
||||||
*
|
*
|
||||||
* 现语义:
|
* <p>现语义(判定器唯一事实源,无回退):
|
||||||
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups)
|
* <ul>
|
||||||
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情
|
* <li>查无此 serviceId → 抛 BusinessException(NOT_FOUND, "服务不存在");</li>
|
||||||
* (不含 userProfile;followups 正文也不返回,仅给条数)
|
* <li>非持有人(无生效中的分配)→ {@link ServiceAssignmentPolicy}
|
||||||
|
* #assertActiveAssignmentOrNotFound:先落 DATA_SCOPE_DENIED 审计,
|
||||||
|
* 再抛与"查无"完全相同的 NOT_FOUND 异常 —— 两种失败响应逐字段一致,防探测;</li>
|
||||||
|
* <li>持有人 → 完整详情(申请人 userId / 诉求描述 / 白名单 userProfile / 跟进正文 /
|
||||||
|
* 推荐列表),不再有 viewerIsOwner 双态字段。</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @param serviceId 服务 ID
|
* <p>行为变化:红娘读自己历史 completed 服务单详情将 404(分配已终态,符合 DEC-002)。
|
||||||
|
*
|
||||||
|
* @param serviceId 服务 ID
|
||||||
* @param requesterId 调用者 operator ID
|
* @param requesterId 调用者 operator ID
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
|
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
|
||||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||||
if (service == null) return null;
|
if (service == null) {
|
||||||
|
// 与"非持有人"路径抛出完全相同的异常 → 响应体逐字段一致(防探测,E2)
|
||||||
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||||
|
}
|
||||||
|
|
||||||
boolean isOwner = requesterId != null && requesterId.equals(service.getAssignedOperatorId());
|
// E1:判定器唯一事实源;E2:非持有人防探测 404(先审计后 NOT_FOUND)
|
||||||
|
serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
|
||||||
|
requesterId, service.getUserId(), "matchmaker_service", serviceId);
|
||||||
|
|
||||||
|
// ZC-MM-006 / T03:持有人读取服务对象详情 = 敏感数据访问,必须落 sensitive_access_logs
|
||||||
|
// (AGENTS.md #8:查看动作本身必须可溯源)。拒绝路径走判定器的 DATA_SCOPE_DENIED 审计,
|
||||||
|
// 双记录互斥 —— 非持有人不会走到本行(不重复写 SensitiveAccessLog)。
|
||||||
|
SensitiveAccessLog accessLog = new SensitiveAccessLog();
|
||||||
|
accessLog.setOperatorId(requesterId);
|
||||||
|
accessLog.setUserId(service.getUserId());
|
||||||
|
accessLog.setDataType(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL);
|
||||||
|
accessLog.setReason(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL);
|
||||||
|
accessLog.setAccessTarget(serviceId);
|
||||||
|
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
|
||||||
|
sensitiveAccessLogMapper.insert(accessLog);
|
||||||
|
|
||||||
Map<String, Object> detail = new HashMap<>();
|
Map<String, Object> detail = new HashMap<>();
|
||||||
detail.put("serviceId", service.getId());
|
detail.put("serviceId", service.getId());
|
||||||
detail.put("serviceType", service.getServiceType());
|
detail.put("serviceType", service.getServiceType());
|
||||||
detail.put("status", service.getStatus());
|
detail.put("status", service.getStatus());
|
||||||
detail.put("assignedOperatorId", service.getAssignedOperatorId());
|
|
||||||
detail.put("createdAt", service.getCreatedAt());
|
detail.put("createdAt", service.getCreatedAt());
|
||||||
detail.put("assignedAt", service.getAssignedAt());
|
detail.put("assignedAt", service.getAssignedAt());
|
||||||
detail.put("completedAt", service.getCompletedAt());
|
detail.put("completedAt", service.getCompletedAt());
|
||||||
detail.put("viewerIsOwner", isOwner);
|
|
||||||
|
|
||||||
// 加载推荐列表(推荐是服务进展的必要摘要,两者都可见)
|
// 推荐列表(服务进展摘要)
|
||||||
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
||||||
detail.put("recommendations", recs.stream().map(r -> {
|
detail.put("recommendations", recs.stream().map(r -> {
|
||||||
Map<String, Object> map = new HashMap<>();
|
Map<String, Object> map = new HashMap<>();
|
||||||
@@ -326,35 +407,78 @@ public class MatchmakerServiceService {
|
|||||||
return map;
|
return map;
|
||||||
}).collect(Collectors.toList()));
|
}).collect(Collectors.toList()));
|
||||||
|
|
||||||
if (isOwner) {
|
// 仅持有人可见:申请人 userId / 诉求描述 / 白名单用户资料 / 跟进正文
|
||||||
// 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文
|
detail.put("userId", service.getUserId());
|
||||||
detail.put("userId", service.getUserId());
|
detail.put("description", service.getDescription());
|
||||||
detail.put("description", service.getDescription());
|
UserPrivacySettings privacy = userPrivacySettingsMapper.findByUserId(service.getUserId())
|
||||||
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
|
.orElse(null);
|
||||||
Map<String, Object> userProfile = new HashMap<>();
|
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile ->
|
||||||
userProfile.put("displayName", profile.getDisplayName());
|
detail.put("userProfile", assembleMatchmakerUserProfile(profile, privacy)));
|
||||||
userProfile.put("gender", profile.getGender());
|
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
||||||
userProfile.put("age", calculateAge(profile.getBirthDate()));
|
|
||||||
userProfile.put("regionName", profile.getRegionName());
|
|
||||||
userProfile.put("educationCode", profile.getEducationCode());
|
|
||||||
userProfile.put("occupationCode", profile.getOccupationCode());
|
|
||||||
userProfile.put("maritalStatus", profile.getMaritalStatus());
|
|
||||||
detail.put("userProfile", userProfile);
|
|
||||||
});
|
|
||||||
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
|
||||||
} else {
|
|
||||||
// 非负责人:只给跟进条数作为进度摘要,不返回正文
|
|
||||||
detail.put("followupCount", followupMapper.findByServiceId(serviceId).size());
|
|
||||||
}
|
|
||||||
|
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红娘侧用户资料装配(ZC-MM-005 / E3 字段白名单显式化)。
|
||||||
|
*
|
||||||
|
* <p>逐字段对照《安全、隐私、合规与风控体系设计》§5.2 字段级隐私清单:
|
||||||
|
* <ul>
|
||||||
|
* <li>displayName —— 基础展示字段,允许</li>
|
||||||
|
* <li>gender —— 基础展示字段,允许</li>
|
||||||
|
* <li>age —— 由 birthDate 计算的派生值(<b>不输出原始 birthDate</b>)</li>
|
||||||
|
* <li>regionName —— 地区展示名(非精确坐标),允许</li>
|
||||||
|
* <li>educationCode —— 枚举码,允许</li>
|
||||||
|
* <li>occupationCode —— 枚举码,<b>受隐私开关 showOccupation 控制</b>:
|
||||||
|
* 用户明确关闭(false)时不输出;隐私记录缺失或开关未设置(null)按缺省放行</li>
|
||||||
|
* <li>maritalStatus —— 枚举码,允许</li>
|
||||||
|
* </ul>
|
||||||
|
* §5.2 禁放字段(phone/mobile/wechat/idNumber/birthDate/address/id_card 等)
|
||||||
|
* 一律不进入装配;守卫见 MatchmakerFieldPrivacyGuardTest(源码级白名单扫描)。
|
||||||
|
*
|
||||||
|
* @param profile 用户资料(可空字段自然输出 null,由前端按缺失处理)
|
||||||
|
* @param privacy 用户隐私设置;null 表示用户未设置过(缺省=全放行)
|
||||||
|
*/
|
||||||
|
private Map<String, Object> assembleMatchmakerUserProfile(UserProfile profile,
|
||||||
|
UserPrivacySettings privacy) {
|
||||||
|
// 缺省=全放行:仅在用户显式关闭(showOccupation == false)时隐藏职业
|
||||||
|
boolean showOccupation = privacy == null || !Boolean.FALSE.equals(privacy.getShowOccupation());
|
||||||
|
Map<String, Object> userProfile = new HashMap<>();
|
||||||
|
userProfile.put("displayName", profile.getDisplayName());
|
||||||
|
userProfile.put("gender", profile.getGender());
|
||||||
|
// 派生年龄:不输出原始 birthDate(§5.2 禁放)
|
||||||
|
userProfile.put("age", calculateAge(profile.getBirthDate()));
|
||||||
|
userProfile.put("regionName", profile.getRegionName());
|
||||||
|
userProfile.put("educationCode", profile.getEducationCode());
|
||||||
|
if (showOccupation) {
|
||||||
|
userProfile.put("occupationCode", profile.getOccupationCode());
|
||||||
|
}
|
||||||
|
userProfile.put("maritalStatus", profile.getMaritalStatus());
|
||||||
|
return userProfile;
|
||||||
|
}
|
||||||
|
|
||||||
private int calculateAge(java.time.LocalDate birthDate) {
|
private int calculateAge(java.time.LocalDate birthDate) {
|
||||||
if (birthDate == null) return 0;
|
if (birthDate == null) return 0;
|
||||||
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
|
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务归属校验(ZC-MM-005 / E1 纯判定器版;ZC-MM-006 / T04 写接口 404 化)。
|
||||||
|
*
|
||||||
|
* <p><b>无任何回退</b>:matchmaker_assignments 是服务对象归属的唯一事实源。
|
||||||
|
* 002 的 assigned_operator_id 回退分支已删除 —— 分配 revoke/end 后服务单残留的
|
||||||
|
* assigned_operator_id 不再构成访问凭据(DEC-002 规则7:撤销即失权)。
|
||||||
|
* 写接口(recommend/followup/complete,资源寻址端点)口径:判定无效 →
|
||||||
|
* {@link ServiceAssignmentPolicy#assertActiveAssignmentOrNotFound}:
|
||||||
|
* 先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」—— 与读详情口径逐字段一致,防探测。
|
||||||
|
*
|
||||||
|
* @return 生效中的分配记录(供审计写入 service_assignment_id;deny 必然抛出,不会返回 null)
|
||||||
|
*/
|
||||||
|
private MatchmakerAssignment assertServiceAccess(String operatorId, MatchmakerServiceRecord service) {
|
||||||
|
return serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(operatorId, service.getUserId(),
|
||||||
|
"matchmaker_service", service.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取红娘为该用户推荐的人员列表
|
* 获取红娘为该用户推荐的人员列表
|
||||||
@@ -394,11 +518,25 @@ public class MatchmakerServiceService {
|
|||||||
|
|
||||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||||
String resourceId, String beforeData, String afterData) {
|
String resourceId, String beforeData, String afterData) {
|
||||||
|
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写(负向用例覆盖)
|
||||||
|
saveAuditLog(operatorId, action, resourceType, resourceId, beforeData, afterData, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T01 扩展版审计写入:附加 purpose_code 与 service_assignment_id。
|
||||||
|
*/
|
||||||
|
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||||
|
String resourceId, String beforeData, String afterData,
|
||||||
|
String purposeCode, String serviceAssignmentId) {
|
||||||
AuditLog auditLog = new AuditLog();
|
AuditLog auditLog = new AuditLog();
|
||||||
auditLog.setOperatorId(operatorId);
|
auditLog.setOperatorId(operatorId);
|
||||||
auditLog.setAction(action);
|
auditLog.setAction(action);
|
||||||
auditLog.setResourceType(resourceType);
|
auditLog.setResourceType(resourceType);
|
||||||
auditLog.setResourceId(resourceId);
|
auditLog.setResourceId(resourceId);
|
||||||
|
auditLog.setBeforeData(beforeData);
|
||||||
|
auditLog.setAfterData(afterData);
|
||||||
|
auditLog.setPurposeCode(purposeCode);
|
||||||
|
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||||
auditLogMapper.insert(auditLog);
|
auditLogMapper.insert(auditLog);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||||
|
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务对象分配判定器(ZC-MM-002 / A1,架构师设计 D6;ZC-MM-005 / E1-E2 增强)。
|
||||||
|
*
|
||||||
|
* <p>红娘对某个用户能否读/写,唯一事实源是 matchmaker_assignments 里
|
||||||
|
* 「生效中的分配」(active 且未过有效期)。所有读路径前先 {@link #expireOverdue}
|
||||||
|
* 惰性过期(无定时任务,D5 决策),保证过期分配立即失效。
|
||||||
|
*
|
||||||
|
* <p>判定无效时的两种处置:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #assertActiveAssignment}:写接口口径 —— 落 DATA_SCOPE_DENIED 审计 +
|
||||||
|
* 抛 AccessDeniedException(403);</li>
|
||||||
|
* <li>{@link #assertActiveAssignmentOrNotFound}(ZC-MM-005 / E2):读详情口径 ——
|
||||||
|
* 先落 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),再抛
|
||||||
|
* BusinessException(NOT_FOUND)(响应 404,与"资源不存在"逐字段一致),
|
||||||
|
* 防止枚举 serviceId 探测资源存在性。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ServiceAssignmentPolicy {
|
||||||
|
|
||||||
|
private final MatchmakerAssignmentMapper assignmentMapper;
|
||||||
|
private final DataScopeGuard dataScopeGuard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 惰性过期:把 expires_at 已届满的 active 分配置为 expired 并落库。
|
||||||
|
* 读路径入口统一调用;无定时任务(D5 决策)。
|
||||||
|
*/
|
||||||
|
public int expireOverdue() {
|
||||||
|
return assignmentMapper.expireOverdue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询某红娘对某用户的生效中分配;不存在返回 empty。 */
|
||||||
|
public Optional<MatchmakerAssignment> findActiveAssignment(String operatorId, String userId) {
|
||||||
|
expireOverdue();
|
||||||
|
return assignmentMapper.findActiveByOperatorAndUser(operatorId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某红娘当前是否可访问某用户(存在生效中的分配)。 */
|
||||||
|
public boolean canAccessUser(String operatorId, String userId) {
|
||||||
|
return findActiveAssignment(operatorId, userId).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断言某红娘对某用户持有生效中的分配;无效时:
|
||||||
|
* 落 DATA_SCOPE_DENIED 审计 + 抛 AccessDeniedException(403)。
|
||||||
|
*
|
||||||
|
* @param resourceType 审计 resourceType(如 matchmaker_service / matchmaker_user)
|
||||||
|
* @return 生效中的分配记录(永不返回 null)
|
||||||
|
*/
|
||||||
|
public MatchmakerAssignment assertActiveAssignment(String operatorId, String userId,
|
||||||
|
String resourceType) {
|
||||||
|
Optional<MatchmakerAssignment> found = findActiveAssignment(operatorId, userId);
|
||||||
|
if (found.isPresent()) {
|
||||||
|
return found.get();
|
||||||
|
}
|
||||||
|
dataScopeGuard.deny(operatorId, resourceType, userId,
|
||||||
|
"未持有该服务对象的分配关系,禁止访问");
|
||||||
|
return null; // deny 必然抛出,此行不可达
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读详情口径的断言(ZC-MM-005 / E2 防探测 404)。
|
||||||
|
*
|
||||||
|
* <p>无效时先写 DATA_SCOPE_DENIED 审计(字段口径与 {@link DataScopeGuard#deny} 对齐,
|
||||||
|
* 语义上这是一次越权尝试),再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}。
|
||||||
|
* 全局异常处理器将其映射为 404 响应体 {@code {success:false, code:NOT_FOUND,
|
||||||
|
* message:"服务不存在"}} —— 与查询一个真正不存在的 serviceId 的响应逐字段一致,
|
||||||
|
* 调用方无法区分"无权"与"不存在",杜绝按 serviceId 枚举探测。
|
||||||
|
*
|
||||||
|
* <p>适用面(ZC-MM-006 / T04 扩展):读详情 + <b>带资源寻址的写端点</b>
|
||||||
|
* (recommend / followup / complete / 转介)范围校验失败统一 404 化(防探测);
|
||||||
|
* create / assign 类无资源寻址端点维持 {@link #assertActiveAssignment} 的 403 口径。
|
||||||
|
*
|
||||||
|
* @param resourceType 审计 resourceType(如 matchmaker_service)
|
||||||
|
* @param resourceId 审计 resourceId(被探测的 serviceId)
|
||||||
|
* @return 生效中的分配记录(永不返回 null)
|
||||||
|
*/
|
||||||
|
public MatchmakerAssignment assertActiveAssignmentOrNotFound(String operatorId, String userId,
|
||||||
|
String resourceType,
|
||||||
|
String resourceId) {
|
||||||
|
Optional<MatchmakerAssignment> found = findActiveAssignment(operatorId, userId);
|
||||||
|
if (found.isPresent()) {
|
||||||
|
return found.get();
|
||||||
|
}
|
||||||
|
// ZC-MM-006 / T04:防探测 404 化 deny —— 审计与 404 抛出统一收敛到
|
||||||
|
// {@link DataScopeGuard#denyAsNotFound}(审计含权限快照 + 请求追踪 ID)。
|
||||||
|
// 该方法同时服务读详情与(本阶段起)写接口范围校验,拒绝口径全程一致。
|
||||||
|
dataScopeGuard.denyAsNotFound(operatorId, resourceType, resourceId, "服务不存在", null, null);
|
||||||
|
return null; // denyAsNotFound 必然抛出,此行不可达
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某红娘名下全部生效中的分配(GET /my 口径),读前惰性过期。 */
|
||||||
|
public List<MatchmakerAssignment> findActiveByOperator(String operatorId) {
|
||||||
|
expireOverdue();
|
||||||
|
return assignmentMapper.findActiveByOperator(operatorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,6 +232,25 @@ public class MeController {
|
|||||||
return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType));
|
return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认直传照片落库(REST §29 补)。
|
||||||
|
* 客户端先 {@code POST /me/photos/upload-token} 拿预签名 URL 直传对象存储,
|
||||||
|
* 上传成功后再调本端点把 fileKey 落成 user_photos 记录;否则照片不展示、签名访问也过不了归属校验。
|
||||||
|
* POST /api/v1/me/photos/confirm
|
||||||
|
*/
|
||||||
|
@PostMapping("/photos/confirm")
|
||||||
|
public ApiResponse<Map<String, Object>> confirmPhoto(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
String fileKey = body.get("fileKey");
|
||||||
|
String photoType = body.getOrDefault("photoType", "lifestyle");
|
||||||
|
UserPhoto photo = photoService.confirmDirectUpload(principal.id(), fileKey, photoType);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("photoId", photo.getId());
|
||||||
|
result.put("photoType", photo.getPhotoType());
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除本人照片(REST §32)。
|
* 删除本人照片(REST §32)。
|
||||||
* DELETE /api/v1/me/photos/{photoId}
|
* DELETE /api/v1/me/photos/{photoId}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
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.GlobalExceptionHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operator 管理服务
|
* Operator 管理服务
|
||||||
@@ -85,7 +86,7 @@ public class OperatorService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void disableOperator(String operatorId) {
|
public void disableOperator(String operatorId) {
|
||||||
Operator operator = operatorMapper.findById(operatorId)
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
|
||||||
|
|
||||||
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
|
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
|
||||||
operator.setUpdatedAt(Instant.now());
|
operator.setUpdatedAt(Instant.now());
|
||||||
@@ -101,7 +102,7 @@ public class OperatorService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void enableOperator(String operatorId) {
|
public void enableOperator(String operatorId) {
|
||||||
Operator operator = operatorMapper.findById(operatorId)
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
|
||||||
|
|
||||||
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
operator.setUpdatedAt(Instant.now());
|
operator.setUpdatedAt(Instant.now());
|
||||||
|
|||||||
@@ -187,6 +187,33 @@ public class PhotoService {
|
|||||||
return photo;
|
return photo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认直传照片落库(客户端直传对象存储成功后调用)。
|
||||||
|
*
|
||||||
|
* <p>直传路径({@code /me/photos/upload-token} 签发预签名 PUT URL)对象先进入私有桶,
|
||||||
|
* 但此前没有任何端点把 {@code fileKey} 写成 {@code user_photos} 记录,导致「传了白传」:
|
||||||
|
* 列表不展示、签名访问的归属校验也过不了。本方法补齐落库,并设两道护栏:
|
||||||
|
* <ol>
|
||||||
|
* <li>归属校验:{@code fileKey} 必须以 {@code photos/{userId}/} 开头(由本用户直传凭证生成),
|
||||||
|
* 禁止客户端传入任意 fileKey 把别人的对象据为己有或写入非法 key;</li>
|
||||||
|
* <li>存在性校验:对象必须已存在于私有桶(客户端确实完成了 PUT),否则拒绝落库,
|
||||||
|
* 避免只拿了凭证却没上传成功就生成孤儿记录。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public UserPhoto confirmDirectUpload(String userId, String fileKey, String photoType) {
|
||||||
|
if (fileKey == null || !fileKey.startsWith("photos/" + userId + "/")) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的文件标识");
|
||||||
|
}
|
||||||
|
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
|
||||||
|
}
|
||||||
|
// countActiveProfilePhotos 返回 long,落库排序用 int(与 savePhoto 形参一致);
|
||||||
|
// 照片数量不会触及 int 上界,此处安全窄化。
|
||||||
|
int sortOrder = (int) (photoMapper.countActiveProfilePhotos(userId) + 1);
|
||||||
|
return savePhoto(userId, fileKey, photoType, sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理后端直传:接收文件 → 上传到 MinIO → 保存记录
|
* 处理后端直传:接收文件 → 上传到 MinIO → 保存记录
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.danyang.match.platform.profile.service;
|
package com.danyang.match.platform.profile.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.danyang.match.platform.profile.entity.UserPreference;
|
import com.danyang.match.platform.profile.entity.UserPreference;
|
||||||
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -25,37 +27,55 @@ public class UserPreferenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存择偶条件
|
* 保存择偶条件(无则插入、有则更新)。
|
||||||
|
*
|
||||||
|
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
|
||||||
|
* 再次保存即触发主键冲突(实测 {@code PUT /me/preferences} → 409
|
||||||
|
* {@code duplicate key value violates unique constraint "user_preferences_pkey"})。
|
||||||
|
*
|
||||||
|
* <p><b>为何不用 {@code updateById}</b>:{@code UserPreference} 继承 {@code BaseEntity},
|
||||||
|
* 其 {@code @TableId} 为 {@code id},而库中真正的主键是 {@code user_id}
|
||||||
|
* ({@code id} 是 V037 后补的可空列 + 唯一索引,V037 之后新插入的行 {@code id} 为 NULL)。
|
||||||
|
* 用 {@code updateById} 会退化成 {@code WHERE id IS NULL} 静默不更新,
|
||||||
|
* 因此按 {@code user_id} 定位。
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public UserPreference savePreference(String userId, Map<String, Object> data) {
|
public UserPreference savePreference(String userId, Map<String, Object> data) {
|
||||||
UserPreference pref = userPreferenceMapper.findByUserId(userId)
|
UserPreference pref = userPreferenceMapper.findByUserId(userId).orElse(null);
|
||||||
.orElseGet(() -> {
|
boolean exists = pref != null;
|
||||||
UserPreference p = new UserPreference();
|
if (!exists) {
|
||||||
p.setUserId(userId);
|
pref = new UserPreference();
|
||||||
return p;
|
pref.setUserId(userId);
|
||||||
});
|
}
|
||||||
|
|
||||||
if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
|
if (data != null) {
|
||||||
if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
|
if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
|
||||||
if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
|
if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
|
||||||
if (data.containsKey("minHeightCm")) pref.setMinHeightCm((Integer) data.get("minHeightCm"));
|
if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
|
||||||
if (data.containsKey("maxHeightCm")) pref.setMaxHeightCm((Integer) data.get("maxHeightCm"));
|
if (data.containsKey("minHeightCm")) pref.setMinHeightCm((Integer) data.get("minHeightCm"));
|
||||||
if (data.containsKey("heightPriority")) pref.setHeightPriority((String) data.get("heightPriority"));
|
if (data.containsKey("maxHeightCm")) pref.setMaxHeightCm((Integer) data.get("maxHeightCm"));
|
||||||
if (data.containsKey("regionScope")) pref.setRegionScope(toJsonNode(data.get("regionScope")));
|
if (data.containsKey("heightPriority")) pref.setHeightPriority((String) data.get("heightPriority"));
|
||||||
if (data.containsKey("regionPriority")) pref.setRegionPriority((String) data.get("regionPriority"));
|
if (data.containsKey("regionScope")) pref.setRegionScope(toJsonNode(data.get("regionScope")));
|
||||||
if (data.containsKey("maritalStatuses")) pref.setMaritalStatuses(toJsonNode(data.get("maritalStatuses")));
|
if (data.containsKey("regionPriority")) pref.setRegionPriority((String) data.get("regionPriority"));
|
||||||
if (data.containsKey("maritalPriority")) pref.setMaritalPriority((String) data.get("maritalPriority"));
|
if (data.containsKey("maritalStatuses")) pref.setMaritalStatuses(toJsonNode(data.get("maritalStatuses")));
|
||||||
if (data.containsKey("minEducationCode")) pref.setMinEducationCode((String) data.get("minEducationCode"));
|
if (data.containsKey("maritalPriority")) pref.setMaritalPriority((String) data.get("maritalPriority"));
|
||||||
if (data.containsKey("educationPriority")) pref.setEducationPriority((String) data.get("educationPriority"));
|
if (data.containsKey("minEducationCode")) pref.setMinEducationCode((String) data.get("minEducationCode"));
|
||||||
if (data.containsKey("childrenAcceptance")) pref.setChildrenAcceptance((String) data.get("childrenAcceptance"));
|
if (data.containsKey("educationPriority")) pref.setEducationPriority((String) data.get("educationPriority"));
|
||||||
if (data.containsKey("childrenPriority")) pref.setChildrenPriority((String) data.get("childrenPriority"));
|
if (data.containsKey("childrenAcceptance")) pref.setChildrenAcceptance((String) data.get("childrenAcceptance"));
|
||||||
if (data.containsKey("smokingPreference")) pref.setSmokingPreference((String) data.get("smokingPreference"));
|
if (data.containsKey("childrenPriority")) pref.setChildrenPriority((String) data.get("childrenPriority"));
|
||||||
if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
|
if (data.containsKey("smokingPreference")) pref.setSmokingPreference((String) data.get("smokingPreference"));
|
||||||
if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
|
if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
|
||||||
if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
|
if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
|
||||||
|
if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
|
||||||
|
}
|
||||||
|
|
||||||
userPreferenceMapper.insert(pref);;
|
if (exists) {
|
||||||
|
pref.setUpdatedAt(Instant.now());
|
||||||
|
userPreferenceMapper.update(pref,
|
||||||
|
new LambdaUpdateWrapper<UserPreference>().eq(UserPreference::getUserId, userId));
|
||||||
|
} else {
|
||||||
|
userPreferenceMapper.insert(pref);
|
||||||
|
}
|
||||||
return pref;
|
return pref;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
|
|||||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
import com.danyang.match.platform.verification.entity.UserIdentity;
|
||||||
|
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -27,6 +31,7 @@ public class UserProfileService {
|
|||||||
private final UserPreferenceMapper userPreferenceMapper;
|
private final UserPreferenceMapper userPreferenceMapper;
|
||||||
private final UserPrivacySettingsMapper privacySettingsMapper;
|
private final UserPrivacySettingsMapper privacySettingsMapper;
|
||||||
private final UserRelationMapper relationMapper;
|
private final UserRelationMapper relationMapper;
|
||||||
|
private final UserIdentityMapper userIdentityMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取自己完整资料(含隐私设置)
|
* 获取自己完整资料(含隐私设置)
|
||||||
@@ -60,8 +65,13 @@ public class UserProfileService {
|
|||||||
prefMap.put("maxHeightCm", pref.getMaxHeightCm());
|
prefMap.put("maxHeightCm", pref.getMaxHeightCm());
|
||||||
prefMap.put("heightPriority", pref.getHeightPriority());
|
prefMap.put("heightPriority", pref.getHeightPriority());
|
||||||
prefMap.put("regionScope", pref.getRegionScope());
|
prefMap.put("regionScope", pref.getRegionScope());
|
||||||
|
prefMap.put("regionPriority", pref.getRegionPriority());
|
||||||
prefMap.put("maritalStatuses", pref.getMaritalStatuses());
|
prefMap.put("maritalStatuses", pref.getMaritalStatuses());
|
||||||
|
prefMap.put("maritalPriority", pref.getMaritalPriority());
|
||||||
|
prefMap.put("minEducationCode", pref.getMinEducationCode());
|
||||||
|
prefMap.put("educationPriority", pref.getEducationPriority());
|
||||||
prefMap.put("childrenAcceptance", pref.getChildrenAcceptance());
|
prefMap.put("childrenAcceptance", pref.getChildrenAcceptance());
|
||||||
|
prefMap.put("childrenPriority", pref.getChildrenPriority());
|
||||||
prefMap.put("smokingPreference", pref.getSmokingPreference());
|
prefMap.put("smokingPreference", pref.getSmokingPreference());
|
||||||
prefMap.put("drinkingPreference", pref.getDrinkingPreference());
|
prefMap.put("drinkingPreference", pref.getDrinkingPreference());
|
||||||
prefMap.put("settlementPreference", pref.getSettlementPreference());
|
prefMap.put("settlementPreference", pref.getSettlementPreference());
|
||||||
@@ -84,56 +94,185 @@ public class UserProfileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新基础资料
|
* 更新基础资料(资料完善流程分节写)。
|
||||||
|
*
|
||||||
|
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
|
||||||
|
* 已有资料再次保存 → 主键冲突 → 500。改为「无则 insert / 有则 updateById」。
|
||||||
|
* 另补 {@code gender}/{@code birth_date} 的 NOT NULL 兜底(见 {@link #ensureIdentityFields})。
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) {
|
public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) {
|
||||||
UserProfile profile = userProfileMapper.findByUserId(userId)
|
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||||
.orElseGet(() -> {
|
boolean exists = profile != null;
|
||||||
UserProfile p = new UserProfile();
|
if (!exists) {
|
||||||
p.setUserId(userId);
|
profile = new UserProfile();
|
||||||
p.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
|
profile.setUserId(userId);
|
||||||
return p;
|
profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
|
||||||
});
|
}
|
||||||
|
|
||||||
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
|
|
||||||
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
|
|
||||||
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
|
|
||||||
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
|
|
||||||
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
|
|
||||||
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
|
|
||||||
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
|
|
||||||
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
|
|
||||||
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
|
|
||||||
|
|
||||||
|
applyProfileFields(profile, fields);
|
||||||
|
ensureIdentityFields(profile);
|
||||||
profile.setProfileCompletion(calculateCompletion(profile));
|
profile.setProfileCompletion(calculateCompletion(profile));
|
||||||
userProfileMapper.insert(profile);
|
persistProfile(profile, exists);
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新个人资料
|
* 更新个人资料(资料完善向导的最终提交,置为 completed)。
|
||||||
|
*
|
||||||
|
* <p>2026-09-23 UAT 走查修复:同 {@link #updateBasicProfile},消除「二次保存必崩」。
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateProfile(String userId, Map<String, Object> fields) {
|
public void updateProfile(String userId, Map<String, Object> fields) {
|
||||||
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(new UserProfile());
|
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||||
profile.setUserId(userId);
|
boolean exists = profile != null;
|
||||||
|
if (!exists) {
|
||||||
// 基础字段映射
|
profile = new UserProfile();
|
||||||
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
|
profile.setUserId(userId);
|
||||||
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
|
}
|
||||||
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
|
|
||||||
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
|
|
||||||
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
|
|
||||||
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
|
|
||||||
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
|
|
||||||
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
|
|
||||||
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
|
|
||||||
|
|
||||||
|
applyProfileFields(profile, fields);
|
||||||
|
ensureIdentityFields(profile);
|
||||||
profile.setProfileCompletion(calculateCompletion(profile));
|
profile.setProfileCompletion(calculateCompletion(profile));
|
||||||
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
|
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
|
||||||
userProfileMapper.insert(profile);
|
persistProfile(profile, exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落库:无则插入、有则按主键 user_id 更新。
|
||||||
|
*
|
||||||
|
* <p>{@code UserProfile} 的 {@code @TableId = userId},与库主键 {@code user_profiles.user_id}
|
||||||
|
* 一致,故 {@code updateById} 安全(对比 {@code UserPreference} 实体 id 与库主键不一致的情形)。
|
||||||
|
*/
|
||||||
|
private void persistProfile(UserProfile profile, boolean exists) {
|
||||||
|
if (exists) {
|
||||||
|
userProfileMapper.updateById(profile);
|
||||||
|
} else {
|
||||||
|
userProfileMapper.insert(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把请求字段映射到资料实体。
|
||||||
|
*
|
||||||
|
* <p>同时接受「规范字段名」与小程序的既有字段名,二者等价:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code childrenStatus}(none/yes)↔ {@code hasChildren}(布尔)</li>
|
||||||
|
* <li>{@code occupationCode} ↔ {@code occupationCategory}</li>
|
||||||
|
* <li>{@code workRegionCode} ↔ {@code workRegion}</li>
|
||||||
|
* </ul>
|
||||||
|
* 这使「REST 规范 / 管理端」与「小程序完善资料向导」共用同一写接口,
|
||||||
|
* 避免因命名不一致导致字段被静默丢弃(UAT 走查发现向导的子女/职业/工作地三项
|
||||||
|
* 此前根本写不进库)。
|
||||||
|
*/
|
||||||
|
private void applyProfileFields(UserProfile profile, Map<String, Object> fields) {
|
||||||
|
if (fields == null) return;
|
||||||
|
if (fields.containsKey("displayName")) profile.setDisplayName(asString(fields.get("displayName")));
|
||||||
|
if (fields.containsKey("gender")) profile.setGender(asString(fields.get("gender")));
|
||||||
|
if (fields.containsKey("birthDate")) profile.setBirthDate(asDate(fields.get("birthDate")));
|
||||||
|
if (fields.containsKey("heightCm")) profile.setHeightCm(asInteger(fields.get("heightCm")));
|
||||||
|
if (fields.containsKey("regionCode")) profile.setRegionCode(asString(fields.get("regionCode")));
|
||||||
|
if (fields.containsKey("regionName")) profile.setRegionName(asString(fields.get("regionName")));
|
||||||
|
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus(asString(fields.get("maritalStatus")));
|
||||||
|
|
||||||
|
// 子女:规范名 childrenStatus(none/yes)优先,兼容向导的 hasChildren(布尔)
|
||||||
|
if (fields.containsKey("childrenStatus")) {
|
||||||
|
profile.setChildrenStatus(asString(fields.get("childrenStatus")));
|
||||||
|
} else if (fields.containsKey("hasChildren")) {
|
||||||
|
Boolean has = asBoolean(fields.get("hasChildren"));
|
||||||
|
if (has != null) profile.setChildrenStatus(has ? "yes" : "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.containsKey("educationCode")) profile.setEducationCode(asString(fields.get("educationCode")));
|
||||||
|
|
||||||
|
// 职业:规范名 occupationCode 优先,兼容向导的 occupationCategory
|
||||||
|
if (fields.containsKey("occupationCode")) {
|
||||||
|
profile.setOccupationCode(asString(fields.get("occupationCode")));
|
||||||
|
} else if (fields.containsKey("occupationCategory")) {
|
||||||
|
profile.setOccupationCode(asString(fields.get("occupationCategory")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工作地:规范名 workRegionCode 优先,兼容向导的 workRegion
|
||||||
|
if (fields.containsKey("workRegionCode")) {
|
||||||
|
profile.setWorkRegionCode(asString(fields.get("workRegionCode")));
|
||||||
|
} else if (fields.containsKey("workRegion")) {
|
||||||
|
profile.setWorkRegionCode(asString(fields.get("workRegion")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.containsKey("smokingStatus")) profile.setSmokingStatus(asString(fields.get("smokingStatus")));
|
||||||
|
if (fields.containsKey("drinkingStatus")) profile.setDrinkingStatus(asString(fields.get("drinkingStatus")));
|
||||||
|
if (fields.containsKey("longTermDanyang")) profile.setLongTermDanyang(asString(fields.get("longTermDanyang")));
|
||||||
|
if (fields.containsKey("futureDanyang")) profile.setFutureDanyang(asString(fields.get("futureDanyang")));
|
||||||
|
if (fields.containsKey("relationshipGoal")) profile.setRelationshipGoal(asString(fields.get("relationshipGoal")));
|
||||||
|
if (fields.containsKey("marriagePlan")) profile.setMarriagePlan(asString(fields.get("marriagePlan")));
|
||||||
|
if (fields.containsKey("selfIntro")) profile.setSelfIntro(asString(fields.get("selfIntro")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code user_profiles.gender} 与 {@code birth_date} 在库中为 NOT NULL,而资料完善向导
|
||||||
|
* 不采集这两项(性别是「已认证」的只读信息)——权威来源是实名认证
|
||||||
|
* ({@code user_identities}),产品流程亦为「先实名、后完善资料」。
|
||||||
|
*
|
||||||
|
* <p>因此:请求未提供时从已通过实名认证的记录补齐;仍缺失则给出明确的
|
||||||
|
* 4xx 业务拒绝(而非让数据库抛 NOT NULL 约束错渲染成 500)。
|
||||||
|
*/
|
||||||
|
private void ensureIdentityFields(UserProfile profile) {
|
||||||
|
boolean needGender = profile.getGender() == null || profile.getGender().isBlank();
|
||||||
|
boolean needBirth = profile.getBirthDate() == null;
|
||||||
|
if (!needGender && !needBirth) return;
|
||||||
|
|
||||||
|
UserIdentity identity = userIdentityMapper.findByUserId(profile.getUserId()).orElse(null);
|
||||||
|
if (identity != null && Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
|
||||||
|
if (needGender && identity.getGender() != null && !identity.getGender().isBlank()) {
|
||||||
|
profile.setGender(identity.getGender());
|
||||||
|
needGender = false;
|
||||||
|
}
|
||||||
|
if (needBirth && identity.getBirthDate() != null) {
|
||||||
|
profile.setBirthDate(identity.getBirthDate());
|
||||||
|
needBirth = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needGender || needBirth) {
|
||||||
|
throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.BAD_REQUEST,
|
||||||
|
"请先完成实名认证后再完善资料");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String asString(Object v) {
|
||||||
|
return v == null ? null : String.valueOf(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer asInteger(Object v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v instanceof Number n) return n.intValue();
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(String.valueOf(v).trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean asBoolean(Object v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v instanceof Boolean b) return b;
|
||||||
|
String s = String.valueOf(v).trim();
|
||||||
|
if ("true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "has".equalsIgnoreCase(s)) return true;
|
||||||
|
if ("false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s) || "none".equalsIgnoreCase(s)) return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate asDate(Object v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v instanceof LocalDate d) return d;
|
||||||
|
String s = String.valueOf(v).trim();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(s);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class ReferralService {
|
|||||||
private final ActivityRegistrationMapper activityRegistrationMapper;
|
private final ActivityRegistrationMapper activityRegistrationMapper;
|
||||||
private final OperatorMapper operatorMapper;
|
private final OperatorMapper operatorMapper;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
|
private final com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。
|
* Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。
|
||||||
@@ -39,16 +39,24 @@ public class ReferralService {
|
|||||||
* 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站,
|
* 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站,
|
||||||
* 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。
|
* 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。
|
||||||
*
|
*
|
||||||
* 修复后:先断言存在进行中的 matchmaker_service(assigned_operator_id=红娘, user_id=目标用户),
|
* 修复后:先断言存在生效中的分配关系,否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
|
||||||
* 否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
|
*
|
||||||
|
* <p>ZC-MM-005 收口(P1-3):授权事实源切换为
|
||||||
|
* {@link com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy}
|
||||||
|
* 判定器 —— 与写接口 recommend / followup 同口径(matchmaker_assignments 生效中分配)。
|
||||||
|
* 分配 revoke / end 后残留列不再构成转介凭据(DEC-002 规则7:撤销即失权)。
|
||||||
|
*
|
||||||
|
* <p>ZC-MM-006 / T04(写接口 404 统一·防探测):转介为资源寻址写端点,拒绝路径
|
||||||
|
* 404 化 {@code denyAsNotFound}(先 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」,
|
||||||
|
* 与读详情口径逐字段一致);并消灭裸 IllegalArgumentException ——
|
||||||
|
* 缺失目标用户也统一映射为同一 404 响应,不泄露"目标是否存在"的任何信号。
|
||||||
*/
|
*/
|
||||||
private void assertMatchmakerServesUser(String matchmakerId, String userId) {
|
private void assertMatchmakerServesUser(String matchmakerId, String userId) {
|
||||||
if (userId == null || userId.isBlank()) {
|
if (userId == null || userId.isBlank()) {
|
||||||
throw new IllegalArgumentException("缺少转介目标用户");
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||||
}
|
}
|
||||||
matchmakerServiceMapper.findActiveByOperatorAndUser(matchmakerId, userId)
|
serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(matchmakerId, userId,
|
||||||
.orElseThrow(() -> new IllegalStateException(
|
"matchmaker_referral", userId);
|
||||||
"您不是该用户的服务负责人,无法发起转介"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import java.util.*;
|
|||||||
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.GlobalExceptionHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关系服务
|
* 关系服务
|
||||||
@@ -104,7 +105,7 @@ public class RelationshipService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void cancelLike(String userId, String targetUserId) {
|
public void cancelLike(String userId, String targetUserId) {
|
||||||
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED)
|
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到喜欢关系"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到喜欢关系"));
|
||||||
|
|
||||||
relationMapper.deleteById(relation.getId());
|
relationMapper.deleteById(relation.getId());
|
||||||
log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId);
|
log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId);
|
||||||
@@ -184,7 +185,7 @@ public class RelationshipService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void unblock(String userId, String targetUserId) {
|
public void unblock(String userId, String targetUserId) {
|
||||||
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED)
|
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED)
|
||||||
.orElseThrow(() -> new RuntimeException("没有拉黑过该用户"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有拉黑过该用户"));
|
||||||
|
|
||||||
relationMapper.deleteById(relation.getId());
|
relationMapper.deleteById(relation.getId());
|
||||||
log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId);
|
log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId);
|
||||||
@@ -323,7 +324,7 @@ public class RelationshipService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void unmatch(String userId, String targetUserId) {
|
public void unmatch(String userId, String targetUserId) {
|
||||||
Match match = matchMapper.findActiveMatch(userId, targetUserId)
|
Match match = matchMapper.findActiveMatch(userId, targetUserId)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到有效的匹配关系"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到有效的匹配关系"));
|
||||||
|
|
||||||
match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
|
match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
|
||||||
match.setUnmatchedAt(Instant.now());
|
match.setUnmatchedAt(Instant.now());
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ public class ReviewController {
|
|||||||
public ApiResponse<ReviewResultDTO> approve(
|
public ApiResponse<ReviewResultDTO> approve(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String taskId,
|
@PathVariable String taskId,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
String operatorId = principal.id();
|
String operatorId = principal.id();
|
||||||
String comment = body.getOrDefault("comment", "");
|
String comment = (String) body.getOrDefault("comment", "");
|
||||||
return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment));
|
return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +140,8 @@ public class ReviewController {
|
|||||||
public ApiResponse<ReviewResultDTO> needEvidence(
|
public ApiResponse<ReviewResultDTO> needEvidence(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String taskId,
|
@PathVariable String taskId,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment")));
|
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), (String) body.get("comment")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{taskId}/approve-local-eligibility")
|
@PostMapping("/{taskId}/approve-local-eligibility")
|
||||||
@@ -149,8 +149,8 @@ public class ReviewController {
|
|||||||
public ApiResponse<ReviewResultDTO> approveLocalEligibility(
|
public ApiResponse<ReviewResultDTO> approveLocalEligibility(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String taskId,
|
@PathVariable String taskId,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment")));
|
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{taskId}/reject-local-eligibility")
|
@PostMapping("/{taskId}/reject-local-eligibility")
|
||||||
@@ -158,8 +158,8 @@ public class ReviewController {
|
|||||||
public ApiResponse<ReviewResultDTO> rejectLocalEligibility(
|
public ApiResponse<ReviewResultDTO> rejectLocalEligibility(
|
||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String taskId,
|
@PathVariable String taskId,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment")));
|
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{taskId}/evidences/{evidenceId}/access")
|
@PostMapping("/{taskId}/evidences/{evidenceId}/access")
|
||||||
@@ -168,9 +168,9 @@ public class ReviewController {
|
|||||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
@PathVariable String taskId,
|
@PathVariable String taskId,
|
||||||
@PathVariable String evidenceId,
|
@PathVariable String evidenceId,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence(
|
return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence(
|
||||||
taskId, evidenceId, principal.id(), body.get("purpose")));
|
taskId, evidenceId, principal.id(), (String) body.get("purpose")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import java.time.Instant;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
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.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -280,7 +281,7 @@ public class ReviewService {
|
|||||||
public ReviewTask resubmit(String userId) {
|
public ReviewTask resubmit(String userId) {
|
||||||
// 找到最新的需修改任务
|
// 找到最新的需修改任务
|
||||||
ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(userId)
|
ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("未找到可重新提交的审核任务"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到可重新提交的审核任务"));
|
||||||
|
|
||||||
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
|
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
|
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
|
||||||
|
|||||||
@@ -330,9 +330,36 @@ public final class Constants {
|
|||||||
public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker";
|
public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker";
|
||||||
public static final String PROFILE_STATUS_PENDING_REVIEW = "pending_review";
|
public static final String PROFILE_STATUS_PENDING_REVIEW = "pending_review";
|
||||||
|
|
||||||
|
// ========== 服务对象分配(ZC-MM-002 / A1,与 V053 数据库 CHECK 白名单一致)==========
|
||||||
|
// 状态机:active → transferred / revoked / ended;expires_at 届满由判定器惰性置 expired。
|
||||||
|
// 终态(ended/revoked/transferred/expired)一律拒绝再转换。
|
||||||
|
public static final String ASSIGNMENT_STATUS_ACTIVE = "active";
|
||||||
|
public static final String ASSIGNMENT_STATUS_ENDED = "ended";
|
||||||
|
public static final String ASSIGNMENT_STATUS_REVOKED = "revoked";
|
||||||
|
public static final String ASSIGNMENT_STATUS_TRANSFERRED = "transferred";
|
||||||
|
public static final String ASSIGNMENT_STATUS_EXPIRED = "expired";
|
||||||
|
|
||||||
|
// 分配范围:primary(主责,同用户仅一条生效中)/ collaborating(协同)
|
||||||
|
public static final String ASSIGNMENT_SCOPE_PRIMARY = "primary";
|
||||||
|
public static final String ASSIGNMENT_SCOPE_COLLABORATING = "collaborating";
|
||||||
|
|
||||||
|
// 服务对象分配审计 action(resourceType = matchmaker_assignment)
|
||||||
|
public static final String AUDIT_ACTION_ASSIGNMENT_CREATE = "MATCHMAKER_ASSIGNMENT_CREATE";
|
||||||
|
public static final String AUDIT_ACTION_ASSIGNMENT_TRANSFER = "MATCHMAKER_ASSIGNMENT_TRANSFER";
|
||||||
|
public static final String AUDIT_ACTION_ASSIGNMENT_REVOKE = "MATCHMAKER_ASSIGNMENT_REVOKE";
|
||||||
|
public static final String AUDIT_ACTION_ASSIGNMENT_END = "MATCHMAKER_ASSIGNMENT_END";
|
||||||
|
|
||||||
// ========== V1.1 本地资格审核 ==========
|
// ========== V1.1 本地资格审核 ==========
|
||||||
/** 本地资格审核任务类型 */
|
/** 本地资格审核任务类型 */
|
||||||
public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility";
|
public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility";
|
||||||
/** 本地资格佐证数据类型(用于敏感访问审计) */
|
/** 本地资格佐证数据类型(用于敏感访问审计) */
|
||||||
public static final String SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE = "local_eligibility_evidence";
|
public static final String SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE = "local_eligibility_evidence";
|
||||||
|
|
||||||
|
// ========== ZC-MM-006 / T03 红娘域敏感访问审计 ==========
|
||||||
|
/** 红娘工作台服务对象详情数据类型(用于敏感访问审计) */
|
||||||
|
public static final String SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL = "matchmaker_service_detail";
|
||||||
|
/** 敏感访问成功结果(与 SensitiveAccessLog.accessResult 白名单 success/denied/failed 对齐) */
|
||||||
|
public static final String SENSITIVE_ACCESS_RESULT_SUCCESS = "success";
|
||||||
|
/** 红娘查看服务对象详情的访问理由文案 */
|
||||||
|
public static final String SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL = "matchmaker 工作台查看服务对象详情";
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package com.danyang.match.platform.shared.filter;
|
package com.danyang.match.platform.shared.filter;
|
||||||
|
|
||||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
import com.danyang.match.platform.permission.service.PermissionService;
|
import com.danyang.match.platform.permission.service.PermissionService;
|
||||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
|
import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
|
||||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -33,6 +35,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
private final JwtTokenService jwtTokenService;
|
private final JwtTokenService jwtTokenService;
|
||||||
private final PermissionService permissionService;
|
private final PermissionService permissionService;
|
||||||
|
private final OperatorMapper operatorMapper;
|
||||||
|
|
||||||
private static final List<String> PERMITTED_PATHS = List.of(
|
private static final List<String> PERMITTED_PATHS = List.of(
|
||||||
"/api/v1/auth/**",
|
"/api/v1/auth/**",
|
||||||
@@ -58,12 +61,26 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String authHeader = request.getHeader("Authorization");
|
String authHeader = request.getHeader("Authorization");
|
||||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
String token = null;
|
||||||
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
|
token = authHeader.substring(7);
|
||||||
|
} else {
|
||||||
|
// 浏览器同源请求:管理端登录后由 HttpOnly Cookie(dy_op_at)携带 access token。
|
||||||
|
// 作为 Authorization 头的兜底(抗 XSS 读取;小程序/用户端仍走 Authorization 头,不受影响)。
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies != null) {
|
||||||
|
for (Cookie c : cookies) {
|
||||||
|
if ("dy_op_at".equals(c.getName())) {
|
||||||
|
token = c.getValue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (token == null) {
|
||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String token = authHeader.substring(7);
|
|
||||||
if (!jwtTokenService.validateToken(token)) {
|
if (!jwtTokenService.validateToken(token)) {
|
||||||
sendUnauthorized(response);
|
sendUnauthorized(response);
|
||||||
return;
|
return;
|
||||||
@@ -72,6 +89,20 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
String subject = jwtTokenService.getUserIdFromToken(token);
|
String subject = jwtTokenService.getUserIdFromToken(token);
|
||||||
String tokenType = jwtTokenService.parseToken(token).get("type", String.class);
|
String tokenType = jwtTokenService.parseToken(token).get("type", String.class);
|
||||||
boolean operatorToken = "operator_access".equals(tokenType);
|
boolean operatorToken = "operator_access".equals(tokenType);
|
||||||
|
if (operatorToken) {
|
||||||
|
// ZC-MM-006 / T02(JWT 权限回收·实时查库路线,不建撤销名单):
|
||||||
|
// 运营 token 每请求实时查 operators.status —— 账号不存在(含软删除)或非 active
|
||||||
|
// 一律 401、不建 principal,已签发 token 立即失效;零缓存。
|
||||||
|
// 注意:此处只接受 "active";"normal" 是用户账号状态(ACCOUNT_STATUS_NORMAL),
|
||||||
|
// 运营账号禁用/待激活均不得通过过滤器。
|
||||||
|
var operator = operatorMapper.findById(subject);
|
||||||
|
if (operator.isEmpty() || !"active".equals(operator.get().getStatus())) {
|
||||||
|
sendUnauthorized(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ZC-MM-006 / T02:权限实时查库(PermissionMapper join role_permissions/operator_roles),
|
||||||
|
// 每请求生效;token claims 中的 permissions 仅作展示快照(只写不读),不是授权事实源。
|
||||||
List<String> permissions = operatorToken
|
List<String> permissions = operatorToken
|
||||||
? permissionService.getPermissions(subject)
|
? permissionService.getPermissions(subject)
|
||||||
: List.of();
|
: List.of();
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ public class AuthorizationAuditListener {
|
|||||||
audit.setAction("AUTHORIZATION_DENIED");
|
audit.setAction("AUTHORIZATION_DENIED");
|
||||||
audit.setResourceType("authorization");
|
audit.setResourceType("authorization");
|
||||||
audit.setResourceId(limit(String.valueOf(event.getObject()), 128));
|
audit.setResourceId(limit(String.valueOf(event.getObject()), 128));
|
||||||
|
// ZC-MM-006 / T01:权限快照(permissions 逗号拼接,截断 128)+ 请求追踪 ID
|
||||||
|
audit.setActorRoleSnapshot(limit(String.join(",", principal.permissions()), 128));
|
||||||
|
audit.setRequestTraceId(org.slf4j.MDC.get("requestId"));
|
||||||
audit.setCreatedAt(Instant.now());
|
audit.setCreatedAt(Instant.now());
|
||||||
|
|
||||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
|
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ 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.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
|
||||||
/** Denies out-of-scope operator access and records the attempt before returning 403. */
|
/** Denies out-of-scope operator access and records the attempt before returning 403. */
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -19,12 +24,28 @@ public class DataScopeGuard {
|
|||||||
private final AuditLogMapper auditLogMapper;
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
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)仍可写
|
||||||
|
deny(operatorId, resourceType, resourceId, message, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T01 扩展版拒绝审计:附加业务目的码与关联分配 ID。
|
||||||
|
*
|
||||||
|
* @param purposeCode 业务目的码(如 matchmaker.service.view),可空
|
||||||
|
* @param serviceAssignmentId 关联的 matchmaker_assignments.id(拒绝路径通常无生效分配 → null)
|
||||||
|
*/
|
||||||
|
public void deny(String operatorId, String resourceType, String resourceId, String message,
|
||||||
|
String purposeCode, String serviceAssignmentId) {
|
||||||
AuditLog audit = new AuditLog();
|
AuditLog audit = new AuditLog();
|
||||||
audit.setId(UUID.randomUUID().toString());
|
audit.setId(UUID.randomUUID().toString());
|
||||||
audit.setOperatorId(operatorId);
|
audit.setOperatorId(operatorId);
|
||||||
audit.setAction("DATA_SCOPE_DENIED");
|
audit.setAction("DATA_SCOPE_DENIED");
|
||||||
audit.setResourceType(resourceType);
|
audit.setResourceType(resourceType);
|
||||||
audit.setResourceId(resourceId);
|
audit.setResourceId(resourceId);
|
||||||
|
audit.setPurposeCode(purposeCode);
|
||||||
|
audit.setServiceAssignmentId(serviceAssignmentId);
|
||||||
|
audit.setActorRoleSnapshot(currentActorRoleSnapshot());
|
||||||
|
audit.setRequestTraceId(MDC.get("requestId"));
|
||||||
audit.setCreatedAt(Instant.now());
|
audit.setCreatedAt(Instant.now());
|
||||||
try {
|
try {
|
||||||
auditLogMapper.insert(audit);
|
auditLogMapper.insert(audit);
|
||||||
@@ -34,4 +55,57 @@ public class DataScopeGuard {
|
|||||||
}
|
}
|
||||||
throw new AccessDeniedException(message);
|
throw new AccessDeniedException(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T04(写接口 404 统一·防探测):范围校验失败的写端点 ——
|
||||||
|
* 先落与 {@link #deny} 完全相同的 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),
|
||||||
|
* 再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}:响应与"资源不存在"逐字段一致,
|
||||||
|
* 调用方无法区分"无权"与"不存在",杜绝按资源 ID 枚举探测写端点。
|
||||||
|
*
|
||||||
|
* <p>适用面:带资源寻址的写端点(transfer/revoke/end/recommend/followup/complete/转介);
|
||||||
|
* create/assign 类无资源寻址端点维持 {@link #deny} 的 403 口径。
|
||||||
|
*
|
||||||
|
* @param message 404 响应 message(统一 "服务不存在",与读详情口径一致)
|
||||||
|
*/
|
||||||
|
public void denyAsNotFound(String operatorId, String resourceType, String resourceId, String message,
|
||||||
|
String purposeCode, String serviceAssignmentId) {
|
||||||
|
AuditLog audit = new AuditLog();
|
||||||
|
audit.setId(UUID.randomUUID().toString());
|
||||||
|
audit.setOperatorId(operatorId);
|
||||||
|
audit.setAction("DATA_SCOPE_DENIED");
|
||||||
|
audit.setResourceType(resourceType);
|
||||||
|
audit.setResourceId(resourceId);
|
||||||
|
audit.setPurposeCode(purposeCode);
|
||||||
|
audit.setServiceAssignmentId(serviceAssignmentId);
|
||||||
|
audit.setActorRoleSnapshot(currentActorRoleSnapshot());
|
||||||
|
audit.setRequestTraceId(MDC.get("requestId"));
|
||||||
|
audit.setCreatedAt(Instant.now());
|
||||||
|
try {
|
||||||
|
auditLogMapper.insert(audit);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
|
||||||
|
operatorId, resourceType, resourceId, exception);
|
||||||
|
}
|
||||||
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前操作者权限快照(与 {@link AuthorizationAuditListener} 同口径):
|
||||||
|
* OperatorPrincipal.permissions() 逗号拼接,截断 128。
|
||||||
|
*/
|
||||||
|
private String currentActorRoleSnapshot() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication != null && authentication.getPrincipal()
|
||||||
|
instanceof com.danyang.match.platform.shared.security.principal.OperatorPrincipal principal) {
|
||||||
|
return limit(String.join(",", principal.permissions()), 128);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String limit(String value, int maxLength) {
|
||||||
|
if (value == null || value.length() <= maxLength) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.substring(0, maxLength);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,15 @@ import java.util.UUID;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock 实名认证 Provider
|
* Mock 实名认证 Provider
|
||||||
* 开发/测试环境使用,生产环境替换为正式 Provider
|
* 开发/测试环境使用,生产环境替换为正式 Provider。
|
||||||
|
*
|
||||||
|
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code identity.verification.provider},
|
||||||
|
* 由 {@link RealIdentityVerificationProvider} 承接 {@code real} 取值;本类保留
|
||||||
|
* {@code matchIfMissing = true},未显式配置时本地默认仍是 Mock。生产必须在
|
||||||
|
* {@code application-production.yml} 显式置 {@code identity.verification.provider=real}。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
|
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "mock", matchIfMissing = true)
|
||||||
public class MockIdentityVerificationProvider implements IdentityVerificationProvider {
|
public class MockIdentityVerificationProvider implements IdentityVerificationProvider {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class);
|
private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class);
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.danyang.match.platform.verification.provider;
|
||||||
|
|
||||||
|
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 org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实实名认证 Provider(生产环境使用)。
|
||||||
|
*
|
||||||
|
* <p>由配置 {@code identity.verification.provider=real} 激活;未显式配置时默认是 Mock
|
||||||
|
* (见 {@link MockIdentityVerificationProvider}),因此生产环境必须在
|
||||||
|
* {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
|
||||||
|
*
|
||||||
|
* <p>核验策略(fail-closed,绝不静默放行):
|
||||||
|
* <ul>
|
||||||
|
* <li>本地强校验:身份证号 GB 11643-1999 校验位(mod 11-2)。校验位不符直接判定未通过,
|
||||||
|
* 在发起任何外部调用前拦截明显伪造的证件号。</li>
|
||||||
|
* <li>第三方核验:必须配置 {@code identity.verification.real.api-url} 与
|
||||||
|
* {@code identity.verification.real.api-key}(环境变量 {@code VERIFICATION_API_URL} /
|
||||||
|
* {@code VERIFICATION_API_KEY})。未配置即 fail-fast —— 真实核验无法凭空完成,
|
||||||
|
* 绝不能回退成「放行」。</li>
|
||||||
|
* <li>任何无法判定为明确的「核验通过」的响应,一律按「未通过」处理(fail-closed)。</li>
|
||||||
|
* <li>绝不把身份证号原文作为日志明文输出;仅记录脱敏值。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>对接说明:第三方请求/响应契约需在接入具体厂商(如公安实名、运营商三要素)时最终确认。
|
||||||
|
* 当前实现以「明确的 success/match/verified/passed 布尔为 true」判定通过,其余一律视为未通过。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "real")
|
||||||
|
public class RealIdentityVerificationProvider implements IdentityVerificationProvider {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RealIdentityVerificationProvider.class);
|
||||||
|
|
||||||
|
/** 身份证号校验位权重(GB 11643-1999,前 17 位) */
|
||||||
|
private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
|
||||||
|
/** 校验位映射:索引 = 加权和 mod 11;'X' 表示余数 2 */
|
||||||
|
private static final String CHECK_CODES = "10X98765432";
|
||||||
|
|
||||||
|
@Value("${identity.verification.real.api-url:}")
|
||||||
|
private String apiUrl;
|
||||||
|
|
||||||
|
@Value("${identity.verification.real.api-key:}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> verify(String realName, String idNumber) {
|
||||||
|
if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) {
|
||||||
|
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意证件
|
||||||
|
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "实名核验未配置第三方核验服务");
|
||||||
|
}
|
||||||
|
if (realName == null || realName.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "实名认证失败:缺少姓名");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 本地校验位强校验:伪造证件号在外部调用前即拒绝
|
||||||
|
if (!isValidChecksum(idNumber)) {
|
||||||
|
log.warn("[Identity] 身份证校验位不合法: idNumber={}", maskIdNumber(idNumber));
|
||||||
|
return buildResult(false, idNumber, realName, null, null, UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDate birthDate = parseBirthDateFromIdNumber(idNumber);
|
||||||
|
String gender = parseGenderFromIdNumber(idNumber);
|
||||||
|
|
||||||
|
// 2. 第三方核验(fail-closed)
|
||||||
|
boolean matched = callThirdParty(realName, idNumber);
|
||||||
|
return buildResult(matched, idNumber, realName, birthDate.toString(), gender, UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用第三方实名核验。仅当响应中明确出现「通过」语义的布尔字段为 true 时才返回 true;
|
||||||
|
* 其余(缺字段、字段为 false、解析失败、网络异常)一律返回 false(fail-closed)。
|
||||||
|
*/
|
||||||
|
private boolean callThirdParty(String realName, String idNumber) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("realName", realName);
|
||||||
|
body.put("idNumber", idNumber);
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Authorization", "Bearer " + apiKey);
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||||
|
ResponseEntity<String> resp = restTemplate.postForEntity(apiUrl, entity, String.class);
|
||||||
|
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
|
||||||
|
log.warn("[Identity] 第三方核验返回非成功状态: status={}", resp.getStatusCode());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
JsonNode node = objectMapper.readTree(resp.getBody());
|
||||||
|
return isExplicitlyVerified(node);
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[Identity] 第三方核验调用异常", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅在响应中明确的「通过」布尔字段为 true 时判定通过。 */
|
||||||
|
private boolean isExplicitlyVerified(JsonNode node) {
|
||||||
|
for (String field : new String[]{"success", "match", "verified", "passed", "isValid"}) {
|
||||||
|
JsonNode v = node.get(field);
|
||||||
|
if (v != null && v.isBoolean() && v.asBoolean()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兼容 { "code": 0, "message": "ok" } 形态
|
||||||
|
JsonNode code = node.get("code");
|
||||||
|
if (code != null && code.isNumber() && code.asInt() == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildResult(boolean verified, String idNumber, String realName,
|
||||||
|
String birthDate, String gender, String requestId) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("verified", verified);
|
||||||
|
result.put("realName", realName);
|
||||||
|
result.put("idNumberHash", idNumber);
|
||||||
|
result.put("birthDate", birthDate);
|
||||||
|
result.put("gender", gender);
|
||||||
|
result.put("requestId", requestId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isMock() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GB 11643-1999 校验位(mod 11-2)。 */
|
||||||
|
private boolean isValidChecksum(String idNumber) {
|
||||||
|
if (idNumber == null || idNumber.length() != 18) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!idNumber.substring(0, 17).matches("\\d{17}")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char last = idNumber.charAt(17);
|
||||||
|
if (!(Character.isDigit(last) || last == 'X' || last == 'x')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int sum = 0;
|
||||||
|
for (int i = 0; i < 17; i++) {
|
||||||
|
sum += (idNumber.charAt(i) - '0') * WEIGHTS[i];
|
||||||
|
}
|
||||||
|
char expected = CHECK_CODES.charAt(sum % 11);
|
||||||
|
return Character.toUpperCase(last) == expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate parseBirthDateFromIdNumber(String idNumber) {
|
||||||
|
try {
|
||||||
|
int year = Integer.parseInt(idNumber.substring(6, 10));
|
||||||
|
int month = Integer.parseInt(idNumber.substring(10, 12));
|
||||||
|
int day = Integer.parseInt(idNumber.substring(12, 14));
|
||||||
|
return LocalDate.of(year, month, day);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return LocalDate.now().minusYears(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseGenderFromIdNumber(String idNumber) {
|
||||||
|
int genderDigit = Integer.parseInt(idNumber.substring(16, 17));
|
||||||
|
return (genderDigit % 2 == 1) ? "male" : "female";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskIdNumber(String idNumber) {
|
||||||
|
if (idNumber == null || idNumber.length() < 10) return "****";
|
||||||
|
return idNumber.substring(0, 6) + "**********" + idNumber.substring(idNumber.length() - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import java.util.Map;
|
|||||||
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;
|
import com.danyang.match.platform.shared.exception.ServerErrorException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -136,7 +137,7 @@ public class IdentityVerificationService {
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> getIdentityStatus(String userId) {
|
public Map<String, Object> getIdentityStatus(String userId) {
|
||||||
User user = userMapper.findById(userId)
|
User user = userMapper.findById(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
|
||||||
return userIdentityMapper.findByUserId(userId)
|
return userIdentityMapper.findByUserId(userId)
|
||||||
.map(identity -> {
|
.map(identity -> {
|
||||||
@@ -212,7 +213,7 @@ public class IdentityVerificationService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void cancelIdentity(String userId) {
|
public void cancelIdentity(String userId) {
|
||||||
UserIdentity identity = userIdentityMapper.findByUserId(userId)
|
UserIdentity identity = userIdentityMapper.findByUserId(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("未找到实名认证记录"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到实名认证记录"));
|
||||||
|
|
||||||
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
|
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
|
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
|
||||||
|
|||||||
@@ -38,10 +38,17 @@ object-storage:
|
|||||||
region: us-east-1
|
region: us-east-1
|
||||||
signature-version: s3v4
|
signature-version: s3v4
|
||||||
|
|
||||||
verification:
|
# 实名核验:docker 本地/CI 默认走 Mock(VERIFICATION_PROVIDER=real 时启用真实实现,需同时配置 api-url/api-key)
|
||||||
provider: ${VERIFICATION_PROVIDER:mock}
|
identity:
|
||||||
api-url: ${VERIFICATION_API_URL:}
|
verification:
|
||||||
api-key: ${VERIFICATION_API_KEY:}
|
provider: ${VERIFICATION_PROVIDER:mock}
|
||||||
|
real:
|
||||||
|
api-url: ${VERIFICATION_API_URL:}
|
||||||
|
api-key: ${VERIFICATION_API_KEY:}
|
||||||
|
|
||||||
|
# 管理端鉴权 Cookie:docker 内部为 http,关闭 Secure 标志
|
||||||
|
app:
|
||||||
|
auth-cookie-secure: false
|
||||||
|
|
||||||
match:
|
match:
|
||||||
rule-version: 1
|
rule-version: 1
|
||||||
|
|||||||
@@ -14,3 +14,27 @@ spring:
|
|||||||
user:
|
user:
|
||||||
name: disabled
|
name: disabled
|
||||||
password: ${SPRING_SECURITY_FALLBACK_PASSWORD}
|
password: ${SPRING_SECURITY_FALLBACK_PASSWORD}
|
||||||
|
|
||||||
|
# 微信登录:生产必须显式启用真实实现(wechat.login.provider=real)。
|
||||||
|
# 未配置时本地默认走 Mock;生产若漏配此行,RealWechatLoginProvider 不会激活、
|
||||||
|
# MockWechatLoginProvider 又因 havingValue=mock 不匹配 → 无 WechatLoginProvider Bean,启动即失败(fail-fast,不悄悄放行任意 code)。
|
||||||
|
wechat:
|
||||||
|
login:
|
||||||
|
provider: real
|
||||||
|
app-id: ${WECHAT_APP_ID}
|
||||||
|
app-secret: ${WECHAT_APP_SECRET}
|
||||||
|
|
||||||
|
# 实名核验:生产必须显式启用真实实现(identity.verification.provider=real)。
|
||||||
|
# 未配置时本地默认走 Mock;生产若漏配此行,RealIdentityVerificationProvider 不会激活、
|
||||||
|
# MockIdentityVerificationProvider 又因 havingValue=mock 不匹配 → 无 IdentityVerificationProvider Bean,启动即失败(fail-fast,不悄悄放行任意证件)。
|
||||||
|
# 真实核验还需配置第三方核验服务地址与密钥(缺失则核验请求 fail-fast)。
|
||||||
|
identity:
|
||||||
|
verification:
|
||||||
|
provider: real
|
||||||
|
real:
|
||||||
|
api-url: ${VERIFICATION_API_URL:}
|
||||||
|
api-key: ${VERIFICATION_API_KEY:}
|
||||||
|
|
||||||
|
# 管理端鉴权 Cookie:生产走 https,开启 Secure 标志(HttpOnly + SameSite=Lax 始终生效)
|
||||||
|
app:
|
||||||
|
auth-cookie-secure: true
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V052: 补齐 matchmaker(公益红娘)角色权限
|
||||||
|
--
|
||||||
|
-- 发现方式:2026-10-09 搭建本地演示栈、创建红娘运营账号 wanghongniang 并授予
|
||||||
|
-- matchmaker 角色后发现,该角色在 role_permissions 中零条授权。
|
||||||
|
-- 用该账号登录后台,点「红娘服务」菜单加载 GET /operator/matchmaker/dashboard
|
||||||
|
-- 直接 403;同时前端菜单权限码(referral.manage / matchmaker.service.view)与后端
|
||||||
|
-- 真实 authority 码(matchmaker.service / matchmaker.view)不匹配,导致这两个菜单
|
||||||
|
-- 对所有角色(含 super_admin)被 visibleNavigation 过滤隐藏。
|
||||||
|
--
|
||||||
|
-- 根因:与 V043/V044 同族 —— 角色定义了、权限定义了,两者之间从未连上。
|
||||||
|
-- V015 只把全部权限授予 super_admin;matchmaker 等运营角色从未被授予任何权限。
|
||||||
|
--
|
||||||
|
-- 授权范围(最小权限原则,与后端 @PreAuthorize 严格对应):
|
||||||
|
-- * matchmaker.view —— 查看红娘服务(GET /operator/matchmaker/*)
|
||||||
|
-- * matchmaker.service —— 认领/推荐/跟进/完成红娘服务 + 转介管理(referral 控制器)
|
||||||
|
-- * matchmaker.assign —— 分配红娘服务
|
||||||
|
-- 后端 MatchmakerOperatorController / ReferralController 的 @PreAuthorize 仅要求这三条,
|
||||||
|
-- 故仅授予这三条,不扩权。
|
||||||
|
--
|
||||||
|
-- 幂等:ON CONFLICT (role_id, permission_id) DO NOTHING。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT '00000000-0000-0000-0000-000000000005', id
|
||||||
|
FROM permissions
|
||||||
|
WHERE code IN ('matchmaker.view', 'matchmaker.service', 'matchmaker.assign')
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V053: 服务对象分配表(ZC-MM-002 / A1 服务对象分配建模)
|
||||||
|
--
|
||||||
|
-- 建模决策(架构师设计 D1/D5):
|
||||||
|
-- * 决策文档 5.1 的 matchmaker_user_id 对应 operators 主体,
|
||||||
|
-- 定名 matchmaker_operator_id(沿用 matchmaker_services.assigned_operator_id 惯例)。
|
||||||
|
-- * status 状态机:active → transferred / revoked / ended;
|
||||||
|
-- expires_at < now 的 active 行由 ServiceAssignmentPolicy 惰性置 expired(无定时任务);
|
||||||
|
-- 终态(transferred/revoked/ended/expired)一律不允许再次转换。
|
||||||
|
-- * assignment_scope:primary(主责)/ collaborating(协同)。
|
||||||
|
-- * 同一用户同时最多一条 active + primary 分配 → 部分唯一索引兜底
|
||||||
|
-- (业务层 SELECT-then-INSERT 有竞态,只有 DB 唯一索引能兜住)。
|
||||||
|
--
|
||||||
|
-- 存量回填说明:
|
||||||
|
-- matchmaker_services.assigned_operator_id IS NOT NULL 且服务仍处于非终态
|
||||||
|
-- (assigned / in_service / waiting_user,与 V009 chk_matchmaker_status 白名单一致)的行,
|
||||||
|
-- 回填为 active + primary 分配。assigned_by 无历史记录可考,取技术回填值
|
||||||
|
-- = assigned_operator_id(仅表示"分配关系成立",不代表当年实际操作者)。
|
||||||
|
-- 幂等:NOT EXISTS 防重复回填(按 service_id 关联)。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS matchmaker_assignments (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
matchmaker_operator_id UUID NOT NULL REFERENCES operators(id),
|
||||||
|
service_id UUID REFERENCES matchmaker_services(id),
|
||||||
|
assigned_by UUID NOT NULL REFERENCES operators(id),
|
||||||
|
assignment_scope VARCHAR(32) NOT NULL DEFAULT 'primary',
|
||||||
|
assignment_reason VARCHAR(500) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
assigned_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
end_reason VARCHAR(500),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE matchmaker_assignments ADD CONSTRAINT chk_mm_assignment_status CHECK (status IN (
|
||||||
|
'active', 'ended', 'revoked', 'transferred', 'expired'
|
||||||
|
));
|
||||||
|
ALTER TABLE matchmaker_assignments ADD CONSTRAINT chk_mm_assignment_scope CHECK (assignment_scope IN (
|
||||||
|
'primary', 'collaborating'
|
||||||
|
));
|
||||||
|
|
||||||
|
-- 部分唯一索引:同一用户最多一条「生效中的主责分配」。
|
||||||
|
-- 终态行(ended/revoked/transferred/expired)不占位,允许历史多条。
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_mm_assignment_active_primary
|
||||||
|
ON matchmaker_assignments(user_id)
|
||||||
|
WHERE status = 'active' AND assignment_scope = 'primary';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mm_assignment_operator ON matchmaker_assignments(matchmaker_operator_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mm_assignment_user ON matchmaker_assignments(user_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mm_assignment_service ON matchmaker_assignments(service_id);
|
||||||
|
|
||||||
|
-- 存量回填(幂等)
|
||||||
|
INSERT INTO matchmaker_assignments (
|
||||||
|
id, user_id, matchmaker_operator_id, service_id, assigned_by,
|
||||||
|
assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
ms.user_id,
|
||||||
|
ms.assigned_operator_id,
|
||||||
|
ms.id,
|
||||||
|
ms.assigned_operator_id, -- assigned_by 为技术回填值:历史分配操作者未记录
|
||||||
|
'primary',
|
||||||
|
'V053 存量回填:原始分配人未记录',
|
||||||
|
'active',
|
||||||
|
COALESCE(ms.assigned_at, NOW()),
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM matchmaker_services ms
|
||||||
|
WHERE ms.assigned_operator_id IS NOT NULL
|
||||||
|
AND ms.status IN ('assigned', 'in_service', 'waiting_user')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM matchmaker_assignments ma WHERE ma.service_id = ms.id
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V054: 新增红娘组长角色与团队分配权限(ZC-MM-002 / A1,架构师设计 D2)
|
||||||
|
--
|
||||||
|
-- 角色权限边界:
|
||||||
|
-- * matchmaker_leader(014)授 view(020) + service(022) + team.assign(023),
|
||||||
|
-- **不给** matchmaker.assign(021) —— 组长只能在同组织范围内分配;
|
||||||
|
-- 组织边界判定:组长团队边界 = 同 organization_id,NULL=NULL 视为平台直属
|
||||||
|
-- (平台直属组长只能分配平台直属红娘;有组织组长不能分配 org=NULL 红娘;
|
||||||
|
-- org=NULL 普通红娘只能由 matchmaker.assign 持有者分配 —— 该技术默认
|
||||||
|
-- 写在 ServiceAssignmentPolicy / MatchmakerAssignmentService 代码注释,
|
||||||
|
-- 治理登记后续补充)。
|
||||||
|
-- * 普通红娘(005)权限现状不变(view/service),由 V052 授予。
|
||||||
|
--
|
||||||
|
-- 幂等:全部 ON CONFLICT DO NOTHING(对照 V052 风格)。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO roles (id, code, name, description, system_role) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000014',
|
||||||
|
'matchmaker_leader',
|
||||||
|
'红娘组长',
|
||||||
|
'公益红娘团队组长,可在同组织范围内分配/转分配服务对象',
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO permissions (id, code, name, module, description) VALUES (
|
||||||
|
'10000000-0000-0000-0000-000000000023',
|
||||||
|
'matchmaker.team.assign',
|
||||||
|
'红娘团队分配',
|
||||||
|
'matchmaker',
|
||||||
|
'在组长同组织范围内分配/转分配/撤销/结束服务对象分配'
|
||||||
|
) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 红娘组长:view + service + team.assign(不含 matchmaker.assign)
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT '00000000-0000-0000-0000-000000000014', id
|
||||||
|
FROM permissions
|
||||||
|
WHERE code IN ('matchmaker.view', 'matchmaker.service', 'matchmaker.team.assign')
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V055: audit_logs 扩展红娘域审计字段(ZC-MM-006 / T01)
|
||||||
|
--
|
||||||
|
-- 新增 4 列(全部可空、存量不回填、语句幂等):
|
||||||
|
-- purpose_code 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view
|
||||||
|
-- service_assignment_id 关联的 matchmaker_assignments.id(可空:非分配场景为 NULL)
|
||||||
|
-- actor_role_snapshot 审计落库时操作者的权限快照(OperatorPrincipal.permissions() 逗号拼接,截断 128)
|
||||||
|
-- request_trace_id 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId,16 位)
|
||||||
|
--
|
||||||
|
-- 兼容性:全部可空 → 缺省调用(不传新字段)仍可正常写入,存量行为不变。
|
||||||
|
-- 幂等:H2/PostgreSQL 均无 ADD COLUMN IF NOT EXISTS 统一保障时,
|
||||||
|
-- 用 information_schema 判空后执行(对照 V034/V035 风格)。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS purpose_code VARCHAR(64);
|
||||||
|
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS service_assignment_id UUID;
|
||||||
|
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS actor_role_snapshot VARCHAR(128);
|
||||||
|
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS request_trace_id VARCHAR(64);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.danyang.match.platform.activity.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||||
|
import com.danyang.match.platform.activity.service.ActivityInterestService;
|
||||||
|
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
|
||||||
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 活动互选确认的**行为型**回归守卫(INV-014 / INV-015)。
|
||||||
|
*
|
||||||
|
* <p><b>存在意义</b>:UAT 走查(2026-09-23)发现遗留端点
|
||||||
|
* {@code POST /activities/{id}/interests/confirm} 仅校验「双向互选」就直调
|
||||||
|
* {@code RelationshipService.confirmActivityInterest} —— 后者是**不做任何同意校验**的
|
||||||
|
* 「建 Match」底层原语(见其实现:查无 active Match 即 createMatch)——
|
||||||
|
* 于是**单方调用即可创建 Match 并开启会话**,绕过「双方各自显式确认」的二阶段同意。
|
||||||
|
*
|
||||||
|
* <p>修复后本端点与 {@code /activities/{id}/mutual/*} 共用
|
||||||
|
* {@link ActivityMutualConsentService}:本端点只登记「我方确认」,
|
||||||
|
* 只有双方都 accepted 才建 Match。本测试把这一接线钉死 ——
|
||||||
|
* 若有人把直连 {@code confirmActivityInterest} 的捷径改回来,这里会红。
|
||||||
|
*/
|
||||||
|
class ActivityInterestConfirmConsentBehaviorTest {
|
||||||
|
|
||||||
|
private ActivityInterestService interestService;
|
||||||
|
private ActivityMutualConsentService consentService;
|
||||||
|
private ActivityInterestController controller;
|
||||||
|
|
||||||
|
private static final String ACTIVITY = "act-1";
|
||||||
|
private static final String ME = "u-a";
|
||||||
|
private static final String PARTNER = "u-b";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
interestService = mock(ActivityInterestService.class);
|
||||||
|
consentService = mock(ActivityMutualConsentService.class);
|
||||||
|
controller = new ActivityInterestController(interestService, consentService, mock(ActivityMapper.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("INV-014/015:单方调用 confirm 只登记『我方确认』,不得直接建 Match")
|
||||||
|
void legacyConfirmOnlyRecordsMyOwnConsent() {
|
||||||
|
when(interestService.hasMutualInterest(ME, PARTNER, ACTIVITY)).thenReturn(true);
|
||||||
|
// 二阶段同意服务回「对方尚未确认」→ 不得建 Match
|
||||||
|
when(consentService.confirmConnection(ME, ACTIVITY, PARTNER))
|
||||||
|
.thenReturn(Map.of("myStatus", "accepted", "partnerStatus", "pending",
|
||||||
|
"bothConfirmed", false, "matchCreated", false));
|
||||||
|
|
||||||
|
ApiResponse<Map<String, Object>> resp =
|
||||||
|
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER));
|
||||||
|
|
||||||
|
assertTrue(resp.isSuccess(), "调用本身应成功(我方确认已登记)");
|
||||||
|
assertEquals(Boolean.FALSE, resp.getData().get("matchCreated"),
|
||||||
|
"对方未确认时不得创建 Match —— 单方不可开启沟通");
|
||||||
|
assertEquals(Boolean.FALSE, resp.getData().get("bothConfirmed"));
|
||||||
|
// 关键接线:必经二阶段同意服务(幂等建档 + 提交我方确认)
|
||||||
|
verify(consentService).createMutualConsent(ACTIVITY, ME, PARTNER);
|
||||||
|
verify(consentService).confirmConnection(ME, ACTIVITY, PARTNER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("判别力:无双向互选时直接拒绝,且完全不触碰同意服务")
|
||||||
|
void noMutualInterestIsRejectedWithoutTouchingConsent() {
|
||||||
|
when(interestService.hasMutualInterest(ME, PARTNER, ACTIVITY)).thenReturn(false);
|
||||||
|
|
||||||
|
ApiResponse<Map<String, Object>> resp =
|
||||||
|
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER));
|
||||||
|
|
||||||
|
assertFalse(resp.isSuccess(), "无双向互选不得进入确认环节");
|
||||||
|
verifyNoInteractions(consentService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("partnerId 为空 → 400 语义拒绝,且不触碰同意服务")
|
||||||
|
void blankPartnerIdIsRejected() {
|
||||||
|
ApiResponse<Map<String, Object>> resp =
|
||||||
|
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", " "));
|
||||||
|
|
||||||
|
assertFalse(resp.isSuccess());
|
||||||
|
verifyNoInteractions(consentService);
|
||||||
|
verify(interestService, never()).hasMutualInterest(anyString(), anyString(), anyString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.danyang.match.platform.auth.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.danyang.match.platform.auth.provider.WechatLoginProvider;
|
||||||
|
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||||
|
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.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归守卫:刷新 Token 在 Redis 中登记的 TTL 必须与 refresh JWT 自身有效期一致
|
||||||
|
* ({@code JwtTokenService#getRefreshExpirationMs},默认 14 天),而不是
|
||||||
|
* {@code getExpirationMs() * 2}(4 小时)。
|
||||||
|
*
|
||||||
|
* <p>此前 AuthService 用 {@code getExpirationMs() * 2} 写入,导致 4h 后 Redis 记录消失、
|
||||||
|
* 刷新即失效,用户被迫频繁重新登录。本测试钉死两处写入点(登录 / 刷新)。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuthServiceRefreshTtlTest {
|
||||||
|
|
||||||
|
@Mock private UserMapper userMapper;
|
||||||
|
@Mock private WechatLoginProvider wechatLoginProvider;
|
||||||
|
@Mock private JwtTokenService jwtTokenService;
|
||||||
|
@Mock private StringRedisTemplate redisTemplate;
|
||||||
|
@Mock private com.danyang.match.platform.audit.mapper.AuditLogMapper auditLogMapper;
|
||||||
|
@Mock private com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper localEligibilityMapper;
|
||||||
|
@Mock private com.danyang.match.platform.capability.service.CapabilityService capabilityService;
|
||||||
|
@Mock private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
|
||||||
|
@Mock private com.danyang.match.platform.review.mapper.ReviewTaskMapper reviewTaskMapper;
|
||||||
|
@Mock private ValueOperations<String, String> valueOps;
|
||||||
|
|
||||||
|
@InjectMocks private AuthService authService;
|
||||||
|
|
||||||
|
private static final long ACCESS_MS = 7_200_000L; // 2h
|
||||||
|
private static final long REFRESH_MS = 1_209_600_000L; // 14d
|
||||||
|
|
||||||
|
/** 捕获 refresh token 在 Redis 中的登记 TTL。 */
|
||||||
|
private Duration captureRefreshTtl() {
|
||||||
|
ArgumentCaptor<Duration> cap = ArgumentCaptor.forClass(Duration.class);
|
||||||
|
verify(valueOps).set(eq("auth:refresh_token:u1"), anyString(), cap.capture());
|
||||||
|
return cap.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wechatLoginStoresRefreshTokenWithRefreshExpiration() {
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(wechatLoginProvider.getCodeSession("code")).thenReturn(Map.of("openid", "o1"));
|
||||||
|
User user = new User();
|
||||||
|
user.setId("u1");
|
||||||
|
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
user.setPhone(null);
|
||||||
|
when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user));
|
||||||
|
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access");
|
||||||
|
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh");
|
||||||
|
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
|
||||||
|
when(auditLogMapper.insert(any())).thenReturn(1);
|
||||||
|
when(userMapper.updateById(any())).thenReturn(1);
|
||||||
|
|
||||||
|
authService.wechatLogin("code");
|
||||||
|
|
||||||
|
Duration ttl = captureRefreshTtl();
|
||||||
|
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "登录:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
|
||||||
|
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshTokenStoresRefreshTokenWithRefreshExpiration() {
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(jwtTokenService.validateRefreshToken("rt")).thenReturn(true);
|
||||||
|
when(jwtTokenService.getUserIdFromRefreshToken("rt")).thenReturn("u1");
|
||||||
|
when(redisTemplate.opsForValue().get("auth:refresh_token:u1")).thenReturn("rt");
|
||||||
|
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess");
|
||||||
|
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh");
|
||||||
|
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
|
||||||
|
|
||||||
|
authService.refreshToken("u1", "rt");
|
||||||
|
|
||||||
|
Duration ttl = captureRefreshTtl();
|
||||||
|
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "刷新:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
|
||||||
|
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,11 @@ class MigrationValidationTest {
|
|||||||
// V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一
|
// V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一
|
||||||
// —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled),
|
// —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled),
|
||||||
// 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭)
|
// 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭)
|
||||||
private static final int LATEST_MIGRATION = 51;
|
// ZC-MM-002(A1 服务对象分配建模):V052 补齐 matchmaker 角色权限(V052 其实早于本轮,漏计);
|
||||||
|
// V053 matchmaker_assignments 建表 + 存量回填;V054 matchmaker_leader 角色与团队分配权限
|
||||||
|
// ZC-MM-006(T01 审计字段扩展):V055 audit_logs 补 purpose_code / service_assignment_id /
|
||||||
|
// actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等)
|
||||||
|
private static final int LATEST_MIGRATION = 55;
|
||||||
|
|
||||||
private static final String MIGRATION_DIR =
|
private static final String MIGRATION_DIR =
|
||||||
"backend/src/main/resources/db/migration/";
|
"backend/src/main/resources/db/migration/";
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ class RecommendationEligibilityPhase06Test {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
config = new MatchRuleConfigProperties();
|
config = new MatchRuleConfigProperties();
|
||||||
service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper,
|
service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper,
|
||||||
config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper);
|
config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper,
|
||||||
|
new MatchScoringService());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.danyang.match.platform.match.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.profile.entity.UserPreference;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评分唯一事实源的行为型测试。
|
||||||
|
*
|
||||||
|
* <p>存在意义:修复「推荐恒空」(2026-09-23 UAT 走查)之前的桩 `calculateScore()=50`
|
||||||
|
* 与阈值 60 使「满足条件的候选」也被丢弃,而既有测试只验阈值数学、从不验真实评分能达标 →
|
||||||
|
* 缺陷长期漏网。本测试直接断言**满足条件的候选评分 ≥ 阈值**。
|
||||||
|
*/
|
||||||
|
class MatchScoringServiceTest {
|
||||||
|
|
||||||
|
private final MatchScoringService svc = new MatchScoringService();
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
private static final int THRESHOLD = 60; // 与 match.default-score-threshold 默认值一致
|
||||||
|
|
||||||
|
private UserProfile profile(int ageYears, Integer heightCm, String region, String marital) {
|
||||||
|
UserProfile p = new UserProfile();
|
||||||
|
p.setBirthDate(LocalDate.now().minusYears(ageYears));
|
||||||
|
p.setHeightCm(heightCm);
|
||||||
|
p.setRegionCode(region);
|
||||||
|
p.setMaritalStatus(marital);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("修复回归:完全符合条件的候选,评分必须 ≥ 阈值(不再恒 50)")
|
||||||
|
void matchingCandidateScoresAtLeastThreshold() {
|
||||||
|
UserPreference pref = new UserPreference();
|
||||||
|
pref.setMinAge(25);
|
||||||
|
pref.setMaxAge(35);
|
||||||
|
pref.setMinHeightCm(160);
|
||||||
|
pref.setMaxHeightCm(175);
|
||||||
|
pref.setRegionScope(mapper.valueToTree(new String[]{"321181"}));
|
||||||
|
pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"}));
|
||||||
|
|
||||||
|
UserProfile candidate = profile(30, 168, "321181", "never_married");
|
||||||
|
|
||||||
|
double score = svc.scoreDirectionally(pref, candidate);
|
||||||
|
assertTrue(score >= THRESHOLD,
|
||||||
|
"满足全部条件的候选必须过阈值,实际:" + score);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("反向:条件明显不符的候选,评分低于阈值(证明有判别力)")
|
||||||
|
void nonMatchingCandidateBelowThreshold() {
|
||||||
|
UserPreference pref = new UserPreference();
|
||||||
|
pref.setMinAge(25);
|
||||||
|
pref.setMaxAge(35);
|
||||||
|
pref.setMinHeightCm(160);
|
||||||
|
pref.setMaxHeightCm(175);
|
||||||
|
pref.setRegionScope(mapper.valueToTree(new String[]{"321181"}));
|
||||||
|
pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"}));
|
||||||
|
|
||||||
|
// 年龄 48(远超)、身高 150(不足)、地区 320500、离异 —— 四个维度全不符
|
||||||
|
UserProfile candidate = profile(48, 150, "320500", "divorced");
|
||||||
|
|
||||||
|
double score = svc.scoreDirectionally(pref, candidate);
|
||||||
|
assertTrue(score < THRESHOLD, "全不符的候选必须被过滤,实际:" + score);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("未设置任何条件 → 视为无约束,返回 100")
|
||||||
|
void noCriteriaMeansNoConstraint() {
|
||||||
|
assertEquals(100.0, svc.scoreDirectionally(new UserPreference(), profile(30, 170, "321181", "never_married")),
|
||||||
|
0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("候选人身高缺失 → 身高维度计 0(不假定满足),且不阻断其他维度")
|
||||||
|
void missingCandidateHeightCountsZero() {
|
||||||
|
UserPreference pref = new UserPreference();
|
||||||
|
pref.setMinHeightCm(170);
|
||||||
|
pref.setMaxHeightCm(180);
|
||||||
|
UserProfile candidate = profile(30, null, "321181", "never_married");
|
||||||
|
|
||||||
|
double score = svc.scoreDirectionally(pref, candidate);
|
||||||
|
assertEquals(0.0, score, 0.001, "唯一条件是身高而资料缺失 → 0 分");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("INV-001:双向 Required 任一不满足即否")
|
||||||
|
void bidirectionalRequiredEnforced() {
|
||||||
|
UserPreference myPref = new UserPreference();
|
||||||
|
myPref.setAgePriority("required");
|
||||||
|
myPref.setMinAge(25);
|
||||||
|
myPref.setMaxAge(30);
|
||||||
|
|
||||||
|
UserPreference targetPref = new UserPreference(); // 无 Required
|
||||||
|
|
||||||
|
UserProfile me = profile(30, 175, "321181", "never_married");
|
||||||
|
UserProfile inRange = profile(28, 170, "321181", "never_married");
|
||||||
|
UserProfile outOfRange = profile(35, 170, "321181", "never_married");
|
||||||
|
|
||||||
|
assertTrue(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, inRange));
|
||||||
|
assertFalse(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, outOfRange));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("推荐理由基于实际达成维度,且非空")
|
||||||
|
void reasonsReflectMatchedDimensions() {
|
||||||
|
UserPreference pref = new UserPreference();
|
||||||
|
pref.setMinAge(25);
|
||||||
|
pref.setMaxAge(35);
|
||||||
|
var reasons = svc.reasons(pref, profile(30, 170, "321181", "never_married"));
|
||||||
|
assertFalse(reasons.isEmpty());
|
||||||
|
assertTrue(reasons.stream().anyMatch(r -> r.contains("年龄")), "应包含年龄维度理由:" + reasons);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.danyang.match.platform.match.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||||
|
import com.danyang.match.platform.match.config.MatchRuleConfigProperties;
|
||||||
|
import com.danyang.match.platform.match.dto.RecommendationCandidateDTO;
|
||||||
|
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||||
|
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserPreference;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐链路的<b>行为型</b>回归守卫。
|
||||||
|
*
|
||||||
|
* <p><b>存在意义</b>:UAT 走查(2026-09-23)在本机真实栈实测
|
||||||
|
* {@code GET /recommendations} 恒返回 {@code {"data":[]}}。根因是评分桩
|
||||||
|
* {@code calculateScore()=50} 而阈值默认 60({@code 50 < 60} 恒真)→ 所有候选被丢弃。
|
||||||
|
* 该缺陷逃过了 757 个后端用例 + 65 个 E2E + 22 个 UI 守卫,因为
|
||||||
|
* <b>没有任何用例断言「推荐非空」</b>(E2E 零推荐用例)。
|
||||||
|
*
|
||||||
|
* <p>本测试在<b>引擎层</b>(候选池查询 → 双向 Required → 评分 → 阈值过滤 → 排序 → 落库)
|
||||||
|
* 断言「存在合格候选时必须产出非空推荐」,并配一条反向用例证明其有判别力。
|
||||||
|
* 这是此类「恒空」缺陷的直接防线 —— 桩式/纯配置断言抓不到它。
|
||||||
|
*/
|
||||||
|
class RecommendationEngineServiceBehaviorTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final String ME = "u-me";
|
||||||
|
private static final String CANDIDATE = "u-cand";
|
||||||
|
|
||||||
|
private UserProfileMapper userProfileMapper;
|
||||||
|
private UserPreferenceMapper preferenceMapper;
|
||||||
|
private RecommendationRecordMapper recommendationRecordMapper;
|
||||||
|
private MatchRuleConfigProperties matchConfig;
|
||||||
|
private StringRedisTemplate redisTemplate;
|
||||||
|
private ValueOperations<String, String> valueOps;
|
||||||
|
private UserRelationMapper relationMapper;
|
||||||
|
private UserMapper userMapper;
|
||||||
|
private UserPrivacySettingsMapper privacySettingsMapper;
|
||||||
|
private LocalEligibilityMapper localEligibilityMapper;
|
||||||
|
|
||||||
|
private RecommendationEngineService engine;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void setUp() {
|
||||||
|
userProfileMapper = mock(UserProfileMapper.class);
|
||||||
|
preferenceMapper = mock(UserPreferenceMapper.class);
|
||||||
|
recommendationRecordMapper = mock(RecommendationRecordMapper.class);
|
||||||
|
matchConfig = mock(MatchRuleConfigProperties.class);
|
||||||
|
redisTemplate = mock(StringRedisTemplate.class);
|
||||||
|
valueOps = mock(ValueOperations.class);
|
||||||
|
relationMapper = mock(UserRelationMapper.class);
|
||||||
|
userMapper = mock(UserMapper.class);
|
||||||
|
privacySettingsMapper = mock(UserPrivacySettingsMapper.class);
|
||||||
|
localEligibilityMapper = mock(LocalEligibilityMapper.class);
|
||||||
|
|
||||||
|
// 用**真实**评分实现(纯函数、无依赖)→ 端到端证明「真实评分能让推荐非空」。
|
||||||
|
// 若用 mock 掉评分,本测试就退化成「只验接线」,无法守住「评分恒 50」这类回归。
|
||||||
|
MatchScoringService scoring = new MatchScoringService();
|
||||||
|
|
||||||
|
engine = new RecommendationEngineService(
|
||||||
|
userProfileMapper, preferenceMapper, recommendationRecordMapper,
|
||||||
|
matchConfig, redisTemplate, relationMapper, userMapper,
|
||||||
|
privacySettingsMapper, localEligibilityMapper, scoring);
|
||||||
|
|
||||||
|
// —— 匹配规则配置(与 application-dev.yml 默认一致)——
|
||||||
|
when(matchConfig.getDefaultDailyLimit()).thenReturn(6);
|
||||||
|
when(matchConfig.getDefaultScoreThreshold()).thenReturn(60);
|
||||||
|
when(matchConfig.getDefaultCooldownDays()).thenReturn(180);
|
||||||
|
when(matchConfig.getRuleVersion()).thenReturn(1);
|
||||||
|
when(matchConfig.isLocalEligibilityRequired()).thenReturn(false);
|
||||||
|
|
||||||
|
// —— Redis:缓存未命中 ——
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||||
|
when(valueOps.get(anyString())).thenReturn(null);
|
||||||
|
|
||||||
|
// —— 双方账号均正常、可被推荐、无隐私限制 ——
|
||||||
|
when(userMapper.findById(ME)).thenReturn(Optional.of(user(ME)));
|
||||||
|
when(userMapper.findById(CANDIDATE)).thenReturn(Optional.of(user(CANDIDATE)));
|
||||||
|
when(privacySettingsMapper.findByUserId(anyString())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
// —— 资料 ——
|
||||||
|
when(userProfileMapper.findByUserId(ME)).thenReturn(Optional.of(myProfile()));
|
||||||
|
when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(candidateProfile()));
|
||||||
|
when(userProfileMapper.selectList(any())).thenReturn(List.of(candidateProfile()));
|
||||||
|
|
||||||
|
// —— 择偶条件:自己设了条件;候选人未设(方向 B→A 不受约束)——
|
||||||
|
when(preferenceMapper.findByUserId(ME)).thenReturn(Optional.of(myPreference()));
|
||||||
|
when(preferenceMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(new UserPreference()));
|
||||||
|
|
||||||
|
// —— 无历史推荐 / 无 Pass / 无拉黑 ——
|
||||||
|
when(recommendationRecordMapper.findRecentByUserId(anyString(), any())).thenReturn(List.of());
|
||||||
|
when(relationMapper.findBlockedByUser(anyString())).thenReturn(List.of());
|
||||||
|
when(relationMapper.findByRelation(anyString(), anyString(), anyString())).thenReturn(Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("回归守卫:存在合格候选 → 推荐必须非空(此前评分桩 50<60 导致恒空)")
|
||||||
|
void recommendationsAreNotEmptyWhenEligibleCandidateExists() {
|
||||||
|
List<RecommendationCandidateDTO> result = engine.getDailyRecommendations(ME);
|
||||||
|
|
||||||
|
assertFalse(result.isEmpty(),
|
||||||
|
"存在满足条件的候选人时,推荐不得为空 —— 这正是 UAT 实测暴露的缺陷");
|
||||||
|
assertEquals(CANDIDATE, result.get(0).getUserId());
|
||||||
|
assertNotNull(result.get(0).getRecommendationReasons());
|
||||||
|
assertFalse(result.get(0).getRecommendationReasons().isEmpty(), "应带中文推荐理由");
|
||||||
|
assertEquals("high", result.get(0).getRecommendationLevel(),
|
||||||
|
"完全匹配的候选应落在 high 档(评分 100)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("判别力:候选各维度均不符 → 被阈值过滤,推荐为空")
|
||||||
|
void ineligibleCandidateIsFilteredOut() {
|
||||||
|
// 候选人年龄 48 / 身高 150 / 地区 320500 / 离异 —— 与择偶条件全不符
|
||||||
|
UserProfile bad = candidateProfile();
|
||||||
|
bad.setBirthDate(LocalDate.now().minusYears(48));
|
||||||
|
bad.setHeightCm(150);
|
||||||
|
bad.setRegionCode("320500");
|
||||||
|
bad.setMaritalStatus("divorced");
|
||||||
|
when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(bad));
|
||||||
|
when(userProfileMapper.selectList(any())).thenReturn(List.of(bad));
|
||||||
|
|
||||||
|
List<RecommendationCandidateDTO> result = engine.getDailyRecommendations(ME);
|
||||||
|
|
||||||
|
assertTrue(result.isEmpty(), "全维度不符的候选必须被阈值过滤(证明评分确有判别力)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== fixtures ====================
|
||||||
|
|
||||||
|
private User user(String id) {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(id);
|
||||||
|
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||||
|
u.setRecommendationEnabled(true);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserProfile myProfile() {
|
||||||
|
UserProfile p = new UserProfile();
|
||||||
|
p.setUserId(ME);
|
||||||
|
p.setGender("male");
|
||||||
|
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||||
|
p.setBirthDate(LocalDate.now().minusYears(33));
|
||||||
|
p.setHeightCm(175);
|
||||||
|
p.setRegionCode("321181");
|
||||||
|
p.setMaritalStatus("never_married");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserProfile candidateProfile() {
|
||||||
|
UserProfile p = new UserProfile();
|
||||||
|
p.setUserId(CANDIDATE);
|
||||||
|
p.setGender("female");
|
||||||
|
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||||
|
p.setBirthDate(LocalDate.now().minusYears(30));
|
||||||
|
p.setHeightCm(168);
|
||||||
|
p.setRegionCode("321181");
|
||||||
|
p.setMaritalStatus("never_married");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 四个维度均设条件,且默认权重(age .15 / height .05 / region .20 / marital .10)。 */
|
||||||
|
private UserPreference myPreference() {
|
||||||
|
UserPreference p = new UserPreference();
|
||||||
|
p.setMinAge(25);
|
||||||
|
p.setMaxAge(35);
|
||||||
|
p.setMinHeightCm(160);
|
||||||
|
p.setMaxHeightCm(175);
|
||||||
|
p.setRegionScope(MAPPER.valueToTree(new String[]{"321181"}));
|
||||||
|
p.setMaritalStatuses(MAPPER.valueToTree(new String[]{"never_married"}));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||||
|
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||||
|
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||||
|
import com.danyang.match.platform.auth.token.TokenBlacklistService;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||||
|
import com.danyang.match.platform.notification.entity.Notification;
|
||||||
|
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||||
|
import com.danyang.match.platform.referral.service.ReferralService;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-004(禁止代替用户决定复核)+ MM-CONTACT-001 行为锁定
|
||||||
|
* ({@code @SpringBootTest} 走真实安全过滤器链,沿用 MatchmakerReferralPhase13Test 数据构造范式)。
|
||||||
|
*
|
||||||
|
* <p>锁定四条行为不变量(架构师设计 T02):
|
||||||
|
* <ol>
|
||||||
|
* <li><b>MM-ACTION-001 强化(负向)</b>:operator 主体 token 打用户专属决定端点
|
||||||
|
* (推荐 accept / pass、转介 confirm)→ 认证层 403
|
||||||
|
* (SecurityConfig 兜底 {@code hasRole("USER")}:运营身份根本进不到 Controller,
|
||||||
|
* SecurityPathCoverageTest 已机械锁定该兜底不得退回 authenticated()),
|
||||||
|
* 且 recommendation / registration 状态零变化 —— 「红娘不能替用户 Like/Pass/确认转介」。</li>
|
||||||
|
* <li><b>MM-ACTION-002</b>:会话开启端点(POST /conversations/from-match/{matchId})同为
|
||||||
|
* UserPrincipal 路径 → operator token 403(match 的产生同样只在用户 Like 路径,
|
||||||
|
* 无 operator 触达面)。</li>
|
||||||
|
* <li><b>MM-ACTION-003 / must-confirm 语义</b>:红娘创建转介 → 响应
|
||||||
|
* needsUserConfirmation=true、库中 userConfirmedAt=null、status 停留 pending;
|
||||||
|
* 红娘侧 recommend / followup / complete 动作不改变用户确认状态 ——
|
||||||
|
* 「转介代建 = 代填草稿、用户确认」语义被永久锁定。</li>
|
||||||
|
* <li><b>MM-CONTACT-001(正向)</b>:红娘对本人分配对象创建推荐/转介 → notifications 表
|
||||||
|
* 落平台内服务通知(type=matchmaker),title/body 不含手机号/微信号模式;
|
||||||
|
* 通知收件面只有 C 端用户(无 operator 收件行)—— 平台内联系经通知中转,
|
||||||
|
* 红娘与用户之间不存在直连联系方式交换。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Transactional
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class MatchmakerActionGuardTest {
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
@Autowired private JwtTokenService jwtTokenService;
|
||||||
|
/** 测试环境无 Redis:黑名单查询打桩为"未拉黑"(ChatPhase09IntegrationTest 同款) */
|
||||||
|
@MockBean private TokenBlacklistService tokenBlacklistService;
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T02 起由真实 DB 路径接管权限装载(schema-h2 已补权限四表),
|
||||||
|
* 此处打桩仅为兼容既有断言口径(本测试只依赖身份类型 403,不依赖具体权限集)。
|
||||||
|
*/
|
||||||
|
@MockBean private com.danyang.match.platform.permission.service.PermissionService permissionService;
|
||||||
|
@Autowired private com.danyang.match.platform.permission.mapper.OperatorMapper operatorMapper;
|
||||||
|
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
|
||||||
|
@Autowired private MatchmakerAssignmentMapper matchmakerAssignmentMapper;
|
||||||
|
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||||
|
@Autowired private ActivityRegistrationMapper activityRegistrationMapper;
|
||||||
|
@Autowired private NotificationMapper notificationMapper;
|
||||||
|
@Autowired private UserMapper userMapper;
|
||||||
|
@Autowired private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
|
||||||
|
@Autowired private UserIdentityMapper userIdentityMapper;
|
||||||
|
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||||
|
@Autowired private ReferralService referralService;
|
||||||
|
|
||||||
|
private static final String MATCHMAKER_OWNER = "op-ag-owner";
|
||||||
|
|
||||||
|
private String user;
|
||||||
|
private String target;
|
||||||
|
private String serviceId;
|
||||||
|
private String operatorToken;
|
||||||
|
|
||||||
|
/** 手机号模式(中国大陆 11 位) */
|
||||||
|
private static final Pattern PHONE_PATTERN = Pattern.compile("1[3-9]\\d{9}");
|
||||||
|
/** 微信号/微信引导词模式 */
|
||||||
|
private static final List<String> WECHAT_TOKENS = List.of("wechat", "weixin", "微信", "vx", "加v", "v信");
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
user = createUser("ag-user");
|
||||||
|
target = createUser("ag-target");
|
||||||
|
// 一条归属 MATCHMAKER_OWNER 的红娘服务(assigned),并补生效分配(E1 纯判定器口径)
|
||||||
|
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||||
|
MatchmakerAssignment assignment = new MatchmakerAssignment();
|
||||||
|
assignment.setUserId(user);
|
||||||
|
assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER);
|
||||||
|
assignment.setServiceId(serviceId);
|
||||||
|
assignment.setAssignedBy(MATCHMAKER_OWNER);
|
||||||
|
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
|
||||||
|
assignment.setAssignmentReason("003/004 守卫前置分配");
|
||||||
|
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||||
|
assignment.setAssignedAt(Instant.now());
|
||||||
|
matchmakerAssignmentMapper.insert(assignment);
|
||||||
|
|
||||||
|
// operator token:type=operator_access → JwtAuthFilter 授 ROLE_OPERATOR
|
||||||
|
// ZC-MM-006 / T02:JwtAuthFilter 实时查 operators.status,须为 token subject 落一条 active 运营行
|
||||||
|
com.danyang.match.platform.permission.entity.Operator op =
|
||||||
|
new com.danyang.match.platform.permission.entity.Operator();
|
||||||
|
op.setId(MATCHMAKER_OWNER);
|
||||||
|
op.setName("003/004 守卫红娘");
|
||||||
|
op.setUsername("ag_mm_owner_" + System.nanoTime());
|
||||||
|
op.setPasswordHash("test-hash");
|
||||||
|
op.setStatus("active");
|
||||||
|
operatorMapper.insert(op);
|
||||||
|
when(permissionService.getPermissions(anyString())).thenReturn(List.of());
|
||||||
|
operatorToken = jwtTokenService.generateOperatorToken(MATCHMAKER_OWNER, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearSecurityContext() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据构造(Phase13 范式) ====================
|
||||||
|
|
||||||
|
/** 基础资格满足的用户:正常账号 + 实名 + 资料通过(RelationService.like 路径可走) */
|
||||||
|
private String createUser(String tag) {
|
||||||
|
User u = new User();
|
||||||
|
u.setWechatOpenid("ag_" + tag + "_" + System.nanoTime());
|
||||||
|
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||||
|
u.setRecommendationEnabled(true);
|
||||||
|
userMapper.insert(u);
|
||||||
|
|
||||||
|
var profile = new com.danyang.match.platform.profile.entity.UserProfile();
|
||||||
|
profile.setUserId(u.getId());
|
||||||
|
profile.setDisplayName(tag);
|
||||||
|
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||||
|
userProfileMapper.insert(profile);
|
||||||
|
|
||||||
|
var identity = new com.danyang.match.platform.verification.entity.UserIdentity();
|
||||||
|
identity.setUserId(u.getId());
|
||||||
|
identity.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
|
||||||
|
userIdentityMapper.insert(identity);
|
||||||
|
return u.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createService(String userId, String operatorId, String status) {
|
||||||
|
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||||
|
svc.setId(UUID.randomUUID().toString());
|
||||||
|
svc.setUserId(userId);
|
||||||
|
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||||
|
svc.setStatus(status);
|
||||||
|
svc.setAssignedOperatorId(operatorId);
|
||||||
|
matchmakerServiceMapper.insert(svc);
|
||||||
|
return svc.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MatchmakerRecommendation insertRecommendation(String recUser, String recTarget) {
|
||||||
|
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||||
|
rec.setId(UUID.randomUUID().toString());
|
||||||
|
rec.setServiceId(serviceId);
|
||||||
|
rec.setUserId(recUser);
|
||||||
|
rec.setTargetUserId(recTarget);
|
||||||
|
rec.setOperatorId(MATCHMAKER_OWNER);
|
||||||
|
rec.setReason("004 守卫测试推荐");
|
||||||
|
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
|
||||||
|
recommendationMapper.insert(rec);
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityRegistration insertReferralRegistration(String regUser, String activityId) {
|
||||||
|
ActivityRegistration reg = new ActivityRegistration();
|
||||||
|
reg.setId(UUID.randomUUID().toString());
|
||||||
|
reg.setActivityId(activityId);
|
||||||
|
reg.setUserId(regUser);
|
||||||
|
reg.setStatus(Constants.REGISTRATION_STATUS_PENDING);
|
||||||
|
reg.setSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
|
||||||
|
reg.setRequestSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
|
||||||
|
reg.setCreatedByOperatorId(MATCHMAKER_OWNER);
|
||||||
|
reg.setCreatedAt(Instant.now());
|
||||||
|
activityRegistrationMapper.insert(reg);
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-ACTION-001:红娘不能替用户做 Like/Pass/确认决定 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 accept → 403,推荐状态零变化")
|
||||||
|
void operatorCannotAcceptRecommendationOnBehalfOfUser() throws Exception {
|
||||||
|
MatchmakerRecommendation rec = insertRecommendation(user, target);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/matchmaker/recommendations/{id}/accept", rec.getId())
|
||||||
|
.header("Authorization", "Bearer " + operatorToken))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
|
||||||
|
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||||
|
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
|
||||||
|
"越权 accept 被认证层 403 拒绝后,推荐状态必须零变化");
|
||||||
|
assertNull(after.getRespondedAt(), "越权 accept 不得写入 respondedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 pass → 403,推荐状态零变化")
|
||||||
|
void operatorCannotPassRecommendationOnBehalfOfUser() throws Exception {
|
||||||
|
MatchmakerRecommendation rec = insertRecommendation(user, target);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/matchmaker/recommendations/{id}/pass", rec.getId())
|
||||||
|
.header("Authorization", "Bearer " + operatorToken))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
|
||||||
|
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||||
|
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
|
||||||
|
"越权 pass 被拒绝后,推荐状态必须零变化(仍 pending_view)");
|
||||||
|
assertNull(after.getRespondedAt(), "越权 pass 不得写入 respondedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-ACTION-001(负向): operator token 打转介 confirm → 403,用户确认状态零变化")
|
||||||
|
void operatorCannotConfirmReferralOnBehalfOfUser() throws Exception {
|
||||||
|
ActivityRegistration reg = insertReferralRegistration(user, "act-ag-001");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/referrals/{id}/confirm", reg.getId())
|
||||||
|
.header("Authorization", "Bearer " + operatorToken)
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("{\"targetType\":\"activity\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
|
||||||
|
ActivityRegistration after = activityRegistrationMapper.selectById(reg.getId());
|
||||||
|
assertNull(after.getUserConfirmedAt(),
|
||||||
|
"越权 confirm 被拒绝后,userConfirmedAt 必须仍为 null(用户决定权)");
|
||||||
|
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
|
||||||
|
"越权 confirm 后报名状态必须停留 pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)")
|
||||||
|
void operatorCannotOpenConversationFromMatch() throws Exception {
|
||||||
|
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在
|
||||||
|
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
|
||||||
|
.header("Authorization", "Bearer " + operatorToken))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-ACTION-003: 转介代建=代填草稿(needsUserConfirmation=true、userConfirmedAt=null);红娘后续动作不改变确认状态")
|
||||||
|
void referralCreationStaysPendingUntilUserConfirms() {
|
||||||
|
// 红娘创建转介(本人分配对象)
|
||||||
|
Map<String, Object> result = referralService.referToActivity(
|
||||||
|
MATCHMAKER_OWNER, user, "act-ag-002", "紧急联系人", "enc-phone");
|
||||||
|
|
||||||
|
// must-confirm 语义:响应标记待确认,库中确认字段为空、状态停留 pending
|
||||||
|
assertEquals(Boolean.TRUE, result.get("needsUserConfirmation"),
|
||||||
|
"红娘创建转介必须标记 needsUserConfirmation=true(不替用户完成报名)");
|
||||||
|
String regId = (String) result.get("registrationId");
|
||||||
|
ActivityRegistration reg = activityRegistrationMapper.selectById(regId);
|
||||||
|
assertNotNull(reg, "转介必须真实落库");
|
||||||
|
assertNull(reg.getUserConfirmedAt(), "创建时 userConfirmedAt 必须为 null");
|
||||||
|
assertEquals(Constants.REGISTRATION_STATUS_PENDING, reg.getStatus(),
|
||||||
|
"创建时报名状态必须停留 pending");
|
||||||
|
|
||||||
|
// 红娘侧后续动作:recommend / followup / complete —— 均不得改变用户确认状态
|
||||||
|
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "合适人选");
|
||||||
|
matchmakerServiceService.addFollowup(serviceId, MATCHMAKER_OWNER, "note", "003 守卫跟进", null);
|
||||||
|
|
||||||
|
MatchmakerServiceRecord svc = matchmakerServiceMapper.selectById(serviceId);
|
||||||
|
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||||
|
matchmakerServiceMapper.updateById(svc);
|
||||||
|
matchmakerServiceService.completeService(serviceId, MATCHMAKER_OWNER);
|
||||||
|
|
||||||
|
ActivityRegistration after = activityRegistrationMapper.selectById(regId);
|
||||||
|
assertNull(after.getUserConfirmedAt(),
|
||||||
|
"recommend/followup/complete 红娘侧动作不得代替用户确认(userConfirmedAt 必须仍为 null)");
|
||||||
|
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
|
||||||
|
"红娘侧动作不得推动报名状态(必须停留 pending 等待用户确认)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-CONTACT-001:平台内联系 = 通知中转,无联系方式交换 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-CONTACT-001(正向): 推荐/转介经平台通知触达用户,文案无手机号/微信号模式,收件面仅用户")
|
||||||
|
void platformNotificationsCarryNoContactPatternsAndOnlyTargetUsers() {
|
||||||
|
// 推荐路径:recommendPartner → sendAsync(type=matchmaker, businessType=matchmaker_recommendation)
|
||||||
|
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "003 正向推荐");
|
||||||
|
// 转介路径:referToServiceStation → sendMatchmakerReferralCreated(type=matchmaker, businessType=service_station)
|
||||||
|
referralService.referToServiceStation(MATCHMAKER_OWNER, user, "loc-ag-001",
|
||||||
|
"counselor_consultation", null, "备注");
|
||||||
|
|
||||||
|
List<Notification> notes = notificationMapper.selectList(new LambdaQueryWrapper<Notification>()
|
||||||
|
.eq(Notification::getUserId, user)
|
||||||
|
.eq(Notification::getType, Constants.NOTIFICATION_TYPE_MATCHMAKER));
|
||||||
|
assertFalse(notes.isEmpty(), "推荐/转介必须为用户落平台内服务通知(type=matchmaker)");
|
||||||
|
|
||||||
|
for (Notification note : notes) {
|
||||||
|
String title = note.getTitle() == null ? "" : note.getTitle();
|
||||||
|
String content = note.getContent() == null ? "" : note.getContent();
|
||||||
|
assertFalse(PHONE_PATTERN.matcher(title).find(),
|
||||||
|
"通知标题不得含手机号模式: " + title);
|
||||||
|
assertFalse(PHONE_PATTERN.matcher(content).find(),
|
||||||
|
"通知正文不得含手机号模式: " + content);
|
||||||
|
for (String token : WECHAT_TOKENS) {
|
||||||
|
assertFalse(title.toLowerCase().contains(token),
|
||||||
|
"通知标题不得含微信引导词「" + token + "」: " + title);
|
||||||
|
assertFalse(content.toLowerCase().contains(token),
|
||||||
|
"通知正文不得含微信引导词「" + token + "」: " + content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 负向佐证:通知收件面只有 C 端用户 —— 红娘 operatorId 不在 notification 收件面
|
||||||
|
long operatorInbox = notificationMapper.selectCount(new LambdaQueryWrapper<Notification>()
|
||||||
|
.eq(Notification::getUserId, MATCHMAKER_OWNER));
|
||||||
|
assertEquals(0L, operatorInbox,
|
||||||
|
"红娘不得成为通知收件人(联系方式交换无 operator 侧触达面)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
|
||||||
|
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy;
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-002(A1 服务对象分配建模)验收测试 —— 真实 H2 数据构造。
|
||||||
|
*
|
||||||
|
* <p>覆盖:
|
||||||
|
* <ul>
|
||||||
|
* <li>MM-AUTH-001:红娘对本人有效分配对象 canAccessUser=true,GET /assignments/my 含该对象;</li>
|
||||||
|
* <li>MM-AUTH-002(负向):访问未分配用户 → DataScopeGuard 拒绝 + DATA_SCOPE_DENIED 审计;</li>
|
||||||
|
* <li>MM-AUTH-003(负向):访问他人(红娘D)分配对象 → 拒绝 + 审计;</li>
|
||||||
|
* <li>MM-AUTH-004(负向):POST /services/{id}/claim → 404(D3:claim 端点已删除);</li>
|
||||||
|
* <li>MM-AUDIT-001:create/transfer/revoke/end 各产生对应 action 审计且 operatorId=操作者;</li>
|
||||||
|
* <li>MM-LIFECYCLE-001:end/revoke 后 canAccessUser=false;expires_at 届满惰性 expired;</li>
|
||||||
|
* <li>负向:组长跨组织分配 → 403 + 审计;普通红娘调 /pending → 403 + 审计。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Transactional
|
||||||
|
class MatchmakerAssignmentA1Test {
|
||||||
|
|
||||||
|
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||||
|
@Autowired private ServiceAssignmentPolicy assignmentPolicy;
|
||||||
|
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||||
|
@Autowired private OperatorMapper operatorMapper;
|
||||||
|
@Autowired private UserMapper userMapper;
|
||||||
|
@Autowired private AuditLogMapper auditLogMapper;
|
||||||
|
@Autowired private MatchmakerAssignmentController assignmentController;
|
||||||
|
@Autowired private MatchmakerOperatorController operatorController;
|
||||||
|
|
||||||
|
private String leaderA; // 组长,org X
|
||||||
|
private String mmB; // 红娘,org X
|
||||||
|
private String mmC; // 红娘,org Y
|
||||||
|
private String mmD; // 红娘,org X
|
||||||
|
private String user1; // B 的服务对象
|
||||||
|
private String user2; // 无人分配
|
||||||
|
private String user3; // D 的服务对象
|
||||||
|
|
||||||
|
private static final String ORG_X = "11111111-1111-1111-1111-111111111111";
|
||||||
|
private static final String ORG_Y = "22222222-2222-2222-2222-222222222222";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
leaderA = createOperator("组长A", ORG_X);
|
||||||
|
mmB = createOperator("红娘B", ORG_X);
|
||||||
|
mmC = createOperator("红娘C", ORG_Y);
|
||||||
|
mmD = createOperator("红娘D", ORG_X);
|
||||||
|
user1 = createUser("a1-user1");
|
||||||
|
user2 = createUser("a1-user2");
|
||||||
|
user3 = createUser("a1-user3");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearSecurityContext() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据构造 ====================
|
||||||
|
|
||||||
|
private String createOperator(String name, String organizationId) {
|
||||||
|
Operator op = new Operator();
|
||||||
|
op.setName(name);
|
||||||
|
op.setOrganizationId(organizationId);
|
||||||
|
op.setUsername("a1_" + name + "_" + System.nanoTime());
|
||||||
|
op.setPasswordHash("test-hash");
|
||||||
|
op.setStatus("active");
|
||||||
|
operatorMapper.insert(op);
|
||||||
|
return op.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createUser(String tag) {
|
||||||
|
User u = new User();
|
||||||
|
u.setWechatOpenid("a1_" + tag + "_" + System.nanoTime());
|
||||||
|
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
userMapper.insert(u);
|
||||||
|
return u.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以指定操作者身份 + 权限集设置 SecurityContext(方法级 @PreAuthorize 据此判定)。 */
|
||||||
|
private void authenticate(String operatorId, String... permissions) {
|
||||||
|
Set<String> permSet = Set.of(permissions);
|
||||||
|
var authorities = Stream.concat(
|
||||||
|
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
|
||||||
|
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
UsernamePasswordAuthenticationToken.authenticated(
|
||||||
|
new OperatorPrincipal(operatorId, permSet), null, authorities));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countAudits(String action, String operatorId) {
|
||||||
|
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||||
|
.eq(AuditLog::getAction, action)
|
||||||
|
.eq(operatorId != null, AuditLog::getOperatorId, operatorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-AUTH-001 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-AUTH-001: 红娘B 对本人有效分配对象 canAccessUser=true,GET /assignments/my 含该对象")
|
||||||
|
void mmAuth001_matchmakerAccessesOwnAssignedUser() {
|
||||||
|
// 组长A(同组织 X,team.assign 路径)创建 B → user1 分配
|
||||||
|
MatchmakerAssignment created = assignmentService.create(
|
||||||
|
leaderA, false, user1, mmB, null, "同一社区,就近服务", null);
|
||||||
|
assertNotNull(created.getId());
|
||||||
|
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, created.getStatus());
|
||||||
|
assertEquals(Constants.ASSIGNMENT_SCOPE_PRIMARY, created.getAssignmentScope());
|
||||||
|
|
||||||
|
assertTrue(assignmentPolicy.canAccessUser(mmB, user1),
|
||||||
|
"红娘B 必须能访问其名下生效分配的服务对象");
|
||||||
|
|
||||||
|
// GET /assignments/my(matchmaker.service 权限)包含该对象
|
||||||
|
authenticate(mmB, "matchmaker.view", "matchmaker.service");
|
||||||
|
var result = assignmentController.my(new OperatorPrincipal(mmB,
|
||||||
|
Set.of("matchmaker.view", "matchmaker.service")));
|
||||||
|
assertTrue(result.isSuccess());
|
||||||
|
List<Map<String, Object>> data = result.getData();
|
||||||
|
assertNotNull(data);
|
||||||
|
assertEquals(1, data.size(), "GET /my 应恰好返回红娘B 名下的生效分配");
|
||||||
|
assertEquals(user1, data.get(0).get("userId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-AUTH-002 / 003 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-AUTH-002(负向): B 访问未分配用户 → DataScopeGuard 拒绝 + DATA_SCOPE_DENIED 审计")
|
||||||
|
void mmAuth002_accessUnassignedUserDeniedAndAudited() {
|
||||||
|
assertFalse(assignmentPolicy.canAccessUser(mmB, user2));
|
||||||
|
|
||||||
|
long before = countAudits("DATA_SCOPE_DENIED", mmB);
|
||||||
|
assertThrows(AccessDeniedException.class,
|
||||||
|
() -> assignmentPolicy.assertActiveAssignment(mmB, user2, "matchmaker_user"),
|
||||||
|
"未持有分配的红娘访问用户必须被拒绝");
|
||||||
|
long after = countAudits("DATA_SCOPE_DENIED", mmB);
|
||||||
|
assertEquals(before + 1, after, "拒绝必须落一条 DATA_SCOPE_DENIED 审计行");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-AUTH-003(负向): B 访问他人(红娘D)分配对象 → 拒绝 + 审计")
|
||||||
|
void mmAuth003_accessOthersAssignedUserDeniedAndAudited() {
|
||||||
|
// D 名下分配(组长A 同组织创建)
|
||||||
|
assignmentService.create(leaderA, false, user3, mmD, null, "D 负责该用户", null);
|
||||||
|
|
||||||
|
assertTrue(assignmentPolicy.canAccessUser(mmD, user3));
|
||||||
|
assertFalse(assignmentPolicy.canAccessUser(mmB, user3), "他人的分配对象对 B 不可见");
|
||||||
|
|
||||||
|
long before = countAudits("DATA_SCOPE_DENIED", mmB);
|
||||||
|
assertThrows(AccessDeniedException.class,
|
||||||
|
() -> assignmentPolicy.assertActiveAssignment(mmB, user3, "matchmaker_user"),
|
||||||
|
"B 访问红娘D 的分配对象必须被拒绝");
|
||||||
|
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", mmB),
|
||||||
|
"拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-AUTH-004 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-AUTH-004(负向): POST /services/{id}/claim → 404(claim 端点已删除)")
|
||||||
|
void mmAuth004_claimEndpointRemoved() throws Exception {
|
||||||
|
// standalone MockMvc:仅注册真实控制器路由,验证 /claim 已不在映射中(D3 决策)
|
||||||
|
MatchmakerServiceService stubService = Mockito.mock(MatchmakerServiceService.class);
|
||||||
|
MockMvc mockMvc = MockMvcBuilders
|
||||||
|
.standaloneSetup(new MatchmakerOperatorController(stubService))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mockMvc.perform(post("/operator/matchmaker/services/some-id/claim"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-AUDIT-001 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-AUDIT-001: create/transfer/revoke/end 各产生对应 action 审计且 operatorId=操作者")
|
||||||
|
void mmAudit001_eachOperationWritesAuditWithActor() {
|
||||||
|
// 1) create: A 分配 B → user1
|
||||||
|
MatchmakerAssignment a1 = assignmentService.create(
|
||||||
|
leaderA, false, user1, mmB, null, "初始分配", null);
|
||||||
|
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, leaderA) > 0,
|
||||||
|
"create 必须落 MATCHMAKER_ASSIGNMENT_CREATE 审计");
|
||||||
|
|
||||||
|
// 2) transfer: B → D
|
||||||
|
MatchmakerAssignment a2 = assignmentService.transfer(
|
||||||
|
a1.getId(), leaderA, false, mmD, "B 休假,转给 D");
|
||||||
|
assertEquals(mmD, a2.getMatchmakerOperatorId());
|
||||||
|
assertEquals(Constants.ASSIGNMENT_STATUS_TRANSFERRED,
|
||||||
|
assignmentMapper.findById(a1.getId()).orElseThrow().getStatus());
|
||||||
|
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER, leaderA) > 0,
|
||||||
|
"transfer 必须落 MATCHMAKER_ASSIGNMENT_TRANSFER 审计");
|
||||||
|
|
||||||
|
// 3) revoke: 撤销 D 名下的新分配
|
||||||
|
assignmentService.revoke(a2.getId(), leaderA, false, "用户主动申请更换");
|
||||||
|
assertEquals(Constants.ASSIGNMENT_STATUS_REVOKED,
|
||||||
|
assignmentMapper.findById(a2.getId()).orElseThrow().getStatus());
|
||||||
|
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE, leaderA) > 0,
|
||||||
|
"revoke 必须落 MATCHMAKER_ASSIGNMENT_REVOKE 审计");
|
||||||
|
|
||||||
|
// 4) end: 新建再结束
|
||||||
|
MatchmakerAssignment a3 = assignmentService.create(
|
||||||
|
leaderA, false, user2, mmB, null, "第二次分配", null);
|
||||||
|
assignmentService.end(a3.getId(), leaderA, false, "服务完结");
|
||||||
|
assertEquals(Constants.ASSIGNMENT_STATUS_ENDED,
|
||||||
|
assignmentMapper.findById(a3.getId()).orElseThrow().getStatus());
|
||||||
|
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_END, leaderA) > 0,
|
||||||
|
"end 必须落 MATCHMAKER_ASSIGNMENT_END 审计");
|
||||||
|
|
||||||
|
// 审计行 operatorId 必须是操作者(组长A),且 resourceType 正确
|
||||||
|
// 注:本用例共 5 次操作(create×2 + transfer + revoke + end),审计 1:1 对应
|
||||||
|
List<AuditLog> audits = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||||
|
.eq(AuditLog::getOperatorId, leaderA)
|
||||||
|
.in(AuditLog::getAction, List.of(
|
||||||
|
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE,
|
||||||
|
Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER,
|
||||||
|
Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE,
|
||||||
|
Constants.AUDIT_ACTION_ASSIGNMENT_END)));
|
||||||
|
assertEquals(5, audits.size(), "五次操作各产生一条审计(create×2/transfer/revoke/end)");
|
||||||
|
assertEquals(2, audits.stream().filter(a ->
|
||||||
|
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count());
|
||||||
|
assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())),
|
||||||
|
"resourceType 必须是 matchmaker_assignment");
|
||||||
|
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason
|
||||||
|
assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null
|
||||||
|
&& a.getAfterData().contains("matchmakerOperatorId=")
|
||||||
|
&& a.getAfterData().contains("scope=")
|
||||||
|
&& a.getAfterData().contains("reason=")),
|
||||||
|
"afterData 必须记录 operatorId/scope/reason 摘要");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-LIFECYCLE-001 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-LIFECYCLE-001: end/revoke 后 canAccessUser=false;expires_at 届满惰性置 expired")
|
||||||
|
void mmLifecycle001_terminalStatesRevokeAccessAndLazyExpiry() {
|
||||||
|
// end 之后不可访问
|
||||||
|
MatchmakerAssignment a1 = assignmentService.create(
|
||||||
|
leaderA, false, user1, mmB, null, "待结束分配", null);
|
||||||
|
assertTrue(assignmentPolicy.canAccessUser(mmB, user1));
|
||||||
|
assignmentService.end(a1.getId(), leaderA, false, "服务完结");
|
||||||
|
assertFalse(assignmentPolicy.canAccessUser(mmB, user1), "end 后必须立即失去访问权");
|
||||||
|
|
||||||
|
// revoke 之后不可访问
|
||||||
|
MatchmakerAssignment a2 = assignmentService.create(
|
||||||
|
leaderA, false, user2, mmB, null, "待撤销分配", null);
|
||||||
|
assignmentService.revoke(a2.getId(), leaderA, false, "误分配");
|
||||||
|
assertFalse(assignmentPolicy.canAccessUser(mmB, user2), "revoke 后必须立即失去访问权");
|
||||||
|
|
||||||
|
// expires_at 届满(固定时间注入:写入已过期的 expires_at)→ 读路径惰性置 expired
|
||||||
|
MatchmakerAssignment a3 = assignmentService.create(
|
||||||
|
leaderA, false, user3, mmB, null, "短期分配",
|
||||||
|
Instant.now().minusSeconds(3600));
|
||||||
|
assertTrue(assignmentPolicy.canAccessUser(mmB, user3) == false,
|
||||||
|
"已过有效期的分配必须被视为失效");
|
||||||
|
MatchmakerAssignment after = assignmentMapper.findById(a3.getId()).orElseThrow();
|
||||||
|
assertEquals(Constants.ASSIGNMENT_STATUS_EXPIRED, after.getStatus(),
|
||||||
|
"惰性过期必须落库(无定时任务,读路径触发)");
|
||||||
|
assertNotNull(after.getEndedAt());
|
||||||
|
assertNotNull(after.getEndReason());
|
||||||
|
|
||||||
|
// 终态一律拒绝再转换(D5 状态机矩阵)
|
||||||
|
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.revoke(a3.getId(), leaderA, false, "对已过期分配撤销"),
|
||||||
|
"expired 是终态,不允许再转换");
|
||||||
|
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.end(a1.getId(), leaderA, false, "对已结束分配再结束"),
|
||||||
|
"ended 是终态,不允许再转换");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 组长组织边界(负向) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("负向: 组长A 跨组织分配红娘C(org=Y) → 403 + DATA_SCOPE_DENIED 审计")
|
||||||
|
void leaderCannotAssignCrossOrganization() {
|
||||||
|
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
|
||||||
|
assertThrows(AccessDeniedException.class,
|
||||||
|
() -> assignmentService.create(leaderA, false, user2, mmC, null, "跨组织分配", null),
|
||||||
|
"组长(org=X)不得分配 org=Y 的红娘");
|
||||||
|
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
|
||||||
|
"跨组织分配拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||||
|
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
|
||||||
|
.eq(MatchmakerAssignment::getUserId, user2)).isEmpty(),
|
||||||
|
"被拒绝的分配不得留下任何分配记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("负向: 平台授权人员(matchmaker.assign)可跨组织分配;组长不可分配 org=NULL 红娘")
|
||||||
|
void globalAssignScopeAndNullOrgBoundary() {
|
||||||
|
// matchmaker.assign 持有者不受组织限制(leaderA 只是被用作操作者身份)
|
||||||
|
assertDoesNotThrow(() ->
|
||||||
|
assignmentService.create(leaderA, true, user1, mmC, null, "平台授权分配", null),
|
||||||
|
"matchmaker.assign 持有者不限组织");
|
||||||
|
|
||||||
|
// org=NULL 的红娘:平台直属组长只能分配平台直属红娘(NULL=NULL 规则)
|
||||||
|
String platformLeader = createOperator("平台直属组长", null);
|
||||||
|
String platformMm = createOperator("平台直属红娘", null);
|
||||||
|
assertDoesNotThrow(() ->
|
||||||
|
assignmentService.create(platformLeader, false, user2, platformMm, null, "平台直属互配", null),
|
||||||
|
"NULL=NULL(平台直属 → 平台直属)必须放行");
|
||||||
|
assertThrows(AccessDeniedException.class,
|
||||||
|
() -> assignmentService.create(platformLeader, false, user3, mmB, null, "平台直属分配有组织红娘", null),
|
||||||
|
"平台直属组长不得分配有组织的红娘(NULL≠org X)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== /pending 方法级权限(负向) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("负向: 普通红娘(无分配权限)调 /pending → 403 + AUTHORIZATION_DENIED 审计")
|
||||||
|
void normalMatchmakerCannotListPendingQueue() {
|
||||||
|
authenticate(mmB, "matchmaker.view", "matchmaker.service");
|
||||||
|
|
||||||
|
long before = countAudits("AUTHORIZATION_DENIED", mmB);
|
||||||
|
assertThrows(AccessDeniedException.class,
|
||||||
|
() -> operatorController.getPending(
|
||||||
|
new OperatorPrincipal(mmB, Set.of("matchmaker.view", "matchmaker.service"))),
|
||||||
|
"/pending 方法级权限收敛后,普通红娘必须 403");
|
||||||
|
assertEquals(before + 1, countAudits("AUTHORIZATION_DENIED", mmB),
|
||||||
|
"方法级授权拒绝必须落 AUTHORIZATION_DENIED 审计");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QA 验收补充(Edward):边界与异常路径 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("QA补充(负向): 理由为 null/空串/纯空格 → 400 VALIDATION_ERROR,且不落任何分配记录或 CREATE 审计")
|
||||||
|
void blankReasonMustBeRejectedWithValidationError() {
|
||||||
|
// null 理由
|
||||||
|
var ex1 = assertThrows(
|
||||||
|
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.create(leaderA, false, user1, mmB, null, null, null),
|
||||||
|
"reason=null 必须被拒绝");
|
||||||
|
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex1.getCode(),
|
||||||
|
"理由缺失必须是 400 VALIDATION_ERROR,而非 500/403");
|
||||||
|
|
||||||
|
// 空串理由
|
||||||
|
var ex2 = assertThrows(
|
||||||
|
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.create(leaderA, false, user1, mmB, null, "", null));
|
||||||
|
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex2.getCode());
|
||||||
|
|
||||||
|
// 纯空格理由(isBlank 语义,防止" "绕过)
|
||||||
|
var ex3 = assertThrows(
|
||||||
|
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.create(leaderA, false, user1, mmB, null, " ", null));
|
||||||
|
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex3.getCode());
|
||||||
|
|
||||||
|
// revoke/end/transfer 同口径
|
||||||
|
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR,
|
||||||
|
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.transfer("any-id", leaderA, false, mmB, " ")).getCode(),
|
||||||
|
"转分配理由空白同样必须 VALIDATION_ERROR");
|
||||||
|
|
||||||
|
// 三次被拒的 create 不得留下分配记录,也不得落 CREATE 审计
|
||||||
|
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
|
||||||
|
.eq(MatchmakerAssignment::getUserId, user1)).isEmpty(),
|
||||||
|
"被拒绝的分配不得留下任何分配记录");
|
||||||
|
assertEquals(0, countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, leaderA),
|
||||||
|
"被拒绝的分配不得产生 CREATE 审计");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("QA补充: collaborating 分配与 active+primary 共存,不触发主责部分唯一索引冲突")
|
||||||
|
void collaboratingAssignmentMustNotConflictWithActivePrimary() {
|
||||||
|
// 先建立主责分配(组长A → 红娘B)
|
||||||
|
assignmentService.create(leaderA, false, user1, mmB, null, "主责分配", null);
|
||||||
|
|
||||||
|
// 直接落一条同用户、同红娘的 collaborating 生效中分配(协同注记不占主责唯一位)
|
||||||
|
MatchmakerAssignment collab = new MatchmakerAssignment();
|
||||||
|
collab.setUserId(user1);
|
||||||
|
collab.setMatchmakerOperatorId(mmB);
|
||||||
|
collab.setAssignedBy(leaderA);
|
||||||
|
collab.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_COLLABORATING);
|
||||||
|
collab.setAssignmentReason("协同服务(组长指派协同)");
|
||||||
|
collab.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||||
|
collab.setAssignedAt(Instant.now());
|
||||||
|
assertDoesNotThrow(() -> assignmentMapper.insert(collab),
|
||||||
|
"collaborating 生效中分配不得触发 uq_mm_assignment_active_primary 冲突");
|
||||||
|
|
||||||
|
// 主责仍在、协同也在,两条均为生效中
|
||||||
|
assertEquals(2, assignmentMapper.selectCount(new LambdaQueryWrapper<MatchmakerAssignment>()
|
||||||
|
.eq(MatchmakerAssignment::getUserId, user1)
|
||||||
|
.eq(MatchmakerAssignment::getStatus, Constants.ASSIGNMENT_STATUS_ACTIVE)),
|
||||||
|
"同一用户应同时存在 1 条 active 主责 + 1 条 active 协同");
|
||||||
|
|
||||||
|
// 主责唯一性仍然生效:同用户第二条 primary 必须被拒(对照组)
|
||||||
|
assertThrows(Exception.class,
|
||||||
|
() -> assignmentService.create(leaderA, false, user1, mmD, null, "第二条主责", null),
|
||||||
|
"同用户第二条 active primary 必须被唯一性约束拒绝");
|
||||||
|
// 预检路径的 CONFLICT
|
||||||
|
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
|
||||||
|
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.create(leaderA, false, user1, mmD, null, "第二条主责", null)).getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("QA补充(负向): 有组织组长分配 org=NULL(平台直属)红娘 → 403(Objects.equals 非对称方向的显式覆盖)")
|
||||||
|
void orgLeaderMustNotAssignPlatformDirectMatchmaker() {
|
||||||
|
// org=NULL 红娘(平台直属)
|
||||||
|
String platformMm = createOperator("平台直属红娘-供拒配", null);
|
||||||
|
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
|
||||||
|
// leaderA(org X)→ org=NULL 红娘:Objects.equals(orgX, null)=false,必须拒绝
|
||||||
|
assertThrows(AccessDeniedException.class,
|
||||||
|
() -> assignmentService.create(leaderA, false, user2, platformMm, null, "有组织组长碰平台直属红娘", null),
|
||||||
|
"有组织组长不得分配 org=NULL(平台直属)红娘");
|
||||||
|
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
|
||||||
|
"拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||||
|
// 同理 transfer 的范围校验也覆盖该方向(ZC-MM-006 / T04:transfer 404 化)
|
||||||
|
MatchmakerAssignment a = assignmentService.create(leaderA, false, user3, mmB, null, "主责", null);
|
||||||
|
var transferDenied = assertThrows(
|
||||||
|
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.transfer(a.getId(), leaderA, false, platformMm, "转给平台直属红娘"),
|
||||||
|
"有组织组长不得把分配转给 org=NULL 红娘(404 化拒绝)");
|
||||||
|
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, transferDenied.getCode(),
|
||||||
|
"transfer 范围违规必须 404 化(防探测,T04)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("P2-1补充(负向): org=X 组长转分配 org=Y 红娘持有的分配 → 404 化拒绝 + DATA_SCOPE_DENIED 审计(原持有人范围校验)")
|
||||||
|
void leaderCannotTransferAssignmentHeldByCrossOrgMatchmaker() {
|
||||||
|
// org=Y 红娘C 持有一条分配(由平台授权路径创建,绕过 create 的范围校验以构造前置状态)
|
||||||
|
MatchmakerAssignment held = assignmentService.create(
|
||||||
|
leaderA, true, user1, mmC, null, "平台授权分配给C", null);
|
||||||
|
assertEquals(mmC, held.getMatchmakerOperatorId());
|
||||||
|
|
||||||
|
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
|
||||||
|
// org=X 组长A 把 C(org=Y)持有的分配转给同组织成员 B:原持有人范围违规,必须拒绝
|
||||||
|
// (ZC-MM-006 / T04:transfer 为资源寻址写端点,拒绝 404 化,审计仍为 DATA_SCOPE_DENIED)
|
||||||
|
var denied = assertThrows(
|
||||||
|
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> assignmentService.transfer(held.getId(), leaderA, false, mmB, "转回本组织"),
|
||||||
|
"组长不得处置跨组织红娘持有的分配(原持有人范围校验,404 化)");
|
||||||
|
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, denied.getCode(),
|
||||||
|
"transfer 范围违规必须 404 化(防探测,T04)");
|
||||||
|
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
|
||||||
|
"拒绝必须落 DATA_SCOPE_DENIED 审计");
|
||||||
|
|
||||||
|
// 被转记录必须原样保留:仍 active 且持有人仍是 C,未产生新分配
|
||||||
|
MatchmakerAssignment after = assignmentMapper.findById(held.getId()).orElseThrow();
|
||||||
|
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, after.getStatus(),
|
||||||
|
"被拒绝的转分配不得改变原分配状态");
|
||||||
|
assertEquals(mmC, after.getMatchmakerOperatorId(),
|
||||||
|
"被拒绝的转分配不得改变原持有人");
|
||||||
|
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
|
||||||
|
.eq(MatchmakerAssignment::getUserId, user1)
|
||||||
|
.eq(MatchmakerAssignment::getMatchmakerOperatorId, mmB))
|
||||||
|
.isEmpty(),
|
||||||
|
"被拒绝的转分配不得给目标红娘留下任何分配记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 分配同事务联动服务单 ====================
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper realServiceMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("联动: 带 serviceId 的分配同事务把服务单置 assigned 并写回 assigned_operator_id")
|
||||||
|
void createWithServiceIdLinksServiceRow() {
|
||||||
|
// 真实落库一条 submitted 服务单
|
||||||
|
var record = new com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord();
|
||||||
|
record.setUserId(user1);
|
||||||
|
record.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||||
|
record.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||||
|
realServiceMapper.insert(record);
|
||||||
|
|
||||||
|
assignmentService.create(leaderA, false, user1, mmB, record.getId(), "带服务单分配", null);
|
||||||
|
|
||||||
|
var updated = realServiceMapper.selectById(record.getId());
|
||||||
|
assertEquals(Constants.MATCHMAKER_STATUS_ASSIGNED, updated.getStatus(),
|
||||||
|
"分配联动必须把服务单置为 assigned");
|
||||||
|
assertEquals(mmB, updated.getAssignedOperatorId(),
|
||||||
|
"服务单 assigned_operator_id 必须写为目标红娘");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
|
||||||
|
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-002(A1)权限契约守卫(standalone 反射扫描,无需 Spring 上下文)。
|
||||||
|
*
|
||||||
|
* <p>锁定两条不变量:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code MatchmakerAssignmentController} 每个端点都有显式 @PreAuthorize;
|
||||||
|
* 写操作(create/transfer/revoke/end/assignable)必须同时包含
|
||||||
|
* {@code matchmaker.assign} 或 {@code matchmaker.team.assign};
|
||||||
|
* 读自己名下(/my)必须是 {@code matchmaker.service}。</li>
|
||||||
|
* <li>{@code MatchmakerOperatorController} 无 claim 残留(D3:claim 端点/方法已删除)。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
class MatchmakerAssignmentPermissionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AssignmentController:全部端点有 @PreAuthorize,写操作限定分配权限")
|
||||||
|
void assignmentControllerPermissionContract() {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
for (Method method : MatchmakerAssignmentController.class.getDeclaredMethods()) {
|
||||||
|
if (paths(method).isEmpty()) {
|
||||||
|
continue; // 非端点方法
|
||||||
|
}
|
||||||
|
PreAuthorize preAuthorize = AnnotatedElementUtils.findMergedAnnotation(method, PreAuthorize.class);
|
||||||
|
if (preAuthorize == null) {
|
||||||
|
violations.add(method.getName() + ": 缺少 @PreAuthorize");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String expr = preAuthorize.value();
|
||||||
|
boolean isReadMy = method.getName().equals("my");
|
||||||
|
boolean isTeamStatistics = method.getName().equals("teamStatistics");
|
||||||
|
if (isReadMy) {
|
||||||
|
if (!expr.contains("matchmaker.service")) {
|
||||||
|
violations.add("my: 读接口必须要求 matchmaker.service,实际=" + expr);
|
||||||
|
}
|
||||||
|
} else if (isTeamStatistics) {
|
||||||
|
// ZC-MM-005/T03:纯组长数据,只要求 team.assign(更强而非更弱)
|
||||||
|
if (!expr.contains("matchmaker.team.assign")) {
|
||||||
|
violations.add("teamStatistics: 必须要求 matchmaker.team.assign,实际=" + expr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
boolean hasAssignPermission = expr.contains("matchmaker.assign")
|
||||||
|
&& expr.contains("matchmaker.team.assign");
|
||||||
|
if (!hasAssignPermission) {
|
||||||
|
violations.add(method.getName()
|
||||||
|
+ ": 写操作必须同时限定 matchmaker.assign / matchmaker.team.assign,实际=" + expr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(List.of(), violations, "AssignmentController 权限契约被破坏");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("守卫自检:必须真的扫到 AssignmentController 端点(防守卫失效)")
|
||||||
|
void guardMustActuallySeeEndpoints() {
|
||||||
|
long endpointCount = Arrays.stream(MatchmakerAssignmentController.class.getDeclaredMethods())
|
||||||
|
.filter(m -> !paths(m).isEmpty())
|
||||||
|
.count();
|
||||||
|
assertTrue(endpointCount >= 5,
|
||||||
|
"扫描到的端点数异常偏少(" + endpointCount + "),守卫可能失效");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("OperatorController:无 claim 残留(端点路径与方法名均不得含 claim)")
|
||||||
|
void operatorControllerMustNotHaveClaimResidue() {
|
||||||
|
List<String> residue = new ArrayList<>();
|
||||||
|
for (Method method : MatchmakerOperatorController.class.getDeclaredMethods()) {
|
||||||
|
if (method.getName().toLowerCase().contains("claim")) {
|
||||||
|
residue.add("方法名残留: " + method.getName());
|
||||||
|
}
|
||||||
|
if (paths(method).stream().anyMatch(p -> p.toLowerCase().contains("claim"))) {
|
||||||
|
residue.add("路径残留: " + method.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(List.of(), residue,
|
||||||
|
"D3 决策:claim(红娘接单)端点必须彻底删除,归属由 matchmaker_assignments 建模");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ZC-MM-005/E4:OperatorController 无 my-services 残留(死端点已删除)")
|
||||||
|
void operatorControllerMustNotHaveMyServicesResidue() {
|
||||||
|
List<String> residue = new ArrayList<>();
|
||||||
|
for (Method method : MatchmakerOperatorController.class.getDeclaredMethods()) {
|
||||||
|
String lower = method.getName().toLowerCase();
|
||||||
|
if (lower.contains("myservices") || lower.contains("my_services")) {
|
||||||
|
residue.add("方法名残留: " + method.getName());
|
||||||
|
}
|
||||||
|
if (paths(method).stream().anyMatch(p -> p.toLowerCase().contains("my-services"))) {
|
||||||
|
residue.add("路径残留: " + method.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(List.of(), residue,
|
||||||
|
"GET /my-services 是死端点(按 C 端 user_id 查 operator 恒空、前后端零调用),"
|
||||||
|
+ "替代路径为 GET /operator/matchmaker/assignments/my");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ZC-MM-005/T03:team/statistics 端点必须显式限定 matchmaker.team.assign")
|
||||||
|
void teamStatisticsMustRequireTeamAssignPermission() {
|
||||||
|
Method statistics = Arrays.stream(MatchmakerAssignmentController.class.getDeclaredMethods())
|
||||||
|
.filter(m -> m.getName().equals("teamStatistics"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("teamStatistics 端点不存在"));
|
||||||
|
PreAuthorize preAuthorize = AnnotatedElementUtils.findMergedAnnotation(statistics, PreAuthorize.class);
|
||||||
|
assertNotNull(preAuthorize, "team/statistics 缺少 @PreAuthorize");
|
||||||
|
assertTrue(preAuthorize.value().contains("matchmaker.team.assign"),
|
||||||
|
"team/statistics 必须限定 matchmaker.team.assign(纯组长数据),实际="
|
||||||
|
+ preAuthorize.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> paths(java.lang.reflect.AnnotatedElement element) {
|
||||||
|
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
|
||||||
|
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
|
||||||
|
? mapping.path() : mapping.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T01 验收:audit_logs 扩展字段(V055)写入链路。
|
||||||
|
*
|
||||||
|
* <p>覆盖:
|
||||||
|
* <ul>
|
||||||
|
* <li>扩展写入(正向):assign / recommend 审计行落 purpose_code + service_assignment_id;</li>
|
||||||
|
* <li>兼容性(负向·缺省调用):applyService 走旧签名(purposeCode/assignmentId 缺省)仍可写,
|
||||||
|
* purpose_code 为 null —— 证明扩展列全可空、存量行为不变;</li>
|
||||||
|
* <li>拒绝路径(负向):无分配 recommend → AccessDeniedException + DATA_SCOPE_DENIED 审计,
|
||||||
|
* 且审计行带 actor_role_snapshot(SecurityContext 权限快照)+ request_trace_id(MDC)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Transactional
|
||||||
|
class MatchmakerAuditFieldsTest {
|
||||||
|
|
||||||
|
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||||
|
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||||
|
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||||
|
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||||
|
@Autowired private OperatorMapper operatorMapper;
|
||||||
|
@Autowired private UserMapper userMapper;
|
||||||
|
@Autowired private AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
|
private String matchmakerId;
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
matchmakerId = createOperator();
|
||||||
|
userId = createUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearContext() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
MDC.remove("requestId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据构造(Phase13 范式) ====================
|
||||||
|
|
||||||
|
private String createOperator() {
|
||||||
|
Operator op = new Operator();
|
||||||
|
op.setName("审计字段测试红娘");
|
||||||
|
op.setUsername("audit_fields_" + System.nanoTime());
|
||||||
|
op.setPasswordHash("test-hash");
|
||||||
|
op.setStatus("active");
|
||||||
|
operatorMapper.insert(op);
|
||||||
|
return op.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createUser() {
|
||||||
|
User u = new User();
|
||||||
|
u.setWechatOpenid("audit_fields_" + System.nanoTime());
|
||||||
|
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
userMapper.insert(u);
|
||||||
|
return u.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createService(String operatorId) {
|
||||||
|
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||||
|
svc.setUserId(userId);
|
||||||
|
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||||
|
svc.setDescription("审计字段测试服务单");
|
||||||
|
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||||
|
svc.setAssignedOperatorId(operatorId);
|
||||||
|
serviceMapper.insert(svc);
|
||||||
|
return svc.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void authenticate(String operatorId, String... permissions) {
|
||||||
|
Set<String> permSet = Set.of(permissions);
|
||||||
|
var authorities = Stream.concat(
|
||||||
|
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
|
||||||
|
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
UsernamePasswordAuthenticationToken.authenticated(
|
||||||
|
new OperatorPrincipal(operatorId, permSet), null, authorities));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuditLog latestAudit(String action, String resourceId) {
|
||||||
|
List<AuditLog> rows = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||||
|
.eq(AuditLog::getAction, action)
|
||||||
|
.eq(AuditLog::getResourceId, resourceId)
|
||||||
|
.orderByDesc(AuditLog::getCreatedAt));
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 正向:扩展写入 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T01 正向: assignService 审计行落 purpose_code=matchmaker.service.assign + service_assignment_id")
|
||||||
|
void assignAuditCarriesPurposeCodeAndAssignmentId() {
|
||||||
|
String serviceId = createService(matchmakerId);
|
||||||
|
|
||||||
|
matchmakerServiceService.assignService(serviceId, matchmakerId, matchmakerId, "T01 分配审计");
|
||||||
|
|
||||||
|
MatchmakerAssignment assignment = assignmentMapper
|
||||||
|
.findActiveByOperatorAndUser(matchmakerId, userId).orElseThrow();
|
||||||
|
AuditLog audit = latestAudit("MATCHMAKER_ASSIGN", serviceId);
|
||||||
|
assertNotNull(audit, "分配审计行必须存在");
|
||||||
|
assertEquals("matchmaker.service.assign", audit.getPurposeCode(), "purpose_code 必须落库");
|
||||||
|
assertEquals(assignment.getId(), audit.getServiceAssignmentId(),
|
||||||
|
"service_assignment_id 必须关联新建分配");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T01 正向: recommendPartner 审计行落 purpose_code=matchmaker.service.recommend + 分配 ID")
|
||||||
|
void recommendAuditCarriesPurposeCodeAndAssignmentId() {
|
||||||
|
String serviceId = createService(matchmakerId);
|
||||||
|
MatchmakerAssignment assignment = assignmentService.create(matchmakerId, true,
|
||||||
|
userId, matchmakerId, serviceId, "T01 前置分配", null);
|
||||||
|
|
||||||
|
matchmakerServiceService.recommendPartner(serviceId, matchmakerId,
|
||||||
|
"audit-target-" + System.nanoTime(), "T01 推荐审计");
|
||||||
|
|
||||||
|
AuditLog audit = latestAudit("MATCHMAKER_RECOMMEND", serviceId);
|
||||||
|
assertNotNull(audit, "推荐审计行必须存在");
|
||||||
|
assertEquals("matchmaker.service.recommend", audit.getPurposeCode());
|
||||||
|
assertEquals(assignment.getId(), audit.getServiceAssignmentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 负向·兼容性:缺省调用仍可写 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T01 负向(兼容): 旧签名审计调用(applyService)不传 purposeCode/assignmentId 仍成功写入,扩展列为 null")
|
||||||
|
void legacyAuditCallStillWritableWithNullExtendedFields() {
|
||||||
|
String serviceId = matchmakerServiceService
|
||||||
|
.applyService(userId, Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER, "T01 兼容性申请")
|
||||||
|
.get("serviceId").toString();
|
||||||
|
|
||||||
|
AuditLog audit = latestAudit("MATCHMAKER_APPLY", serviceId);
|
||||||
|
assertNotNull(audit, "缺省调用(旧签名)审计行必须仍可写入");
|
||||||
|
assertNull(audit.getPurposeCode(), "缺省调用 purpose_code 必须为 null(全可空)");
|
||||||
|
assertNull(audit.getServiceAssignmentId(), "缺省调用 service_assignment_id 必须为 null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 负向:拒绝路径带快照 + 追踪 ID ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T01 负向: 无分配 recommend → 404 化拒绝(T04)+ DATA_SCOPE_DENIED 审计含权限快照与追踪 ID")
|
||||||
|
void deniedAuditCarriesActorRoleSnapshotAndTraceId() {
|
||||||
|
String serviceId = createService(matchmakerId);
|
||||||
|
// 无分配的红娘(matchmakerId 对 userId 无生效分配)+ SecurityContext 携带权限 → 快照非空
|
||||||
|
authenticate(matchmakerId, "matchmaker.service");
|
||||||
|
MDC.put("requestId", "trace01test0001");
|
||||||
|
|
||||||
|
BusinessException denied = assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.recommendPartner(serviceId, matchmakerId,
|
||||||
|
"audit-target-x", "T01 拒绝路径"),
|
||||||
|
"无生效分配必须拒绝(ZC-MM-006 / T04 起为 404 化拒绝)");
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, denied.getCode(),
|
||||||
|
"写接口范围违规 404 化(T04 防探测)");
|
||||||
|
|
||||||
|
AuditLog audit = latestAudit("DATA_SCOPE_DENIED", serviceId);
|
||||||
|
assertNotNull(audit, "拒绝路径必须落 DATA_SCOPE_DENIED 审计");
|
||||||
|
assertEquals("matchmaker.service", audit.getActorRoleSnapshot(),
|
||||||
|
"快照必须来自 SecurityContext 权限(逗号拼接,截断 128)");
|
||||||
|
assertEquals("trace01test0001", audit.getRequestTraceId(),
|
||||||
|
"追踪 ID 必须取自 MDC(requestId)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-003(B1 联系规则护栏)+ ZC-MM-004 佐证 —— standalone 源码扫描守卫
|
||||||
|
* (沿用 {@code MatchmakerFieldPrivacyGuardTest} 范式,无需 Spring 上下文)。
|
||||||
|
*
|
||||||
|
* <p>锁定四条不变量(架构师设计 T01):
|
||||||
|
* <ol>
|
||||||
|
* <li><b>MM-CONTACT-002</b>:ReferralService 全部响应 put 键 ⊆ 白名单(逐键对照实际代码
|
||||||
|
* 核定,见 {@link #RESPONSE_KEY_WHITELIST}),且键名不得含 emergencyContact / phone /
|
||||||
|
* mobile / wechat / address / idNumber 等联系方式字样 —— 锁定「红娘侧零联系方式回显」;
|
||||||
|
* emergencyContact* 是纯入口 write-only(写入 activity_registration 的紧急联系人字段),
|
||||||
|
* 连红娘本人都无读回面。</li>
|
||||||
|
* <li><b>MM-CONTACT-003</b>:matchmaker / referral Controller 无 contact / call / phone /
|
||||||
|
* channel / offline 类端点;全仓无 ContactAuthorization 类、无
|
||||||
|
* matchmaker_contact_authorizations 建表迁移 —— 锁定「不存在联系授权机制」。
|
||||||
|
* 契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新,守卫即红。</li>
|
||||||
|
* <li><b>MM-CONTACT-004</b>:无联系授权机制即无授权到期场景 —— 与 MM-CONTACT-003 同源断言
|
||||||
|
* 承载(若未来引入 ContactAuthorization / matchmaker_contact_authorizations,
|
||||||
|
* 003/004 两条守卫同时判红,迫使开发者补「到期 + 审计」语义并过 007 评审)。</li>
|
||||||
|
* <li><b>ZC-MM-004 佐证</b>:matchmaker / referral 包无 @Scheduled —— 全仓无批处理
|
||||||
|
* 代操作面(禁止代替用户决定的自动化通道不存在)。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
class MatchmakerContactGuardTest {
|
||||||
|
|
||||||
|
private static final Path SRC_ROOT = resolveSourceRoot();
|
||||||
|
private static final Path REFERRAL_SERVICE =
|
||||||
|
SRC_ROOT.resolve("com/danyang/match/platform/referral/service/ReferralService.java");
|
||||||
|
private static final List<Path> CONTROLLER_DIRS = List.of(
|
||||||
|
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/controller"),
|
||||||
|
SRC_ROOT.resolve("com/danyang/match/platform/referral/controller"));
|
||||||
|
private static final List<Path> PACKAGE_DIRS = List.of(
|
||||||
|
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker"),
|
||||||
|
SRC_ROOT.resolve("com/danyang/match/platform/referral"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReferralService 响应键白名单(ZC-MM-003 / T01:以实际代码逐键核对后核定):
|
||||||
|
* <ul>
|
||||||
|
* <li>referToServiceStation → appointmentId / status / referralType /
|
||||||
|
* referralFromOperatorId / needsUserConfirmation</li>
|
||||||
|
* <li>referToActivity → registrationId / activityId / status / referralType /
|
||||||
|
* referralFromOperatorId / needsUserConfirmation</li>
|
||||||
|
* <li>confirmReferral → id / status / confirmedAt</li>
|
||||||
|
* <li>getReferralHistory → type / id / serviceType|activityId / status /
|
||||||
|
* createdByOperatorId / userConfirmedAt / createdAt|registeredAt</li>
|
||||||
|
* <li>getReferralHistoryByOperator → 同上 + userId(operator 查看本人发起的
|
||||||
|
* 转介记录需要 userId 标识服务对象;userId 是内部标识符,非联系方式,
|
||||||
|
* 不落入 B1 禁放范畴)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private static final Set<String> RESPONSE_KEY_WHITELIST = Set.of(
|
||||||
|
"appointmentId", "registrationId", "activityId", "id", "status", "type",
|
||||||
|
"serviceType", "referralType", "referralFromOperatorId", "needsUserConfirmation",
|
||||||
|
"confirmedAt", "userConfirmedAt", "createdByOperatorId", "createdAt",
|
||||||
|
"registeredAt", "userId");
|
||||||
|
|
||||||
|
/** 联系方式字样(出现在响应键名中即违规;emergencyContact* 仅允许作为入口 write-only 存在) */
|
||||||
|
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
|
||||||
|
"emergencycontact", "phone", "mobile", "wechat", "weixin", "address",
|
||||||
|
"idnumber", "id_card");
|
||||||
|
|
||||||
|
/** MM-CONTACT-003:联系/触达类禁用端点词 */
|
||||||
|
private static final List<String> FORBIDDEN_ENDPOINT_TOKENS = List.of(
|
||||||
|
"contact", "call", "phone", "channel", "offline");
|
||||||
|
|
||||||
|
// ==================== MM-CONTACT-002:红娘侧零联系方式回显 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-CONTACT-002: ReferralService 全部响应 put 键 ⊆ 白名单,无任何联系方式回显")
|
||||||
|
void referralResponseKeysAreWhitelistedWithNoContactEcho() throws IOException {
|
||||||
|
String src = stripComments(Files.readString(REFERRAL_SERVICE, StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
Set<String> keys = new LinkedHashSet<>();
|
||||||
|
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(src);
|
||||||
|
while (put.find()) {
|
||||||
|
keys.add(put.group(1));
|
||||||
|
}
|
||||||
|
// 防止"装配被搬空后守卫永真":核心语义键必须仍在
|
||||||
|
assertTrue(keys.contains("needsUserConfirmation"),
|
||||||
|
"must-confirm 语义键必须存在(守卫非永真自检)");
|
||||||
|
assertTrue(keys.contains("referralFromOperatorId"),
|
||||||
|
"来源追溯键必须存在(守卫非永真自检)");
|
||||||
|
assertTrue(keys.contains("status"), "状态键必须存在(守卫非永真自检)");
|
||||||
|
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
for (String key : keys) {
|
||||||
|
if (!RESPONSE_KEY_WHITELIST.contains(key)) {
|
||||||
|
violations.add("非白名单响应键: " + key);
|
||||||
|
}
|
||||||
|
String lower = key.toLowerCase();
|
||||||
|
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||||
|
if (lower.contains(token)) {
|
||||||
|
violations.add("响应键含联系方式 token: " + key + "(含 " + token + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(List.of(), violations,
|
||||||
|
"B1 联系规则契约被破坏(MM-CONTACT-002):响应键出现白名单外键或联系方式字样。\n"
|
||||||
|
+ "实际输出键=" + keys + "\n违规项:\n - " + String.join("\n - ", violations));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-CONTACT-003:无联系端点 / 无授权机制 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-CONTACT-003: matchmaker/referral Controller 无 contact 类端点;全仓无 ContactAuthorization 机制")
|
||||||
|
void noContactEndpointsAndNoContactAuthorizationMechanism() throws IOException {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
|
||||||
|
// a) 端点扫描:matchmaker / referral Controller 无联系/触达类路径
|
||||||
|
for (Path dir : CONTROLLER_DIRS) {
|
||||||
|
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
|
||||||
|
try (Stream<Path> files = Files.list(dir)) {
|
||||||
|
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||||
|
String src = Files.readString(f, StandardCharsets.UTF_8);
|
||||||
|
Matcher mapping = Pattern.compile(
|
||||||
|
"@(Get|Post|Put|Delete)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
|
||||||
|
while (mapping.find()) {
|
||||||
|
String args = mapping.group(2).toLowerCase();
|
||||||
|
for (String banned : FORBIDDEN_ENDPOINT_TOKENS) {
|
||||||
|
if (args.contains(banned)) {
|
||||||
|
violations.add(f.getFileName() + " → " + mapping.group(0)
|
||||||
|
.replaceAll("\\s+", " ") + " 含联系类端点: " + banned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// b) 机制扫描:全仓 main 源码无 ContactAuthorization 类;迁移目录无建表
|
||||||
|
violations.addAll(scanForContactAuthorizationMechanism());
|
||||||
|
|
||||||
|
assertEquals(List.of(), violations,
|
||||||
|
"B1 联系规则契约被破坏(MM-CONTACT-003):出现联系类端点或联系授权机制。\n"
|
||||||
|
+ "契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新。\n违规项:\n - "
|
||||||
|
+ String.join("\n - ", violations));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-CONTACT-004:无机制即无到期场景 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-CONTACT-004: 无联系授权机制 ⇒ 不存在授权到期场景(同源断言承载)")
|
||||||
|
void noContactAuthorizationMechanismImpliesNoExpiryScenario() throws IOException {
|
||||||
|
/*
|
||||||
|
* ZC-MM-003(B1)「临时授权到期」场景的守卫:授权机制本身不存在(MM-CONTACT-003),
|
||||||
|
* 因此不存在"到期后权限回收"场景。本用例与 MM-CONTACT-003 共享同一底层断言 ——
|
||||||
|
* 若未来引入 ContactAuthorizationPolicy / matchmaker_contact_authorizations 表,
|
||||||
|
* 两条守卫同时判红,迫使实现者补齐「授权期限 + 到期回收 + 审计」语义,
|
||||||
|
* 并同步 007 治理登记(安全默认 = 不开放完整联系方式 / 未授权线下联系)。
|
||||||
|
*/
|
||||||
|
List<String> violations = scanForContactAuthorizationMechanism();
|
||||||
|
assertEquals(List.of(), violations,
|
||||||
|
"MM-CONTACT-004:联系授权机制出现 ⇒ 必须同步实现到期回收与审计,并过 007 评审。\n违规项:\n - "
|
||||||
|
+ String.join("\n - ", violations));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ZC-MM-004 佐证:无批处理代操作面 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ZC-MM-004 佐证: matchmaker/referral 包无 @Scheduled(无批处理代操作通道)")
|
||||||
|
void noScheduledTasksInMatchmakerAndReferralPackages() throws IOException {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
for (Path dir : PACKAGE_DIRS) {
|
||||||
|
assertTrue(Files.isDirectory(dir), "包目录不存在: " + dir);
|
||||||
|
try (Stream<Path> files = Files.walk(dir)) {
|
||||||
|
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||||
|
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||||
|
if (src.contains("@Scheduled")) {
|
||||||
|
violations.add(f.getFileName() + " 含 @Scheduled 定时任务"
|
||||||
|
+ "(ZC-MM-004:禁止引入代替用户决定/代操作的自动化通道)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(List.of(), violations,
|
||||||
|
"红娘/转介域出现定时任务必须先过「禁止代用户决定」评审(ZC-MM-004)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 解析实现 ====================
|
||||||
|
|
||||||
|
/** 全仓 main 源码 + 迁移脚本扫描 ContactAuthorization 机制(003/004 共享)。 */
|
||||||
|
private List<String> scanForContactAuthorizationMechanism() throws IOException {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
try (Stream<Path> files = Files.walk(SRC_ROOT)) {
|
||||||
|
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||||
|
String name = f.getFileName().toString();
|
||||||
|
if (name.contains("ContactAuthorization")) {
|
||||||
|
violations.add("出现联系授权类(机制被引入): " + name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||||
|
if (src.contains("ContactAuthorization")) {
|
||||||
|
violations.add("源码引用联系授权机制: " + f.getFileName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Path migrationDir : migrationDirCandidates()) {
|
||||||
|
if (!Files.isDirectory(migrationDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try (Stream<Path> files = Files.list(migrationDir)) {
|
||||||
|
for (Path f : files.filter(p -> p.toString().endsWith(".sql")).toList()) {
|
||||||
|
String src = Files.readString(f, StandardCharsets.UTF_8).toLowerCase();
|
||||||
|
if (src.contains("matchmaker_contact_authorizations")) {
|
||||||
|
violations.add("出现 matchmaker_contact_authorizations 建表迁移: " + f.getFileName()
|
||||||
|
+ "(安全默认=不开放;仅当产品确认开放联系功能时随 006 评审引入)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移脚本目录(兼容仓库根与单模块两种布局)。 */
|
||||||
|
private List<Path> migrationDirCandidates() {
|
||||||
|
Path parent = SRC_ROOT.getParent();
|
||||||
|
return List.of(
|
||||||
|
parent != null ? parent.resolve("resources/db/migration") : Path.of("backend/src/main/resources/db/migration"),
|
||||||
|
Path.of("backend/src/main/resources/db/migration"),
|
||||||
|
Path.of("src/main/resources/db/migration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripComments(String src) {
|
||||||
|
return src.replaceAll("//[^\\n]*", "").replaceAll("/\\*.*?\\*/", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 定位源码根目录(与 SecurityPathCoverageTest 同款双候选)。 */
|
||||||
|
private static Path resolveSourceRoot() {
|
||||||
|
for (String candidate : new String[]{"src/main/java", "backend/src/main/java"}) {
|
||||||
|
Path path = Path.of(candidate);
|
||||||
|
if (Files.isDirectory(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"无法定位源码根目录(尝试过 src/main/java 与 backend/src/main/java);当前工作目录="
|
||||||
|
+ Path.of("").toAbsolutePath());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.referral.service.ReferralService;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-005(统一数据范围与字段隐私)验收测试 —— 读侧纯化 + 防探测 404 + 字段白名单 + 组长统计。
|
||||||
|
*
|
||||||
|
* <p>覆盖:
|
||||||
|
* <ul>
|
||||||
|
* <li>MM-PRIVACY-001(负向×3):非持有人读详情 404+审计;查无 serviceId 响应与前者
|
||||||
|
* 逐字段一致(防探测);撤销后原持有人再读 404;</li>
|
||||||
|
* <li>MM-AUTH-001 读侧(正向):持分配红娘读完整详情、无 viewerIsOwner 双态残留;</li>
|
||||||
|
* <li>MM-AUTH-003 读侧(负向):读他人对象 → 404+审计;</li>
|
||||||
|
* <li>MM-LIFECYCLE-001 读侧:end/revoke/expired 后 404;</li>
|
||||||
|
* <li>组长团队统计正例 + 普通红娘 403+审计(负向);</li>
|
||||||
|
* <li>showOccupation 隐私开关:false 隐藏 occupationCode,true/未设置放行。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Transactional
|
||||||
|
class MatchmakerDataScope005Test {
|
||||||
|
|
||||||
|
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||||
|
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||||
|
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||||
|
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||||
|
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||||
|
@Autowired private OperatorMapper operatorMapper;
|
||||||
|
@Autowired private UserMapper userMapper;
|
||||||
|
@Autowired private UserProfileMapper userProfileMapper;
|
||||||
|
@Autowired private UserPrivacySettingsMapper userPrivacySettingsMapper;
|
||||||
|
@Autowired private AuditLogMapper auditLogMapper;
|
||||||
|
@Autowired private MatchmakerAssignmentController assignmentController;
|
||||||
|
@Autowired private ReferralService referralService;
|
||||||
|
|
||||||
|
private String leaderA; // 组长,org X
|
||||||
|
private String mmB; // 红娘,org X
|
||||||
|
private String mmC; // 红娘,org Y
|
||||||
|
private String mmD; // 红娘,org X
|
||||||
|
private String user1;
|
||||||
|
private String user2;
|
||||||
|
private String user3;
|
||||||
|
|
||||||
|
private static final String ORG_X = "33333333-3333-3333-3333-333333333331";
|
||||||
|
private static final String ORG_Y = "33333333-3333-3333-3333-333333333332";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
leaderA = createOperator("组长A-005", ORG_X);
|
||||||
|
mmB = createOperator("红娘B-005", ORG_X);
|
||||||
|
mmC = createOperator("红娘C-005", ORG_Y);
|
||||||
|
mmD = createOperator("红娘D-005", ORG_X);
|
||||||
|
user1 = createUserWithProfile("ds-user1");
|
||||||
|
user2 = createUserWithProfile("ds-user2");
|
||||||
|
user3 = createUserWithProfile("ds-user3");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearSecurityContext() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据构造 ====================
|
||||||
|
|
||||||
|
private String createOperator(String name, String organizationId) {
|
||||||
|
Operator op = new Operator();
|
||||||
|
op.setName(name);
|
||||||
|
op.setOrganizationId(organizationId);
|
||||||
|
op.setUsername("ds_" + name + "_" + System.nanoTime());
|
||||||
|
op.setPasswordHash("test-hash");
|
||||||
|
op.setStatus("active");
|
||||||
|
operatorMapper.insert(op);
|
||||||
|
return op.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createUserWithProfile(String tag) {
|
||||||
|
User u = new User();
|
||||||
|
u.setWechatOpenid("ds_" + tag + "_" + System.nanoTime());
|
||||||
|
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
userMapper.insert(u);
|
||||||
|
|
||||||
|
UserProfile profile = new UserProfile();
|
||||||
|
profile.setUserId(u.getId());
|
||||||
|
profile.setDisplayName(tag);
|
||||||
|
profile.setGender("female");
|
||||||
|
profile.setBirthDate(java.time.LocalDate.of(1995, 6, 15));
|
||||||
|
profile.setRegionName("丹阳市云阳街道");
|
||||||
|
profile.setEducationCode("bachelor");
|
||||||
|
profile.setOccupationCode("teacher");
|
||||||
|
profile.setMaritalStatus("never_married");
|
||||||
|
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||||
|
userProfileMapper.insert(profile);
|
||||||
|
return u.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createService(String userId, String operatorId) {
|
||||||
|
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||||
|
svc.setUserId(userId);
|
||||||
|
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||||
|
svc.setDescription("希望找一位本地稳定的伴侣");
|
||||||
|
// submitted:createAssignment 会联动把服务单置 assigned(A1 同事务联动语义)
|
||||||
|
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||||
|
svc.setAssignedOperatorId(operatorId);
|
||||||
|
serviceMapper.insert(svc);
|
||||||
|
return svc.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台授权路径创建分配(绕过组织范围校验以构造前置状态),返回 assignmentId */
|
||||||
|
private String createAssignment(String operatorId, String userId, String serviceId) {
|
||||||
|
MatchmakerAssignment a = assignmentService.create(leaderA, true, userId, operatorId,
|
||||||
|
serviceId, "005 测试前置分配", null);
|
||||||
|
return a.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void authenticate(String operatorId, String... permissions) {
|
||||||
|
Set<String> permSet = Set.of(permissions);
|
||||||
|
var authorities = Stream.concat(
|
||||||
|
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
|
||||||
|
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
UsernamePasswordAuthenticationToken.authenticated(
|
||||||
|
new OperatorPrincipal(operatorId, permSet), null, authorities));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countAudits(String action, String operatorId) {
|
||||||
|
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||||
|
.eq(AuditLog::getAction, action)
|
||||||
|
.eq(operatorId != null, AuditLog::getOperatorId, operatorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countDeniedAuditsForResource(String operatorId, String resourceId) {
|
||||||
|
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||||
|
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||||
|
.eq(AuditLog::getOperatorId, operatorId)
|
||||||
|
.eq(AuditLog::getResourceId, resourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private BusinessException notFoundCall(String operatorId, String serviceId) {
|
||||||
|
try {
|
||||||
|
matchmakerServiceService.getServiceDetail(serviceId, operatorId);
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
throw new AssertionError("预期抛出 BusinessException(NOT_FOUND),但正常返回了");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-PRIVACY-001(负向×3) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-PRIVACY-001a(负向): 非持有人读服务单 → 404 + DATA_SCOPE_DENIED 审计(语义403)")
|
||||||
|
void mmPrivacy001a_nonHolderReadDetailNotFoundAndAudited() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
createAssignment(mmB, user1, serviceId);
|
||||||
|
|
||||||
|
long before = countDeniedAuditsForResource(mmC, serviceId);
|
||||||
|
BusinessException e = notFoundCall(mmC, serviceId);
|
||||||
|
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "非持有人响应必须是 404(防探测)");
|
||||||
|
assertEquals("服务不存在", e.getMessage());
|
||||||
|
assertEquals(before + 1, countDeniedAuditsForResource(mmC, serviceId),
|
||||||
|
"越权读尝试必须先落 DATA_SCOPE_DENIED 审计(语义 403)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-PRIVACY-001b(负向): 查无 serviceId 的响应与非持有人 404 逐字段一致(防探测)")
|
||||||
|
void mmPrivacy001b_notFoundResponseIndistinguishableFromDenied() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
createAssignment(mmB, user1, serviceId);
|
||||||
|
|
||||||
|
BusinessException denied = notFoundCall(mmC, serviceId); // 非持有人(真实存在)
|
||||||
|
BusinessException missing = notFoundCall(mmC, "no-such-service-id"); // 查无
|
||||||
|
|
||||||
|
// 全局异常处理器把 BusinessException 映射为 ApiResponse.error(code, message),
|
||||||
|
// code+message 一致 ⇒ 响应体逐字段一致(success/code/message),无法区分"无权"与"不存在"
|
||||||
|
assertEquals(denied.getCode(), missing.getCode(), "HTTP/业务码必须一致");
|
||||||
|
assertEquals(denied.getMessage(), missing.getMessage(), "响应 message 必须一致");
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, missing.getCode());
|
||||||
|
assertEquals("服务不存在", missing.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-PRIVACY-001c(负向): 撤销后原持有人再读详情 → 404(E1:无 assigned_operator_id 回退)")
|
||||||
|
void mmPrivacy001c_revokedHolderReadDetailNotFound() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||||
|
|
||||||
|
// 撤销前持有人可读
|
||||||
|
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||||
|
assertNotNull(detail);
|
||||||
|
|
||||||
|
assignmentService.revoke(assignmentId, leaderA, false, "005 撤销验证");
|
||||||
|
|
||||||
|
// E1 核心断言:撤销后服务单仍残留 assigned_operator_id=mmB,但回退分支已删 → 404
|
||||||
|
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
|
||||||
|
"前置确认:服务单 assigned_operator_id 残留(回退分支必须对此免疫)");
|
||||||
|
long before = countDeniedAuditsForResource(mmB, serviceId);
|
||||||
|
BusinessException e = notFoundCall(mmB, serviceId);
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||||
|
assertEquals(before + 1, countDeniedAuditsForResource(mmB, serviceId),
|
||||||
|
"原持有人失权后的读取也必须落 DATA_SCOPE_DENIED 审计");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-AUTH-001 读侧(正向) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-AUTH-001 读侧: 持分配红娘读详情 200 语义、完整字段、无 viewerIsOwner 双态残留")
|
||||||
|
void mmAuth001_readSideHolderGetsFullDetailWithoutDualState() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
createAssignment(mmB, user1, serviceId);
|
||||||
|
|
||||||
|
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||||
|
|
||||||
|
assertNotNull(detail);
|
||||||
|
assertEquals(user1, detail.get("userId"));
|
||||||
|
assertEquals("希望找一位本地稳定的伴侣", detail.get("description"));
|
||||||
|
assertTrue(detail.containsKey("userProfile"), "持有人可见白名单用户资料");
|
||||||
|
assertTrue(detail.containsKey("followups"), "持有人可见跟进正文");
|
||||||
|
assertTrue(detail.containsKey("recommendations"), "持有人可见推荐列表");
|
||||||
|
// E1:双态残留字段必须消失
|
||||||
|
assertFalse(detail.containsKey("viewerIsOwner"), "viewerIsOwner 双态字段已删除");
|
||||||
|
assertFalse(detail.containsKey("followupCount"), "脱敏分支的 followupCount 摘要已随 404 消失");
|
||||||
|
// E3:userProfile 白名单键集合
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> userProfile = (Map<String, Object>) detail.get("userProfile");
|
||||||
|
Set<String> whitelist = Set.of("displayName", "gender", "age", "regionName",
|
||||||
|
"educationCode", "occupationCode", "maritalStatus");
|
||||||
|
assertTrue(whitelist.containsAll(userProfile.keySet()),
|
||||||
|
"userProfile 键必须落在白名单内,实际=" + userProfile.keySet());
|
||||||
|
assertEquals("丹阳市云阳街道", userProfile.get("regionName"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-AUTH-003 读侧(负向) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-AUTH-003 读侧(负向): B 读 D 的服务对象详情 → 404 + 审计")
|
||||||
|
void mmAuth003_readSideCrossHolderAccessDeniedAndAudited() {
|
||||||
|
String serviceId = createService(user3, mmD);
|
||||||
|
createAssignment(mmD, user3, serviceId);
|
||||||
|
|
||||||
|
long before = countDeniedAuditsForResource(mmB, serviceId);
|
||||||
|
BusinessException e = notFoundCall(mmB, serviceId);
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||||
|
assertEquals(before + 1, countDeniedAuditsForResource(mmB, serviceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-LIFECYCLE-001 读侧 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-LIFECYCLE-001 读侧: end 后 404;expires_at 届满惰性置 expired 后 404")
|
||||||
|
void mmLifecycle001_readSideTerminalStatesAllNotFound() {
|
||||||
|
// end 后 404
|
||||||
|
String serviceId1 = createService(user1, mmB);
|
||||||
|
String a1 = createAssignment(mmB, user1, serviceId1);
|
||||||
|
assignmentService.end(a1, leaderA, false, "005 服务完结");
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND,
|
||||||
|
notFoundCall(mmB, serviceId1).getCode(), "end 后读详情必须 404");
|
||||||
|
|
||||||
|
// expires_at 届满(固定时间注入:写入已过期的 expires_at)→ 惰性 expired → 404
|
||||||
|
String serviceId2 = createService(user2, mmB);
|
||||||
|
MatchmakerAssignment a2 = assignmentService.create(leaderA, true, user2, mmB,
|
||||||
|
serviceId2, "短期分配", Instant.now().minusSeconds(3600));
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND,
|
||||||
|
notFoundCall(mmB, serviceId2).getCode(), "已过期分配读详情必须 404");
|
||||||
|
assertEquals(Constants.ASSIGNMENT_STATUS_EXPIRED,
|
||||||
|
assignmentMapper.findById(a2.getId()).orElseThrow().getStatus(),
|
||||||
|
"惰性过期必须落库");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== E1 边界:active 分配 × completed 服务单 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("E1 边界: active 分配 + completed 服务单 → 持有人可读详情(200);分配终态(end)后同单再读→404")
|
||||||
|
void activeAssignmentWithCompletedServiceStillReadableThen404AfterEnd() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||||
|
|
||||||
|
// 推进服务单到 in_service 并完成。completeService 仅改服务单状态、不结束分配
|
||||||
|
// (服务单与分配是两条独立生命周期)
|
||||||
|
MatchmakerServiceRecord svc = serviceMapper.selectById(serviceId);
|
||||||
|
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||||
|
serviceMapper.updateById(svc);
|
||||||
|
matchmakerServiceService.completeService(serviceId, mmB);
|
||||||
|
assertEquals(Constants.MATCHMAKER_STATUS_COMPLETED,
|
||||||
|
serviceMapper.selectById(serviceId).getStatus());
|
||||||
|
|
||||||
|
// 分配仍处于 active → mmB 在该用户数据范围内,读 completed 单应放行(200,非 404)。
|
||||||
|
// 说明:决策"completed 服务单 404"语义实为"分配已终态则 404"(代码 L348 注释"分配已终态"),
|
||||||
|
// 本用例锁定"分配仍 active"时 completed 单仍属本人数据范围、可正常回看。
|
||||||
|
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||||
|
assertNotNull(detail, "active 分配持有人读 completed 服务单应放行(200),不应 404");
|
||||||
|
assertEquals(user1, detail.get("userId"));
|
||||||
|
|
||||||
|
// 对照:分配 end(终态)后,同一 completed 单再读 → 404(E1:防 assigned_operator_id 回退)
|
||||||
|
assignmentService.end(assignmentId, leaderA, false, "005 边界:服务完结后结束分配");
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, notFoundCall(mmB, serviceId).getCode(),
|
||||||
|
"分配终态后读 completed 服务单详情必须 404");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 组长团队统计(T03) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("组长统计: 以组长 org 为边界(跨组织不计入);NULL 组长统计平台直属")
|
||||||
|
void teamStatisticsScopedByLeaderOrganization() {
|
||||||
|
createAssignment(mmB, user1, null);
|
||||||
|
createAssignment(mmD, user3, null);
|
||||||
|
createAssignment(mmC, user2, null); // org Y,不应计入 org X 组长的统计
|
||||||
|
|
||||||
|
Map<String, Object> stats = assignmentService.teamStatistics(leaderA);
|
||||||
|
|
||||||
|
assertEquals(ORG_X, stats.get("organizationId"));
|
||||||
|
assertEquals(2L, stats.get("activeAssignmentCount"), "跨组织分配不得计入");
|
||||||
|
assertEquals(2L, stats.get("activeMatchmakerCount"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> perMatchmaker = (List<Map<String, Object>>) stats.get("perMatchmakerCounts");
|
||||||
|
assertEquals(2, perMatchmaker.size());
|
||||||
|
assertTrue(perMatchmaker.stream().allMatch(row ->
|
||||||
|
Set.of("operatorId", "operatorName", "activeCount").equals(row.keySet())
|
||||||
|
|| row.keySet().containsAll(Set.of("operatorId", "operatorName", "activeCount"))),
|
||||||
|
"分组行键必须为 operatorId/operatorName/activeCount(脱敏,无联系方式),实际=" + perMatchmaker);
|
||||||
|
|
||||||
|
// NULL 组长 → 平台直属边界(IS NOT DISTINCT FROM NULL)
|
||||||
|
String platformLeader = createOperator("平台直属组长-005", null);
|
||||||
|
String platformMm = createOperator("平台直属红娘-005", null);
|
||||||
|
String platformUser = createUserWithProfile("ds-platform-user");
|
||||||
|
assignmentService.create(platformLeader, true, platformUser, platformMm, null,
|
||||||
|
"平台直属分配", null);
|
||||||
|
Map<String, Object> platformStats = assignmentService.teamStatistics(platformLeader);
|
||||||
|
assertNull(platformStats.get("organizationId"));
|
||||||
|
assertEquals(1L, platformStats.get("activeAssignmentCount"),
|
||||||
|
"NULL 组长统计平台直属(IS NOT DISTINCT FROM NULL)");
|
||||||
|
// 该平台直属分配不得计入 orgX 组长的统计
|
||||||
|
assertEquals(2L, assignmentService.teamStatistics(leaderA).get("activeAssignmentCount"),
|
||||||
|
"平台直属分配不得计入有组织组长的统计");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("负向: 普通红娘(无 team.assign)调 team/statistics → 403 + AUTHORIZATION_DENIED 审计")
|
||||||
|
void normalMatchmakerCannotReadTeamStatistics() {
|
||||||
|
// 必须走 Spring 注入的代理 Bean(@PreAuthorize 方法级安全在代理上生效)
|
||||||
|
authenticate(mmB, "matchmaker.view", "matchmaker.service");
|
||||||
|
|
||||||
|
long before = countAudits("AUTHORIZATION_DENIED", mmB);
|
||||||
|
assertThrows(AccessDeniedException.class,
|
||||||
|
() -> assignmentController.teamStatistics(
|
||||||
|
new OperatorPrincipal(mmB, Set.of("matchmaker.view", "matchmaker.service"))),
|
||||||
|
"team/statistics 仅组长(team.assign)可读");
|
||||||
|
assertEquals(before + 1, countAudits("AUTHORIZATION_DENIED", mmB),
|
||||||
|
"授权拒绝必须落 AUTHORIZATION_DENIED 审计");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== showOccupation 隐私开关(E3) ====================
|
||||||
|
|
||||||
|
// ==================== P1-1:推荐列表切判定器(读路径) ====================
|
||||||
|
|
||||||
|
private MatchmakerRecommendation createRecommendation(String serviceId, String userId,
|
||||||
|
String targetUserId, String operatorId) {
|
||||||
|
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||||
|
rec.setId(UUID.randomUUID().toString());
|
||||||
|
rec.setServiceId(serviceId);
|
||||||
|
rec.setUserId(userId);
|
||||||
|
rec.setTargetUserId(targetUserId);
|
||||||
|
rec.setOperatorId(operatorId);
|
||||||
|
rec.setReason("005 收口测试推荐");
|
||||||
|
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
|
||||||
|
recommendationMapper.insert(rec);
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("P1-1: 推荐列表按生效分配过滤 —— revoke 后不再可见 assigned_operator_id 残留列放行的推荐")
|
||||||
|
void recommendationsFollowActiveAssignmentsNotResidualColumn() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||||
|
MatchmakerRecommendation rec = createRecommendation(serviceId, user1, user2, mmB);
|
||||||
|
|
||||||
|
// 正向:active 分配持有人可见本人分配对象名下服务的推荐
|
||||||
|
List<Map<String, Object>> visible = matchmakerServiceService.getRecommendationsByOperator(mmB);
|
||||||
|
assertEquals(1, visible.size(), "active 分配持有人应看到该推荐");
|
||||||
|
assertEquals(rec.getId(), visible.get(0).get("id"));
|
||||||
|
|
||||||
|
// 横向:无分配的红娘不可见
|
||||||
|
assertTrue(matchmakerServiceService.getRecommendationsByOperator(mmC).isEmpty(),
|
||||||
|
"无生效分配的红娘不得看到他人对象的推荐");
|
||||||
|
|
||||||
|
// 负向:revoke 后服务单残留 assigned_operator_id=mmB(前置确认),推荐列表必须不再含该条
|
||||||
|
assignmentService.revoke(assignmentId, leaderA, false, "005 P1-1 撤销验证");
|
||||||
|
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
|
||||||
|
"前置确认:残留列仍在(回退式过滤必须对此免疫)");
|
||||||
|
assertTrue(matchmakerServiceService.getRecommendationsByOperator(mmB).isEmpty(),
|
||||||
|
"撤销后推荐列表必须不再包含已失联对象的推荐");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== P1-2:dashboard 三计数切判定器(读路径) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("P1-2: dashboard inProgress/today/completed 按生效分配口径 —— end 后全部归零(他人接手的单不再计入)")
|
||||||
|
void dashboardCountsFollowActiveAssignments() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||||
|
|
||||||
|
Map<String, Object> d0 = matchmakerServiceService.getDashboard(mmB);
|
||||||
|
assertEquals(1L, d0.get("todayCount"), "分配创建时服务单 assigned_at=今天 → today=1");
|
||||||
|
assertEquals(0L, d0.get("inProgressCount"));
|
||||||
|
assertEquals(0L, d0.get("completedCount"));
|
||||||
|
|
||||||
|
// 服务单推进到 in_service → 进行中 +1
|
||||||
|
MatchmakerServiceRecord svc = serviceMapper.selectById(serviceId);
|
||||||
|
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||||
|
serviceMapper.updateById(svc);
|
||||||
|
assertEquals(1L, matchmakerServiceService.getDashboard(mmB).get("inProgressCount"));
|
||||||
|
|
||||||
|
// 完成 → inProgress 归零、completed +1
|
||||||
|
matchmakerServiceService.completeService(serviceId, mmB);
|
||||||
|
Map<String, Object> d1 = matchmakerServiceService.getDashboard(mmB);
|
||||||
|
assertEquals(0L, d1.get("inProgressCount"));
|
||||||
|
assertEquals(1L, d1.get("completedCount"), "分配仍生效时历史 completed 计入本人");
|
||||||
|
|
||||||
|
// 分配 end(终态)→ 三项计数全部归零(DEC-002 规则7:服务结束失去访问权)
|
||||||
|
assignmentService.end(assignmentId, leaderA, false, "005 P1-2 结束验证");
|
||||||
|
Map<String, Object> d2 = matchmakerServiceService.getDashboard(mmB);
|
||||||
|
assertEquals(0L, d2.get("inProgressCount"));
|
||||||
|
assertEquals(0L, d2.get("todayCount"), "分配 end 后该用户服务单不再计入本人今日数");
|
||||||
|
assertEquals(0L, d2.get("completedCount"), "分配 end 后历史 completed 不再计入本人");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== P1-3:转介授权切判定器(写路径) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("P1-3: 转介授权走判定器 —— 有效分配内正常转介(正向);revoke/无分配 → 404 化拒绝 + DATA_SCOPE_DENIED 审计(负向)")
|
||||||
|
void referralAuthorizationFollowsActiveAssignments() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
String assignmentId = createAssignment(mmB, user1, serviceId);
|
||||||
|
|
||||||
|
// 正向:有效分配内红娘可转介
|
||||||
|
Map<String, Object> ok = referralService.referToActivity(mmB, user1, "act-005-p13", "紧急联系人", "enc");
|
||||||
|
assertNotNull(ok.get("registrationId"), "有效分配内转介必须成功落库");
|
||||||
|
|
||||||
|
// 负向①:无任何分配的用户 → 404 化拒绝(T04 防探测)+ 审计
|
||||||
|
long before = countDeniedAuditsForResource(mmB, user2);
|
||||||
|
BusinessException denied1 = assertThrows(BusinessException.class,
|
||||||
|
() -> referralService.referToActivity(mmB, user2, "act-005-p14", "紧急联系人", "enc"),
|
||||||
|
"无生效分配的红娘不得对任意用户发起转介");
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, denied1.getCode(), "拒绝必须 404 化(防探测)");
|
||||||
|
assertEquals("服务不存在", denied1.getMessage());
|
||||||
|
assertEquals(before + 1, countDeniedAuditsForResource(mmB, user2),
|
||||||
|
"越权转介必须先落 DATA_SCOPE_DENIED 审计");
|
||||||
|
|
||||||
|
// 负向②:revoke 后原对象转介 → 404 化拒绝 + 审计(残留 assigned_operator_id 不再构成转介凭据)
|
||||||
|
assignmentService.revoke(assignmentId, leaderA, false, "005 P1-3 撤销验证");
|
||||||
|
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
|
||||||
|
"前置确认:服务单残留 assigned_operator_id");
|
||||||
|
long beforeRevoked = countDeniedAuditsForResource(mmB, user1);
|
||||||
|
BusinessException denied2 = assertThrows(BusinessException.class,
|
||||||
|
() -> referralService.referToServiceStation(mmB, user1, "loc-005-p13",
|
||||||
|
"counselor_consultation", null, "备注"),
|
||||||
|
"撤销分配后红娘对原对象发起服务站转介必须拒绝");
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, denied2.getCode());
|
||||||
|
assertEquals(beforeRevoked + 1, countDeniedAuditsForResource(mmB, user1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("E3 showOccupation: false→无 occupationCode;true/未设置→有(缺省全放行)")
|
||||||
|
void showOccupationPrivacySwitch() {
|
||||||
|
String serviceId = createService(user1, mmB);
|
||||||
|
createAssignment(mmB, user1, serviceId);
|
||||||
|
|
||||||
|
// 未设置(无隐私记录)→ 放行
|
||||||
|
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> profileDefault = (Map<String, Object>) detail.get("userProfile");
|
||||||
|
assertTrue(profileDefault.containsKey("occupationCode"), "未设置隐私记录时缺省放行");
|
||||||
|
|
||||||
|
// false → 隐藏 occupationCode,其余白名单字段不受影响
|
||||||
|
UserPrivacySettings privacyOff = new UserPrivacySettings();
|
||||||
|
privacyOff.setUserId(user1);
|
||||||
|
privacyOff.setShowOccupation(false);
|
||||||
|
userPrivacySettingsMapper.insert(privacyOff);
|
||||||
|
Map<String, Object> detailOff = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> profileOff = (Map<String, Object>) detailOff.get("userProfile");
|
||||||
|
assertFalse(profileOff.containsKey("occupationCode"), "showOccupation=false 必须隐藏职业");
|
||||||
|
assertTrue(profileOff.containsKey("displayName") && profileOff.containsKey("educationCode"),
|
||||||
|
"其他白名单字段不受影响");
|
||||||
|
|
||||||
|
// true → 放行
|
||||||
|
privacyOff.setShowOccupation(true);
|
||||||
|
userPrivacySettingsMapper.updateById(privacyOff);
|
||||||
|
Map<String, Object> detailOn = matchmakerServiceService.getServiceDetail(serviceId, mmB);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> profileOn = (Map<String, Object>) detailOn.get("userProfile");
|
||||||
|
assertTrue(profileOn.containsKey("occupationCode"), "showOccupation=true 放行");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-005 / E3+MM-PRIVACY-002 字段级隐私守卫(standalone 源码扫描,无需 Spring 上下文)。
|
||||||
|
*
|
||||||
|
* <p>锁定两条不变量:
|
||||||
|
* <ol>
|
||||||
|
* <li>红娘侧 userProfile 装配(MatchmakerServiceService#assembleMatchmakerUserProfile)
|
||||||
|
* 的输出键集合 ⊆ §5.2 白名单 {displayName, gender, age, regionName, educationCode,
|
||||||
|
* occupationCode, maritalStatus};方法体内不得出现 phone/mobile/wechat/idNumber/
|
||||||
|
* birthDate/address/id_card 等禁放字段的 getter 调用(birthDate 仅允许作为
|
||||||
|
* calculateAge 的输入存在,绝不能成为输出键);</li>
|
||||||
|
* <li>MM-PRIVACY-002:matchmaker / referral 运营端 Controller 不存在
|
||||||
|
* export / download / batch 类端点(锁定"不存在"——导出能力引入时守卫即红)。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
class MatchmakerFieldPrivacyGuardTest {
|
||||||
|
|
||||||
|
private static final Path SERVICE_SOURCE =
|
||||||
|
Paths.get("backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java");
|
||||||
|
private static final Path CONTROLLER_DIRS = Paths.get("backend/src/main/java/com/danyang/match/platform");
|
||||||
|
|
||||||
|
/** §5.2 红娘侧用户资料白名单(与 assembleMatchmakerUserProfile 一一对照) */
|
||||||
|
private static final Set<String> WHITELIST = Set.of(
|
||||||
|
"displayName", "gender", "age", "regionName", "educationCode", "occupationCode", "maritalStatus");
|
||||||
|
|
||||||
|
/** §5.2 禁放字段(作为输出键或 getter 调用出现即违规) */
|
||||||
|
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
|
||||||
|
"phone", "mobile", "wechat", "idNumber", "birthDate", "address", "id_card");
|
||||||
|
|
||||||
|
private static final List<String> FORBIDDEN_GETTERS = List.of(
|
||||||
|
"getPhone", "getMobile", "getWechat", "getIdNumber", "getAddress", "getIdCard");
|
||||||
|
|
||||||
|
// ==================== E3:userProfile 白名单 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("E3 守卫:userProfile 装配键 ⊆ §5.2 白名单,禁放字段不得出现")
|
||||||
|
void userProfileAssemblyKeysAreWhitelisted() throws IOException {
|
||||||
|
String body = extractMethodBody("assembleMatchmakerUserProfile");
|
||||||
|
assertTrue(body != null && !body.isBlank(), "未能从源码提取 assembleMatchmakerUserProfile 方法体");
|
||||||
|
|
||||||
|
// 输出键白名单
|
||||||
|
Set<String> keys = new LinkedHashSet<>();
|
||||||
|
Matcher put = Pattern.compile("userProfile\\.put\\(\"([^\"]+)\"").matcher(body);
|
||||||
|
while (put.find()) {
|
||||||
|
keys.add(put.group(1));
|
||||||
|
}
|
||||||
|
assertFalse(keys.isEmpty(), "装配方法必须显式输出字段(防止装配被搬空后守卫永真)");
|
||||||
|
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
for (String key : keys) {
|
||||||
|
if (!WHITELIST.contains(key)) {
|
||||||
|
violations.add("非白名单输出键: " + key);
|
||||||
|
}
|
||||||
|
String lower = key.toLowerCase();
|
||||||
|
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||||
|
if (lower.contains(token.toLowerCase())) {
|
||||||
|
violations.add("输出键含禁放字段 token: " + key + "(含 " + token + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 禁放字段的 getter 调用(birthDate 仅允许经 calculateAge 派生,无独立 getter 白名单问题)
|
||||||
|
for (String getter : FORBIDDEN_GETTERS) {
|
||||||
|
if (body.contains(getter + "(")) {
|
||||||
|
violations.add("装配方法调用了禁放字段 getter: " + getter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(List.of(), violations,
|
||||||
|
"userProfile 字段级隐私契约被破坏(§5.2):\n - " + String.join("\n - ", violations)
|
||||||
|
+ "\n实际输出键=" + keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("守卫自检:装配方法必须包含 showOccupation 开关逻辑(防装配被改回无隐私开关)")
|
||||||
|
void assemblyMustKeepOccupationPrivacySwitch() throws IOException {
|
||||||
|
String body = extractMethodBody("assembleMatchmakerUserProfile");
|
||||||
|
assertTrue(body.contains("showOccupation"),
|
||||||
|
"装配方法必须读取隐私开关 showOccupation(E3)");
|
||||||
|
assertTrue(body.contains("Boolean.FALSE.equals"),
|
||||||
|
"缺省=全放行的语义必须保留(仅显式 false 隐藏)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MM-PRIVACY-002:无导出端点 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MM-PRIVACY-002: matchmaker/referral Controller 无 export/download/batch 端点")
|
||||||
|
void noExportDownloadBatchEndpoints() throws IOException {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
List<Path> controllerDirs = List.of(
|
||||||
|
CONTROLLER_DIRS.resolve("matchmaker/controller"),
|
||||||
|
CONTROLLER_DIRS.resolve("referral/controller"));
|
||||||
|
for (Path dir : controllerDirs) {
|
||||||
|
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
|
||||||
|
try (Stream<Path> files = Files.list(dir)) {
|
||||||
|
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||||
|
String src = Files.readString(f, StandardCharsets.UTF_8);
|
||||||
|
Matcher mapping = Pattern.compile(
|
||||||
|
"@(Get|Post|Put|Delete)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
|
||||||
|
while (mapping.find()) {
|
||||||
|
String args = mapping.group(2).toLowerCase();
|
||||||
|
for (String banned : List.of("export", "download", "batch")) {
|
||||||
|
if (args.contains(banned)) {
|
||||||
|
violations.add(f.getFileName() + " → " + mapping.group(0)
|
||||||
|
.replaceAll("\\s+", " ") + " 含禁用能力: " + banned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(List.of(), violations,
|
||||||
|
"红娘/转介域引入导出/批量能力必须先过隐私评审(§5.2 + MM-PRIVACY-002)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 解析实现 ====================
|
||||||
|
|
||||||
|
/** 按花括号配对提取指定方法的方法体(去注释后匹配)。 */
|
||||||
|
private String extractMethodBody(String methodName) throws IOException {
|
||||||
|
String src = Files.readString(SERVICE_SOURCE, StandardCharsets.UTF_8)
|
||||||
|
.replaceAll("//[^\\n]*", "")
|
||||||
|
.replaceAll("/\\*.*?\\*/", "");
|
||||||
|
int sig = src.indexOf(methodName);
|
||||||
|
if (sig < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int open = src.indexOf('{', sig);
|
||||||
|
if (open < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = open; i < src.length(); i++) {
|
||||||
|
char ch = src.charAt(i);
|
||||||
|
if (ch == '{') depth++;
|
||||||
|
else if (ch == '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {
|
||||||
|
return src.substring(open + 1, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ class MatchmakerReferralPhase13Test {
|
|||||||
@Autowired private com.danyang.match.platform.verification.mapper.UserIdentityMapper userIdentityMapper;
|
@Autowired private com.danyang.match.platform.verification.mapper.UserIdentityMapper userIdentityMapper;
|
||||||
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
|
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
|
||||||
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||||
|
@Autowired private com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper matchmakerAssignmentMapper;
|
||||||
@Autowired private MatchmakerService matchmakerService;
|
@Autowired private MatchmakerService matchmakerService;
|
||||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||||
@Autowired private ReferralService referralService;
|
@Autowired private ReferralService referralService;
|
||||||
@@ -73,6 +74,19 @@ class MatchmakerReferralPhase13Test {
|
|||||||
target = createUser("mm-target");
|
target = createUser("mm-target");
|
||||||
// 一条归属 MATCHMAKER_OWNER 的红娘服务
|
// 一条归属 MATCHMAKER_OWNER 的红娘服务
|
||||||
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
|
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||||
|
// ZC-MM-005(E1):归属判定切纯判定器(无 assigned_operator_id 回退)后,
|
||||||
|
// 服务层归属断言只认 matchmaker_assignments —— 前置补一条生效分配
|
||||||
|
com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment assignment =
|
||||||
|
new com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment();
|
||||||
|
assignment.setUserId(user);
|
||||||
|
assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER);
|
||||||
|
assignment.setServiceId(serviceId);
|
||||||
|
assignment.setAssignedBy(MATCHMAKER_OWNER);
|
||||||
|
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
|
||||||
|
assignment.setAssignmentReason("Phase13 前置分配");
|
||||||
|
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
|
||||||
|
assignment.setAssignedAt(java.time.Instant.now());
|
||||||
|
matchmakerAssignmentMapper.insert(assignment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -289,24 +303,29 @@ class MatchmakerReferralPhase13Test {
|
|||||||
@DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)")
|
@DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)")
|
||||||
void otherMatchmakerCannotReferUser() {
|
void otherMatchmakerCannotReferUser() {
|
||||||
// MATCHMAKER_OTHER 与该用户之间没有任何服务关系
|
// MATCHMAKER_OTHER 与该用户之间没有任何服务关系
|
||||||
assertThrows(IllegalStateException.class,
|
// ZC-MM-005 收口(P1-3):转介授权切换为 ServiceAssignmentPolicy 判定器;
|
||||||
|
// ZC-MM-006 / T04:转介为资源寻址写端点,拒绝 404 化(先 DATA_SCOPE_DENIED 审计,
|
||||||
|
// 再抛 404「服务不存在」,与"查无"逐字段一致,防探测)
|
||||||
|
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
() -> referralService.referToActivity(
|
() -> referralService.referToActivity(
|
||||||
MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"),
|
MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"),
|
||||||
"非该用户服务负责人的红娘不得发起转介");
|
"非该用户服务负责人的红娘不得发起转介(404 化拒绝)");
|
||||||
|
|
||||||
assertThrows(IllegalStateException.class,
|
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
() -> referralService.referToServiceStation(
|
() -> referralService.referToServiceStation(
|
||||||
MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null),
|
MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null),
|
||||||
"非该用户服务负责人的红娘不得发起服务站转介");
|
"非该用户服务负责人的红娘不得发起服务站转介(404 化拒绝)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)")
|
@DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)")
|
||||||
void otherMatchmakerCannotRecommend() {
|
void otherMatchmakerCannotRecommend() {
|
||||||
assertThrows(IllegalStateException.class,
|
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy;
|
||||||
|
// ZC-MM-006 / T04:写接口拒绝 404 化(DATA_SCOPE_DENIED 审计 + BusinessException 404)
|
||||||
|
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
() -> matchmakerServiceService.recommendPartner(
|
() -> matchmakerServiceService.recommendPartner(
|
||||||
serviceId, MATCHMAKER_OTHER, target, "越权推荐"),
|
serviceId, MATCHMAKER_OTHER, target, "越权推荐"),
|
||||||
"非服务负责人不得添加推荐");
|
"非服务负责人不得添加推荐(404 化拒绝)");
|
||||||
|
|
||||||
assertTrue(recommendationMapper.findByService(serviceId).isEmpty(),
|
assertTrue(recommendationMapper.findByService(serviceId).isEmpty(),
|
||||||
"越权推荐失败后不得留下任何推荐记录");
|
"越权推荐失败后不得留下任何推荐记录");
|
||||||
@@ -337,21 +356,15 @@ class MatchmakerReferralPhase13Test {
|
|||||||
// ==================== 计划项 3:红娘只读摘要,不读敏感正文 ====================
|
// ==================== 计划项 3:红娘只读摘要,不读敏感正文 ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("计划项3/D4: 非负责人的 operator 读详情只得到脱敏视图(无 userProfile / 无 followup 正文)")
|
@DisplayName("计划项3/D4+ZC-MM-005 E2: 非持有人读详情 → 防探测 404(与「查无」一致,无脱敏双态)")
|
||||||
void nonOwnerSeesSanitizedDetail() {
|
void nonOwnerReadsDetailIsRejectedAsNotFound() {
|
||||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER);
|
// ZC-MM-005(E1+E2):002 的"非负责人脱敏详情"双态已删除;
|
||||||
|
// 非持有人读任意 serviceId 得到与"查无"逐字段一致的 404,防枚举探测。
|
||||||
assertNotNull(detail);
|
// 读侧不再走 403(写接口维持 403,见 assertServiceAccess 口径)。
|
||||||
assertEquals(Boolean.FALSE, detail.get("viewerIsOwner"),
|
org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
"非负责人必须被标记为非 owner");
|
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
|
||||||
assertFalse(detail.containsKey("userProfile"),
|
() -> matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER),
|
||||||
"INV-013 同类口径:非负责人不得读取服务对象的用户资料");
|
"非持有人读详情必须被拒(防探测 404)");
|
||||||
assertFalse(detail.containsKey("followups"),
|
|
||||||
"非负责人不得读取跟进正文");
|
|
||||||
assertFalse(detail.containsKey("userId"),
|
|
||||||
"非负责人不需要知道申请人 userId");
|
|
||||||
assertEquals(0, ((Number) detail.getOrDefault("followupCount", 0)).intValue(),
|
|
||||||
"非负责人只能拿到跟进条数摘要");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -360,7 +373,8 @@ class MatchmakerReferralPhase13Test {
|
|||||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER);
|
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER);
|
||||||
|
|
||||||
assertNotNull(detail);
|
assertNotNull(detail);
|
||||||
assertEquals(Boolean.TRUE, detail.get("viewerIsOwner"));
|
// ZC-MM-005(E1):viewerIsOwner 双态字段已删除,持有人语义由"能拿到完整视图"表达
|
||||||
|
assertFalse(detail.containsKey("viewerIsOwner"), "双态字段已随读侧纯化删除");
|
||||||
assertTrue(detail.containsKey("userId"), "负责人应能看到申请人 userId");
|
assertTrue(detail.containsKey("userId"), "负责人应能看到申请人 userId");
|
||||||
assertTrue(detail.containsKey("followups"), "负责人应能看到跟进记录");
|
assertTrue(detail.containsKey("followups"), "负责人应能看到跟进记录");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T03 验收:SensitiveAccessLog 接入红娘服务对象详情。
|
||||||
|
*
|
||||||
|
* <p>语义(AGENTS.md #8:敏感数据默认拒绝,查看动作本身必须可溯源):
|
||||||
|
* <ul>
|
||||||
|
* <li>持有人读 GET /operator/matchmaker/services/{id}(getServiceDetail)→
|
||||||
|
* sensitive_access_logs 落 success 行(dataType=matchmaker_service_detail);</li>
|
||||||
|
* <li>拒绝路径维持 audit_logs DATA_SCOPE_DENIED(判定器写入),<b>不重复写</b>
|
||||||
|
* SensitiveAccessLog —— 双记录互斥;</li>
|
||||||
|
* <li>/my 与 /team/statistics 不接入敏感访问日志(无服务对象敏感明细)。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>覆盖:正例 success 行;负例(非持有人)无 SensitiveAccessLog 行且 audit_logs 有
|
||||||
|
* DATA_SCOPE_DENIED;连读 N 次落 N 行(幂等逐次记录,不聚合去重)。
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Transactional
|
||||||
|
class MatchmakerSensitiveAccessAuditTest {
|
||||||
|
|
||||||
|
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||||
|
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||||
|
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||||
|
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||||
|
@Autowired private OperatorMapper operatorMapper;
|
||||||
|
@Autowired private UserMapper userMapper;
|
||||||
|
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||||
|
@Autowired private AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
|
private String holderId; // 分配持有人(红娘 B)
|
||||||
|
private String outsiderId; // 无分配红娘 C
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
holderId = createOperator("敏感审计持有人");
|
||||||
|
outsiderId = createOperator("敏感审计外人");
|
||||||
|
userId = createUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据构造(Phase13 范式) ====================
|
||||||
|
|
||||||
|
private String createOperator(String name) {
|
||||||
|
Operator op = new Operator();
|
||||||
|
op.setName(name);
|
||||||
|
op.setUsername("sa_" + name + "_" + System.nanoTime());
|
||||||
|
op.setPasswordHash("test-hash");
|
||||||
|
op.setStatus("active");
|
||||||
|
operatorMapper.insert(op);
|
||||||
|
return op.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createUser() {
|
||||||
|
User u = new User();
|
||||||
|
u.setWechatOpenid("sa_user_" + System.nanoTime());
|
||||||
|
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
userMapper.insert(u);
|
||||||
|
return u.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createServiceWithAssignment() {
|
||||||
|
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||||
|
svc.setUserId(userId);
|
||||||
|
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||||
|
svc.setDescription("敏感访问审计测试服务单");
|
||||||
|
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||||
|
svc.setAssignedOperatorId(holderId);
|
||||||
|
serviceMapper.insert(svc);
|
||||||
|
assignmentService.create(holderId, true, userId, holderId, svc.getId(),
|
||||||
|
"T03 前置分配", null);
|
||||||
|
return svc.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SensitiveAccessLog> accessRows(String operatorId, String serviceId) {
|
||||||
|
return sensitiveAccessLogMapper.selectList(new LambdaQueryWrapper<SensitiveAccessLog>()
|
||||||
|
.eq(SensitiveAccessLog::getOperatorId, operatorId)
|
||||||
|
.eq(SensitiveAccessLog::getDataType, Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL)
|
||||||
|
.eq(SensitiveAccessLog::getAccessTarget, serviceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 正例:持有人读详情落 success 行 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T03 正例: 持有人读服务详情 → sensitive_access_logs 落 success 行(dataType/target/reason 齐全)")
|
||||||
|
void holderReadDetailWritesSensitiveAccessSuccessRow() {
|
||||||
|
String serviceId = createServiceWithAssignment();
|
||||||
|
|
||||||
|
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||||
|
assertNotNull(detail);
|
||||||
|
|
||||||
|
List<SensitiveAccessLog> rows = accessRows(holderId, serviceId);
|
||||||
|
assertEquals(1, rows.size(), "持有人读详情必须落恰好 1 行敏感访问日志");
|
||||||
|
SensitiveAccessLog row = rows.get(0);
|
||||||
|
assertEquals(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL, row.getDataType());
|
||||||
|
assertEquals(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS, row.getAccessResult());
|
||||||
|
assertEquals(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL, row.getReason());
|
||||||
|
assertEquals(userId, row.getUserId());
|
||||||
|
assertEquals(serviceId, row.getAccessTarget());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 负例:非持有人读 → 双记录互斥 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T03 负例: 非持有人读详情 404 → 无 SensitiveAccessLog 行,audit_logs 有 DATA_SCOPE_DENIED")
|
||||||
|
void outsiderReadDetailWritesNoSensitiveAccessRowButDeniedAudit() {
|
||||||
|
String serviceId = createServiceWithAssignment();
|
||||||
|
|
||||||
|
BusinessException e = assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.getServiceDetail(serviceId, outsiderId),
|
||||||
|
"非持有人读详情必须 404(防探测口径)");
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||||
|
|
||||||
|
// 双记录互斥:拒绝路径只写 audit_logs,不写 sensitive_access_logs
|
||||||
|
assertTrue(accessRows(outsiderId, serviceId).isEmpty(),
|
||||||
|
"拒绝路径不得写 SensitiveAccessLog(与 DATA_SCOPE_DENIED 互斥)");
|
||||||
|
Long deniedAudits = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||||
|
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||||
|
.eq(AuditLog::getOperatorId, outsiderId)
|
||||||
|
.eq(AuditLog::getResourceId, serviceId));
|
||||||
|
assertEquals(1L, deniedAudits, "拒绝路径必须落 DATA_SCOPE_DENIED 审计");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 幂等逐次记录:连读 N 次落 N 行 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T03 幂等: 持有人连读 3 次 → 落 3 行(逐次记录,不聚合去重)")
|
||||||
|
void repeatedReadsWriteRowPerAccess() {
|
||||||
|
String serviceId = createServiceWithAssignment();
|
||||||
|
|
||||||
|
matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||||
|
matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||||
|
matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||||
|
|
||||||
|
assertEquals(3, accessRows(holderId, serviceId).size(),
|
||||||
|
"每次查看必须逐次记录(可溯源,不允许聚合去重)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 边界:查无 serviceId 不写敏感日志 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T03 边界: 查无 serviceId → 404,不落任何敏感访问行")
|
||||||
|
void missingServiceWritesNoSensitiveAccessRow() {
|
||||||
|
assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.getServiceDetail("sa-no-such-service", holderId));
|
||||||
|
assertTrue(accessRows(holderId, "sa-no-such-service").isEmpty(),
|
||||||
|
"查无资源不构成敏感访问,不得落行");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,4 +185,29 @@ class MatchmakerServiceTest {
|
|||||||
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_OPERATOR_ASSISTED), whitelist.toString())
|
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_OPERATOR_ASSISTED), whitelist.toString())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ZC-MM-002 守卫:assignment 状态常量必须落在 chk_mm_assignment_status 白名单内")
|
||||||
|
void assignmentStatusConstantsAreWithinProductionWhitelist() {
|
||||||
|
Set<String> whitelist = resolveCheckValues("chk_mm_assignment_status");
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_ACTIVE), whitelist.toString()),
|
||||||
|
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_ENDED), whitelist.toString()),
|
||||||
|
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_REVOKED), whitelist.toString()),
|
||||||
|
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_TRANSFERRED), whitelist.toString()),
|
||||||
|
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_EXPIRED), whitelist.toString())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ZC-MM-002 守卫:assignment scope 常量必须落在 chk_mm_assignment_scope 白名单内")
|
||||||
|
void assignmentScopeConstantsAreWithinProductionWhitelist() {
|
||||||
|
Set<String> whitelist = resolveCheckValues("chk_mm_assignment_scope");
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_SCOPE_PRIMARY), whitelist.toString()),
|
||||||
|
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_SCOPE_COLLABORATING), whitelist.toString())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
||||||
|
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||||
|
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.referral.service.ReferralService;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZC-MM-006 / T04 验收:写接口 404 统一(防探测)。
|
||||||
|
*
|
||||||
|
* <p>语义:带资源寻址的写端点(transfer / revoke / end / recommend / followup / complete /
|
||||||
|
* 转介)范围校验失败时,先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」——
|
||||||
|
* 响应与"资源不存在"(读详情 404)逐字段一致,调用方无法区分"无权"与"不存在",
|
||||||
|
* 杜绝按资源 ID 枚举探测写端点。create / assign 类无资源寻址端点维持 403(不在本测试面)。
|
||||||
|
*
|
||||||
|
* <p>覆盖:负向×5(跨组织 transfer / 无分配 recommend / followup / complete / 非负责人转介)
|
||||||
|
* + 正向×2(持有人 recommend / followup 正常)+ 五个拒绝响应与读详情 404 逐字段一致。
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Transactional
|
||||||
|
class MatchmakerWriteDenyUniformityTest {
|
||||||
|
|
||||||
|
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||||
|
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||||
|
@Autowired private ReferralService referralService;
|
||||||
|
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||||
|
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||||
|
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||||
|
@Autowired private MatchmakerFollowupMapper followupMapper;
|
||||||
|
@Autowired private OperatorMapper operatorMapper;
|
||||||
|
@Autowired private UserMapper userMapper;
|
||||||
|
@Autowired private AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
|
private String leaderA; // 组长,org X
|
||||||
|
private String mmB; // 红娘,org X(持有人)
|
||||||
|
private String mmC; // 红娘,org Y(跨组织)
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
private static final String ORG_X = "44444444-4444-4444-4444-444444444441";
|
||||||
|
private static final String ORG_Y = "44444444-4444-4444-4444-444444444442";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
leaderA = createOperator("组长A-006", ORG_X);
|
||||||
|
mmB = createOperator("红娘B-006", ORG_X);
|
||||||
|
mmC = createOperator("红娘C-006", ORG_Y);
|
||||||
|
userId = createUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据构造(Phase13 范式) ====================
|
||||||
|
|
||||||
|
private String createOperator(String name, String organizationId) {
|
||||||
|
Operator op = new Operator();
|
||||||
|
op.setName(name);
|
||||||
|
op.setOrganizationId(organizationId);
|
||||||
|
op.setUsername("wu_" + name + "_" + System.nanoTime());
|
||||||
|
op.setPasswordHash("test-hash");
|
||||||
|
op.setStatus("active");
|
||||||
|
operatorMapper.insert(op);
|
||||||
|
return op.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createUser() {
|
||||||
|
User u = new User();
|
||||||
|
u.setWechatOpenid("wu_user_" + System.nanoTime());
|
||||||
|
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
userMapper.insert(u);
|
||||||
|
return u.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createService(String operatorId) {
|
||||||
|
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||||
|
svc.setUserId(userId);
|
||||||
|
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||||
|
svc.setDescription("写接口 404 统一测试服务单");
|
||||||
|
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||||
|
svc.setAssignedOperatorId(operatorId);
|
||||||
|
serviceMapper.insert(svc);
|
||||||
|
return svc.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台授权路径创建分配(绕过组织范围校验以构造前置状态) */
|
||||||
|
private String createAssignment(String operatorId, String serviceId) {
|
||||||
|
MatchmakerAssignment a = assignmentService.create(leaderA, true, userId, operatorId,
|
||||||
|
serviceId, "006 测试前置分配", null);
|
||||||
|
return a.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long deniedAudits(String operatorId, String resourceId) {
|
||||||
|
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||||
|
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||||
|
.eq(AuditLog::getOperatorId, operatorId)
|
||||||
|
.eq(AuditLog::getResourceId, resourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读详情 404 基准("查无"路径)—— 五个写拒绝必须与其逐字段一致 */
|
||||||
|
private BusinessException readDetailNotFoundBaseline() {
|
||||||
|
try {
|
||||||
|
matchmakerServiceService.getServiceDetail("wu-no-such-service", mmB);
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
throw new AssertionError("预期读详情 404 基准异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 负向×5:写拒绝 404 化 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T04 负向①: 跨组织 transfer → 404「服务不存在」+ DATA_SCOPE_DENIED 审计")
|
||||||
|
void crossOrgTransferDeniedAsNotFound() {
|
||||||
|
String serviceId = createService(mmB);
|
||||||
|
String assignmentId = createAssignment(mmB, serviceId);
|
||||||
|
|
||||||
|
long before = deniedAudits(leaderA, assignmentId);
|
||||||
|
BusinessException e = assertThrows(BusinessException.class,
|
||||||
|
() -> assignmentService.transfer(assignmentId, leaderA, false, mmC, "跨组织转分配"),
|
||||||
|
"跨组织 transfer 必须拒绝");
|
||||||
|
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||||
|
assertEquals("服务不存在", e.getMessage());
|
||||||
|
assertEquals(before + 1, deniedAudits(leaderA, assignmentId), "必须先落 DATA_SCOPE_DENIED 审计");
|
||||||
|
// 被转记录原样保留
|
||||||
|
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE,
|
||||||
|
assignmentMapper.findById(assignmentId).orElseThrow().getStatus(),
|
||||||
|
"被拒绝的转分配不得改变原分配状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T04 负向②: 无分配 recommend → 404「服务不存在」+ 审计")
|
||||||
|
void recommendWithoutAssignmentDeniedAsNotFound() {
|
||||||
|
String serviceId = createService(mmB);
|
||||||
|
createAssignment(mmB, serviceId);
|
||||||
|
|
||||||
|
long before = deniedAudits(mmC, serviceId);
|
||||||
|
BusinessException e = assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.recommendPartner(serviceId, mmC, "wu-target", "无权推荐"));
|
||||||
|
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||||
|
assertEquals("服务不存在", e.getMessage());
|
||||||
|
assertEquals(before + 1, deniedAudits(mmC, serviceId));
|
||||||
|
assertTrue(recommendationMapper.findByService(serviceId).isEmpty(),
|
||||||
|
"拒绝的推荐不得落库");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T04 负向③: 无分配 followup → 404「服务不存在」+ 审计")
|
||||||
|
void followupWithoutAssignmentDeniedAsNotFound() {
|
||||||
|
String serviceId = createService(mmB);
|
||||||
|
createAssignment(mmB, serviceId);
|
||||||
|
|
||||||
|
long before = deniedAudits(mmC, serviceId);
|
||||||
|
BusinessException e = assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.addFollowup(serviceId, mmC, "note", "越权跟进", null));
|
||||||
|
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||||
|
assertEquals("服务不存在", e.getMessage());
|
||||||
|
assertEquals(before + 1, deniedAudits(mmC, serviceId));
|
||||||
|
assertTrue(followupMapper.findByServiceId(serviceId).isEmpty(),
|
||||||
|
"拒绝的跟进不得落库");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T04 负向④: 无分配 complete → 404「服务不存在」+ 审计")
|
||||||
|
void completeWithoutAssignmentDeniedAsNotFound() {
|
||||||
|
String serviceId = createService(mmB);
|
||||||
|
createAssignment(mmB, serviceId);
|
||||||
|
|
||||||
|
long before = deniedAudits(mmC, serviceId);
|
||||||
|
BusinessException e = assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.completeService(serviceId, mmC));
|
||||||
|
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||||
|
assertEquals("服务不存在", e.getMessage());
|
||||||
|
assertEquals(before + 1, deniedAudits(mmC, serviceId));
|
||||||
|
assertNotEquals(Constants.MATCHMAKER_STATUS_COMPLETED,
|
||||||
|
serviceMapper.selectById(serviceId).getStatus(),
|
||||||
|
"拒绝的完成动作不得改变服务单状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T04 负向⑤: 非负责人转介 → 404「服务不存在」+ 审计(含裸异常消灭回归)")
|
||||||
|
void referralWithoutAssignmentDeniedAsNotFound() {
|
||||||
|
String serviceId = createService(mmB);
|
||||||
|
createAssignment(mmB, serviceId);
|
||||||
|
|
||||||
|
long before = deniedAudits(mmC, userId);
|
||||||
|
BusinessException e1 = assertThrows(BusinessException.class,
|
||||||
|
() -> referralService.referToActivity(mmC, userId, "act-006-t04", "紧急联系人", "enc"));
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e1.getCode());
|
||||||
|
assertEquals("服务不存在", e1.getMessage());
|
||||||
|
|
||||||
|
BusinessException e2 = assertThrows(BusinessException.class,
|
||||||
|
() -> referralService.referToServiceStation(mmC, userId, "loc-006-t04",
|
||||||
|
"counselor_consultation", null, null));
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode());
|
||||||
|
assertEquals(before + 2, deniedAudits(mmC, userId), "两次越权转介各落一条审计");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 正向×2:持有人正常 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T04 正向①: 持有人 recommend 正常(200 语义)")
|
||||||
|
void holderRecommendSucceeds() {
|
||||||
|
String serviceId = createService(mmB);
|
||||||
|
createAssignment(mmB, serviceId);
|
||||||
|
|
||||||
|
Map<String, Object> result = matchmakerServiceService.recommendPartner(
|
||||||
|
serviceId, mmB, "wu-target-ok", "T04 持有人推荐");
|
||||||
|
assertNotNull(result.get("recommendationId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T04 正向②: 持有人 followup 正常(200 语义)")
|
||||||
|
void holderFollowupSucceeds() {
|
||||||
|
String serviceId = createService(mmB);
|
||||||
|
createAssignment(mmB, serviceId);
|
||||||
|
|
||||||
|
MatchmakerFollowup followup = matchmakerServiceService.addFollowup(
|
||||||
|
serviceId, mmB, "note", "T04 持有人跟进", null);
|
||||||
|
assertNotNull(followup.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 逐字段一致:五个写拒绝 ≡ 读详情 404 ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("T04 统一性: 五个写拒绝的 code+message 与读详情 404(查无基准)逐字段一致")
|
||||||
|
void writeDeniesIndistinguishableFromReadNotFound() {
|
||||||
|
String serviceId = createService(mmB);
|
||||||
|
String assignmentId = createAssignment(mmB, serviceId);
|
||||||
|
|
||||||
|
BusinessException baseline = readDetailNotFoundBaseline();
|
||||||
|
|
||||||
|
BusinessException transferDenied = assertThrows(BusinessException.class,
|
||||||
|
() -> assignmentService.transfer(assignmentId, leaderA, false, mmC, "跨组织"));
|
||||||
|
BusinessException recommendDenied = assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.recommendPartner(serviceId, mmC, "wu-t", "r"));
|
||||||
|
BusinessException followupDenied = assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.addFollowup(serviceId, mmC, "note", "f", null));
|
||||||
|
BusinessException completeDenied = assertThrows(BusinessException.class,
|
||||||
|
() -> matchmakerServiceService.completeService(serviceId, mmC));
|
||||||
|
BusinessException referralDenied = assertThrows(BusinessException.class,
|
||||||
|
() -> referralService.referToActivity(mmC, userId, "act-006-u", "联系人", "enc"));
|
||||||
|
|
||||||
|
List<BusinessException> denies = List.of(transferDenied, recommendDenied,
|
||||||
|
followupDenied, completeDenied, referralDenied);
|
||||||
|
for (BusinessException denied : denies) {
|
||||||
|
assertEquals(baseline.getCode(), denied.getCode(),
|
||||||
|
"业务码必须与读详情 404 一致(逐字段防探测)");
|
||||||
|
assertEquals(baseline.getMessage(), denied.getMessage(),
|
||||||
|
"message 必须与读详情 404 一致(逐字段防探测)");
|
||||||
|
}
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, baseline.getCode());
|
||||||
|
assertEquals("服务不存在", baseline.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.danyang.match.platform.photo;
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.AmazonS3;
|
||||||
|
import com.danyang.match.platform.photo.entity.UserPhoto;
|
||||||
|
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
|
||||||
|
import com.danyang.match.platform.photo.service.PhotoService;
|
||||||
|
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.policy.ProfilePolicy;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归守卫:照片直传落库(此前「传了白传」——upload-token 之后没有任何端点写 user_photos)。
|
||||||
|
* <ul>
|
||||||
|
* <li>非法的 fileKey(不归属本人)必须被归属护栏拒绝;</li>
|
||||||
|
* <li>对象尚未上传完成(存储不存在)必须被存在性护栏拒绝;</li>
|
||||||
|
* <li>合法且已上传的直传必须真正落库(写入 user_photos 记录)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class PhotoServiceConfirmUploadTest {
|
||||||
|
|
||||||
|
private final UserPhotoMapper photoMapper = mock(UserPhotoMapper.class);
|
||||||
|
private final AmazonS3 amazonS3 = mock(AmazonS3.class);
|
||||||
|
private final FeatureFlagConfig featureFlags = mock(FeatureFlagConfig.class);
|
||||||
|
private final ProfilePolicy profilePolicy = mock(ProfilePolicy.class);
|
||||||
|
private final UserMapper userMapper = mock(UserMapper.class);
|
||||||
|
private final AmazonS3 amazonS3Signer = mock(AmazonS3.class);
|
||||||
|
|
||||||
|
private final PhotoService photoService =
|
||||||
|
new PhotoService(photoMapper, amazonS3, featureFlags, profilePolicy, userMapper, amazonS3Signer);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void illegalFileKeyRejected() {
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> photoService.confirmDirectUpload("u1", "evil/key.jpg", "avatar"));
|
||||||
|
assertEquals("非法的文件标识", ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingObjectRejected() {
|
||||||
|
when(amazonS3.doesObjectExist(any(), eq("photos/u1/x.jpg"))).thenReturn(false);
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class,
|
||||||
|
() -> photoService.confirmDirectUpload("u1", "photos/u1/x.jpg", "lifestyle"));
|
||||||
|
assertEquals("文件尚未上传完成", ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validUploadPersistsPhoto() {
|
||||||
|
when(amazonS3.doesObjectExist(any(), eq("photos/u1/p.jpg"))).thenReturn(true);
|
||||||
|
when(photoMapper.countActiveProfilePhotos("u1")).thenReturn(0L);
|
||||||
|
when(profilePolicy.getMaxPhotoCount()).thenReturn(6);
|
||||||
|
when(photoMapper.insert(any())).thenReturn(1);
|
||||||
|
|
||||||
|
UserPhoto photo = photoService.confirmDirectUpload("u1", "photos/u1/p.jpg", "lifestyle");
|
||||||
|
|
||||||
|
assertNotNull(photo, "直传必须真实落库,不得白传");
|
||||||
|
assertEquals("u1", photo.getUserId());
|
||||||
|
assertEquals("photos/u1/p.jpg", photo.getFileKey());
|
||||||
|
assertEquals("lifestyle", photo.getPhotoType());
|
||||||
|
assertEquals(Constants.REVIEW_STATUS_PENDING, photo.getReviewStatus());
|
||||||
|
verify(photoMapper).insert(any(UserPhoto.class));
|
||||||
|
}
|
||||||
|
}
|
||||||