Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377429995c | ||
|
|
f6744d3f91 | ||
|
|
692d71696a | ||
|
|
ec9252bd3d | ||
|
|
dd291868fb | ||
|
|
b38056f1d0 | ||
|
|
34b9ea1169 | ||
|
|
88f4176e60 | ||
|
|
a3bd1e8a95 | ||
|
|
d51c4c6a76 | ||
|
|
69ebf33e18 | ||
|
|
8b505d9133 | ||
|
|
3c2b967274 | ||
|
|
827c23db1d | ||
|
|
ef61d51f61 | ||
|
|
02bc01f9c9 | ||
|
|
16c82739db | ||
|
|
f73a116a10 | ||
|
|
21275ef770 | ||
|
|
d3735e3afa | ||
|
|
4248e9574e | ||
|
|
6c2601950b | ||
|
|
1be0d98ebd | ||
|
|
5e484d0e74 | ||
|
|
3caf5f8816 | ||
|
|
861c78efb5 | ||
|
|
6187ec69df | ||
|
|
2d60f7899f | ||
|
|
f76bda1efa | ||
|
|
4edb7619bc | ||
|
|
dfb74f3386 | ||
|
|
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
|
||||
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
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -61,6 +61,9 @@ tests/fixtures/*.json
|
||||
.worktrees/
|
||||
.zcode/
|
||||
.workbuddy/
|
||||
.mimosa/
|
||||
.trae/
|
||||
.wb_tmp_bcrypt/
|
||||
project.private.config.json
|
||||
/nul
|
||||
/infrastructure/nul
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task 1 完成报告:角色权限与范围基线
|
||||
|
||||
## 变更
|
||||
|
||||
- 新增 `docs/v12/V1.2-角色权限与前端入口矩阵.md`。
|
||||
- 冻结 14 个正式角色的数据范围、工作台入口、允许操作和禁止操作。
|
||||
- 明确普通红娘为 SELF、红娘组长为 TEAM、平台/超级管理员为 ALL。
|
||||
- 明确五类工作台业务卡片的查看及审核、待办分配、确认、完成权限,同时保留红娘不得代用户 Like/Pass、不得查看身份证原始佐证和咨询敏感备注等边界。
|
||||
- 说明 V070/V071 是历史收敛迁移,不能覆盖 2026-10-25 最新裁决,后续通过幂等迁移恢复正式最小权限。
|
||||
- 修改 `docs/governance/decision-register.md`,登记 V070/V071 冲突裁决。
|
||||
- 新增 `backend/src/test/java/com/danyang/match/platform/deployment/PermissionBaselineContractTest.java`,锁定角色、范围、操作、敏感权限和历史迁移裁决。
|
||||
|
||||
## 测试
|
||||
|
||||
命令:
|
||||
|
||||
```text
|
||||
mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=PermissionBaselineContractTest test
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
## 证据边界与风险
|
||||
|
||||
- 本任务未修改 V070/V071 历史迁移,也未直接修改数据库;活跃 PostgreSQL 角色权限/版本查询留给活栈验收任务。
|
||||
- 矩阵锁定的是产品权限与数据范围契约,实际恢复权限需要后续 Task 5 的幂等迁移并通过干净数据库和活栈验证。
|
||||
- 未运行全量 Maven、前端构建或 E2E;这些属于后续任务门禁。
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 2 完成报告
|
||||
|
||||
## 结果
|
||||
|
||||
- 工作台六项指标先按各自 `detailPermission` 判断;无权限仅返回 `available=false` 与权限标识,不执行统计查询或返回真实值。
|
||||
- 授权指标继续返回真实统计值,空数据保持 `0`。
|
||||
- `matchmaker_leader` 未绑定组织时返回 `CROSS_ORGANIZATION_DENIED`。
|
||||
- 团队范围使用严格 `organization_id = ?`,不再把 NULL 组织视为同一团队。
|
||||
- 统计统一排除没有已发布资料 revision 的用户。
|
||||
|
||||
## 验证
|
||||
|
||||
执行:
|
||||
|
||||
```text
|
||||
mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=MatchmakerWorkbenchAuthorizationTest,MatchmakerWorkbenchScopeTest test
|
||||
```
|
||||
|
||||
结果:3 个测试通过,0 失败,0 错误。
|
||||
|
||||
## 范围
|
||||
|
||||
本次仅涉及工作台服务及新增授权/范围单元测试;未修改迁移、控制器或其他任务文件。
|
||||
@@ -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/>
|
||||
@@ -32,8 +32,12 @@ server {
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
# 用 resolver + 变量 proxy_pass:避免 nginx 启动时 backend DNS 还没就绪就 [emerg] 退出
|
||||
# Docker Compose 默认网络的内置 DNS resolver 是 127.0.0.11
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
set $backend_upstream "http://backend:8080";
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -42,7 +46,7 @@ server {
|
||||
|
||||
# WebSocket proxy for chat
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "danyang-match-admin",
|
||||
"version": "1.0.0-rc.2",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
# 后者生成 Vue 版本桥接),允许它们执行。
|
||||
packages:
|
||||
- '.'
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- vue-demi
|
||||
|
||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 672 KiB |
@@ -1,14 +1,18 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// 防止 401 时并发触发多次刷新
|
||||
let refreshing = false
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('accessToken')
|
||||
router.push('/login')
|
||||
async (err) => {
|
||||
const status = err.response?.status
|
||||
const original = err.config
|
||||
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('登录已过期,请重新登录')
|
||||
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) {
|
||||
ElMessage.error(err.response.data.message)
|
||||
} else {
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
<template>
|
||||
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
|
||||
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
|
||||
<header class="scene-header" aria-label="爱在丹阳公益服务品牌信息">
|
||||
<!-- 背景装饰层:粉渐变兜底 + 丹阳剪影底图 + 爱心点缀 -->
|
||||
<div class="header-bg" aria-hidden="true"></div>
|
||||
<div class="header-hearts" aria-hidden="true">
|
||||
<span>♡</span><span>♡</span><span>♡</span><span>♡</span>
|
||||
</div>
|
||||
|
||||
<div class="scene-brand">
|
||||
<img :src="BRAND_LOGO" alt="善缘丹阳" />
|
||||
<div class="brand-logo-wrap">
|
||||
<img :src="BRAND_LOGO" alt="爱在丹阳" />
|
||||
<span class="brand-dot" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="scene-brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
<div class="brand-name-row">
|
||||
<strong>爱在丹阳</strong>
|
||||
<em class="brand-badge">公益运营</em>
|
||||
</div>
|
||||
<span class="brand-tagline">公益婚恋服务平台</span>
|
||||
<span class="brand-ornament" aria-hidden="true">❖ ● ❖ ● ❖</span>
|
||||
</div>
|
||||
<div class="scene-title">
|
||||
<h1>管理端(运营后台)</h1>
|
||||
<p>用专业的服务,帮助更多人遇见对的人</p>
|
||||
<p class="title-sub">
|
||||
<span>公益婚恋服务平台</span>
|
||||
<span class="sep">·</span>
|
||||
<span>用爱连接美好姻缘</span>
|
||||
<span class="sep">·</span>
|
||||
<span>让更多人在丹阳遇见幸福</span>
|
||||
</p>
|
||||
<p class="title-meta">
|
||||
<span class="meta-chip" title="当前权限范围内今日新增、分配、跟进或完成的红娘服务单,按北京时间去重">今日服务 {{ todayStats?.total ?? '—' }} 单</span>
|
||||
<span class="meta-chip" title="当前权限范围内尚未完成的红娘服务单">待处理 {{ todayStats?.pending ?? '—' }} 件</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scene-slogan" aria-hidden="true">
|
||||
<span>善缘相牵</span>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span class="slogan-top">爱在丹阳</span>
|
||||
<strong class="slogan-main">原来是你</strong>
|
||||
<em class="slogan-en">Love In Danyang</em>
|
||||
</div>
|
||||
|
||||
<div class="scene-benefits" aria-label="平台服务原则">
|
||||
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span>
|
||||
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span>
|
||||
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span>
|
||||
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><StarFilled /></el-icon></i><b>公益为本</b><small>不以营利为目的</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><Lock /></el-icon></i><b>真实可靠</b><small>严格审核·多重保障</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><LocationFilled /></el-icon></i><b>本地服务</b><small>深耕丹阳·服务家乡</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><Opportunity /></el-icon></i><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -29,7 +55,7 @@
|
||||
<div class="brand" :class="{ compact: isCollapse }">
|
||||
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
|
||||
<div v-if="!isCollapse" class="brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,14 +147,15 @@
|
||||
</el-container>
|
||||
|
||||
<footer class="scene-footer">
|
||||
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span>
|
||||
<strong>爱在丹阳</strong><span>公益婚恋服务平台</span>
|
||||
<small>真实 · 真诚 · 公益 · 幸福</small>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -174,8 +201,8 @@ const navigation: NavigationItem[] = [
|
||||
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
|
||||
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
|
||||
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
|
||||
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
|
||||
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection },
|
||||
{ label: '公益红娘', path: '/referral', permissions: ['matchmaker.service'], icon: Connection },
|
||||
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.view'], icon: Connection },
|
||||
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
|
||||
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
|
||||
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
|
||||
@@ -190,6 +217,7 @@ const navigation: NavigationItem[] = [
|
||||
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
|
||||
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
|
||||
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
|
||||
{ label: '红娘团队管理', path: '/matchmaker-team', permissions: ['matchmaker.role.manage'], icon: Connection },
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
@@ -199,6 +227,23 @@ const isCollapse = ref(false)
|
||||
const globalSearch = ref('')
|
||||
const notificationCount = ref(0)
|
||||
|
||||
const todayStats = ref<{ total: number; pending: number } | null>(null)
|
||||
|
||||
watch(() => route.fullPath, async () => {
|
||||
todayStats.value = null
|
||||
const perms = authStore.permissions
|
||||
if (authStore.token === 'ui-preview-only') return
|
||||
try {
|
||||
if (perms.includes('workbench.view')) {
|
||||
const response: any = await get('/operator/workbench/dashboard')
|
||||
const value = response.data?.metrics?.matchmakerTodos?.value
|
||||
if (response.success && typeof value === 'number') todayStats.value = { total: value, pending: value }
|
||||
}
|
||||
} catch {
|
||||
todayStats.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path === '/users/detail') return '/users'
|
||||
return route.path
|
||||
@@ -209,6 +254,13 @@ const permissions = computed(() => authStore.permissions || [])
|
||||
const currentRoleName = computed(() => {
|
||||
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
|
||||
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
|
||||
const role = operator.value?.roles?.[0]
|
||||
const roleNames: Record<string, string> = {
|
||||
super_admin: '超级管理员', platform_admin: '平台管理员', matchmaker_leader: '红娘组长', matchmaker: '公益红娘',
|
||||
reviewer: '资料审核人员', counselor: '公益咨询人员', counseling_manager: '咨询管理员', service_station_operator: '服务站运营人员',
|
||||
content_editor: '内容编辑员', risk_operator: '风控运营人员',
|
||||
}
|
||||
if (role && roleNames[role]) return roleNames[role]
|
||||
const perms = permissions.value
|
||||
if (perms.includes('permission.manage')) return '平台管理员'
|
||||
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
|
||||
@@ -217,10 +269,15 @@ const currentRoleName = computed(() => {
|
||||
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
||||
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
||||
if (perms.includes('review.view')) return '资料审核人员'
|
||||
if (perms.includes('matchmaker.service.view')) return '公益红娘'
|
||||
if (perms.includes('matchmaker.team.assign')) return '红娘组长'
|
||||
if (perms.includes('matchmaker.view')) return '公益红娘'
|
||||
return '运营人员'
|
||||
})
|
||||
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
|
||||
const visibleNavigation = computed(() => navigation.filter((item) => {
|
||||
const adminRole = operator.value?.roles?.some((role) => ['super_admin', 'platform_admin'].includes(role))
|
||||
if ((item.path === '/referral' || item.path === '/matchmaker') && (permissions.value.includes('permission.manage') || adminRole)) return false
|
||||
return item.permissions.every(hasPerm)
|
||||
}))
|
||||
|
||||
function hasPerm(permission: string) {
|
||||
return permissions.value.includes(permission)
|
||||
@@ -258,137 +315,307 @@ function handleLogout() {
|
||||
<style scoped>
|
||||
.admin-scene {
|
||||
min-width: 1100px;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px 34px 14px;
|
||||
overflow: hidden;
|
||||
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
|
||||
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
|
||||
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
|
||||
/* 先给纯色兜底,避免图未加载完成时闪白 */
|
||||
background: var(--admin-coral-lighter) center / cover fixed no-repeat;
|
||||
background-image: url(v-bind('BRAND_BACKDROP'));
|
||||
/* 外层:纯色粉渐变兜底,再叠加背景图。
|
||||
原实现把 background 和 background-image 拆两行在某些浏览器会覆盖 —— 这里统一写一条,
|
||||
并显式声明 position/size/repeat/attachment,确保 cover 全区域。 */
|
||||
background:
|
||||
linear-gradient(180deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 40%, var(--admin-scene-bg-end) 100%),
|
||||
url(v-bind('BRAND_BACKDROP')) center / cover fixed no-repeat;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
max-width: var(--admin-shell-max-width);
|
||||
height: 188px;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: minmax(640px, 1fr) auto;
|
||||
grid-template-rows: 112px 76px;
|
||||
/* 100vw 撑满浏览器:用负 margin 抵消 admin-scene 的 padding */
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 105px;
|
||||
margin: -18px -34px 12px;
|
||||
padding: 0 34px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: 1fr 36px;
|
||||
grid-template-areas:
|
||||
"brand title slogan"
|
||||
"benefits benefits benefits";
|
||||
align-items: center;
|
||||
column-gap: var(--space-8);
|
||||
column-gap: 20px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ===== 背景装饰层 ===== */
|
||||
.header-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(135deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 55%, var(--admin-scene-bg-end) 100%),
|
||||
url(v-bind('BRAND_BACKDROP')) center / cover no-repeat;
|
||||
z-index: 0;
|
||||
}
|
||||
/* 左上角柔光晕 */
|
||||
.header-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60px;
|
||||
left: -40px;
|
||||
width: 300px;
|
||||
height: 180px;
|
||||
background: radial-gradient(ellipse at center, var(--admin-scene-glow) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 右下角柔光晕 */
|
||||
.header-bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 40px;
|
||||
bottom: -30px;
|
||||
width: 280px;
|
||||
height: 140px;
|
||||
background: radial-gradient(ellipse at center, var(--admin-scene-glow-soft) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 爱心点缀 */
|
||||
.header-hearts {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 34px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 16px;
|
||||
color: var(--admin-coral-border);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.header-hearts span {
|
||||
animation: heartbeat 3s ease-in-out infinite;
|
||||
}
|
||||
.header-hearts span:nth-child(2) { animation-delay: .4s; }
|
||||
.header-hearts span:nth-child(3) { animation-delay: .8s; }
|
||||
.header-hearts span:nth-child(4) { animation-delay: 1.2s; }
|
||||
@keyframes heartbeat {
|
||||
0%, 100% { transform: scale(1); opacity: .5; }
|
||||
50% { transform: scale(1.25); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== 所有 header 内容浮在背景层之上 ===== */
|
||||
.scene-brand,
|
||||
.scene-slogan,
|
||||
.scene-benefits {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ===== 品牌区 ===== */
|
||||
.scene-brand,
|
||||
.scene-benefits,
|
||||
.scene-benefits span,
|
||||
.scene-brand-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scene-brand > img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
.scene-brand {
|
||||
grid-area: brand;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scene-slogan {
|
||||
/* Logo + 装饰圆点 */
|
||||
.brand-logo-wrap {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.brand-logo-wrap img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
filter: drop-shadow(0 2px 6px rgba(244,95,104,0.25));
|
||||
}
|
||||
.brand-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--gradient-brand-solid);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(200, 74, 83, 0.45);
|
||||
}
|
||||
|
||||
/* 品牌名 + 徽章 — tagline/ornament 在 105px 高度里塞不下,隐藏 */
|
||||
.scene-brand-copy {
|
||||
min-width: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
color: var(--admin-coral-strong);
|
||||
gap: 8px;
|
||||
}
|
||||
.brand-name-row {
|
||||
display: flex;
|
||||
padding-right: var(--space-6);
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.brand-name-row strong {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 2px;
|
||||
background: var(--gradient-brand-text);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: none;
|
||||
}
|
||||
.brand-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--gradient-brand-solid);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 6px rgba(200, 74, 83, 0.3);
|
||||
}
|
||||
.brand-tagline,
|
||||
.brand-ornament {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 标题区 */
|
||||
.scene-title {
|
||||
min-width: 0;
|
||||
margin-left: 20px;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid var(--admin-scene-divider);
|
||||
}
|
||||
.scene-title h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.title-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.title-sub .sep {
|
||||
margin: 0 5px;
|
||||
color: var(--admin-coral-solid);
|
||||
font-weight: 700;
|
||||
}
|
||||
.title-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
height: 18px;
|
||||
color: var(--admin-coral-strong);
|
||||
background: var(--admin-scene-surface);
|
||||
border: 1px solid var(--admin-coral-border);
|
||||
border-radius: 9px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* ===== 书法 slogan ===== */
|
||||
.scene-slogan {
|
||||
grid-area: slogan;
|
||||
display: flex;
|
||||
padding-right: 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: var(--admin-coral-strong);
|
||||
font-family: var(--font-family-brand);
|
||||
line-height: 1.05;
|
||||
transform: rotate(-7deg);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.scene-slogan strong {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.scene-brand-copy {
|
||||
min-width: 172px;
|
||||
margin-left: 10px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
.slogan-top {
|
||||
font-size: 12px;
|
||||
letter-spacing: 6px;
|
||||
color: var(--admin-coral-strong);
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: var(--font-size-4xl);
|
||||
line-height: 1.15;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.scene-brand-copy span {
|
||||
margin-top: 5px;
|
||||
font-size: var(--font-size-xs);
|
||||
.slogan-main {
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 4px;
|
||||
background: var(--gradient-brand-text);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.slogan-en {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
min-width: 370px;
|
||||
margin-left: 28px;
|
||||
padding-left: 28px;
|
||||
border-left: 1px solid var(--admin-coral-border);
|
||||
}
|
||||
|
||||
.scene-title h1 {
|
||||
font-size: clamp(var(--font-size-3xl), 2.2vw, var(--font-size-display));
|
||||
line-height: 1.1;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.scene-title p {
|
||||
margin-top: 9px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ===== 公益原则行 ===== */
|
||||
.scene-benefits {
|
||||
grid-column: 1 / -1;
|
||||
align-self: start;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(38px, 5vw, 92px);
|
||||
padding: var(--space-1) 0 0 50px;
|
||||
grid-area: benefits;
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: clamp(10px, 1.6vw, 28px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scene-benefits span {
|
||||
display: grid;
|
||||
grid-template-columns: 36px auto;
|
||||
column-gap: var(--space-2);
|
||||
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
|
||||
color: var(--admin-coral-strong);
|
||||
}
|
||||
|
||||
.scene-benefits .el-icon {
|
||||
grid-row: 1 / 3;
|
||||
font-size: var(--font-size-4xl);
|
||||
}
|
||||
|
||||
.scene-benefits b {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.scene-benefits small {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 14px 4px 8px;
|
||||
white-space: nowrap;
|
||||
background: var(--admin-scene-surface);
|
||||
border: 1px solid var(--admin-coral-border);
|
||||
border-radius: 18px;
|
||||
color: var(--admin-coral-strong);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 1px 4px rgba(200, 74, 83, 0.06);
|
||||
}
|
||||
.scene-benefits b {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.scene-benefits small {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: min(var(--admin-shell-max-width), 100%);
|
||||
height: calc(100vh - 238px);
|
||||
min-height: 560px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background: var(--admin-shell-bg);
|
||||
@@ -427,6 +654,8 @@ function handleLogout() {
|
||||
height: 42px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
@@ -629,10 +858,15 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
background: var(--color-bg-page);
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge 旧版 */
|
||||
/* 暖色调背景,与整体品牌粉调一致;冷灰蓝 --color-bg-page 仅用于通用组件 */
|
||||
background: var(--admin-scene-bg-end);
|
||||
}
|
||||
.admin-content::-webkit-scrollbar { display: none; } /* Chrome/Safari/Edge */
|
||||
|
||||
.scene-footer {
|
||||
display: flex;
|
||||
@@ -656,62 +890,49 @@ function handleLogout() {
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1450px), (max-height: 820px) {
|
||||
@media (max-width: 1280px), (max-height: 780px) {
|
||||
.admin-scene {
|
||||
padding: 12px 24px 10px;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: grid;
|
||||
height: 120px;
|
||||
grid-template-columns: minmax(520px, 1fr) auto;
|
||||
grid-template-rows: 66px 54px;
|
||||
column-gap: 18px;
|
||||
height: 160px;
|
||||
grid-template-columns: minmax(480px, 1fr) auto;
|
||||
grid-template-rows: 92px 64px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.scene-slogan {
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
.brand-logo-wrap { width: 64px; height: 64px; }
|
||||
.brand-logo-wrap img { width: 56px; height: 56px; }
|
||||
.brand-dot { display: none; }
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.scene-slogan strong {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.scene-brand > img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
.brand-name-row strong { font-size: 30px; letter-spacing: 3px; }
|
||||
.brand-badge { display: none; }
|
||||
.brand-tagline { font-size: 12px; letter-spacing: 2px; }
|
||||
.brand-ornament { display: none; }
|
||||
|
||||
.scene-title {
|
||||
min-width: 310px;
|
||||
margin-left: 18px;
|
||||
padding-left: 18px;
|
||||
min-width: 280px;
|
||||
margin-left: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.scene-title h1 { font-size: 22px; letter-spacing: 1px; }
|
||||
.title-sub { font-size: 12px; }
|
||||
.title-meta { display: none; }
|
||||
|
||||
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
|
||||
.scene-title h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
.slogan-top { font-size: 13px; letter-spacing: 6px; }
|
||||
.slogan-main { font-size: 34px; letter-spacing: 4px; }
|
||||
.slogan-en { display: none; }
|
||||
|
||||
.scene-benefits {
|
||||
padding: 0 0 0 28px;
|
||||
gap: clamp(22px, 4vw, 54px);
|
||||
}
|
||||
.benefit-icon { width: 34px; height: 34px; }
|
||||
.benefit-icon .el-icon { font-size: 16px; }
|
||||
|
||||
.scene-benefits { gap: clamp(10px, 2vw, 28px); }
|
||||
.scene-benefits b { font-size: 12px; }
|
||||
.scene-benefits small { display: none; font-size: 12px; }
|
||||
|
||||
.admin-shell {
|
||||
height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
.scene-footer {
|
||||
height: 28px;
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="inner-card">
|
||||
<template #header><span>手工签到(特殊情形)</span></template>
|
||||
<el-input v-model="manualUserId" placeholder="输入用户 ID" />
|
||||
<el-input v-model="manualUserNumber" placeholder="输入13位用户编号" />
|
||||
<div class="btn-row">
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserId"
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserNumber"
|
||||
@click="manual">手工签到</el-button>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
@@ -65,8 +65,8 @@
|
||||
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
|
||||
<template #header><span>最近一次签到</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="用户 ID">{{ lastResult.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户编号">{{ lastResult.userDisplayNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动编号">{{ lastResult.activityDisplayId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本次是否为重复签到">
|
||||
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
|
||||
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
|
||||
@@ -80,6 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
/**
|
||||
* U32 活动签到工作台(Phase 15)
|
||||
*
|
||||
@@ -95,7 +96,7 @@ import { get, post } from '@/api/client'
|
||||
const activities = ref<any[]>([])
|
||||
const activityId = ref('')
|
||||
const token = ref('')
|
||||
const manualUserId = ref('')
|
||||
const manualUserNumber = ref('')
|
||||
const stats = ref<any>(null)
|
||||
const lastResult = ref<any>(null)
|
||||
const scanning = ref(false)
|
||||
@@ -111,7 +112,7 @@ const TYPE_TEXT: Record<string, string> = {
|
||||
}
|
||||
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
|
||||
|
||||
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` })
|
||||
const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
|
||||
|
||||
const formatTime = (v: any) => {
|
||||
if (!v) return '-'
|
||||
@@ -150,15 +151,15 @@ async function scan() {
|
||||
}
|
||||
|
||||
async function manual() {
|
||||
if (!activityId.value || !manualUserId.value.trim()) return
|
||||
if (!activityId.value || !manualUserNumber.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await post(
|
||||
`/activities/operator/${activityId.value}/checkin/manual`,
|
||||
{ userId: manualUserId.value.trim() },
|
||||
{ userId: manualUserNumber.value.trim() },
|
||||
authHeader())
|
||||
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value }
|
||||
manualUserId.value = ''
|
||||
lastResult.value = { ...res.data, userDisplayNumber: res.data?.userDisplayNumber || manualUserNumber.value.trim(), activityDisplayId: activities.value.find((item: any) => item.id === activityId.value)?.displayId }
|
||||
manualUserNumber.value = ''
|
||||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
|
||||
await loadStats()
|
||||
} finally {
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>活动互选管理</span>
|
||||
<el-input v-model="searchActivityId" placeholder="活动ID搜索" clearable style="width:260px" />
|
||||
<el-input v-model="searchActivityNumber" placeholder="活动编号搜索" clearable style="width:260px" />
|
||||
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="activityId" label="活动ID" width="160" />
|
||||
<el-table-column prop="activityDisplayId" label="活动编号" width="160" />
|
||||
<el-table-column prop="activityTitle" label="活动名称" min-width="180" />
|
||||
<el-table-column label="双向互选对数" width="140">
|
||||
<template #default="{row}">{{ row.mutualPairs }} 对</template>
|
||||
@@ -31,8 +31,8 @@
|
||||
<div v-if="currentActivity">
|
||||
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
|
||||
<el-table :data="mutualList" border size="small" style="margin-top:16px">
|
||||
<el-table-column prop="sourceUserId" label="表达者ID" min-width="180" />
|
||||
<el-table-column prop="targetUserId" label="被表达者ID" min-width="180" />
|
||||
<el-table-column prop="sourceUserDisplayNumber" label="表达者编号" min-width="180" />
|
||||
<el-table-column prop="targetUserDisplayNumber" label="被表达者编号" min-width="180" />
|
||||
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
|
||||
</el-table>
|
||||
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
|
||||
@@ -42,10 +42,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const searchActivityId = ref('')
|
||||
const searchActivityNumber = ref('')
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
@@ -55,11 +56,11 @@ const mutualList = ref<any[]>([])
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
|
||||
const rows = res.data || []
|
||||
tableData.value = searchActivityId.value
|
||||
? rows.filter((row: any) => row.activityId.includes(searchActivityId.value.trim()))
|
||||
tableData.value = searchActivityNumber.value
|
||||
? rows.filter((row: any) => (row.activityDisplayId || '').includes(searchActivityNumber.value.trim()))
|
||||
: rows
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
|
||||
drawerVisible.value = true
|
||||
currentActivity.value = tableData.value.find(row => row.activityId === activityId)
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
|
||||
mutualList.value = mutualRes.data || []
|
||||
} catch (e) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="displayId" label="活动编号" min-width="180" />
|
||||
<el-table-column prop="title" label="活动名称" min-width="180" />
|
||||
<el-table-column prop="activityType" label="活动类型" width="130">
|
||||
<template #default="{ row }">
|
||||
@@ -128,7 +129,6 @@
|
||||
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
|
||||
<h4>组织关系</h4>
|
||||
<el-table :data="orgs" border size="small">
|
||||
<el-table-column prop="organizationId" label="组织ID" min-width="160" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="{ row }">{{ roleText(row.role) }}</template>
|
||||
@@ -168,6 +168,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
/**
|
||||
* 活动管理(V1.1 Phase 14)
|
||||
*
|
||||
@@ -259,7 +260,7 @@ function emptyForm() {
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
|
||||
return { Authorization: `Bearer ${getAccessToken()}` }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
import { token } from '@/theme/tokens'
|
||||
@@ -302,7 +303,7 @@ async function loadFunnel() {
|
||||
try {
|
||||
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
|
||||
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
|
||||
const authToken = localStorage.getItem('accessToken')
|
||||
const authToken = getAccessToken()
|
||||
if (dateRange.value[0] && dateRange.value[1]) {
|
||||
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
|
||||
get('/operator/analytics/user-funnel', {
|
||||
@@ -328,7 +329,7 @@ async function loadFunnel() {
|
||||
|
||||
async function loadDailySummary() {
|
||||
try {
|
||||
const authToken = localStorage.getItem('accessToken')
|
||||
const authToken = getAccessToken()
|
||||
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
|
||||
dailySummary.value = res.data || []
|
||||
} catch (e) { console.error(e) }
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { token as tokenColor } from '@/theme/tokens'
|
||||
@@ -109,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
loadStats()
|
||||
@@ -119,7 +120,7 @@ async function loadData() {
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
const data = res.data || {}
|
||||
stats.value = [
|
||||
@@ -138,7 +139,7 @@ async function openDetail(id: string) {
|
||||
decision.value = 'approve'
|
||||
decisionNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res.data
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -148,7 +149,7 @@ async function handleProcess() {
|
||||
if (!decision.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await post(`/operator/appeals/${currentId.value}/process`,
|
||||
{ decision: decision.value, decisionNote: decisionNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="operatorId" label="操作人ID" width="140" />
|
||||
<el-table-column prop="operatorDisplayId" label="操作人编号" min-width="180" />
|
||||
<el-table-column prop="action" label="操作" width="160">
|
||||
<template #default="{row}">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resourceType" label="资源类型" width="120">
|
||||
<template #default="{row}">{{ row.resourceType }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resourceId" label="资源ID" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="resourceDisplayId" label="业务编号" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.resourceDisplayId || '未公开编号(已脱敏)' }}</template></el-table-column>
|
||||
<el-table-column prop="ip" label="IP地址" width="140" />
|
||||
<el-table-column prop="createdAt" label="时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
@@ -38,6 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/audit/logs', {
|
||||
resourceType: resourceFilter.value || undefined,
|
||||
page: page.value,
|
||||
|
||||
@@ -179,7 +179,7 @@ function isExternalLink(value: string) {
|
||||
function previewTargetText(banner: any) {
|
||||
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
|
||||
if (!banner.targetValue) return dest
|
||||
return `${dest} · ${banner.targetValue}`
|
||||
return `${dest} · 已配置站内目标`
|
||||
}
|
||||
function assignForm(source: any) {
|
||||
form.value = {
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="咨询师">
|
||||
<el-input v-model="assignForm.counselorId" placeholder="请输入咨询师工号 / ID" />
|
||||
<el-input v-model="assignForm.counselorNumber" placeholder="请输入13位工作人员编号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -143,7 +143,7 @@ const assignedServices = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const followupDialogVisible = ref(false)
|
||||
const currentId = ref('')
|
||||
const assignForm = ref({ counselorId: '' })
|
||||
const assignForm = ref({ counselorNumber: '' })
|
||||
const followupForm = ref({ type: 'phone_call', content: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
@@ -211,19 +211,19 @@ function onTabChange(name: string | number) {
|
||||
|
||||
function openAssign(row: any) {
|
||||
currentId.value = row.id
|
||||
assignForm.value.counselorId = ''
|
||||
assignForm.value.counselorNumber = ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doAssign() {
|
||||
if (!assignForm.value.counselorId) {
|
||||
ElMessage.warning('请填写咨询师 ID')
|
||||
if (!assignForm.value.counselorNumber) {
|
||||
ElMessage.warning('请填写咨询师编号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post(`/counseling/services/operator/${currentId.value}/assign`,
|
||||
{ counselorId: assignForm.value.counselorId })
|
||||
{ counselorId: assignForm.value.counselorNumber })
|
||||
ElMessage.success('分配成功')
|
||||
assignDialogVisible.value = false
|
||||
loadQueue()
|
||||
|
||||
@@ -2,10 +2,24 @@
|
||||
<div class="matchmaker-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="服务请求队列" name="services">
|
||||
<el-tabs v-model="activeTab" @tab-change="refresh">
|
||||
<el-tab-pane v-if="canAssign" label="服务进展与跟进" name="management">
|
||||
<el-table :data="managedServices" stripe v-loading="loading">
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="150" />
|
||||
<el-table-column prop="operatorDisplayId" label="红娘编号" min-width="150" />
|
||||
<el-table-column label="服务状态" width="120"><template #default="{ row }">{{ statusLabel(row.status) }}</template></el-table-column>
|
||||
<el-table-column prop="latestFollowupContent" label="最新跟进" min-width="230" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110"><template #default="{ row }">
|
||||
<el-button link type="primary" @click="openServiceDetail(row.serviceId, true)">查看记录</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
|
||||
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
|
||||
<el-table :data="services" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="190">
|
||||
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="serviceType" label="服务类型" width="160">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -19,7 +33,8 @@
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<el-button v-if="row.status==='assigned' || row.status==='processing'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
||||
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
||||
<el-button v-if="row.status!=='submitted'" size="small" @click="openServiceDetail(row.serviceId || row.id)">查看服务</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -27,8 +42,8 @@
|
||||
|
||||
<el-tab-pane label="推荐列表" name="recommendations">
|
||||
<el-table :data="recommendations" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户" width="110" />
|
||||
<el-table-column prop="targetUserId" label="推荐对象" width="110" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户" width="110" />
|
||||
<el-table-column prop="targetUserDisplayNumber" label="推荐对象" width="110" />
|
||||
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="130">
|
||||
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
|
||||
@@ -38,16 +53,80 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
|
||||
<el-tab-pane label="服务对象分配" name="assignments">
|
||||
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="190">
|
||||
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignmentScope" label="范围" width="90">
|
||||
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignmentReason" label="分配理由" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag :type="assignmentStatusTag(row.status)">{{ assignmentStatusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignedAt" label="分配时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.assignedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiresAt" label="有效期至" width="170">
|
||||
<template #default="{ row }">{{ row.expiresAt ? formatDateTime(row.expiresAt) : '长期' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.serviceId" size="small" @click="openServiceDetail(row.serviceId)">查看服务</el-button>
|
||||
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
|
||||
<el-tab-pane label="联系授权审批" name="contactAuths">
|
||||
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
|
||||
<el-table-column prop="operatorDisplayId" label="红娘编号" width="120" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="120" />
|
||||
<el-table-column prop="channel" label="渠道" width="110">
|
||||
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="purpose" label="授权目的" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }"><el-tag :type="contactStatusTag(row.status)">{{ contactStatusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="申请时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiresAt" label="窗口/有效期至" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending_approval' && canApprove">
|
||||
<el-button size="small" type="primary" @click="doContactApprove(row)">批准</el-button>
|
||||
<el-button size="small" type="danger" @click="doContactReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
<el-button v-if="['active','pending_approval','pending_consent'].includes(row.status)"
|
||||
size="small" @click="doContactRevoke(row)">撤销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 分配弹窗 -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
|
||||
<el-option label="王红娘" value="operator_wang" />
|
||||
<el-option label="陈红娘" value="operator_chen" />
|
||||
</el-select></el-form-item>
|
||||
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="红娘">
|
||||
<el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
|
||||
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配理由" required>
|
||||
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||
@@ -55,11 +134,42 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
|
||||
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="新红娘">
|
||||
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
|
||||
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="转分配理由" required>
|
||||
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
|
||||
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
|
||||
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="terminateDialogVisible=false">取消</el-button>
|
||||
<el-button type="danger" @click="doTerminate">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建推荐弹窗 -->
|
||||
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="推荐给用户"><el-input v-model="recForm.userId" disabled /></el-form-item>
|
||||
<el-form-item label="推荐对象ID"><el-input v-model="recForm.targetUserId" placeholder="输入被推荐对象的用户ID" /></el-form-item>
|
||||
<el-form-item label="推荐给用户"><el-input v-model="recForm.userDisplayNumber" disabled /></el-form-item>
|
||||
<el-form-item label="推荐对象编号"><el-input v-model="recForm.targetUserDisplayNumber" placeholder="输入13位用户编号" /></el-form-item>
|
||||
<el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -67,33 +177,108 @@
|
||||
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="serviceDetailVisible" title="服务详情" width="620px">
|
||||
<el-descriptions v-if="serviceDetail" :column="2" border>
|
||||
<el-descriptions-item label="服务编号">{{ serviceDetail.serviceDisplayId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(serviceDetail.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务类型">{{ serviceTypeLabel(serviceDetail.serviceType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(serviceDetail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务需求" :span="2">{{ serviceDetail.description || '未填写' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider content-position="left">服务历史</el-divider>
|
||||
<el-empty v-if="!serviceDetail?.followups?.length" description="暂无服务记录" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="item in serviceDetail.followups" :key="item.id" :timestamp="formatDateTime(item.createdAt)">
|
||||
{{ item.content }}
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<template v-if="serviceDetail && !managementDetail">
|
||||
<el-divider />
|
||||
<el-button type="primary" @click="openFollowup">添加跟进记录</el-button>
|
||||
<el-button
|
||||
v-if="['completed', 'cancelled'].indexOf(serviceDetail.status) < 0"
|
||||
type="success"
|
||||
plain
|
||||
@click="completeCurrentService"
|
||||
>完成服务</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="520px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="跟进方式">
|
||||
<el-select v-model="followupForm.followupType" style="width: 100%">
|
||||
<el-option label="电话沟通" value="phone_call" />
|
||||
<el-option label="微信沟通" value="wechat" />
|
||||
<el-option label="面对面" value="face_to_face" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进内容" required>
|
||||
<el-input v-model="followupForm.content" type="textarea" :rows="4" placeholder="记录本次服务进展" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="followupDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitFollowup">保存记录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onActivated } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const activeTab = ref('services')
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const managementDetail = ref(false)
|
||||
const managedServices = ref<any[]>([])
|
||||
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
|
||||
const canAssign = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||
})
|
||||
|
||||
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
|
||||
const loading = ref(false)
|
||||
const services = ref<any[]>([])
|
||||
const recommendations = ref<any[]>([])
|
||||
const assignments = ref<any[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignableMatchmakers = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const transferDialogVisible = ref(false)
|
||||
const terminateDialogVisible = ref(false)
|
||||
const recDialogVisible = ref(false)
|
||||
const currentServiceId = ref('')
|
||||
const assignForm = ref({ operatorId: '' })
|
||||
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
||||
const currentUserId = ref('')
|
||||
const currentAssignmentId = ref('')
|
||||
const terminateAction = ref<'revoke' | 'end'>('revoke')
|
||||
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
|
||||
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
|
||||
const terminateForm = ref({ reason: '' })
|
||||
const recForm = ref({ userDisplayNumber: '', targetUserDisplayNumber: '', reason: '' })
|
||||
const serviceDetailVisible = ref(false)
|
||||
const serviceDetail = ref<any>(null)
|
||||
const followupDialogVisible = ref(false)
|
||||
const followupForm = ref({ followupType: 'phone_call', content: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
recommendation: '推荐对象', referral_counseling: '转介咨询',
|
||||
referral_activity: '转介活动', referral_station: '转介服务站'
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||
submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
|
||||
processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||
}
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger'
|
||||
submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
|
||||
processing: '', completed: 'success', cancelled: 'danger'
|
||||
}
|
||||
const REC_STATUS_LABEL: Record<string, string> = {
|
||||
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
|
||||
@@ -103,18 +288,128 @@ const REC_STATUS_TAG: Record<string, string> = {
|
||||
pending_view: 'warning', viewed: 'info', liked: 'primary',
|
||||
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
|
||||
}
|
||||
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
|
||||
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
|
||||
}
|
||||
const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
|
||||
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
|
||||
}
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权(枚举码不上屏,中文映射) =====
|
||||
const CONTACT_CHANNEL_LABEL: Record<string, string> = {
|
||||
phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式'
|
||||
}
|
||||
const CONTACT_STATUS_LABEL: Record<string, string> = {
|
||||
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
|
||||
rejected: '已拒绝', expired: '已过期', revoked: '已撤销'
|
||||
}
|
||||
const CONTACT_STATUS_TAG: Record<string, string> = {
|
||||
pending_approval: 'warning', pending_consent: 'warning', active: 'success',
|
||||
rejected: 'danger', expired: 'info', revoked: 'info'
|
||||
}
|
||||
|
||||
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
|
||||
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
|
||||
function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || s }
|
||||
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
|
||||
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
|
||||
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
|
||||
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' }
|
||||
|
||||
async function openServiceDetail(serviceId: string, management = false) {
|
||||
managementDetail.value = management
|
||||
if (!serviceId) return
|
||||
try {
|
||||
const res: any = await get(`/operator/matchmaker/${management ? 'management/' : ''}services/${serviceId}`)
|
||||
serviceDetail.value = res?.data || null
|
||||
serviceDetailVisible.value = true
|
||||
} catch { serviceDetail.value = null }
|
||||
}
|
||||
|
||||
function openFollowup() {
|
||||
followupForm.value = { followupType: 'phone_call', content: '' }
|
||||
followupDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitFollowup() {
|
||||
if (!followupForm.value.content.trim() || !serviceDetail.value?.serviceId) {
|
||||
ElMessage.warning('请填写跟进内容')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/followup`, followupForm.value)
|
||||
ElMessage.success('跟进记录已保存')
|
||||
followupDialogVisible.value = false
|
||||
await openServiceDetail(serviceDetail.value.serviceId)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存跟进记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCurrentService() {
|
||||
if (!serviceDetail.value?.serviceId) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将该红娘服务标记为已完成吗?', '完成服务', { type: 'warning' })
|
||||
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/complete`, {})
|
||||
ElMessage.success('服务已完成')
|
||||
await openServiceDetail(serviceDetail.value.serviceId)
|
||||
await loadAssignments()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.response?.data?.message || '完成服务失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权 =====
|
||||
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
|
||||
const canApprove = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||
})
|
||||
const contactAuths = ref<any[]>([])
|
||||
const contactAuthsLoading = ref(false)
|
||||
|
||||
async function loadContactAuths() {
|
||||
contactAuthsLoading.value = true
|
||||
try {
|
||||
const endpoint = canApprove.value
|
||||
? '/operator/matchmaker/contact-authorizations/pending'
|
||||
: '/operator/matchmaker/contact-authorizations/my'
|
||||
const res: any = await get(endpoint)
|
||||
contactAuths.value = res?.data?.list || []
|
||||
} finally { contactAuthsLoading.value = false }
|
||||
}
|
||||
async function doContactApprove(row: any) {
|
||||
try {
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/approve`, {})
|
||||
ElMessage.success('已批准,等待用户单独确认'); loadContactAuths()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function doContactReject(row: any) {
|
||||
try {
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/reject`, {})
|
||||
ElMessage.success('已拒绝'); loadContactAuths()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function doContactRevoke(row: any) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('撤销理由(必填)', '撤销联系授权', {
|
||||
confirmButtonText: '确认撤销', cancelButtonText: '取消',
|
||||
inputValidator: (v: string) => (v && v.trim() ? true : '撤销理由必填'),
|
||||
})
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/revoke`, { reason: value.trim() })
|
||||
ElMessage.success('已撤销'); loadContactAuths()
|
||||
} catch { /* 用户取消 */ }
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
loading.value = true
|
||||
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
|
||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
|
||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
|
||||
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadRecommendations() {
|
||||
@@ -123,36 +418,109 @@ async function loadRecommendations() {
|
||||
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
|
||||
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadAssignments() {
|
||||
assignmentsLoading.value = true
|
||||
// ZC-MM-002:本人名下生效中的服务对象分配
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
|
||||
}
|
||||
async function loadAssignableMatchmakers() {
|
||||
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
|
||||
}
|
||||
async function refresh() {
|
||||
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
|
||||
if (activeTab.value === 'management') {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/operator/matchmaker/management/services'); managedServices.value = res?.data || [] }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
else if (activeTab.value === 'services') await loadServices()
|
||||
else if (activeTab.value === 'assignments') await loadAssignments()
|
||||
else if (activeTab.value === 'contactAuths') await loadContactAuths()
|
||||
else await loadRecommendations()
|
||||
}
|
||||
|
||||
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
|
||||
async function openAssign(row: any) {
|
||||
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
|
||||
currentServiceId.value = row.serviceId || row.id
|
||||
currentUserId.value = row.userId
|
||||
assignForm.value = { matchmakerOperatorId: '', reason: '' }
|
||||
await loadAssignableMatchmakers()
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
async function doAssign() {
|
||||
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
|
||||
if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
|
||||
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
|
||||
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
|
||||
await post('/operator/matchmaker/assignments', {
|
||||
userId: currentUserId.value,
|
||||
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
|
||||
serviceId: currentServiceId.value,
|
||||
reason: assignForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
async function openTransfer(row: any) {
|
||||
currentAssignmentId.value = row.id
|
||||
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
|
||||
await loadAssignableMatchmakers()
|
||||
transferDialogVisible.value = true
|
||||
}
|
||||
async function doTransfer() {
|
||||
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
|
||||
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
|
||||
try {
|
||||
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
|
||||
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
|
||||
reason: transferForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openTerminate(row: any, action: 'revoke' | 'end') {
|
||||
currentAssignmentId.value = row.id
|
||||
terminateAction.value = action
|
||||
terminateForm.value = { reason: '' }
|
||||
terminateDialogVisible.value = true
|
||||
}
|
||||
async function doTerminate() {
|
||||
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
|
||||
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
|
||||
reason: terminateForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openRecommendation(row: any) {
|
||||
currentServiceId.value = row.id
|
||||
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
|
||||
recForm.value = { userDisplayNumber: row.userDisplayNumber || '实名后生成', targetUserDisplayNumber: '', reason: '' }
|
||||
recDialogVisible.value = true
|
||||
}
|
||||
async function doCreateRecommendation() {
|
||||
if (!recForm.value.targetUserId || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
|
||||
if (!recForm.value.targetUserDisplayNumber || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
|
||||
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
|
||||
targetUserId: recForm.value.targetUserId, reason: recForm.value.reason
|
||||
targetUserId: recForm.value.targetUserDisplayNumber, reason: recForm.value.reason
|
||||
})
|
||||
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
onActivated(async () => {
|
||||
if (canAssign.value && typeof route.query.serviceId === 'string') {
|
||||
activeTab.value = 'management'
|
||||
await openServiceDetail(route.query.serviceId, true)
|
||||
}
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<span>红娘团队管理</span>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="status" clearable placeholder="全部状态" style="width:120px" @change="load">
|
||||
<el-option label="正常" value="normal" /><el-option label="停用" value="suspended" />
|
||||
</el-select>
|
||||
<el-input v-model="keyword" clearable placeholder="姓名或用户名" style="width:180px" @keyup.enter="load" />
|
||||
<el-button type="primary" @click="openCreate">新增红娘</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert v-if="error" type="error" :closable="false" :title="error" />
|
||||
<el-table v-loading="loading" :data="items" border stripe empty-text="暂无红娘团队成员">
|
||||
<el-table-column prop="username" label="用户名" width="150" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column label="角色" width="130"><template #default="{ row }">{{ roleName(row.roles?.[0]) }}</template></el-table-column>
|
||||
<el-table-column label="数据范围" width="110"><template #default="{ row }">{{ scopeName(row.scope) }}</template></el-table-column>
|
||||
<el-table-column label="所属组织" min-width="180"><template #default="{ row }">{{ orgMap[row.organizationId] || '未绑定' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'normal' ? 'success' : 'danger'">{{ row.status === 'normal' ? '正常' : '停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" @click="resetPassword(row)">重置密码</el-button>
|
||||
<el-button size="small" :type="row.status === 'normal' ? 'warning' : 'success'" @click="toggleStatus(row)">{{ row.status === 'normal' ? '停用' : '启用' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-if="total" v-model:current-page="page" style="margin-top:16px;text-align:right" layout="total, prev, pager, next" :page-size="pageSize" :total="total" @current-change="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing ? '编辑红娘团队成员' : '新增红娘团队成员'" width="520px">
|
||||
<el-form label-width="92px">
|
||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="角色"><el-radio-group v-model="form.roleCode"><el-radio value="matchmaker">普通红娘</el-radio><el-radio value="matchmaker_leader">红娘组长</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="所属组织" required><el-select v-model="form.organizationId" clearable style="width:100%"><el-option v-for="org in organizations" :key="org.id" :label="org.name" :value="org.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="!editing" label="初始密码"><el-input v-model="form.password" type="password" show-password /></el-form-item>
|
||||
<el-alert type="info" :closable="false" :title="`数据范围:${scopeName(form.roleCode === 'matchmaker_leader' ? 'TEAM' : 'SELF')}`" />
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<any[]>([])
|
||||
const organizations = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const keyword = ref('')
|
||||
const status = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<any | null>(null)
|
||||
const form = ref({ name: '', roleCode: 'matchmaker', organizationId: '', password: '' })
|
||||
const orgMap = computed(() => Object.fromEntries(organizations.value.map((org) => [org.id, org.name])))
|
||||
|
||||
function roleName(role: string) { return role === 'matchmaker_leader' ? '红娘组长' : '普通红娘' }
|
||||
function scopeName(scope: string) { return scope === 'TEAM' ? '团队范围' : scope === 'ALL' ? '全部范围' : '本人范围' }
|
||||
async function load() {
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const res: any = await get('/operator/matchmaker-team/members', { page: page.value - 1, pageSize, keyword: keyword.value || undefined, status: status.value || undefined })
|
||||
items.value = res.data?.items || []; total.value = res.data?.total || 0
|
||||
} catch (e: any) { error.value = e?.response?.data?.message || '红娘团队数据加载失败' } finally { loading.value = false }
|
||||
}
|
||||
async function loadOrganizations() {
|
||||
const res: any = await get('/operator/operators/organizations')
|
||||
organizations.value = res.data || []
|
||||
}
|
||||
function openCreate() { editing.value = null; form.value = { name: '', roleCode: 'matchmaker', organizationId: '', password: '' }; dialogVisible.value = true }
|
||||
function openEdit(row: any) { editing.value = row; form.value = { name: row.name, roleCode: row.roles?.[0] || 'matchmaker', organizationId: row.organizationId || '', password: '' }; dialogVisible.value = true }
|
||||
async function save() {
|
||||
if (!form.value.name.trim()) return ElMessage.warning('请填写红娘姓名')
|
||||
if (form.value.roleCode === 'matchmaker_leader' && !form.value.organizationId) return ElMessage.warning('红娘组长必须选择所属组织')
|
||||
if (!editing.value && form.value.password.length < 8) return ElMessage.warning('初始密码至少8位')
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = { name: form.value.name, roleCode: form.value.roleCode, organizationId: form.value.organizationId || null }
|
||||
if (!editing.value) Object.assign(payload, { password: form.value.password })
|
||||
const res: any = editing.value ? await put(`/operator/matchmaker-team/members/${editing.value.id}`, payload) : await post('/operator/matchmaker-team/members', payload)
|
||||
if (!res.success) throw new Error(res.message || '保存失败')
|
||||
dialogVisible.value = false; ElMessage.success('保存成功'); await load()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || e.message || '保存失败') } finally { saving.value = false }
|
||||
}
|
||||
async function toggleStatus(row: any) {
|
||||
await put(`/operator/matchmaker-team/members/${row.id}/${row.status === 'normal' ? 'disable' : 'enable'}`)
|
||||
ElMessage.success('状态已更新'); await load()
|
||||
}
|
||||
async function resetPassword(row: any) {
|
||||
const value = await ElMessageBox.prompt('请输入新密码(至少8位)', '重置密码', { inputType: 'password', inputValidator: (v) => v.length >= 8 || '密码至少8位' })
|
||||
await put(`/operator/matchmaker-team/members/${row.id}/reset-password`, { newPassword: value.value })
|
||||
ElMessage.success('密码已重置')
|
||||
}
|
||||
onMounted(async () => { await loadOrganizations(); await load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-row,.header-actions{display:flex;align-items:center}.header-row{justify-content:space-between}.header-actions{gap:8px}
|
||||
</style>
|
||||
@@ -10,7 +10,16 @@
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column prop="displayId" label="人员编号" width="150" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column label="角色" min-width="190">
|
||||
<template #default="{row}">
|
||||
<el-tag v-for="role in row.roles || []" :key="role" size="small" style="margin-right:4px">{{ roleName(role) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据范围" width="110">
|
||||
<template #default="{row}">{{ scopeName(row.scope) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="organizationId" label="所属机构" width="160">
|
||||
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
||||
</el-table-column>
|
||||
@@ -25,8 +34,9 @@
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<el-table-column label="操作" width="270">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="openEdit(row)">角色权限</el-button>
|
||||
<el-button size="small" @click="resetPwd(row.id)">重置密码</el-button>
|
||||
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
|
||||
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
|
||||
@@ -40,15 +50,23 @@
|
||||
</el-card>
|
||||
|
||||
<!-- 新增对话框 -->
|
||||
<el-dialog v-model="formVisible" title="新增Operator" width="480px">
|
||||
<el-dialog v-model="formVisible" title="新增工作人员" width="560px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="所属机构">
|
||||
<el-select v-model="form.organizationId" style="width:100%">
|
||||
<el-select v-model="form.organizationId" clearable style="width:100%">
|
||||
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-checkbox-group v-model="form.roleCodes">
|
||||
<el-checkbox v-for="role in roleTemplates" :key="role.code" :label="role.code">{{ role.name }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-alert v-if="selectedPermissions.length" type="info" :closable="false" title="将继承以下权限">
|
||||
{{ selectedPermissions.join('、') }}
|
||||
</el-alert>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
@@ -58,11 +76,35 @@
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" title="调整角色与数据范围" width="560px">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="账号">{{ editForm.name }}({{ editForm.username }})</el-form-item>
|
||||
<el-form-item label="所属机构">
|
||||
<el-select v-model="editForm.organizationId" clearable style="width:100%">
|
||||
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-checkbox-group v-model="editForm.roleCodes">
|
||||
<el-checkbox v-for="role in roleTemplates" :key="role.code" :label="role.code">{{ role.name }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-alert v-if="editPermissions.length" type="info" :closable="false" title="将继承以下权限">
|
||||
{{ editPermissions.join('、') }}
|
||||
</el-alert>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible=false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -72,24 +114,35 @@ const page = ref(0)
|
||||
const pageSize = ref(20)
|
||||
const formVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({ username: '', name: '', organizationId: '', password: '' })
|
||||
const form = ref({ username: '', name: '', organizationId: '', password: '', roleCodes: [] as string[] })
|
||||
const editVisible = ref(false)
|
||||
const editForm = ref({ id: '', username: '', name: '', organizationId: '', roleCodes: [] as string[] })
|
||||
const roleTemplates = ref<any[]>([])
|
||||
const orgList = ref<any[]>([])
|
||||
const orgMap = ref<Record<string, string>>({})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
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}` })
|
||||
tableData.value = res.data?.list || []
|
||||
tableData.value = res.data?.items || res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/operators/roles', {}, { Authorization: `Bearer ${token}` })
|
||||
roleTemplates.value = res.data || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||
orgList.value = res.data || []
|
||||
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
|
||||
@@ -97,7 +150,7 @@ async function loadOrgs() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = { username: '', name: '', organizationId: '', password: '' }
|
||||
form.value = { username: '', name: '', organizationId: '', password: '', roleCodes: ['matchmaker'] }
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
@@ -105,7 +158,9 @@ async function save() {
|
||||
if (!form.value.username || !form.value.password) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
if (!form.value.roleCodes.length) return
|
||||
if (form.value.roleCodes.includes('matchmaker_leader') && !form.value.organizationId) return
|
||||
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
@@ -113,9 +168,32 @@ async function save() {
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editForm.value = { id: row.id, username: row.username, name: row.name, organizationId: row.organizationId || '', roleCodes: [...(row.roles || [])] }
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editForm.value.roleCodes.length) return
|
||||
if (editForm.value.roleCodes.includes('matchmaker_leader') && !editForm.value.organizationId) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = getAccessToken()
|
||||
await put(`/operator/operators/${editForm.value.id}/roles`, { roleCodes: editForm.value.roleCodes, organizationId: editForm.value.organizationId || null }, { Authorization: `Bearer ${token}` })
|
||||
editVisible.value = false
|
||||
await loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
function roleName(code: string) { return roleTemplates.value.find(r => r.code === code)?.name || code }
|
||||
function scopeName(scope: string) { return scope === 'ALL' ? '全平台' : scope === 'TEAM' ? '本组织' : '本人' }
|
||||
const selectedPermissions = computed(() => [...new Set(form.value.roleCodes.flatMap(code => roleTemplates.value.find(r => r.code === code)?.permissions || []))])
|
||||
const editPermissions = computed(() => [...new Set(editForm.value.roleCodes.flatMap(code => roleTemplates.value.find(r => r.code === code)?.permissions || []))])
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
|
||||
loadData()
|
||||
@@ -126,7 +204,7 @@ async function resetPwd(id: string) {
|
||||
const pwd = prompt('请输入新密码(至少8位)')
|
||||
if (!pwd || pwd.length < 8) return
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
@@ -134,7 +212,7 @@ async function resetPwd(id: string) {
|
||||
function handlePageChange(p: number) { page.value = p - 1; loadData() }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(() => { loadData(); loadOrgs() })
|
||||
onMounted(() => { loadData(); loadOrgs(); loadRoles() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<el-table-column prop="contactName" label="联系人" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.status==='normal'?'success':'danger'">{{ row.status==='normal'?'正常':'已停用' }}</el-tag>
|
||||
<el-tag :type="row.status==='active'?'success':'danger'">{{ row.status==='active'?'正常':'已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
@@ -25,8 +25,8 @@
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
|
||||
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
|
||||
<el-button size="small" :type="row.status==='active'?'warning':'success'"
|
||||
@click="toggleStatus(row)">{{ row.status==='active'?'停用':'启用' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -40,10 +40,11 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="机构类型">
|
||||
<el-select v-model="form.type" style="width:100%">
|
||||
<el-option label="妇联" value="fulian" />
|
||||
<el-option label="工会" value="gonghui" />
|
||||
<el-option label="公益服务中心" value="public_service" />
|
||||
<el-option label="志愿者团队" value="volunteer" />
|
||||
<el-option label="妇联" value="women_federation" />
|
||||
<el-option label="工会" value="trade_union" />
|
||||
<el-option label="公益服务中心" value="charity_center" />
|
||||
<el-option label="志愿者团队" value="volunteer_team" />
|
||||
<el-option label="民政合作单位" value="civil_affairs_partner" />
|
||||
<el-option label="其他合作机构" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -60,6 +61,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
@@ -71,14 +73,14 @@ const form = ref({ name: '', type: '', contactName: '' })
|
||||
const saving = ref(false)
|
||||
|
||||
const ORG_TYPE_TEXT: Record<string, string> = {
|
||||
fulian: '妇联', gonghui: '工会', public_service: '公益服务中心',
|
||||
volunteer: '志愿者团队', other: '其他合作机构'
|
||||
women_federation: '妇联', trade_union: '工会', charity_center: '公益服务中心',
|
||||
volunteer_team: '志愿者团队', civil_affairs_partner: '民政合作单位', other: '其他合作机构'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -87,7 +89,7 @@ async function loadData() {
|
||||
|
||||
function openCreate() {
|
||||
editId.value = ''
|
||||
form.value = { name: '', type: 'fulian', contactName: '' }
|
||||
form.value = { name: '', type: 'women_federation', contactName: '' }
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
@@ -101,7 +103,7 @@ async function save() {
|
||||
if (!form.value.name) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
if (editId.value) {
|
||||
await put(`/operator/organizations/${editId.value}`, form.value, {
|
||||
Authorization: `Bearer ${token}`
|
||||
@@ -119,8 +121,8 @@ async function save() {
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||
const token = getAccessToken()
|
||||
const action = row.status === 'active' ? 'disable' : 'enable'
|
||||
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
||||
Authorization: `Bearer ${token}`
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="关联ID" width="120" />
|
||||
<el-table-column prop="referralDisplayId" label="转介编号" min-width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
|
||||
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
|
||||
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="displayId" label="任务编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -42,7 +43,8 @@
|
||||
|
||||
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
|
||||
<el-table :data="myTasks" v-loading="loading" row-key="taskId">
|
||||
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="displayId" label="任务编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -65,7 +67,7 @@
|
||||
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
|
||||
<template v-else-if="currentTask">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户编号">{{ currentTask.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户编号">{{ currentTask.userDisplayNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="reasonText" label="举报原因" width="140" />
|
||||
<el-table-column prop="reportedUserId" label="被举报人ID" width="140" />
|
||||
<el-table-column prop="displayId" label="举报编号" min-width="180" />
|
||||
<el-table-column prop="reportedUserDisplayNumber" label="被举报人编号" width="140" />
|
||||
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
@@ -98,6 +99,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { token as tokenColor } from '@/theme/tokens'
|
||||
@@ -124,7 +126,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
loadStats()
|
||||
@@ -134,7 +136,7 @@ async function loadData() {
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
const data = res.data || {}
|
||||
stats.value = [
|
||||
@@ -152,7 +154,7 @@ async function openDetail(reportId: string) {
|
||||
processResult.value = 'no_action'
|
||||
processNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res.data
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -162,7 +164,7 @@ async function handleProcess() {
|
||||
if (!processResult.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await post(`/operator/risk/reports/${currentReportId.value}/process`,
|
||||
{ processResult: processResult.value, processNote: processNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</el-radio-group>
|
||||
</div></template>
|
||||
<el-table :data="appointments" stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="100" />
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="displayId" label="预约编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="180" />
|
||||
<el-table-column prop="serviceType" label="服务类型" width="150" />
|
||||
<el-table-column prop="source" label="来源" width="140">
|
||||
<template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template>
|
||||
@@ -21,7 +21,7 @@
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="preferredDate" label="预约日期" width="110" />
|
||||
<el-table-column prop="assignedOperatorId" label="分配人员" width="100" />
|
||||
<el-table-column prop="assignedOperatorDisplayId" label="分配人员" width="100" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="members" stripe v-loading="memberLoading">
|
||||
<el-table-column prop="userId" label="用户ID" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="page-container page-shell">
|
||||
<header class="page-heading"><div><h1>后台建档</h1><p>建立草稿档案,手机号核验和资料审核仍需独立完成。</p></div></header>
|
||||
<section class="surface-card form-card">
|
||||
<el-form label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="所属组织"><el-input v-model="form.organization" /></el-form-item>
|
||||
<el-form-item label="组织联系电话"><el-input v-model="form.organizationPhone" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="submit">创建草稿</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { post } from '@/api/client'
|
||||
const loading = ref(false)
|
||||
const form = reactive({ name: '', organization: '', organizationPhone: '', phone: '' })
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
try { await post('/operator/users', form); ElMessage.success('草稿档案已创建'); Object.assign(form, { name: '', organization: '', organizationPhone: '', phone: '' }) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
<style scoped>.page-container{padding:28px}.form-card{max-width:620px;padding:24px}</style>
|
||||
@@ -19,7 +19,7 @@
|
||||
<section v-else-if="user" class="surface-card detail-card">
|
||||
<div class="identity-summary">
|
||||
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
|
||||
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
|
||||
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.displayNumber || '实名后生成' }}</span></div>
|
||||
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="page-container page-shell">
|
||||
<header class="page-heading"><div><h1>CSV 批量建档</h1><p>仅支持 UTF-8 CSV;预览不会写入用户表。</p></div></header>
|
||||
<section class="surface-card form-card">
|
||||
<input type="file" accept=".csv,text/csv" @change="onFile" />
|
||||
<el-button type="primary" :loading="loading" :disabled="!fileText" @click="preview">预览</el-button>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<el-table v-if="result" :data="result.rows" class="preview-table"><el-table-column prop="rowNumber" label="行号" width="80" /><el-table-column prop="status" label="状态" /><el-table-column prop="error" label="错误" /></el-table>
|
||||
<el-button v-if="result && result.validRows" type="success" :loading="committing" @click="commit">提交 {{ result.validRows }} 行</el-button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { post } from '@/api/client'
|
||||
const fileText = ref(''); const result = ref<any>(null); const error = ref(''); const loading = ref(false); const committing = ref(false)
|
||||
function onFile(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { fileText.value = String(reader.result || '') }; reader.readAsText(file, 'UTF-8') }
|
||||
async function preview() { loading.value = true; error.value = ''; try { const response: any = await post('/operator/users/import/preview', fileText.value, { 'Content-Type': 'text/csv' }); result.value = response.data } catch (e: any) { error.value = e.response?.data?.message || '预览失败' } finally { loading.value = false } }
|
||||
async function commit() { if (!result.value?.batchId) return; committing.value = true; try { await post('/operator/users/import/commit', { batchId: result.value.batchId, idempotencyKey: crypto.randomUUID() }); ElMessage.success('导入提交成功'); result.value = null } finally { committing.value = false } }
|
||||
</script>
|
||||
<style scoped>.page-container{padding:28px}.form-card{padding:24px;display:grid;gap:16px}.preview-table{margin-top:12px}</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
<h1>用户管理</h1>
|
||||
<p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain">共 {{ total }} 人</el-tag>
|
||||
<div class="heading-actions"><el-button v-if="canCreate" type="primary" @click="router.push('/users/create')">后台建档</el-button><el-button v-if="canCreate" @click="router.push('/users/import')">CSV导入</el-button><el-tag type="info" effect="plain">共 {{ total }} 人</el-tag></div>
|
||||
</header>
|
||||
|
||||
<section class="surface-card filter-panel" aria-label="用户筛选">
|
||||
@@ -42,7 +42,9 @@
|
||||
</el-alert>
|
||||
|
||||
<el-table v-else :data="tableData" v-loading="loading" row-key="id">
|
||||
<el-table-column prop="id" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="displayNumber" label="用户编号" min-width="190" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.displayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="displayName" label="显示名称" min-width="110">
|
||||
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
|
||||
</el-table-column>
|
||||
@@ -99,12 +101,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { get } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const canCreate = computed(() => authStore.permissions.includes('user.create'))
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const keyword = ref('')
|
||||
|
||||
@@ -25,9 +25,12 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
|
||||
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
|
||||
{ path: 'users', name: 'UserManagement', component: () => import('@/modules/user/UserManagementView.vue'), meta: { title: '用户管理', permissions: ['user.view'] } },
|
||||
{ path: 'users/create', name: 'UserCreate', component: () => import('@/modules/user/UserCreateView.vue'), meta: { title: '后台建档', permissions: ['user.create'] } },
|
||||
{ path: 'users/import', name: 'UserImport', component: () => import('@/modules/user/UserImportView.vue'), meta: { title: '批量建档', permissions: ['user.create'] } },
|
||||
{ path: 'users/detail', name: 'UserDetail', component: () => import('@/modules/user/UserDetailView.vue'), meta: { title: '用户详情', permissions: ['user.view', 'user.sensitive.view'] } },
|
||||
{ path: 'organizations', name: 'OrganizationManagement', component: () => import('@/modules/organization/OrganizationView.vue'), meta: { title: '组织管理', permissions: ['organization.manage'] } },
|
||||
{ path: 'operators', name: 'OperatorManagement', component: () => import('@/modules/operator/OperatorManagementView.vue'), meta: { title: '工作人员管理', permissions: ['permission.manage'] } },
|
||||
{ path: 'matchmaker-team', name: 'MatchmakerTeamManagement', component: () => import('@/modules/operator/MatchmakerTeamManagementView.vue'), meta: { title: '红娘团队管理', permissions: ['matchmaker.role.manage'] } },
|
||||
{ path: 'analytics', name: 'Analytics', component: () => import('@/modules/analytics/AnalyticsView.vue'), meta: { title: '数据统计', permissions: ['analytics.view'] } },
|
||||
{ path: 'audit', name: 'AuditLog', component: () => import('@/modules/audit/AuditLogView.vue'), meta: { title: '操作日志', permissions: ['user.view'] } },
|
||||
// V1.1 新增模块
|
||||
@@ -37,8 +40,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
|
||||
{ path: 'service-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: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
|
||||
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } },
|
||||
{ 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.view'] } },
|
||||
|
||||
],
|
||||
},
|
||||
@@ -48,16 +51,23 @@ const routes: RouteRecordRaw[] = [
|
||||
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
|
||||
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
const requiredPerms = (to.meta.permissions as string[]) || []
|
||||
|
||||
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
|
||||
await authStore.initializeSession()
|
||||
|
||||
// 1. 登录检查
|
||||
if (to.path !== '/login' && !authStore.isLoggedIn) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
// 2. 权限检查
|
||||
if ((to.path === '/referral' || to.path === '/matchmaker') && authStore.permissions.includes('permission.manage')) {
|
||||
ElMessage.info('管理员请在工作台查看红娘业务统计')
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
if (requiredPerms.length > 0 && !hasPermission(requiredPerms)) {
|
||||
ElMessage.warning('您没有访问该页面的权限')
|
||||
return { path: '/dashboard' }
|
||||
|
||||
@@ -18,6 +18,20 @@ interface ApiEnvelope<T> {
|
||||
|
||||
const REQUEST_TIMEOUT = 8000
|
||||
|
||||
export function summarizeMatchmakerServices(rows: JsonRecord[]) {
|
||||
const day = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
const today = day.format(new Date())
|
||||
return {
|
||||
pending: rows.filter(row => ['submitted', 'assigned', 'in_service', 'waiting_user'].includes(String(row.status))).length,
|
||||
completed: rows.filter(row => row.status === 'completed').length,
|
||||
total: rows.filter(row => ['createdAt', 'assignedAt', 'completedAt', 'latestFollowupAt'].some(key => {
|
||||
if (!row[key]) return false
|
||||
const date = new Date(String(row[key]))
|
||||
return !Number.isNaN(date.getTime()) && day.format(date) === today
|
||||
})).length,
|
||||
}
|
||||
}
|
||||
|
||||
const numberOf = (source: JsonRecord | undefined, key: string) => {
|
||||
const value = source?.[key]
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
@@ -40,18 +54,18 @@ const emptyMetric = (
|
||||
key: string,
|
||||
label: string,
|
||||
tone: DashboardMetric['tone'],
|
||||
route: string,
|
||||
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, route })
|
||||
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, status: 'forbidden', route: '' })
|
||||
|
||||
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
|
||||
return {
|
||||
followups: [],
|
||||
metrics: [
|
||||
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
|
||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'),
|
||||
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
|
||||
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
|
||||
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
|
||||
emptyMetric('activity_registrations', '活动报名', 'red', '/activities/interests'),
|
||||
emptyMetric('pending_reviews', '待审核资料', 'rose'),
|
||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber'),
|
||||
emptyMetric('counseling_todos', '咨询服务待办', 'blue'),
|
||||
emptyMetric('station_appointments', '服务站预约', 'green'),
|
||||
emptyMetric('effective_matches', '有效匹配', 'purple'),
|
||||
emptyMetric('activity_registrations', '活动报名', 'red'),
|
||||
],
|
||||
todos: [],
|
||||
activities: [],
|
||||
@@ -65,8 +79,12 @@ export function createEmptyDashboard(notice = '当前账号暂无可展示的工
|
||||
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
|
||||
const canViewAnalytics = permissions.includes('analytics.view')
|
||||
const canViewActivities = permissions.includes('activity.view')
|
||||
const canViewMatchmaker = permissions.includes('matchmaker.view')
|
||||
const canManageMatchmaker = permissions.includes('matchmaker.assign') || permissions.includes('matchmaker.team.assign')
|
||||
const canViewReviews = permissions.includes('review.view')
|
||||
|
||||
const [coreResult, serviceResult, activityResult] = await Promise.allSettled([
|
||||
const [workbenchResult, coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
|
||||
client.get('/operator/workbench/dashboard', { timeout: REQUEST_TIMEOUT }),
|
||||
canViewAnalytics
|
||||
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
@@ -76,53 +94,83 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
canViewActivities
|
||||
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
canViewMatchmaker
|
||||
? client.get('/operator/matchmaker/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
canViewReviews ? client.get('/operator/reviews/stats', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||
canManageMatchmaker ? client.get('/operator/matchmaker/management/services', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
const workbench = fulfilledData<JsonRecord>(workbenchResult)
|
||||
const workbenchMetrics = recordOf(workbench || undefined, 'metrics')
|
||||
const workbenchValue = (key: string) => numberOf(recordOf(workbenchMetrics, key), 'value')
|
||||
const hasWorkbenchMetric = (key: string) => Object.prototype.hasOwnProperty.call(workbenchMetrics, key)
|
||||
const workbenchAvailable = (key: string) => Boolean(recordOf(workbenchMetrics, key).available)
|
||||
const metricStatus = (key: string): DashboardMetric['status'] => {
|
||||
if (hasWorkbenchMetric(key)) return workbenchAvailable(key) ? 'ready' : 'forbidden'
|
||||
return 'error'
|
||||
}
|
||||
const metricAvailable = (key: string) => metricStatus(key) === 'ready'
|
||||
const metricRoute = (status: DashboardMetric['status'], route: string) => status === 'ready' ? route : ''
|
||||
const core = fulfilledData<JsonRecord>(coreResult)
|
||||
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
|
||||
const matchmaker = fulfilledData<JsonRecord>(matchmakerResult)
|
||||
const reviews = fulfilledData<JsonRecord>(reviewResult)
|
||||
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
|
||||
const managedStats = summarizeMatchmakerServices(managedServices || [])
|
||||
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
|
||||
const matchmakerCompleted = canManageMatchmaker
|
||||
? managedStats.completed
|
||||
: numberOf(matchmaker || undefined, 'completedCount')
|
||||
const followups = (managedServices || []).filter(row => row.latestFollowupContent)
|
||||
.sort((a, b) => String(b.latestFollowupAt).localeCompare(String(a.latestFollowupAt)))
|
||||
.slice(0, 5).map(row => ({ serviceId: String(row.serviceId), userDisplayNumber: String(row.userDisplayNumber || '未生成编号'),
|
||||
content: String(row.latestFollowupContent), createdAt: String(row.latestFollowupAt || '') }))
|
||||
const analyticsAvailable = Boolean(core || service)
|
||||
|
||||
const eligibility = recordOf(service || undefined, 'localEligibility')
|
||||
const counseling = recordOf(service || undefined, 'counseling')
|
||||
const appointments = recordOf(service || undefined, 'serviceAppointments')
|
||||
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
||||
const registrationSources = recordOf(service || undefined, 'registrationSources')
|
||||
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
|
||||
|
||||
const metrics: DashboardMetric[] = [
|
||||
{
|
||||
key: 'pending_reviews', label: '待审核资料', value: numberOf(eligibility, 'pending'),
|
||||
trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review',
|
||||
key: 'pending_reviews', label: '待审核资料', value: workbenchValue('pendingReviews'),
|
||||
trendText: workbenchAvailable('pendingReviews') ? '当前数据范围内待审核资料' : '暂无授权数据', tone: 'rose', available: metricAvailable('pendingReviews'), status: metricStatus('pendingReviews'), route: metricRoute(metricStatus('pendingReviews'), '/review'),
|
||||
},
|
||||
{
|
||||
key: 'matchmaker_todos', label: '红娘服务待办', value: 0,
|
||||
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral',
|
||||
key: 'matchmaker_todos', label: '红娘服务待办',
|
||||
value: workbenchValue('matchmakerTodos'),
|
||||
trendText: workbenchAvailable('matchmakerTodos') ? `${workbench?.scopeLabel || '当前范围'}待完成服务` : '暂无授权数据', tone: 'amber', available: metricAvailable('matchmakerTodos'), status: metricStatus('matchmakerTodos'), route: metricRoute(metricStatus('matchmakerTodos'), permissions.includes('permission.manage') ? '' : '/matchmaker'),
|
||||
},
|
||||
{
|
||||
key: 'counseling_todos', label: '咨询服务待办',
|
||||
value: numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'),
|
||||
trendText: service ? '待受理与已分配' : '暂无授权数据', tone: 'blue', available: Boolean(service), route: '/counseling',
|
||||
value: workbenchValue('counselingTodos'),
|
||||
trendText: workbenchAvailable('counselingTodos') ? `${workbench?.scopeLabel || '当前范围'}咨询待办` : '暂无授权数据', tone: 'blue', available: metricAvailable('counselingTodos'), status: metricStatus('counselingTodos'), route: metricRoute(metricStatus('counselingTodos'), '/counseling'),
|
||||
},
|
||||
{
|
||||
key: 'station_appointments', label: '服务站预约',
|
||||
value: numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'),
|
||||
trendText: service ? '待确认与已确认' : '暂无授权数据', tone: 'green', available: Boolean(service), route: '/service-appointment',
|
||||
value: workbenchValue('serviceStationAppointments'),
|
||||
trendText: workbenchAvailable('serviceStationAppointments') ? `${workbench?.scopeLabel || '当前范围'}服务站预约` : '暂无授权数据', tone: 'green', available: metricAvailable('serviceStationAppointments'), status: metricStatus('serviceStationAppointments'), route: metricRoute(metricStatus('serviceStationAppointments'), '/service-appointment'),
|
||||
},
|
||||
{
|
||||
key: 'effective_matches', label: '有效匹配', value: numberOf(effectiveConnections, 'activeMatches'),
|
||||
trendText: service ? '当前活跃匹配' : '暂无授权数据', tone: 'purple', available: Boolean(service), route: '/analytics',
|
||||
key: 'effective_matches', label: '有效匹配', value: workbenchValue('effectiveMatches'),
|
||||
trendText: workbenchAvailable('effectiveMatches') ? `${workbench?.scopeLabel || '当前范围'}有效匹配` : '暂无授权数据', tone: 'purple', available: metricAvailable('effectiveMatches'), status: metricStatus('effectiveMatches'), route: metricRoute(metricStatus('effectiveMatches'), '/analytics'),
|
||||
},
|
||||
{
|
||||
key: 'activity_registrations', label: '活动报名', value: numberOf(registrationSources, 'totalRegistrations'),
|
||||
trendText: service ? '累计有效报名' : '暂无授权数据', tone: 'red', available: Boolean(service), route: '/activities/interests',
|
||||
key: 'activity_registrations', label: '活动报名', value: workbenchValue('activityRegistrations'),
|
||||
trendText: workbenchAvailable('activityRegistrations') ? `${workbench?.scopeLabel || '当前范围'}活动报名` : '暂无授权数据', tone: 'red', available: metricAvailable('activityRegistrations'), status: metricStatus('activityRegistrations'), route: metricRoute(metricStatus('activityRegistrations'), '/activities/interests'),
|
||||
},
|
||||
// 保留 forbidden 卡片以明确展示“无权限”;其数值只在 available 时渲染,route 由 metricRoute 置空。
|
||||
]
|
||||
const metricByKey = (key: string) => metrics.find(metric => metric.key === key)
|
||||
|
||||
const todos = ([
|
||||
{ key: 'review', title: '资料审核', description: '本地资格材料等待核验', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
|
||||
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available },
|
||||
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available },
|
||||
{ key: 'review', title: '资料审核', description: '待领取与审核中资料', count: metricByKey('pending_reviews')?.value || 0, actionText: '立即审核', route: '/review', tone: 'rose', available: metricByKey('pending_reviews')?.available || false },
|
||||
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metricByKey('counseling_todos')?.value || 0, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metricByKey('counseling_todos')?.available || false },
|
||||
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metricByKey('station_appointments')?.value || 0, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metricByKey('station_appointments')?.available || false },
|
||||
{ key: 'matchmaker', title: '红娘服务', description: metricByKey('matchmaker_todos')?.trendText || '暂无授权数据', count: metricByKey('matchmaker_todos')?.value || 0, actionText: permissions.includes('permission.manage') ? '统计' : '查看服务', route: permissions.includes('permission.manage') ? '' : '/matchmaker', tone: 'amber', available: metricByKey('matchmaker_todos')?.available || false },
|
||||
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
|
||||
] as DashboardTodo[]).filter((item) => item.available)
|
||||
|
||||
@@ -142,6 +190,7 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
{ key: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) },
|
||||
{ key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) },
|
||||
{ key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) },
|
||||
{ key: 'matchmaker_completed', label: '红娘服务完成', value: matchmakerCompleted, compareText: canManageMatchmaker ? '管理范围内已完成服务' : '本人已完成服务', tone: 'amber', available: matchmakerAvailable },
|
||||
] as DashboardOverviewItem[]).filter((item) => item.available)
|
||||
|
||||
const effectiveness = ([
|
||||
@@ -152,12 +201,14 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
|
||||
] as DashboardEffectivenessItem[]).filter((item) => item.available)
|
||||
|
||||
const rejectedCount = [coreResult, serviceResult, activityResult].filter((item) => item.status === 'rejected').length
|
||||
const notice = !analyticsAvailable && !activityRows
|
||||
const rejectedCount = [workbenchResult, coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
|
||||
const anyData = Boolean(workbench) || analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
|
||||
const notice = !anyData
|
||||
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
||||
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
||||
|
||||
return {
|
||||
followups,
|
||||
metrics,
|
||||
todos,
|
||||
activities,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { post } from '@/api/client'
|
||||
import { setAccessToken, clearAccessToken } from '@/utils/token'
|
||||
|
||||
interface OperatorInfo {
|
||||
operatorId: string
|
||||
@@ -8,53 +9,87 @@ interface OperatorInfo {
|
||||
username: string
|
||||
permissions: string[]
|
||||
organizationId: string | null
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
const previewRolePermissions: Record<string, string[]> = {
|
||||
reviewer: ['review.view', 'user.view'],
|
||||
counselor: ['counseling.service.view', 'referral.manage'],
|
||||
counselor: ['counseling.service.view', 'matchmaker.service'],
|
||||
station: ['service_location.view', 'service_appointment.view'],
|
||||
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', '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', () => {
|
||||
const previewParams = import.meta.env.DEV ? new URLSearchParams(window.location.search) : null
|
||||
const previewRole = previewParams?.get('preview') || ''
|
||||
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
||||
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
|
||||
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null, roles: [previewRole] }
|
||||
: 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 || [])
|
||||
// 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,
|
||||
roles: Array.isArray(data.roles) ? data.roles : (Array.isArray(data.roleCodes) ? data.roleCodes : [data.roleCode || data.role].filter(Boolean)),
|
||||
}
|
||||
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
||||
setAccessToken(token.value)
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const res: any = await post('/auth/operator/login', { username, password })
|
||||
if (res.success) {
|
||||
token.value = res.data.accessToken
|
||||
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))
|
||||
applyAuthData(res.data)
|
||||
return res.data
|
||||
}
|
||||
throw new Error(res.message || '登录失败')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
operator.value = null
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('operator')
|
||||
/**
|
||||
* 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
|
||||
* 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
|
||||
* 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
|
||||
*/
|
||||
async function initializeSession(): Promise<boolean> {
|
||||
if (sessionInitialized) return isLoggedIn.value
|
||||
sessionInitialized = true
|
||||
if (token.value) return true
|
||||
try {
|
||||
const res: any = await post('/auth/operator/refresh', {})
|
||||
if (res.success) {
|
||||
applyAuthData(res.data)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// 未登录或 refresh 失效,交由路由守卫跳转登录页
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -37,8 +37,18 @@
|
||||
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
|
||||
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
|
||||
--admin-coral-border: rgba(244, 95, 104, 0.22);
|
||||
--admin-scene-bg-start: #FFEDEE;
|
||||
--admin-scene-bg-mid: #FFF6F4;
|
||||
--admin-scene-bg-end: #FBF5F4;
|
||||
--admin-scene-glow: rgba(244, 95, 104, 0.28);
|
||||
--admin-scene-glow-soft: rgba(255, 150, 170, 0.22);
|
||||
--admin-scene-surface: rgba(255, 255, 255, 0.65);
|
||||
--admin-scene-border: rgba(244, 95, 104, 0.18);
|
||||
--admin-scene-divider: rgba(244, 95, 104, 0.30);
|
||||
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
|
||||
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
|
||||
/* 文字用渐变(background-clip:text):比 solid 更鲜亮,用于「善缘丹阳」等大字号品牌标题 */
|
||||
--gradient-brand-text: linear-gradient(135deg, #FF7D6E 0%, #E8475D 60%, #B8434A 100%);
|
||||
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
|
||||
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
|
||||
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red'
|
||||
export type DashboardMetricStatus = 'ready' | 'forbidden' | 'error'
|
||||
|
||||
export interface DashboardMetric {
|
||||
key: string
|
||||
@@ -7,6 +8,7 @@ export interface DashboardMetric {
|
||||
trendText: string
|
||||
tone: DashboardTone
|
||||
available: boolean
|
||||
status: DashboardMetricStatus
|
||||
route: string
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ export interface DashboardEffectivenessItem {
|
||||
}
|
||||
|
||||
export interface DashboardModel {
|
||||
followups: { serviceId: string; userDisplayNumber: string; content: string; createdAt: string }[]
|
||||
metrics: DashboardMetric[]
|
||||
todos: DashboardTodo[]
|
||||
activities: DashboardActivity[]
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
@@ -106,6 +106,17 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<article v-if="dashboard.followups.length" class="dashboard-panel">
|
||||
<header class="panel-header"><div><h2>最新红娘跟进记录</h2></div></header>
|
||||
<div class="todo-list">
|
||||
<button v-for="item in dashboard.followups" :key="item.serviceId" type="button" class="todo-row"
|
||||
@click="goTo(`/matchmaker?serviceId=${encodeURIComponent(item.serviceId)}`)">
|
||||
<span class="todo-copy"><strong>{{ item.userDisplayNumber }}</strong><small>{{ item.content }}</small></span>
|
||||
<em>{{ formatDateTime(item.createdAt) }} · 查看服务</em>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="dashboard-grid dashboard-grid-bottom">
|
||||
<article class="dashboard-panel service-overview-panel">
|
||||
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
|
||||
@@ -136,7 +147,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onActivated, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
@@ -277,15 +288,19 @@ async function retryLoad() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(retryLoad)
|
||||
onActivated(retryLoad)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
max-width: var(--admin-content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) 26px 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dashboard-welcome {
|
||||
@@ -478,6 +493,19 @@ onMounted(retryLoad)
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.dashboard-grid-top {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-grid-bottom {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid-bottom .dashboard-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-panel {
|
||||
min-width: 0;
|
||||
padding: 14px var(--space-4);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<main class="login-shell">
|
||||
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
|
||||
<section class="brand-panel" aria-label="爱在丹阳公益婚恋服务平台">
|
||||
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
|
||||
<div class="brand-overlay">
|
||||
<div class="brand-title">
|
||||
<img :src="BRAND_LOGO" alt="" />
|
||||
<div>
|
||||
<strong>善缘丹阳</strong>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer>善缘丹阳 · 公益婚恋服务平台</footer>
|
||||
<footer>爱在丹阳 · 公益婚恋服务平台</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -135,6 +135,8 @@ async function handleLogin() {
|
||||
.brand-title img {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
}
|
||||
|
||||
.brand-title div {
|
||||
|
||||
@@ -18,6 +18,8 @@ export default defineConfig(({ command }) => ({
|
||||
server: {
|
||||
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
|
||||
port: 13520,
|
||||
// 允许同一局域网的浏览器访问开发页面。
|
||||
host: '0.0.0.0',
|
||||
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
|
||||
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
|
||||
strictPort: true,
|
||||
|
||||
|
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>
|
||||
<artifactId>danyang-match-platform</artifactId>
|
||||
<version>1.0.0-RC.1</version>
|
||||
<version>1.2.0</version>
|
||||
<name>Danyang Match Platform</name>
|
||||
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
|
||||
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.danyang.match.platform.account.controller;
|
||||
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/account/service-consent")
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceConsentController {
|
||||
private final ServiceConsentService service;
|
||||
@GetMapping public ApiResponse<?> get(@AuthenticationPrincipal UserPrincipal p) { return ApiResponse.ok(service.getConsentState(p.id())); }
|
||||
@PostMapping public ApiResponse<?> agree(@AuthenticationPrincipal UserPrincipal p, @RequestBody Map<String, Object> body, jakarta.servlet.http.HttpServletRequest req) {
|
||||
@SuppressWarnings("unchecked") Map<String, String> versions = (Map<String, String>) body.get("documentVersions");
|
||||
return ApiResponse.ok(service.agree(p.id(), versions, req.getRemoteAddr(), req.getHeader("User-Agent")));
|
||||
}
|
||||
@PostMapping("/withdraw") public ApiResponse<?> withdraw(@AuthenticationPrincipal UserPrincipal p, @RequestBody Map<String, String> body, jakarta.servlet.http.HttpServletRequest req) {
|
||||
return ApiResponse.ok(service.withdraw(p.id(), body.get("consentType"), req.getRemoteAddr(), req.getHeader("User-Agent")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.danyang.match.platform.account.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("users")
|
||||
public class UserAccountLifecycle extends BaseEntity {
|
||||
@TableField("phone_verification_status")
|
||||
private String phoneVerificationStatus;
|
||||
@TableField("account_creation_status")
|
||||
private String accountCreationStatus;
|
||||
@TableField("credential_delivery_status")
|
||||
private String credentialDeliveryStatus;
|
||||
@TableField("service_eligibility_status")
|
||||
private String serviceEligibilityStatus;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.danyang.match.platform.account.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("user_consent_states")
|
||||
public class UserConsentState extends BaseEntity {
|
||||
private String userId;
|
||||
private String consentType;
|
||||
private String documentVersion;
|
||||
private String status;
|
||||
private Instant agreedAt;
|
||||
private Instant withdrawnAt;
|
||||
private String actorUserId;
|
||||
private String ipAddress;
|
||||
private String userAgent;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.danyang.match.platform.account.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("user_credentials")
|
||||
public class UserCredential extends BaseEntity {
|
||||
private String userId;
|
||||
private String passwordHash;
|
||||
private Integer credentialVersion;
|
||||
private Boolean mustChangePassword;
|
||||
private Instant issuedAt;
|
||||
private Instant firstUsedAt;
|
||||
private Instant lastChangedAt;
|
||||
private Instant revokedAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.danyang.match.platform.account.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@TableName("user_service_states")
|
||||
public class UserServiceState {
|
||||
@TableId private String userId;
|
||||
private Boolean serviceActive;
|
||||
private String serviceBlockReason;
|
||||
private Instant evaluatedAt;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
private Instant deletedAt;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.danyang.match.platform.account.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.account.entity.UserConsentState;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface UserConsentStateMapper extends BaseMapper<UserConsentState> {
|
||||
@Select("SELECT COUNT(*) FROM user_consent_states WHERE user_id = #{userId} AND status = 'agreed' AND deleted_at IS NULL")
|
||||
int countAgreed(String userId);
|
||||
|
||||
@Select("SELECT * FROM user_consent_states WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY consent_type")
|
||||
List<UserConsentState> findByUserId(String userId);
|
||||
|
||||
@Select("SELECT * FROM user_consent_states WHERE user_id = #{userId} AND consent_type = #{consentType} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<UserConsentState> findByUserAndType(String userId, String consentType);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.danyang.match.platform.account.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.account.entity.UserServiceState;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface UserServiceStateMapper extends BaseMapper<UserServiceState> {
|
||||
@Select("SELECT * FROM user_service_states WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<UserServiceState> findByUserId(String userId);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.danyang.match.platform.account.service;
|
||||
|
||||
import com.danyang.match.platform.account.entity.UserConsentState;
|
||||
import com.danyang.match.platform.account.entity.UserServiceState;
|
||||
import com.danyang.match.platform.account.mapper.UserConsentStateMapper;
|
||||
import com.danyang.match.platform.account.mapper.UserServiceStateMapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ServiceConsentService {
|
||||
public static final String SERVICE_TERMS = "service_terms";
|
||||
public static final String PRIVACY_SAFETY = "privacy_safety";
|
||||
private final UserConsentStateMapper consentMapper;
|
||||
private final UserServiceStateMapper serviceStateMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper profileMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
@Value("${account.service-consent.service-terms-version:v1}") private String serviceTermsVersion = "v1";
|
||||
@Value("${account.service-consent.privacy-safety-version:v1}") private String privacySafetyVersion = "v1";
|
||||
|
||||
public List<Map<String, Object>> getConsentState(String userId) {
|
||||
Map<String, UserConsentState> current = new HashMap<>();
|
||||
consentMapper.findByUserId(userId).forEach(c -> current.put(c.getConsentType(), c));
|
||||
return List.of(document(SERVICE_TERMS, serviceTermsVersion, current.get(SERVICE_TERMS)),
|
||||
document(PRIVACY_SAFETY, privacySafetyVersion, current.get(PRIVACY_SAFETY)));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> agree(String userId, Map<String, String> versions, String ip, String ua) {
|
||||
if (versions == null || !versions.containsKey(SERVICE_TERMS) || !versions.containsKey(PRIVACY_SAFETY))
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "两份服务授权必须分别确认");
|
||||
agreeOne(userId, SERVICE_TERMS, serviceTermsVersion, versions.get(SERVICE_TERMS), ip, ua);
|
||||
agreeOne(userId, PRIVACY_SAFETY, privacySafetyVersion, versions.get(PRIVACY_SAFETY), ip, ua);
|
||||
boolean active = evaluate(userId);
|
||||
audit(userId, "SERVICE_CONSENT_AGREE", "service_consent", ip, ua);
|
||||
return Map.of("serviceActive", active, "consents", getConsentState(userId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> withdraw(String userId, String type, String ip, String ua) {
|
||||
if (!required().contains(type)) throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的授权类型");
|
||||
UserConsentState state = consentMapper.findByUserAndType(userId, type)
|
||||
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该授权记录"));
|
||||
state.setStatus("withdrawn"); state.setWithdrawnAt(Instant.now()); state.setActorUserId(userId);
|
||||
state.setIpAddress(ip); state.setUserAgent(ua); consentMapper.updateById(state); evaluate(userId);
|
||||
audit(userId, "SERVICE_CONSENT_WITHDRAW", type, ip, ua);
|
||||
return Map.of("withdrawn", true, "consentType", type, "serviceActive", false);
|
||||
}
|
||||
|
||||
public void requireActive(String userId) {
|
||||
User user = userMapper.findById(userId).orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||
UserProfile profile = profileMapper.findByUserId(userId).orElse(null);
|
||||
if (!Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus()) || !"verified".equals(user.getPhoneVerificationStatus()))
|
||||
throw blocked("账号或手机号尚未满足服务条件");
|
||||
if (profile == null || !Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||
|| profile.getPublishedProfileRevision() == null
|
||||
|| !Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision()))
|
||||
throw blocked("资料尚未审核通过");
|
||||
if (consentMapper.countAgreed(userId) != required().size()) throw blocked("请先确认两份服务授权");
|
||||
if (!"eligible".equals(user.getServiceEligibilityStatus())) throw blocked("当前账号不具备服务资格");
|
||||
}
|
||||
|
||||
public boolean isActive(String userId) { try { requireActive(userId); return true; } catch (RuntimeException e) { return false; } }
|
||||
|
||||
private void agreeOne(String userId, String type, String expected, String actual, String ip, String ua) {
|
||||
if (!Objects.equals(expected, actual)) throw new BusinessException(ApiResponseCode.CONFLICT, "授权文档版本已更新,请刷新后重新确认");
|
||||
UserConsentState s = consentMapper.findByUserAndType(userId, type).orElseGet(UserConsentState::new);
|
||||
s.setUserId(userId); s.setConsentType(type); s.setDocumentVersion(expected); s.setStatus("agreed"); s.setAgreedAt(Instant.now());
|
||||
s.setWithdrawnAt(null); s.setActorUserId(userId); s.setIpAddress(ip); s.setUserAgent(ua);
|
||||
if (s.getId() == null) { s.setId(UUID.randomUUID().toString()); consentMapper.insert(s); } else consentMapper.updateById(s);
|
||||
}
|
||||
|
||||
private boolean evaluate(String userId) {
|
||||
UserServiceState s = serviceStateMapper.findByUserId(userId).orElseGet(UserServiceState::new);
|
||||
s.setUserId(userId); boolean active = consentMapper.countAgreed(userId) == required().size();
|
||||
s.setServiceActive(active); s.setServiceBlockReason(active ? null : "consent_required"); s.setEvaluatedAt(Instant.now());
|
||||
if (s.getCreatedAt() == null) serviceStateMapper.insert(s); else serviceStateMapper.updateById(s);
|
||||
return active;
|
||||
}
|
||||
|
||||
private void audit(String userId, String action, String resourceId, String ip, String ua) {
|
||||
AuditLog log = new AuditLog(); log.setSubjectUserId(userId); log.setAction(action); log.setResourceType("service_consent");
|
||||
log.setResourceId(resourceId); log.setIp(ip); log.setUserAgent(ua); auditLogMapper.insert(log);
|
||||
}
|
||||
|
||||
private Set<String> required() { return Set.of(SERVICE_TERMS, PRIVACY_SAFETY); }
|
||||
private BusinessException blocked(String message) { return new BusinessException(ApiResponseCode.FORBIDDEN, message); }
|
||||
private Map<String, Object> document(String type, String version, UserConsentState state) {
|
||||
return Map.of("consentType", type, "documentVersion", version, "status", state == null ? "required" : state.getStatus(), "agreed", state != null && "agreed".equals(state.getStatus()));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import com.danyang.match.platform.activity.entity.ActivityInterest;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||
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.security.principal.OperatorPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* 活动反馈与互选控制器
|
||||
@@ -30,8 +31,11 @@ import java.util.Map;
|
||||
public class ActivityInterestController {
|
||||
|
||||
private final ActivityInterestService interestService;
|
||||
private final RelationshipService relationshipService;
|
||||
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
|
||||
private final ActivityMutualConsentService consentService;
|
||||
private final ActivityMapper activityMapper;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private ServiceConsentService serviceConsentService;
|
||||
|
||||
@GetMapping("/operator/activity-interest-summary")
|
||||
@PreAuthorize("hasAuthority('activity.view')")
|
||||
@@ -62,6 +66,7 @@ public class ActivityInterestController {
|
||||
@PathVariable String id,
|
||||
@RequestBody ActivityFeedbackDTO body) {
|
||||
String userId = principal.id();
|
||||
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||
Map<String, Object> result = interestService.submitFeedback(
|
||||
userId,
|
||||
id,
|
||||
@@ -82,6 +87,7 @@ public class ActivityInterestController {
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
String userId = principal.id();
|
||||
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||
return ApiResponse.ok(interestService.getMutualMatches(userId, id));
|
||||
}
|
||||
|
||||
@@ -95,6 +101,7 @@ public class ActivityInterestController {
|
||||
@PathVariable String id,
|
||||
@PathVariable String partnerId) {
|
||||
String userId = principal.id();
|
||||
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||
boolean mutual = interestService.hasMutualInterest(userId, partnerId, id);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("hasMutual", mutual);
|
||||
@@ -105,7 +112,12 @@ public class ActivityInterestController {
|
||||
/**
|
||||
* 确认开启线上沟通(V1.1 Phase 08)
|
||||
* 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
|
||||
@PostMapping("/{id}/interests/confirm")
|
||||
@@ -114,19 +126,23 @@ public class ActivityInterestController {
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = principal.id();
|
||||
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||
String partnerId = body.get("partnerId");
|
||||
if (partnerId == null || partnerId.isBlank()) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
"partnerId不能为空");
|
||||
}
|
||||
// 验证互选状态
|
||||
// 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
|
||||
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
|
||||
return ApiResponse.error(
|
||||
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 com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
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. 验证用户已报名且通过审核
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
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;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -407,7 +408,7 @@ public class ActivityService {
|
||||
@Transactional
|
||||
public ActivityRegistration cancelRegistration(String userId, String activityId) {
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
|
||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
|
||||
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
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.exception.GlobalExceptionHandler.BusinessException;
|
||||
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) {
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
|
||||
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
|
||||
|
||||
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -67,7 +69,7 @@ public class ActivityMutualConsentService {
|
||||
@Transactional
|
||||
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
|
||||
|
||||
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
|
||||
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
|
||||
@@ -76,7 +78,7 @@ public class ActivityMutualConsentService {
|
||||
|
||||
// 回读权威状态(可能受并发另一方提交影响)
|
||||
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 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) {
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
|
||||
if (userId.equals(consent.getUserAId())) {
|
||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
||||
} else {
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.risk.entity.RiskAction;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -46,6 +48,7 @@ public class AppealService {
|
||||
|
||||
private final AppealMapper appealMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final NotificationService notificationService;
|
||||
@@ -281,6 +284,11 @@ public class AppealService {
|
||||
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
|
||||
}
|
||||
|
||||
// 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。
|
||||
// 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
|
||||
// users.account_status=banned/suspended 无人回滚。
|
||||
restoreAccountStatusIfNoActiveRestriction(appeal.getUserId());
|
||||
|
||||
// 通知用户
|
||||
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||
"申诉审核通过",
|
||||
@@ -371,6 +379,38 @@ public class AppealService {
|
||||
return "limited";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。
|
||||
*
|
||||
* <p>否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
|
||||
* users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
|
||||
*
|
||||
* <p>仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。
|
||||
*/
|
||||
private void restoreAccountStatusIfNoActiveRestriction(String userId) {
|
||||
boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream()
|
||||
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())
|
||||
|| Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
|
||||
if (stillRestricted) {
|
||||
return;
|
||||
}
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
String oldStatus = user.getAccountStatus();
|
||||
if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus)
|
||||
&& !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) {
|
||||
return;
|
||||
}
|
||||
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
userMapper.updateById(user);
|
||||
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。
|
||||
saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId,
|
||||
"{\"accountStatus\":\"" + oldStatus + "\"}",
|
||||
"{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}");
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
@@ -378,6 +418,10 @@ public class AppealService {
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
// 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意
|
||||
// audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。
|
||||
auditLog.setBeforeData(beforeData);
|
||||
auditLog.setAfterData(afterData);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,20 @@ public class AuditLog extends BaseEntity {
|
||||
private String afterData;
|
||||
private String ip;
|
||||
private String userAgent;
|
||||
|
||||
// ==================== ZC-MM-006 / T01 扩展审计字段(V055,全可空) ====================
|
||||
|
||||
/** 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view。 */
|
||||
private String purposeCode;
|
||||
/** 关联的 matchmaker_assignments.id(非分配场景为 null)。 */
|
||||
private String serviceAssignmentId;
|
||||
/** 落库时操作者权限快照(permissions 逗号拼接,截断 128)。 */
|
||||
private String actorRoleSnapshot;
|
||||
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
|
||||
private String requestTraceId;
|
||||
|
||||
// ==================== V057 / 运行时实测修复:C 端用户主体维度 ====================
|
||||
|
||||
/** 用户主体 ID(C 端触发的审计写此列且 operator_id=null;两者恰有一者非空)。 */
|
||||
private String subjectUserId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.danyang.match.platform.audit.service;
|
||||
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 拒绝类审计的独立事务写入器(运行时实测修复)。
|
||||
*
|
||||
* <p>背景:拒绝审计(DATA_SCOPE_DENIED / AUTHORIZATION_DENIED)原先与业务方法同一事务——
|
||||
* 拒绝时抛异常必然触发业务事务回滚,审计 insert(已执行 Updates:1)随之被回滚吞掉:
|
||||
* 生产全量 deny 审计零落库。H2 集成测试因测试类 @Transactional(测试事务内可见未提交行)
|
||||
* 结构性假绿。运行时实测(outsider consent → deny 行 0 条)抓到。
|
||||
*
|
||||
* <p>语义:REQUIRES_NEW 独立事务提交——外层业务事务回滚与否均不影响拒绝审计落库;
|
||||
* 内层失败仍由调用方 catch 记日志后照常拒绝(审计失败不阻断拒绝响应)。
|
||||
* 成功路径审计(SENSITIVE_ACCESS / 业务动作)保持与业务同事务(业务回滚审计一并消失,语义正确),
|
||||
* 不走本写入器。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuditDenyWriter {
|
||||
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void insertIndependent(AuditLog auditLog) {
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@ public class AuditLogService {
|
||||
m.put("ip", log.getIp());
|
||||
m.put("userAgent", log.getUserAgent());
|
||||
m.put("createdAt", log.getCreatedAt());
|
||||
// ZC-MM-006 / T01:扩展审计字段输出(可空键,存量行输出 null,读取兼容)
|
||||
m.put("purposeCode", log.getPurposeCode());
|
||||
m.put("serviceAssignmentId", log.getServiceAssignmentId());
|
||||
m.put("actorRoleSnapshot", log.getActorRoleSnapshot());
|
||||
m.put("requestTraceId", log.getRequestTraceId());
|
||||
return m;
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
|
||||
@@ -73,11 +78,48 @@ public class AuditLogService {
|
||||
*/
|
||||
@Transactional
|
||||
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
|
||||
recordSensitiveAccess(operatorId, resourceType, resourceId, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZC-MM-006 / T01 扩展版敏感访问记录:附加目的码与关联分配 ID。
|
||||
* 旧 3 参签名委托本方法(purposeCode/serviceAssignmentId 为 null),存量调用兼容。
|
||||
*/
|
||||
@Transactional
|
||||
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId,
|
||||
String purposeCode, String serviceAssignmentId) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction("SENSITIVE_ACCESS");
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setPurposeCode(purposeCode);
|
||||
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
auditLog.setIp(req.getRemoteAddr());
|
||||
String ua = req.getHeader("User-Agent");
|
||||
auditLog.setUserAgent(ua != null ? (ua.length() > 512 ? ua.substring(0, 512) : ua) : null);
|
||||
}
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
|
||||
/**
|
||||
* V057 / 运行时实测修复:C 端用户主体的审计入口。
|
||||
* operator 主体照旧走 recordSensitiveAccess/saveAuditLog(operator_id 维度);
|
||||
* user 主体(consent/decline 等用户动作)写 subject_user_id 且 operator_id=null——
|
||||
* 否则 userId 撞 audit_logs_operator_id_fkey(→operators) 必 FK violation(H2 无 FK 假绿)。
|
||||
*/
|
||||
@Transactional
|
||||
public void recordUserAction(String userId, String action, String resourceType, String resourceId,
|
||||
String purposeCode, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setSubjectUserId(userId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setAfterData(afterData);
|
||||
auditLog.setPurposeCode(purposeCode);
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
auditLog.setIp(req.getRemoteAddr());
|
||||
|
||||
@@ -9,6 +9,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.validation.Valid;
|
||||
import com.danyang.match.platform.user.dto.PhoneVerificationRequest;
|
||||
import com.danyang.match.platform.user.dto.UserRegistrationRequest;
|
||||
import com.danyang.match.platform.user.service.PhoneVerificationService;
|
||||
import com.danyang.match.platform.user.service.UserRegistrationService;
|
||||
import com.danyang.match.platform.auth.service.PasswordAuthService;
|
||||
import com.danyang.match.platform.user.dto.PasswordLoginRequest;
|
||||
import com.danyang.match.platform.user.dto.InitialPasswordChangeRequest;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -18,6 +26,43 @@ import java.util.Map;
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final PhoneVerificationService phoneVerificationService;
|
||||
private final UserRegistrationService userRegistrationService;
|
||||
private final PasswordAuthService passwordAuthService;
|
||||
|
||||
@PostMapping("/password/login")
|
||||
public ApiResponse<Map<String, Object>> passwordLogin(@Valid @RequestBody PasswordLoginRequest request) {
|
||||
return ApiResponse.ok(passwordAuthService.login(request.phone(), request.password()));
|
||||
}
|
||||
|
||||
@PostMapping("/password/change-initial")
|
||||
@PreAuthorize("hasRole('USER')")
|
||||
public ApiResponse<Void> changeInitialPassword(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody InitialPasswordChangeRequest request) {
|
||||
passwordAuthService.changeInitialPassword(principal.id(), request.oldPassword(), request.newPassword());
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<Map<String, Object>> register(@Valid @RequestBody UserRegistrationRequest request) {
|
||||
return ApiResponse.ok(userRegistrationService.register(request));
|
||||
}
|
||||
|
||||
@PostMapping("/phone/send-code")
|
||||
public ApiResponse<Void> sendPhoneCode(@RequestBody Map<String, String> body) {
|
||||
String phone = body.get("phone");
|
||||
if (phone == null || !phone.matches("^1\\d{10}$")) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号格式不正确");
|
||||
}
|
||||
phoneVerificationService.sendCode(phone);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/phone/verify")
|
||||
public ApiResponse<Map<String, String>> verifyPhone(@Valid @RequestBody PhoneVerificationRequest request) {
|
||||
return ApiResponse.ok(Map.of("verificationTransaction",
|
||||
phoneVerificationService.verify(request.phone(), request.code())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信登录
|
||||
@@ -44,10 +89,11 @@ public class AuthController {
|
||||
public ApiResponse<Void> bindPhone(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String phone = body.get("phone");
|
||||
if (phone == null || phone.isBlank()) {
|
||||
String transaction = body.get("verificationTransaction");
|
||||
if (phone == null || phone.isBlank() || transaction == null || transaction.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
|
||||
}
|
||||
authService.bindPhone(principal.id(), phone);
|
||||
authService.bindPhone(principal.id(), phone, transaction);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
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.shared.dto.ApiResponse;
|
||||
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 org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -17,14 +23,80 @@ import java.util.Map;
|
||||
public class OperatorAuthController {
|
||||
|
||||
private final OperatorAuthService operatorAuthService;
|
||||
private final OperatorCookieWriter cookieWriter;
|
||||
|
||||
@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 password = body.get("password");
|
||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.danyang.match.platform.auth.provider;
|
||||
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@Profile("!test & !demo")
|
||||
public class ConfiguredSmsVerificationProvider implements SmsVerificationProvider {
|
||||
private final String provider;
|
||||
|
||||
public ConfiguredSmsVerificationProvider(
|
||||
@Value("${sms.provider:}") String provider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendCode(String phone, String code) {
|
||||
if (provider == null || provider.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "短信服务未配置");
|
||||
}
|
||||
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "短信服务适配器尚未配置完成");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.danyang.match.platform.auth.provider;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@Profile({"test", "demo"})
|
||||
public class MockSmsVerificationProvider implements SmsVerificationProvider {
|
||||
private final Map<String, String> sentCodes = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void sendCode(String phone, String code) {
|
||||
sentCodes.put(phone, code);
|
||||
}
|
||||
|
||||
public String lastCode(String phone) {
|
||||
return sentCodes.get(phone);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,15 @@ import java.util.UUID;
|
||||
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
|
||||
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
|
||||
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
|
||||
public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
|
||||
@@ -24,6 +29,10 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
@Value("${wechat.app-id:dev-app-id}")
|
||||
private String appId;
|
||||
|
||||
/** 演示环境可固定到预置用户;生产真实微信登录不使用此 Provider。 */
|
||||
@Value("${wechat.mock.openid:}")
|
||||
private String configuredOpenid;
|
||||
|
||||
// 模拟 openid 映射
|
||||
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -31,8 +40,21 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
public Map<String, String> getCodeSession(String code) {
|
||||
log.debug("[Mock] 微信登录 code: {}", code);
|
||||
|
||||
// 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。
|
||||
// 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。
|
||||
if (code != null && code.startsWith("demo_openid_")) {
|
||||
return Map.of(
|
||||
"openid", code,
|
||||
"unionid", "mock_unionid_" + code,
|
||||
"session_key", "mock_session_key",
|
||||
"appid", appId
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟:相同 code 返回相同 openid
|
||||
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
|
||||
String openid = configuredOpenid != null && !configuredOpenid.isBlank()
|
||||
? configuredOpenid
|
||||
: codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
return Map.of(
|
||||
"openid", openid,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.danyang.match.platform.auth.provider;
|
||||
|
||||
public interface SmsVerificationProvider {
|
||||
void sendCode(String phone, String code);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import com.danyang.match.platform.review.entity.ReviewTask;
|
||||
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.user.service.PhoneVerificationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -30,6 +31,7 @@ import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -46,6 +48,7 @@ public class AuthService {
|
||||
private final CapabilityService capabilityService;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final ReviewTaskMapper reviewTaskMapper;
|
||||
private final PhoneVerificationService phoneVerificationService;
|
||||
|
||||
@Value("${jwt.access-token-expiration-ms:7200000}")
|
||||
private long accessTokenExpireMs;
|
||||
@@ -90,7 +93,9 @@ public class AuthService {
|
||||
|
||||
// 将 refreshToken 存入 Redis(用于撤销)
|
||||
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);
|
||||
@@ -108,10 +113,10 @@ public class AuthService {
|
||||
* 绑定手机号
|
||||
*/
|
||||
@Transactional
|
||||
public void bindPhone(String userId, String phoneCode) {
|
||||
// TODO: Phase 03 对接真实短信验证码
|
||||
public void bindPhone(String userId, String phoneCode, String verificationTransaction) {
|
||||
phoneVerificationService.consumeTransaction(verificationTransaction, phoneCode);
|
||||
User user = userMapper.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||
|
||||
// 检查手机号是否已被其他用户占用
|
||||
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
|
||||
@@ -121,6 +126,7 @@ public class AuthService {
|
||||
});
|
||||
|
||||
user.setPhone(phoneCode);
|
||||
user.setPhoneVerificationStatus("verified");
|
||||
userMapper.updateById(user);
|
||||
|
||||
saveAuditLog(userId, null, "PHONE_BIND", "user", userId, null, null);
|
||||
@@ -172,9 +178,9 @@ public class AuthService {
|
||||
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
|
||||
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
|
||||
|
||||
// 更新 Redis
|
||||
// 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
|
||||
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
|
||||
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
|
||||
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("accessToken", newAccessToken);
|
||||
@@ -200,7 +206,7 @@ public class AuthService {
|
||||
*/
|
||||
public Map<String, Object> getMeStatus(String userId) {
|
||||
User user = userMapper.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
status.put("userId", user.getId());
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -100,6 +101,9 @@ public class OperatorAuthService {
|
||||
.toList();
|
||||
|
||||
// 签发 Token
|
||||
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
|
||||
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
|
||||
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("operatorId", operator.getId());
|
||||
claims.put("organizationId", operator.getOrganizationId());
|
||||
@@ -120,6 +124,7 @@ public class OperatorAuthService {
|
||||
result.put("accessToken", accessToken);
|
||||
result.put("refreshToken", refreshToken);
|
||||
result.put("operatorId", operator.getId());
|
||||
result.put("operatorDisplayId", operator.getDisplayId());
|
||||
result.put("name", operator.getName());
|
||||
result.put("username", operator.getUsername());
|
||||
result.put("organizationId", operator.getOrganizationId());
|
||||
@@ -144,7 +149,7 @@ public class OperatorAuthService {
|
||||
*/
|
||||
public Map<String, Object> getMe(String 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<String> permissionCodes = permissions.stream()
|
||||
@@ -153,6 +158,7 @@ public class OperatorAuthService {
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("operatorId", operator.getId());
|
||||
result.put("operatorDisplayId", operator.getDisplayId());
|
||||
result.put("name", operator.getName());
|
||||
result.put("username", operator.getUsername());
|
||||
result.put("username", operator.getUsername());
|
||||
@@ -176,6 +182,11 @@ public class OperatorAuthService {
|
||||
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);
|
||||
if (storedToken == null || !storedToken.equals(refreshToken)) {
|
||||
throw new UnauthorizedException("Refresh Token 无效");
|
||||
@@ -193,6 +204,13 @@ public class OperatorAuthService {
|
||||
result.put("accessToken", accessToken);
|
||||
result.put("refreshToken", newRefreshToken);
|
||||
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
|
||||
result.put("operatorId", operatorInfo.get("operatorId"));
|
||||
result.put("operatorDisplayId", operatorInfo.get("operatorDisplayId"));
|
||||
result.put("name", operatorInfo.get("name"));
|
||||
result.put("username", operatorInfo.get("username"));
|
||||
result.put("organizationId", operatorInfo.get("organizationId"));
|
||||
result.put("permissions", operatorInfo.get("permissions"));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.danyang.match.platform.auth.service;
|
||||
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.UnauthorizedException;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserCredentialMapper;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.account.entity.UserCredential;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PasswordAuthService {
|
||||
private final UserMapper userMapper;
|
||||
private final UserCredentialMapper credentialMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> login(String phone, String password) {
|
||||
User user = userMapper.findByPhone(phone)
|
||||
.orElseThrow(() -> new UnauthorizedException("手机号或密码错误"));
|
||||
if (!"verified".equals(user.getPhoneVerificationStatus())
|
||||
|| !Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())) {
|
||||
throw new UnauthorizedException("账号尚未完成手机号核验或已停用");
|
||||
}
|
||||
UserCredential credential = credentialMapper.findByUserId(user.getId())
|
||||
.orElseThrow(() -> new UnauthorizedException("手机号或密码错误"));
|
||||
if (credential.getRevokedAt() != null || !passwordEncoder.matches(password, credential.getPasswordHash())) {
|
||||
throw new UnauthorizedException("手机号或密码错误");
|
||||
}
|
||||
credential.setFirstUsedAt(credential.getFirstUsedAt() == null ? Instant.now() : credential.getFirstUsedAt());
|
||||
credentialMapper.updateById(credential);
|
||||
String access = jwtTokenService.generateAccessToken(user.getId(), null);
|
||||
String refresh = jwtTokenService.generateRefreshToken(user.getId());
|
||||
redisTemplate.opsForValue().set("auth:refresh_token:" + user.getId(), refresh,
|
||||
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("accessToken", access);
|
||||
result.put("refreshToken", refresh);
|
||||
result.put("userId", user.getId());
|
||||
result.put("mustChangePassword", Boolean.TRUE.equals(credential.getMustChangePassword()));
|
||||
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changeInitialPassword(String userId, String oldPassword, String newPassword) {
|
||||
if (newPassword == null || newPassword.length() < 8) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "新密码至少 8 位");
|
||||
}
|
||||
UserCredential credential = credentialMapper.findByUserId(userId)
|
||||
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "凭据不存在"));
|
||||
if (!Boolean.TRUE.equals(credential.getMustChangePassword())
|
||||
|| !passwordEncoder.matches(oldPassword, credential.getPasswordHash())) {
|
||||
throw new UnauthorizedException("初始密码不正确或已完成修改");
|
||||
}
|
||||
credential.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
credential.setCredentialVersion((credential.getCredentialVersion() == null ? 0 : credential.getCredentialVersion()) + 1);
|
||||
credential.setMustChangePassword(false);
|
||||
credential.setLastChangedAt(Instant.now());
|
||||
credentialMapper.updateById(credential);
|
||||
}
|
||||
}
|
||||
@@ -215,9 +215,21 @@ public class JwtTokenService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 过期时间
|
||||
* 获取 Access Token 过期时间(毫秒)
|
||||
*/
|
||||
public long getExpirationMs() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.danyang.match.platform.capability.service;
|
||||
|
||||
import com.danyang.match.platform.home.dto.HomeDTO;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
@@ -32,6 +33,7 @@ public class CapabilityService {
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final UserIdentityMapper userIdentityMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
private final ServiceConsentService serviceConsentService;
|
||||
|
||||
/**
|
||||
* 获取用户的能力开关集合
|
||||
@@ -45,7 +47,8 @@ public class CapabilityService {
|
||||
|
||||
boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())
|
||||
&& isIdentityVerified(userId)
|
||||
&& isProfileApproved(user);
|
||||
&& isProfileApproved(user)
|
||||
&& serviceConsentService.isActive(userId);
|
||||
|
||||
HomeDTO.Capabilities caps = new HomeDTO.Capabilities();
|
||||
caps.setCanReceiveRecommendations(baseEligible
|
||||
@@ -77,10 +80,11 @@ public class CapabilityService {
|
||||
/** 资料是否审核通过 */
|
||||
public boolean isProfileApproved(User user) {
|
||||
if (user == null) return false;
|
||||
String status = userProfileMapper.findByUserId(user.getId())
|
||||
.map(UserProfile::getProfileStatus).orElse(null);
|
||||
return Constants.PROFILE_STATUS_APPROVED.equals(status)
|
||||
|| Constants.PROFILE_STATUS_COMPLETED.equals(status);
|
||||
return userProfileMapper.findByUserId(user.getId()).map(profile ->
|
||||
Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||
&& (profile.getPublishedProfileRevision() == null
|
||||
|| java.util.Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision()))
|
||||
).orElse(false);
|
||||
}
|
||||
|
||||
/** 是否在认真寻找中 */
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.danyang.match.platform.chat.config;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 产品当前不提供用户之间的即时聊天。
|
||||
*
|
||||
* <p>在 HTTP 和 WebSocket 握手进入 Controller/Handler 前统一拒绝,
|
||||
* 防止只隐藏小程序入口却留下可调用的通信通道。
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ChatDisabledFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String path = request.getRequestURI();
|
||||
String context = request.getContextPath();
|
||||
if (context != null && !context.isEmpty() && path.startsWith(context)) {
|
||||
path = path.substring(context.length());
|
||||
}
|
||||
if (path.equals("/conversations") || path.startsWith("/conversations/")
|
||||
|| path.equals("/ws/chat") || path.startsWith("/ws/chat/")) {
|
||||
response.setStatus(HttpServletResponse.SC_GONE);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(
|
||||
"{\"success\":false,\"code\":\"CHAT_DISABLED\","
|
||||
+ "\"message\":\"平台未提供用户之间的即时聊天功能\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* Conversation 端点
|
||||
@@ -35,6 +36,8 @@ public class ConversationController {
|
||||
|
||||
private final ChatService chatService;
|
||||
private final MatchMapper matchMapper;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private ServiceConsentService serviceConsentService;
|
||||
|
||||
/**
|
||||
* 从 Match 创建/获取 Conversation
|
||||
@@ -47,6 +50,7 @@ public class ConversationController {
|
||||
public ApiResponse<ConversationDTO> fromMatch(@PathVariable String matchId,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
Match match = matchMapper.findById(matchId).orElse(null);
|
||||
if (match == null) return ApiResponse.notFound();
|
||||
// 权限:必须是 Match 参与者
|
||||
@@ -68,6 +72,7 @@ public class ConversationController {
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
// 游标契约(小程序):cursor + pageSize;偏移契约:offset + limit。两者兼容。
|
||||
int off = cursor != null && !cursor.isBlank() ? parseIntOrDefault(cursor, 0) : Math.max(offset, 0);
|
||||
int size = Math.min(Math.max(pageSize != null ? pageSize : limit, 1), MAX_PAGE_SIZE);
|
||||
@@ -92,6 +97,7 @@ public class ConversationController {
|
||||
public ApiResponse<ConversationDTO> detail(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
// 对象级鉴权:非会话成员一律 404 —— 否则可凭 conversationId 读到对方昵称、最后一条消息
|
||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||
ConversationDTO dto = chatService.getConversation(principal.id(), id);
|
||||
@@ -107,6 +113,7 @@ public class ConversationController {
|
||||
public ApiResponse<Map<String, Object>> unread(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
// 对象级鉴权:非成员不得读取他人会话未读
|
||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||
return ApiResponse.ok(Map.of("unreadCount", chatService.getUnreadCount(principal.id(), id)));
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* Message 端点(HTTP REST + 幂等)
|
||||
@@ -36,12 +37,15 @@ public class MessageController {
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
private final ChatService chatService;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private ServiceConsentService serviceConsentService;
|
||||
|
||||
@PostMapping("/{id}/messages")
|
||||
public ApiResponse<MessageResponse> send(@PathVariable String id,
|
||||
@RequestBody SendRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
// 幂等键:优先 clientMessageId,兼容小程序早期约定的 messageId
|
||||
String idempotentKey = req.getClientMessageId() != null ? req.getClientMessageId() : req.getMessageId();
|
||||
Message msg = chatService.sendText(id, principal.id(), req.getContent(), idempotentKey);
|
||||
@@ -59,6 +63,7 @@ public class MessageController {
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
// 对象级鉴权:非会话成员一律 404(不区分"不存在"与"无权",避免探测他人会话)
|
||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||
@@ -84,6 +89,7 @@ public class MessageController {
|
||||
@RequestBody Map<String, String> body,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
// 对象级鉴权:非成员不得改写他人会话的已读状态
|
||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||
String lastReadId = body.get("lastReadMessageId");
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.danyang.match.platform.contactauth.controller;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 受控联系授权控制器(C 端用户,ZC-MM-003 / B1 立项)。
|
||||
*
|
||||
* <p>端点(基路径 {@code /api/v1/contact-authorizations},USER 角色兜底):
|
||||
* <ul>
|
||||
* <li>GET {@code /my} —— 我的授权列表 + 待同意队列;</li>
|
||||
* <li>POST {@code /{id}/consent} —— 用户<b>单独明示同意</b>(独立端点,
|
||||
* 非任何其他操作的捆绑步骤、无预勾选/预同意参数);</li>
|
||||
* <li>POST {@code /{id}/decline} —— 用户拒绝。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>归属校验(userId.equals)在 Service 层 {@code requireOwnedAuthorization} 承担;
|
||||
* 不碰 SecurityConfig(路径落入 {@code /** → hasRole("USER")} 兜底规则)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/contact-authorizations")
|
||||
@RequiredArgsConstructor
|
||||
public class ContactAuthorizationUserController {
|
||||
|
||||
private final ContactAuthorizationService contactAuthorizationService;
|
||||
|
||||
/** 我的授权列表 + 待同意队列。 */
|
||||
@GetMapping("/my")
|
||||
public ApiResponse<Map<String, Object>> my(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.myAuthorizationsForUser(principal.id()));
|
||||
}
|
||||
|
||||
/** 单独明示同意(独立端点,无预同意参数)。 */
|
||||
@PostMapping("/{id}/consent")
|
||||
public ApiResponse<Object> consent(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.consent(id, principal.id()));
|
||||
}
|
||||
|
||||
/** 拒绝授权请求。 */
|
||||
@PostMapping("/{id}/decline")
|
||||
public ApiResponse<Object> decline(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.decline(id, principal.id()));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* 婚恋咨询 / 婚前辅导 端点(Phase 13A)
|
||||
@@ -56,6 +57,8 @@ public class CounselingController {
|
||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
|
||||
|
||||
private final CounselingService counselingService;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private ServiceConsentService serviceConsentService;
|
||||
|
||||
// ==================== 用户端 ====================
|
||||
|
||||
@@ -64,6 +67,7 @@ public class CounselingController {
|
||||
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
return ApiResponse.ok(counselingService.apply(
|
||||
principal.id(), // 修复:userId 来自认证主体,不取请求体
|
||||
req.serviceType,
|
||||
@@ -79,6 +83,7 @@ public class CounselingController {
|
||||
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
List<CounselingServiceRecord> list = counselingService.myServices(principal.id(), limit);
|
||||
// 列表场景剥离正文与 followups(INV-013)
|
||||
list.forEach(CounselingService::sanitize);
|
||||
@@ -98,6 +103,7 @@ public class CounselingController {
|
||||
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
// 非法 UUID 直接 400。否则参数会打到数据库,PostgreSQL 抛
|
||||
// `invalid input syntax for type uuid` → 500 且泄漏数据库异常。
|
||||
if (!UUID_PATTERN.matcher(id).matches()) {
|
||||
@@ -114,6 +120,7 @@ public class CounselingController {
|
||||
@RequestBody(required = false) CancelRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
return ApiResponse.ok(counselingService.cancel(id, principal.id(),
|
||||
req != null ? req.reason : null));
|
||||
}
|
||||
@@ -124,6 +131,7 @@ public class CounselingController {
|
||||
public ApiResponse<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
return ApiResponse.ok(counselingService.confirmCompanion(id, principal.id()));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* 找对象控制器
|
||||
@@ -21,6 +22,7 @@ public class DiscoverController {
|
||||
|
||||
private final DiscoverService discoverService;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final ServiceConsentService serviceConsentService;
|
||||
|
||||
/**
|
||||
* 搜索用户(临时筛选)
|
||||
@@ -38,6 +40,7 @@ public class DiscoverController {
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
|
||||
String userId = principal.id();
|
||||
serviceConsentService.requireActive(userId);
|
||||
Map<String, Object> myProfile = getMyProfile(userId);
|
||||
String myGender = (String) myProfile.get("gender");
|
||||
|
||||
@@ -61,6 +64,7 @@ public class DiscoverController {
|
||||
public ApiResponse<Map<String, Object>> getProfile(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String userId) {
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
Map<String, Object> profile = discoverService.getPublicProfile(principal.id(), userId);
|
||||
if (profile == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在");
|
||||
|
||||
@@ -40,6 +40,8 @@ public class DiscoverService {
|
||||
LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.ne(UserProfile::getGender, myGender)
|
||||
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
|
||||
.isNotNull(UserProfile::getPublishedProfileRevision)
|
||||
.apply("profile_revision = published_profile_revision")
|
||||
.isNull(UserProfile::getDeletedAt);
|
||||
|
||||
// INV-004 搜索不可见:排除自己 + 双向拉黑者。
|
||||
@@ -117,6 +119,11 @@ public class DiscoverService {
|
||||
}
|
||||
return userProfileMapper.findByUserId(userId)
|
||||
.map(profile -> {
|
||||
if (!Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||
|| profile.getPublishedProfileRevision() == null
|
||||
|| !Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision())) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("userId", profile.getUserId());
|
||||
map.put("displayName", profile.getDisplayName());
|
||||
@@ -135,7 +142,7 @@ public class DiscoverService {
|
||||
map.put("longTermDanyang", profile.getLongTermDanyang());
|
||||
map.put("futureDanyang", profile.getFutureDanyang());
|
||||
map.put("verified", true);
|
||||
map.put("reviewed", Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus()));
|
||||
map.put("reviewed", true);
|
||||
return map;
|
||||
})
|
||||
.orElse(null);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.danyang.match.platform.match.controller;
|
||||
|
||||
import com.danyang.match.platform.chat.entity.Conversation;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.match.service.RelationService;
|
||||
import com.danyang.match.platform.match.service.RelationService.LikeResult;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
@@ -11,9 +9,9 @@ import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* Like 端点(canonical 路径,2026-09-14 版)
|
||||
@@ -22,29 +20,29 @@ import org.springframework.web.bind.annotation.*;
|
||||
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
|
||||
*
|
||||
* 核心规则:INV-005 未 Mutual 不可聊天
|
||||
* 响应契约对齐 miniapp/types/index.d.ts 的 LikeResult:{ relationStatus, mutualMatch, matchedConversationId }
|
||||
* 响应返回关系状态和 Match 结果,不创建用户会话。
|
||||
*
|
||||
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/likes")
|
||||
@RequiredArgsConstructor
|
||||
public class LikeController {
|
||||
|
||||
private final RelationService relationService;
|
||||
private final ChatService chatService;
|
||||
private final ServiceConsentService serviceConsentService;
|
||||
|
||||
@Idempotent
|
||||
@PostMapping
|
||||
public ApiResponse<LikeResponse> like(@RequestBody LikeRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
||||
}
|
||||
LikeResult result = relationService.like(principal.id(), req.targetUserId);
|
||||
return ApiResponse.ok(LikeResponse.from(result, ensureConversation(result, principal.id(), req.targetUserId)));
|
||||
return ApiResponse.ok(LikeResponse.from(result));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +53,7 @@ public class LikeController {
|
||||
public ApiResponse<Void> unlike(@PathVariable String targetUserId,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
relationService.unlike(principal.id(), targetUserId);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
@@ -62,20 +61,8 @@ public class LikeController {
|
||||
/**
|
||||
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
|
||||
*
|
||||
* - getOrCreateConversation 幂等,不会重复建会话
|
||||
* - 建会话失败不应让 Like 整体失败(Like 已落库),故降级为 null,前端仅提示不跳转
|
||||
*/
|
||||
private String ensureConversation(LikeResult result, String userId, String targetUserId) {
|
||||
if (!result.mutual || result.match == null) return null;
|
||||
try {
|
||||
Conversation conv = chatService.getOrCreateConversation(result.match.getId(), userId, targetUserId);
|
||||
return conv.getId();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Mutual 达成但会话创建失败,已降级:user={}, target={}, matchId={}, reason={}",
|
||||
userId, targetUserId, result.match.getId(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 当前产品不提供用户间会话;Match 成功后仅展示结果和公益服务入口。
|
||||
|
||||
// ==================== DTO ====================
|
||||
|
||||
@@ -89,16 +76,14 @@ public class LikeController {
|
||||
public static class LikeResponse {
|
||||
private String relationStatus;
|
||||
private boolean mutualMatch;
|
||||
private String matchedConversationId;
|
||||
private String matchId;
|
||||
|
||||
public static LikeResponse from(LikeResult r, String matchedConversationId) {
|
||||
public static LikeResponse from(LikeResult r) {
|
||||
LikeResponse resp = new LikeResponse();
|
||||
resp.relationStatus = r.mutual
|
||||
? Constants.RELATION_STATUS_MUTUAL_MATCH
|
||||
: Constants.RELATION_STATUS_LIKED;
|
||||
resp.mutualMatch = r.mutual;
|
||||
resp.matchedConversationId = matchedConversationId;
|
||||
resp.matchId = r.match != null ? r.match.getId() : null;
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* Match 端点(canonical 路径,2026-09-14 版)
|
||||
@@ -46,12 +47,16 @@ public class MatchController {
|
||||
private final RelationService relationService;
|
||||
private final RelationshipStatusService relationshipStatusService;
|
||||
private final ChatService chatService;
|
||||
private final ServiceConsentService serviceConsentService;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
List<Match> matches = matchMapper.findActiveByUser(principal.id());
|
||||
List<MatchResponse> items = matches.stream().map(MatchResponse::from).collect(Collectors.toList());
|
||||
List<MatchResponse> items = matches.stream()
|
||||
.map(match -> MatchResponse.from(match, principal.id()))
|
||||
.collect(Collectors.toList());
|
||||
// 与全平台列表接口统一信封 { items, nextCursor };当前一次性返回全部,nextCursor 恒为 null
|
||||
return ApiResponse.ok(CursorPage.all(items));
|
||||
}
|
||||
@@ -60,6 +65,7 @@ public class MatchController {
|
||||
public ApiResponse<MatchResponse> get(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
Optional<Match> opt = matchMapper.findById(id);
|
||||
if (opt.isEmpty()) return ApiResponse.notFound();
|
||||
Match m = opt.get();
|
||||
@@ -67,7 +73,7 @@ public class MatchController {
|
||||
if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) {
|
||||
return ApiResponse.forbidden();
|
||||
}
|
||||
return ApiResponse.ok(MatchResponse.from(m));
|
||||
return ApiResponse.ok(MatchResponse.from(m, principal.id()));
|
||||
}
|
||||
|
||||
@Idempotent
|
||||
@@ -75,6 +81,7 @@ public class MatchController {
|
||||
public ApiResponse<Void> unmatch(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
Optional<Match> opt = matchMapper.findById(id);
|
||||
if (opt.isEmpty()) return ApiResponse.notFound();
|
||||
Match m = opt.get();
|
||||
@@ -166,13 +173,13 @@ public class MatchController {
|
||||
private java.time.Instant lastActivityAt;
|
||||
private Integer episode;
|
||||
|
||||
public static MatchResponse from(Match m) {
|
||||
public static MatchResponse from(Match m, String currentUserId) {
|
||||
MatchResponse r = new MatchResponse();
|
||||
r.id = m.getId();
|
||||
r.source = m.getSource();
|
||||
r.sourceId = m.getSourceId();
|
||||
r.status = m.getStatus();
|
||||
r.peerUserId = m.getUserAId(); // 客户端需要自己判断对方
|
||||
r.peerUserId = currentUserId.equals(m.getUserAId()) ? m.getUserBId() : m.getUserAId();
|
||||
r.matchedAt = m.getMatchedAt();
|
||||
r.lastActivityAt = m.getLastActivityAt();
|
||||
r.episode = m.getEpisode();
|
||||
|
||||
@@ -9,6 +9,7 @@ import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* Pass 端点(canonical 路径,补齐 2026-09-14 Like/Block/Match 模块)
|
||||
@@ -28,12 +29,14 @@ import org.springframework.web.bind.annotation.*;
|
||||
public class PassController {
|
||||
|
||||
private final RelationService relationService;
|
||||
private final ServiceConsentService serviceConsentService;
|
||||
|
||||
@Idempotent
|
||||
@PostMapping
|
||||
public ApiResponse<Void> pass(@RequestBody PassRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
serviceConsentService.requireActive(principal.id());
|
||||
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||
|
||||
/**
|
||||
* 推荐控制器
|
||||
@@ -21,6 +22,7 @@ import java.util.Map;
|
||||
public class RecommendationController {
|
||||
|
||||
private final RecommendationEngineService engineService;
|
||||
private final ServiceConsentService serviceConsentService;
|
||||
|
||||
/**
|
||||
* 获取今日推荐
|
||||
@@ -30,6 +32,7 @@ public class RecommendationController {
|
||||
public ApiResponse<List<RecommendationCandidateDTO>> getRecommendations(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
serviceConsentService.requireActive(userId);
|
||||
return ApiResponse.ok(engineService.getDailyRecommendations(userId));
|
||||
}
|
||||
|
||||
@@ -45,6 +48,7 @@ public class RecommendationController {
|
||||
@PathVariable String targetUserId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = principal.id();
|
||||
serviceConsentService.requireActive(userId);
|
||||
String action = body.get("action");
|
||||
if (action == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "action不能为空");
|
||||
@@ -62,6 +66,7 @@ public class RecommendationController {
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String targetUserId) {
|
||||
String userId = principal.id();
|
||||
serviceConsentService.requireActive(userId);
|
||||
boolean eligible = engineService.isStillEligible(userId, targetUserId);
|
||||
return ApiResponse.ok(Map.of("eligible", eligible));
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
/** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
|
||||
private final MatchScoringService matchScoringService;
|
||||
|
||||
/**
|
||||
* 获取每日推荐列表
|
||||
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 双向 Required 检查
|
||||
// 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
|
||||
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
|
||||
if (targetPref == null) {
|
||||
return null; // 目标用户未设置择偶条件,无法双向验证
|
||||
}
|
||||
|
||||
// A → B Required 检查
|
||||
if (!checkBidirectionalRequired(myPref, targetPref)) {
|
||||
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||
UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
|
||||
if (myProfile == null || targetProfile == null) {
|
||||
return null; // 资料缺失则无法核验 Required,不假定满足
|
||||
}
|
||||
if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -154,6 +159,9 @@ public class MatchEligibilityService {
|
||||
if (targetProfile == null) return false;
|
||||
String ps = targetProfile.getProfileStatus();
|
||||
if (!Constants.PROFILE_STATUS_APPROVED.equals(ps) && !Constants.PROFILE_STATUS_COMPLETED.equals(ps)) return false;
|
||||
if (Constants.PROFILE_STATUS_APPROVED.equals(ps)
|
||||
&& targetProfile.getPublishedProfileRevision() != null
|
||||
&& !java.util.Objects.equals(targetProfile.getProfileRevision(), targetProfile.getPublishedProfileRevision())) return false;
|
||||
|
||||
// V1.1: 本地资格核验(可配置是否强制)
|
||||
if (matchConfig.isLocalEligibilityRequired()) {
|
||||
@@ -175,36 +183,18 @@ public class MatchEligibilityService {
|
||||
return "verified".equals(el.getVerificationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 双向 Required 检查
|
||||
* Required 是 Hard Constraint,不可自动放宽
|
||||
*/
|
||||
private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
|
||||
// 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
|
||||
// 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
|
||||
// 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
|
||||
return true;
|
||||
}
|
||||
// checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
|
||||
// (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
|
||||
|
||||
/**
|
||||
* 方向性评分(A对B的适配度)
|
||||
* 基于 Preferred 维度计算 0-100 分
|
||||
*/
|
||||
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
|
||||
double score = 0;
|
||||
double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() +
|
||||
pref.getMaritalWeight() + pref.getChildrenWeight() +
|
||||
pref.getEducationWeight() + pref.getHeightWeight() +
|
||||
pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
|
||||
pref.getLifestyleWeight() + pref.getInterestWeight();
|
||||
|
||||
// TODO: 实现各维度评分逻辑
|
||||
// 年龄匹配:在范围内得满分,超出范围按比例扣分
|
||||
// 地区匹配:同地区得满分,不同地区按比例扣分
|
||||
// ...
|
||||
|
||||
if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
|
||||
return (int)(Math.round(score / totalWeight * 100));
|
||||
// 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
|
||||
return userProfileMapper.findByUserId(toUserId)
|
||||
.map(p -> matchScoringService.scoreDirectionally(pref, p))
|
||||
.orElse(0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,20 +202,21 @@ public class MatchEligibilityService {
|
||||
*/
|
||||
private List<String> generateReasons(String userId, String targetUserId,
|
||||
UserPreference myPref, UserPreference targetPref) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
// TODO: 根据实际资料生成真实推荐理由
|
||||
reasons.add("年龄符合双方择偶要求");
|
||||
reasons.add("都长期生活在丹阳");
|
||||
reasons.add("婚姻计划接近");
|
||||
return reasons;
|
||||
// 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
|
||||
return userProfileMapper.findByUserId(targetUserId)
|
||||
.map(p -> matchScoringService.reasons(myPref, p))
|
||||
.orElseGet(ArrayList::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已Pass的用户ID集合(冷却期内)
|
||||
*/
|
||||
private Set<String> getPassedUserIds(String userId, Instant since) {
|
||||
// TODO: 查询 user_relations 表中 passed 关系的 target_user_id
|
||||
return Collections.emptySet();
|
||||
// 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
|
||||
return recommendationRecordMapper.findRecentByUserId(userId, since).stream()
|
||||
.filter(r -> "passed".equals(r.getAction()))
|
||||
.map(RecommendationRecord::getTargetUserId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,14 +231,28 @@ public class MatchEligibilityService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建候选池(简化版)
|
||||
* TODO: Phase 06 后续接入数据库查询可用用户
|
||||
* 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
|
||||
* (2026-09-23:替换原 `return List.of()` 空实现。)
|
||||
*/
|
||||
private List<String> buildCandidatePool(String userId, UserPreference myPref,
|
||||
Set<String> excludedIds, Set<String> alreadyRecommended) {
|
||||
// TODO: 从数据库查询符合条件的用户
|
||||
// 当前返回空列表,等待后续阶段完善
|
||||
return List.of();
|
||||
// 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
|
||||
// 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
|
||||
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) {
|
||||
|
||||
@@ -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.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
|
||||
private final UserMapper userMapper;
|
||||
private final UserPrivacySettingsMapper privacySettingsMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
/** 评分唯一事实源(2026-09-23:替换原 `return 50.0` 桩,修复「推荐恒空」)。 */
|
||||
private final MatchScoringService matchScoringService;
|
||||
|
||||
/**
|
||||
* 获取每日推荐列表
|
||||
@@ -126,6 +127,8 @@ public class RecommendationEngineService {
|
||||
|
||||
// 检查审核状态
|
||||
if (!Constants.PROFILE_STATUS_APPROVED.equals(target.getProfileStatus())) return false;
|
||||
if (target.getPublishedProfileRevision() != null
|
||||
&& !java.util.Objects.equals(target.getProfileRevision(), target.getPublishedProfileRevision())) return false;
|
||||
|
||||
// 检查是否已被当前用户Block
|
||||
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) {
|
||||
@@ -241,86 +244,16 @@ public class RecommendationEngineService {
|
||||
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,
|
||||
UserPreference targetPref, UserProfile myProfile) {
|
||||
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
|
||||
// 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;
|
||||
return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
|
||||
}
|
||||
|
||||
private boolean isParticipantEligible(String userId) {
|
||||
@@ -341,23 +274,19 @@ public class RecommendationEngineService {
|
||||
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) {
|
||||
// TODO: Phase 07 后续完善评分逻辑
|
||||
return 50.0; // V1 简化:默认中间分
|
||||
return matchScoringService.scoreDirectionally(pref, target);
|
||||
}
|
||||
|
||||
private List<String> generateReasons(String userId, UserProfile candidate,
|
||||
UserPreference myPref, UserPreference targetPref) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
// TODO: 基于真实资料生成推荐理由
|
||||
reasons.add("年龄符合双方择偶要求");
|
||||
reasons.add("都希望长期在丹阳生活");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private int calculateAge(LocalDate birthDate) {
|
||||
if (birthDate == null) return 0;
|
||||
return Period.between(birthDate, LocalDate.now()).getYears();
|
||||
return matchScoringService.reasons(myPref, candidate);
|
||||
}
|
||||
|
||||
private Set<String> getPassedUserIds(String userId, Instant since) {
|
||||
@@ -383,7 +312,11 @@ public class RecommendationEngineService {
|
||||
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
|
||||
record.setScore(java.math.BigDecimal.valueOf(score));
|
||||
record.setLevel(toLevel(score));
|
||||
record.setReasons(String.join(";", reasons));
|
||||
// recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。
|
||||
// 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会
|
||||
// 触发 PostgreSQL「invalid input syntax for type json」,导致整个
|
||||
// /home 聚合接口 500。改为序列化为 JSON 数组。
|
||||
record.setReasons(writeReasonsJson(reasons));
|
||||
record.setShownAt(Instant.now());
|
||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||
@@ -415,6 +348,16 @@ public class RecommendationEngineService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
|
||||
private String writeReasonsJson(List<String> reasons) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
|
||||
} catch (Exception e) {
|
||||
log.warn("序列化推荐理由失败", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部评分结果
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.profile.service.UserProfileService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
|
||||
public class RecommendationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final MatchEligibilityService eligibilityService;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
@@ -61,7 +63,9 @@ public class RecommendationService {
|
||||
record.setTargetUserId(targetUserId);
|
||||
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
|
||||
record.setLevel(level);
|
||||
record.setReasons(new java.util.ArrayList<>(reasons).toString());
|
||||
// recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON
|
||||
// (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。
|
||||
record.setReasons(writeReasonsJson(reasons));
|
||||
record.setShownAt(Instant.now());
|
||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||
@@ -70,6 +74,16 @@ public class RecommendationService {
|
||||
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
|
||||
}
|
||||
|
||||
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
|
||||
private String writeReasonsJson(List<String> reasons) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
|
||||
} catch (Exception e) {
|
||||
log.warn("序列化推荐理由失败", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户对推荐的响应(Like/Pass/Block)
|
||||
*/
|
||||
|
||||