chore: establish V1.1 recovery baseline
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
working-directory: backend
|
||||
|
||||
- name: Build and Test
|
||||
working-directory: backend
|
||||
run: mvn verify -B
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-test-results
|
||||
path: backend/target/surefire-reports/
|
||||
|
||||
admin-web:
|
||||
name: Admin Web Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: admin-web/pnpm-lock.yaml
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: admin-web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: admin-web
|
||||
run: pnpm build
|
||||
|
||||
miniapp:
|
||||
name: Mini Program Compile Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: miniapp
|
||||
run: npm install
|
||||
|
||||
- name: TypeScript Compile Check
|
||||
working-directory: miniapp
|
||||
run: npx tsc --noEmit
|
||||
|
||||
migration:
|
||||
name: Migration Validation
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate Flyway migrations
|
||||
working-directory: backend
|
||||
run: |
|
||||
# 验证 migration SQL 语法(通过检查文件是否存在且非空)
|
||||
find src/main/resources/db/migration -name "V*.sql" -type f | sort | while read f; do
|
||||
echo "Validating: $f"
|
||||
if [ ! -s "$f" ]; then
|
||||
echo "ERROR: Empty migration file: $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "All migration files validated."
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
backend/local-maven/
|
||||
backend/build-temp/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.lock
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Build outputs
|
||||
backend/target/
|
||||
admin-web/dist/
|
||||
miniapp/dist/
|
||||
miniapp/unpackage/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
.DS_Store
|
||||
|
||||
# Environment variables with secrets
|
||||
backend/src/main/resources/application-dev.yml
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Test data
|
||||
.pytest_cache/
|
||||
tests/fixtures/*.csv
|
||||
tests/fixtures/*.json
|
||||
|
||||
# Local workspace and developer settings
|
||||
.worktrees/
|
||||
.zcode/
|
||||
project.private.config.json
|
||||
/nul
|
||||
/infrastructure/nul
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Secrets and certificates
|
||||
docker-compose.prod.yml
|
||||
infrastructure/secrets/
|
||||
infrastructure/certs/
|
||||
infrastructure/backups/
|
||||
@@ -0,0 +1,166 @@
|
||||
# 善缘丹阳 V1.1 - AGENTS.md
|
||||
|
||||
> 本文件为项目最高执行规则摘要,Codex 每次启动时必须首先阅读。
|
||||
> 版本:V1.1 | 最后更新:2026-09-08 | 基线:v1.0.0-RC.2
|
||||
|
||||
---
|
||||
|
||||
# V1.1 客户修订文档共同基线
|
||||
|
||||
以下文件共同构成当前 V1.1 需求,不得只读取其中一份:
|
||||
|
||||
1. `docs/善缘丹阳 V1.1 Codex最终主启动提示词(整合版).md`:执行纪律、产品红线、不变量和阶段门禁;
|
||||
2. `docs/善缘丹阳 V1.1 页面级功能清单与交互流程修订版.md`:页面、功能、状态和用户路径;
|
||||
3. `docs/善缘丹阳 V1.1 前端 UI-UX 高保真原型修订说明.md`:视觉、组件、响应式和前端验收;
|
||||
4. `docs/善缘丹阳 V1.1 数据库与API增量设计.md`:数据模型、API、事务、幂等和权限契约;
|
||||
5. `docs/善缘丹阳 V1.1 Codex 开发执行计划修订版.md`:Phase 顺序和增量实施策略;
|
||||
6. `docs/善缘丹阳 V1.1 Codex-Jira 最终开发任务增量清单.md`:任务粒度、依赖和验收条件。
|
||||
|
||||
冲突时先服从用户最新明确指令,再由上述文件各自在其专业域内裁决。文档中的“已完成、已部署、✅”是历史进度声明,不是需求本身;只有当前代码、测试和人工验收可以证明完成。
|
||||
|
||||
V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因为重新审查而推倒重写;错误首页和活动互选规则必须替换,新增业务按 EXTEND / REFACTOR / REPLACE / ADD 执行。
|
||||
|
||||
当前唯一执行计划:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
|
||||
|
||||
---
|
||||
|
||||
# 不可改变的核心规则
|
||||
|
||||
以下规则继承自 V1.0 核心规则,并扩展至 V1.1 新增业务。
|
||||
|
||||
## 用户基础规则(继承 V1.0)
|
||||
|
||||
1. **用户永久免费** — 禁止任何 VIP/会员/金币/付费功能
|
||||
2. **Required 不可自动放宽** — 用户"必须符合"的择偶条件是 Hard Constraint
|
||||
3. **Mutual Match 后才能聊天** — 陌生人不能私信
|
||||
4. **Block 优先级最高** — 拉黑后聊天停止、推荐排除、搜索不可见
|
||||
5. **红娘不可替用户操作** — 只能辅助推荐,不能代 Like
|
||||
6. **活动单向意向保密** — A 选 B 但 B 没选 → B 不知道
|
||||
7. **普通工作人员不能查看私人聊天** — 仅案件上下文可有限访问
|
||||
8. **敏感数据默认拒绝** — 身份证/手机号需特殊权限 + 审计
|
||||
9. **PostgreSQL 是 Source of Truth** — Redis/前端 Store 不可覆盖 DB
|
||||
10. **使用模块化单体** — 暂不引入微服务/Kubernetes/Kafka
|
||||
11. **V1 不引入微服务/K8s** — 仅在后述必要性明确时再考虑
|
||||
12. **所有未确认数量参数配置化** — 不得硬编码业务常量
|
||||
13. **新功能不得擅自扩大 V1 范围** — 遵循 Phase 顺序开发
|
||||
|
||||
## V1.1 新增硬规则
|
||||
|
||||
14. **首页 V1.1 结构固定** — 顶部品牌 + 用户状态 + Banner + 8个核心入口 + 今日推荐 + 善缘公益服务 + 近期公益活动 + 善缘服务站 + 婚恋课堂 + 安全提醒。顺序不可更改,运营不可重排
|
||||
15. **首页 8 核心入口固定** — 第一排:找对象/公益红娘/公益活动/婚恋咨询;第二排:婚恋课堂/婚前辅导/婚恋反诈/善缘服务站
|
||||
16. **Banner 仅内部跳转** — 不支持外部 URL 跳转,target_type 仅限 activity/matchmaker/counseling/premarital/service_location/article/internal_page/none
|
||||
17. **本地资格与实名认证分离** — 本地资格(丹阳户籍/工作/居住)独立于实名认证,不得塞入 user_identities 字段
|
||||
18. **活动互选二阶段同意** — Activity Mutual Interest 不能直接创建 Match/Chat。必须经过:互选结果 → 双方分别确认开启沟通 → 才创建 Match Episode
|
||||
19. **活动 Mutual 已有 Match 则复用** — 如果双方已有 active Match,活动互选确认后不创建新 Match,直接引导已有聊天
|
||||
20. **活动 Mutual 曾 Unmatch 则新建** — 如果双方以前已 Unmatch,本次重新形成沟通时创建新 Match Episode
|
||||
21. **咨询与红娘业务边界分离** — 婚恋咨询不得塞进红娘 Notes,两者权限、数据范围、服务记录必须分离
|
||||
22. **Counselor 只能访问 Assigned Service** — 咨询人员默认只看到明确分配给自己的服务,不浏览全平台
|
||||
23. **Content Editor 零用户数据权限** — 内容编辑员默认无用户婚恋数据访问权限
|
||||
24. **Service Station Operator 不可浏览全用户库** — 服务站人员只能处理现场预约和指定任务
|
||||
25. **主办单位关系不自动授予数据权限** — Organization relationship ≠ 用户数据访问权限,权限由 Role+Permission+DataScope 控制
|
||||
26. **前端 Capability 控制入口** — 用户端各功能入口可见性由服务端 CapabilityService 返回决定,前端不自行拼状态判断
|
||||
|
||||
---
|
||||
|
||||
# 产品定位
|
||||
|
||||
面向丹阳市成年单身人士,由公益组织参与运营,集实名认证、本地资格核验、人工资料审核、双向婚恋匹配、安全沟通、公益红娘、线下公益活动、固定服务阵地、婚恋咨询、婚前辅导、婚恋课堂、婚恋反诈和婚俗新风于一体的本地公益婚恋综合服务平台。
|
||||
|
||||
---
|
||||
|
||||
# 技术栈
|
||||
|
||||
- Backend: Java 21 + Spring Boot 3.x + PostgreSQL + Redis + WebSocket + Flyway + S3-Compatible Private Object Storage
|
||||
- Miniapp: 微信小程序原生 + TypeScript
|
||||
- Admin Web: Vue 3 + TypeScript + Vite + Pinia + Element Plus
|
||||
- Deployment: Docker + Docker Compose + Nginx
|
||||
|
||||
---
|
||||
|
||||
# API 规范
|
||||
|
||||
- Base URL: `/api/v1`
|
||||
- 统一响应: `{ success, code, message, data }`
|
||||
- 认证: `Authorization: Bearer <token>`
|
||||
- 幂等: 关键接口支持 `Idempotency-Key`
|
||||
- 新增 V1.1 聚合接口: `GET /api/v1/home`(HomeApplicationService 聚合)
|
||||
|
||||
---
|
||||
|
||||
# 核心 Invariant(V1.0 + V1.1)
|
||||
|
||||
## V1.0 不变量
|
||||
- 未 Mutual 不可聊天
|
||||
- Required 不可自动放宽
|
||||
- Block 即时禁止互动
|
||||
- 工作人员不能替用户 Like
|
||||
- 成功牵手必须双确认
|
||||
- 单向活动意向不泄露
|
||||
- 敏感身份默认脱敏
|
||||
- 跨组织默认拒绝
|
||||
|
||||
## V1.1 新增不变量
|
||||
- INV-011: 未授权不能查看本地资格佐证
|
||||
- INV-012: Counselor 只能读取 Assigned Service
|
||||
- INV-013: Matchmaker 不能读取 Counseling 正文
|
||||
- INV-014: Activity Mutual 不能直接创建 Match
|
||||
- INV-015: 双方确认后才创建 Activity Match
|
||||
- INV-016: Banner 不能任意外链
|
||||
- INV-017: 首页 8 核心入口顺序固定
|
||||
- INV-018: ServiceStationOperator 不能浏览全用户库
|
||||
- INV-019: ContentEditor 不能访问用户婚恋档案
|
||||
- INV-020: Sponsor 组织关系不自动赋予用户访问权限
|
||||
|
||||
---
|
||||
|
||||
# 新增领域模块
|
||||
|
||||
V1.1 在 V1.0 基础上新增以下领域模块:
|
||||
|
||||
```
|
||||
banner/ — HomeBanner 管理
|
||||
service_location/ — 善缘服务站
|
||||
appointment/ — 服务预约
|
||||
counseling/ — 婚恋咨询与婚前辅导
|
||||
content/ — 婚恋课堂/反诈内容管理
|
||||
service_program/ — 专项公益服务项目
|
||||
local_eligibility/ — 本地资格核验
|
||||
home/ — 首页聚合 API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 新增 Operator 角色
|
||||
|
||||
```
|
||||
service_station_operator — 服务站运营人员
|
||||
counselor — 婚恋咨询人员
|
||||
counseling_manager — 咨询管理人员
|
||||
content_editor — 内容编辑员
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 开发流程
|
||||
|
||||
每阶段必须:
|
||||
```
|
||||
读取本阶段规则 → 确认数据模型 → 建立测试 → 实现后端 → 实现前端 → 运行测试 → 验证核心流程 → 更新文档 → 进入下阶段
|
||||
```
|
||||
|
||||
Phase 完成后必须输出 Completion Report,不得自动进入下一阶段。
|
||||
|
||||
---
|
||||
|
||||
# 文档
|
||||
|
||||
- 主启动提示词: `docs/善缘丹阳 V1.1 Codex最终主启动提示词(整合版).md`
|
||||
- Jira 任务清单: `docs/善缘丹阳 V1.1 Codex-Jira 最终开发任务增量清单.md`
|
||||
- 开发执行计划: `docs/善缘丹阳 V1.1 Codex 开发执行计划修订版.md`
|
||||
- 数据库/API设计: `docs/善缘丹阳 V1.1 数据库与API增量设计.md`
|
||||
- 前端 UI/UX: `docs/善缘丹阳 V1.1 前端 UI-UX 高保真原型修订说明.md`
|
||||
- 页面级功能: `docs/善缘丹阳 V1.1 页面级功能清单与交互流程修订版.md`
|
||||
- 状态跟踪: `docs/STATUS.md`
|
||||
- 待确认项: `docs/PENDING_DECISIONS.md`
|
||||
- 任务清单: `docs/JIRA_BACKLOG.md`
|
||||
- 版本基线: `docs/ARCHITECTURE_BASELINE_V1.1.md`
|
||||
@@ -0,0 +1,54 @@
|
||||
# 丹阳公益婚恋服务平台 - 项目规则与任务拆解(摘要)
|
||||
|
||||
> 完整规则见:`Codex开发执行总指令、项目规则与任务拆解.md`
|
||||
|
||||
## 最高原则
|
||||
|
||||
1. **用户永久免费** — 禁止任何 VIP/会员/付费功能
|
||||
2. **Required 不可自动放宽** — 硬约束
|
||||
3. **Mutual Match 后才能聊天** — 陌生人不能私信
|
||||
4. **Block 优先级最高** — 拉黑后立即生效
|
||||
5. **红娘不可替用户操作** — 只能辅助
|
||||
6. **活动单向意向保密** — A选B≠B知道A选了TA
|
||||
7. **敏感数据默认拒绝** — 需权限+审计
|
||||
8. **PostgreSQL 是 Source of Truth**
|
||||
9. **模块化单体,暂不微服务**
|
||||
10. **所有未确认参数配置化**
|
||||
|
||||
## 26 阶段开发路线
|
||||
|
||||
| Phase | 内容 |
|
||||
|---|---|
|
||||
| P01 | 项目基础架构 ✅ |
|
||||
| P02 | 认证与Operator |
|
||||
| P03 | 实名认证 |
|
||||
| P04 | 用户资料 |
|
||||
| P05 | 人工审核 |
|
||||
| P06 | Match Engine |
|
||||
| P07 | 首页与找对象 |
|
||||
| P08 | Like与Match |
|
||||
| P09 | 文字聊天 |
|
||||
| P10 | 图片与安全聊天 |
|
||||
| P11 | 通知系统 |
|
||||
| P12 | 关系状态 |
|
||||
| P13 | 公益红娘 |
|
||||
| P14 | 活动基础 |
|
||||
| P15 | 签到 |
|
||||
| P16 | 活动互选 |
|
||||
| P17 | 举报与风控 |
|
||||
| P18 | 申诉 |
|
||||
| P19 | 后台完善 |
|
||||
| P20 | Analytics |
|
||||
| P21 | 账号生命周期 |
|
||||
| P22 | 部署运维 |
|
||||
| P23 | 系统测试 |
|
||||
| P24 | UAT |
|
||||
| P25 | RC |
|
||||
| P26 | 生产上线 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Backend: Java 21 + Spring Boot 3.x + PostgreSQL + Redis
|
||||
- Miniapp: 微信原生 + TypeScript
|
||||
- Admin: Vue 3 + TypeScript + Vite + Element Plus
|
||||
- Deployment: Docker + Docker Compose
|
||||
@@ -0,0 +1,70 @@
|
||||
# CHANGELOG
|
||||
|
||||
所有重要变更将记录在此文件中。
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0-RC.1] — 2026-09-09
|
||||
|
||||
### Added — V1.1 新增功能
|
||||
- **公益红娘服务转介**: `POST /referrals/service-station`, `POST /referrals/activity`, 转介追踪链路
|
||||
- **公益婚恋咨询与婚前辅导**: 咨询申请/分配/排期/跟进/完成全流程, 咨询师独立DataScope(INV-012)
|
||||
- **多类型公益活动**: matchmaking/social/lecture等6种活动类型, 独立名额配置
|
||||
- **活动互选二阶段同意**: Mutual Interest → 双方确认 → Match Episode, 不自动创建聊天(INV-014/015)
|
||||
- **风险来源扩展**: risk_events/risk_actions支持counseling/appointment/activity类型(INV-017)
|
||||
- **首页V1.1重构**: Banner运营+8个固定核心入口+今日推荐+善缘公益服务+近期活动+婚恋课堂+安全提醒
|
||||
- **善缘服务站**: 固定阵地预约服务, 状态机submitted→confirmed→completed
|
||||
- **婚恋课堂/反诈内容**: 分类管理+文章CRUD+发布下架流程
|
||||
- **本地资格核验**: 户籍/工作/居住三类型, 佐证上传+审核流程
|
||||
- **专项公益服务项目**: 项目申请+成员审批工作流
|
||||
- **首页聚合API**: `GET /home` 聚合Banner/推荐/活动/服务站/内容
|
||||
- **Feature Flags系统**: 15项开关控制V1.1功能灰度发布
|
||||
- **V1.1仪表盘**: 11项运营统计(咨询/预约/转介/内容/资格/服务程序)
|
||||
- **账户生命周期扩展**: 注销联动服务预约/咨询/服务项目/本地资格
|
||||
- **运营商认证**: `POST /auth/operator/login` 密码登录(4角色:admin/mm001/counselor01/stationop)
|
||||
- **4个新Operator角色**: service_station_operator, counselor, counseling_manager, content_editor
|
||||
- **~30个新权限码**: banner.manage, content.manage, counseling.manage等
|
||||
- **Admin Web 7个新模块**: Banner管理/内容管理/善缘服务站/服务预约/专项服务/婚恋咨询/转介管理
|
||||
- **Miniapp 9个新页面**: 咨询介绍/申请/我的咨询/婚前辅导介绍/申请/活动互选结果/等待确认
|
||||
|
||||
### Fixed
|
||||
- Counselor通知FK约束: operator用户不在users表, 通知前检查用户存在性
|
||||
- HomeController路径重复: `/api/v1/home` → `/home`(与context-path一致)
|
||||
- LocalEligibilityController路径重复: `/api/v1/local-eligibility` → `/local-eligibility`
|
||||
- 服务预约userId获取: ServiceAppointmentRequest新增userId字段, Controller优先使用请求体userId
|
||||
- 数据库updated_at列: 为18个V1.1表补充ALTER COLUMN
|
||||
- CounselingView.vue截断: 重写完整143行Vue组件
|
||||
- ContentManagementView.vue截断: 补全缺失的</style>闭合标签
|
||||
|
||||
### Changed
|
||||
- Flyway迁移V021-V027: 新增7个迁移文件, 覆盖全部V1.1数据模型
|
||||
- 数据库27张表: V001-V027迁移全部成功
|
||||
- 种子数据: 4个运营商+1个测试用户+4个分类+9个Banner+3个服务站
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0-RC.2] — 2026-09-01
|
||||
|
||||
> 本次为代码审查修复版本,解决全部 P0/P1/P2 阻塞及重要问题,实现10条核心不变量完整覆盖。
|
||||
|
||||
### Fixed
|
||||
|
||||
#### P0 阻塞问题
|
||||
- **INV-003 修复**: `MessagingService.isBlocked()` 硬编码 false → 改为真实 DB 查询
|
||||
- **INV-005 修复**: `RelationshipStatusService.confirmSuccess()` 完整重写双确认逻辑
|
||||
- **注销查询修复**: `AccountLifecycleService.checkCanWithdraw()` 空查询条件修正
|
||||
- **INV-002 修复**: `RelationshipService.createMatch()` 后自动创建 Conversation
|
||||
|
||||
#### P1 重要问题
|
||||
- **Required 全字段过滤**: RecommendationEngineService 补充身高/地区/婚姻状况检查
|
||||
- **Admin Web 权限守卫**: 路由前置守卫 + 菜单 v-if 权限控制,9角色×18权限矩阵生效
|
||||
- **消息速率限制**: MessageRiskService 改为 Redis 滑动窗口限流
|
||||
- **HealthCheck 接入依赖检查**: `/health/ready` 接入 JdbcTemplate + Redis
|
||||
|
||||
#### P2 快速修复
|
||||
- PhotoService 接入 MinIO/S3 Presigned URL
|
||||
- 小程序 TypeScript 迁移(40+ 类型定义)
|
||||
- ChatController 图片端点完善
|
||||
|
||||
---
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
# 善缘丹阳公益婚恋服务平台 V1.1 增量恢复中
|
||||
|
||||
> 面向丹阳市成年单身人士,由公益组织参与运营的实名制公益婚恋服务平台。
|
||||
|
||||
真实 · 公益 · 本地 · 安全 · 高效
|
||||
|
||||
**当前基线**: V1.0.0-RC.2 + V1.1 客户需求增量 · 状态:INCREMENTAL RECOVERY / NO-GO
|
||||
|
||||
> 当前代码包含 V1.1 部分实现,但尚未通过 Admin、Miniapp、权限和 E2E 发布门禁。请勿依据历史 Completion Report 或 Jira 勾选项判断已经上线。唯一执行计划见 `docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
|
||||
|
||||
---
|
||||
|
||||
## 项目简介
|
||||
|
||||
本平台帮助丹阳本地单身人士通过实名认证、人工审核、智能匹配、双向喜欢、在线沟通、公益红娘和线下相亲活动,安全、高效、真实地寻找合适伴侣。
|
||||
|
||||
**用户永久免费。**
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Java 21 + Spring Boot 3.x |
|
||||
| 数据库 | PostgreSQL |
|
||||
| 缓存/Session | Redis |
|
||||
| 对象存储 | S3 兼容(MinIO / 国内云存储) |
|
||||
| 实时通信 | WebSocket |
|
||||
| 数据库迁移 | Flyway |
|
||||
| 小程序 | 微信原生 + TypeScript |
|
||||
| 管理后台 | Vue 3 + TypeScript + Vite + Pinia + Element Plus |
|
||||
| 容器化 | Docker + Docker Compose + Nginx |
|
||||
| CI/CD | GitHub Actions |
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── backend/ # Spring Boot 后端服务
|
||||
├── miniapp/ # 微信小程序用户端
|
||||
├── admin-web/ # 运营管理后台(Web)
|
||||
├── infrastructure/ # Docker Compose 编排
|
||||
├── scripts/ # 运维脚本
|
||||
├── tests/ # 测试代码
|
||||
│ ├── unit/
|
||||
│ ├── integration/
|
||||
│ ├── api/
|
||||
│ ├── permission/
|
||||
│ ├── concurrency/
|
||||
│ ├── e2e/
|
||||
│ └── invariants/ # 核心不变量测试(每次CI必跑)
|
||||
├── docs/ # 项目文档
|
||||
│ ├── product/
|
||||
│ ├── architecture/
|
||||
│ ├── api/
|
||||
│ ├── database/
|
||||
│ ├── security/
|
||||
│ ├── testing/
|
||||
│ ├── ops/
|
||||
│ └── decisions/
|
||||
├── AGENTS.md # 项目最高执行规则摘要
|
||||
├── CHANGELOG.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速开始(开发环境)
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Docker & Docker Compose
|
||||
- JDK 21
|
||||
- Node.js 18+
|
||||
- npm / pnpm
|
||||
|
||||
### 一键启动基础设施
|
||||
|
||||
```bash
|
||||
# 进入基础设施目录
|
||||
cd infrastructure
|
||||
|
||||
# 启动 PostgreSQL / Redis / MinIO
|
||||
docker compose up -d
|
||||
|
||||
# 等待服务就绪(约30秒)
|
||||
# 可运行:docker compose logs -f
|
||||
```
|
||||
|
||||
### 启动后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 复制环境变量
|
||||
cp src/main/resources/application-dev.yml.example src/main/resources/application-dev.yml
|
||||
# 编辑 application-dev.yml,设置数据库连接等
|
||||
|
||||
# 构建并启动
|
||||
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
||||
```
|
||||
|
||||
### 启动管理后台
|
||||
|
||||
```bash
|
||||
cd admin-web
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 启动小程序
|
||||
|
||||
使用微信开发者工具打开 `miniapp/` 目录,配置 AppID 后即可预览。
|
||||
|
||||
---
|
||||
|
||||
## 环境变量
|
||||
|
||||
所有环境变量参见各模块的 `.env.example` 文件。
|
||||
|
||||
关键变量:
|
||||
|
||||
```bash
|
||||
# 数据库
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=danyang_match
|
||||
DB_USER=app_rw
|
||||
DB_PASSWORD=<secret>
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# 对象存储
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=<secret>
|
||||
MINIO_SECRET_KEY=<secret>
|
||||
MINIO_BUCKET_PUBLIC=public
|
||||
MINIO_BUCKET_PRIVATE=private
|
||||
|
||||
# 微信
|
||||
WECHAT_APP_ID=<secret>
|
||||
WECHAT_APP_SECRET=<secret>
|
||||
```
|
||||
|
||||
**Secret 不进 Git。**
|
||||
|
||||
---
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://localhost:5432/danyang_match
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行测试
|
||||
|
||||
### Phase 01 一键基线检查
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
.\scripts\verify-phase01.ps1
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
./scripts/verify-phase01.sh
|
||||
```
|
||||
|
||||
脚本依次检查 Git、Backend tests、Admin type-check/build、Miniapp TypeScript 和 V1.1 E2E。任一门禁失败时脚本返回非零退出码,并在结尾列出失败项。Windows 未安装 JDK/Maven 时,Backend 检查使用 Docker 中的 JDK 21 与仓库内 Maven 包;不会永久修改系统环境。
|
||||
|
||||
首次执行 Admin/Miniapp 检查前,在 `admin-web` 目录运行 `npm ci`。E2E 使用 `python -m pytest`,不要依赖全局 `pytest` 命令。
|
||||
|
||||
### 分项命令
|
||||
|
||||
```bash
|
||||
# 后端单元测试
|
||||
cd backend
|
||||
./mvnw test
|
||||
|
||||
# 后端集成测试
|
||||
./mvnw verify
|
||||
|
||||
# 管理后台构建检查
|
||||
cd admin-web
|
||||
pnpm build
|
||||
|
||||
# 小程序编译检查
|
||||
cd miniapp
|
||||
# 在微信开发者工具中查看编译结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 构建部署
|
||||
|
||||
```bash
|
||||
# 构建后端镜像
|
||||
docker build -t danyang-match-backend:latest ./backend
|
||||
|
||||
# 构建管理后台
|
||||
cd admin-web && pnpm build
|
||||
|
||||
# 完整部署
|
||||
cd infrastructure && docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心业务闭环
|
||||
|
||||
```
|
||||
微信登录 → 绑定手机 → 实名认证 → 填写资料 → 设置择偶 → 提交审核
|
||||
↓
|
||||
审核通过 → 系统推荐 → 用户Like → 双向Match → 安全聊天
|
||||
↓
|
||||
公益红娘辅助 / 参加线下活动 → 进一步了解 → 双方确认成功牵手
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发阶段
|
||||
|
||||
项目按 Phase 01~Phase 26 顺序推进。详见 `docs/STATUS.md`。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目为公益项目,服务于丹阳市单身人士婚恋匹配。
|
||||
@@ -0,0 +1,322 @@
|
||||
# 丹阳公益婚恋服务平台 V1.0 — 上线前剩余工作
|
||||
|
||||
> 本文件为上线前必须完成的工作清单,按优先级排序。
|
||||
> 交由 Codex/zcode 执行。
|
||||
|
||||
---
|
||||
|
||||
## P0 — 上线前必须完成(阻塞性)
|
||||
|
||||
### P0-1: 产品确认16项技术默认值
|
||||
|
||||
**文件**: `docs/PENDING_DECISIONS.md`
|
||||
|
||||
**问题**: 以下技术默认值已硬编码,但无产品最终确认:
|
||||
|
||||
| 决策项 | 当前技术默认值 | 需要确认 |
|
||||
|---|---|---|
|
||||
| 每日推荐数量 | 6 人 | 产品确认 |
|
||||
| 推荐最低阈值分数 | 60 分 | 产品确认 |
|
||||
| Pass 冷却时间 | 180 天 | 产品确认 |
|
||||
| 照片上限 | 6 张 | 产品确认 |
|
||||
| 兴趣标签上限 | 8 个 | 产品确认 |
|
||||
| 自我介绍长度上限 | 500 字符 | 产品确认 |
|
||||
| Privacy 默认值 | 全部 TRUE | 产品确认 |
|
||||
| 活动是否需要审核 | TRUE | 产品确认 |
|
||||
| 活动互选最大人数 | 3 人 | 产品确认 |
|
||||
| 活动互选截止时间 | 48 小时 | 产品确认 |
|
||||
| 红娘是否允许电话联系 | FALSE | 产品确认 |
|
||||
| "正在了解"是否自动暂停推荐 | 用户选择 | 产品确认 |
|
||||
| RelationshipStatus 最终命名 | searching/getting_to_know/paused/matched_successfully/service_exited | 产品确认 |
|
||||
| Appeal 是否 V1.0 首发 | 否(Feature Flag 预留) | 产品确认 |
|
||||
| 每日推荐重置时间 | 00:00 Asia/Shanghai | 产品确认 |
|
||||
|
||||
**操作**:
|
||||
1. 产品负责人逐一确认以上16项
|
||||
2. 确认后更新 `application.yml` 或对应配置类
|
||||
3. 从 `docs/PENDING_DECISIONS.md` 移除已确认项
|
||||
4. 更新 `AGENTS.md` 中的规则说明(如有影响)
|
||||
|
||||
---
|
||||
|
||||
### P0-2: 隐私政策最终版本确认
|
||||
|
||||
**问题**: Go/No-Go 检查单标注"待确认",为上线前绝对阻塞项。
|
||||
|
||||
**操作**:
|
||||
1. 确认隐私政策文本(用户数据采集、存储、使用、共享、权利)
|
||||
2. 确认用户协议文本
|
||||
3. 将最终版本放入 `docs/legal/` 目录
|
||||
4. 更新小程序登录页和设置页的协议链接
|
||||
|
||||
---
|
||||
|
||||
### P0-3: PIA(隐私影响评估)书面记录
|
||||
|
||||
**问题**: 9个场景需要书面记录,当前无记录。
|
||||
|
||||
**操作**:
|
||||
1. 按以下场景撰写PIA记录:
|
||||
- 实名认证(身份证采集)
|
||||
- 照片存储与展示
|
||||
- 位置/地区信息
|
||||
- 聊天消息存储
|
||||
- 活动报名数据
|
||||
- 红娘服务数据
|
||||
- 举报与风控数据
|
||||
- 账号注销与数据删除
|
||||
- 跨组织数据访问
|
||||
2. 保存到 `docs/security/PIA.md`
|
||||
|
||||
---
|
||||
|
||||
### P0-4: 提交微信小程序审核
|
||||
|
||||
**问题**: 用户端核心入口,无审核通过的小程序无法服务用户。
|
||||
|
||||
**操作**:
|
||||
1. 在微信公众平台提交小程序审核
|
||||
2. 确保所有页面符合微信小程序审核规范
|
||||
3. 配置好 `app.json` 中的类目和标签
|
||||
4. 准备好隐私政策链接(见 P0-2)
|
||||
|
||||
---
|
||||
|
||||
### P0-5: SSL证书申请与DNS配置
|
||||
|
||||
**问题**: 生产HTTPS必需。
|
||||
|
||||
**操作**:
|
||||
1. 申请SSL证书(Let's Encrypt 或商业证书)
|
||||
2. 配置生产域名 DNS 解析(`match.danyang.gov.cn` 或约定域名)
|
||||
3. 将证书放入 `infrastructure/certs/`
|
||||
4. 验证 `nginx-production.conf` 中的证书路径
|
||||
|
||||
---
|
||||
|
||||
## P1 — 上线前应该完成(重要)
|
||||
|
||||
### P1-1: CoreInvariantsTest 补充实质性测试
|
||||
|
||||
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java`
|
||||
|
||||
**问题**: 当前测试只是检查常量定义,不是真正的业务逻辑验证。
|
||||
|
||||
**需要实现**(每个不变量需要真实业务逻辑测试):
|
||||
|
||||
```java
|
||||
// INV-001: Required不可自动放宽
|
||||
@Test
|
||||
void testRequiredConditionNotRelaxed() {
|
||||
// 创建用户A(要求对方学历=本科)和用户B(学历=专科)
|
||||
// B进入A的推荐池 → 应该被Required条件过滤
|
||||
// 验证B不在推荐结果中
|
||||
}
|
||||
|
||||
// INV-002: 未Mutual不可聊天
|
||||
@Test
|
||||
void testCannotMessageWithoutMutual() {
|
||||
// 用户A Like用户B,但B未Like A
|
||||
// 调用发送消息接口 → 应返回403/禁止
|
||||
}
|
||||
|
||||
// INV-003: Block优先级最高
|
||||
@Test
|
||||
void testBlockStopsAllInteractions() {
|
||||
// A Block B后
|
||||
// 验证B不在A的推荐中
|
||||
// 验证A Like B后自动取消
|
||||
// 验证A和B的Match自动解除
|
||||
}
|
||||
|
||||
// INV-004: 双向并发Like只创建一个Match
|
||||
@Test
|
||||
void testConcurrentLikeCreatesOneMatch() {
|
||||
// 线程A: A Like B
|
||||
// 线程B: B Like A(并发)
|
||||
// 验证只创建1个Match记录
|
||||
}
|
||||
|
||||
// INV-005: 活动名额不超卖
|
||||
@Test
|
||||
void testActivityCapacityNotOversold() {
|
||||
// 活动剩余1个名额
|
||||
// 线程A和线程B同时报名
|
||||
// 验证只有1人成功,1人被拒绝
|
||||
}
|
||||
|
||||
// INV-006: 单向活动意向不泄露
|
||||
@Test
|
||||
void testActivityInterestNotDisclosed() {
|
||||
// A选择B,但B没选A
|
||||
// 验证B的查询结果中看不到A的选择
|
||||
}
|
||||
|
||||
// INV-007: 红娘不能替用户Like
|
||||
@Test
|
||||
void testMatchmakerCannotLikeForUser() {
|
||||
// 红娘角色调用Like接口 → 应返回403
|
||||
}
|
||||
|
||||
// INV-008: 非授权Operator不能读取身份证
|
||||
@Test
|
||||
void testUnauthorizedOperatorCannotViewIdCard() {
|
||||
// reviewer角色调用敏感身份查看接口 → 应返回403
|
||||
}
|
||||
|
||||
// INV-009: 非案件上下文不能读取聊天
|
||||
@Test
|
||||
void testNoChatAccessOutsideCaseContext() {
|
||||
// risk_operator无案件授权调用聊天记录 → 应返回403
|
||||
}
|
||||
|
||||
// INV-010: 跨Organization不能越权
|
||||
@Test
|
||||
void testCrossOrgCannotAccess() {
|
||||
// Organization A的用户,不能访问Organization B的数据
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 测试应使用真实数据库(集成测试模式),通过 `@SpringBootTest` + Testcontainers 或指定测试数据库。
|
||||
|
||||
---
|
||||
|
||||
### P1-2: PermissionMatrixTest 补充自动化验证
|
||||
|
||||
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/PermissionMatrixTest.java`
|
||||
|
||||
**问题**: 当前只有权限矩阵定义,没有实际API调用验证。
|
||||
|
||||
**需要实现**:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void testPermissionMatrixAllRoles() {
|
||||
// 遍历9个角色 × 18个权限
|
||||
// 每个组合:
|
||||
// 有权限 → 调用对应API → 应返回2xx
|
||||
// 无权限 → 调用对应API → 应返回403
|
||||
}
|
||||
|
||||
// 需要验证的角色:
|
||||
// super_admin, platform_admin, organization_admin,
|
||||
// reviewer, matchmaker, activity_manager,
|
||||
// risk_operator, volunteer, data_viewer
|
||||
|
||||
// 需要验证的权限模块:
|
||||
// user.view, user.sensitive.view, user.status.change,
|
||||
// review.view, review.process,
|
||||
// matchmaker.view, matchmaker.assign, matchmaker.service,
|
||||
// activity.view, activity.create, activity.review_registration,
|
||||
// risk.report.view, risk.report.process, risk.user.ban,
|
||||
// organization.manage, permission.manage,
|
||||
// analytics.view, data.export
|
||||
```
|
||||
|
||||
**验证方式**: 通过 `MockMvc` 或 `RestAssured` 调用管理端API,使用不同角色的JWT Token认证。
|
||||
|
||||
---
|
||||
|
||||
### P1-3: 补全 Phase 23 系统测试完成报告
|
||||
|
||||
**文件**: `docs/Phase-23-Completion-Report.md`
|
||||
|
||||
**问题**: Phase 23(系统测试)缺少完成报告。
|
||||
|
||||
**需要包含**:
|
||||
- 执行的测试类型和数量
|
||||
- 测试结果(通过/失败/阻塞)
|
||||
- 安全测试结果(SQL注入/XSS/CSRF等)
|
||||
- 并发测试结果
|
||||
- 发现的问题和修复情况
|
||||
|
||||
---
|
||||
|
||||
### P1-4: 清理 CHANGELOG.md 重复内容
|
||||
|
||||
**文件**: `CHANGELOG.md`
|
||||
|
||||
**问题**: 文件大小3.3MB,存在大量重复条目(同一个Phase变更被复制数十次)。
|
||||
|
||||
**操作**:
|
||||
1. 去重,保留每项变更的唯一记录
|
||||
2. 按版本(v1.0.0-rc.1, v1.0.0)组织
|
||||
3. 文件压缩到合理大小(目标 < 100KB)
|
||||
|
||||
---
|
||||
|
||||
## P2 — 建议完成(非阻塞)
|
||||
|
||||
### P2-1: AccountLifecycle 注销联动完善
|
||||
|
||||
**现状**: 代码已实现,STATUS.md 标记为TODO。
|
||||
|
||||
**验证**: 确认注销后以下联动正确:
|
||||
- 推荐停止
|
||||
- 聊天停止(新消息禁止)
|
||||
- 红娘服务停止
|
||||
- 活动处理(未开始的按规则)
|
||||
- 历史关系保留但标记为已注销
|
||||
|
||||
---
|
||||
|
||||
### P2-2: PhotoService Presigned URL 实现
|
||||
|
||||
**现状**: 代码骨架存在,功能未完整实现。
|
||||
|
||||
**需要完成**:
|
||||
- `PhotoService` 生成 MinIO Presigned URL
|
||||
- 上传凭证接口 `POST /me/photos/upload-token`
|
||||
- 下载凭证接口(私有图片访问)
|
||||
- EXIF信息清理逻辑
|
||||
|
||||
---
|
||||
|
||||
### P2-3: WebSocket 在线状态管理
|
||||
|
||||
**现状**: 骨架实现。
|
||||
|
||||
**需要完善**:
|
||||
- 用户在线/离线状态跟踪
|
||||
- 消息送达状态(已发送/已读)
|
||||
- WebSocket 重连后的状态恢复
|
||||
- 通知推送(`notification.new` 事件)
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
1. P0-1 (产品确认) → 产品负责人
|
||||
2. P0-2 (隐私政策) → 产品/法务
|
||||
3. P0-3 (PIA记录) → 安全负责人
|
||||
4. P1-1 (Invariants测试) → 研发
|
||||
5. P1-2 (权限矩阵测试) → 研发
|
||||
6. P1-3 (Phase23报告) → 研发
|
||||
7. P1-4 (CHANGELOG) → 研发
|
||||
8. P0-4 (微信审核) → 产品
|
||||
9. P0-5 (SSL/DNS) → 运维
|
||||
10. P2-1 ~ P2-3 → 研发(可并行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
每项完成后应满足:
|
||||
|
||||
| 项目 | 验收条件 |
|
||||
|---|---|
|
||||
| P0-1 | 16项全部产品确认,配置正确,文档更新 |
|
||||
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 |
|
||||
| P0-3 | PIA.md 存在,覆盖全部9个场景 |
|
||||
| P1-1 | CoreInvariantsTest 10个用例全部包含真实业务逻辑 |
|
||||
| P1-2 | PermissionMatrixTest 9×18全部覆盖,实际调用验证 |
|
||||
| P1-3 | Phase-23-Completion-Report.md 存在且内容完整 |
|
||||
| P1-4 | CHANGELOG.md < 100KB,无重复条目 |
|
||||
| P0-4 | 微信审核提交并通过 |
|
||||
| P0-5 | HTTPS正常,证书有效 |
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-09-01*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
# Admin Web Dockerfile
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --ignore-scripts
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx-admin.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>丹阳公益婚恋 - 运营管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
# Admin Web Nginx配置(单页应用)
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy for chat
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "danyang-match-admin",
|
||||
"version": "1.0.0-rc.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.6.1",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.6.8",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.6",
|
||||
"vue-tsc": "^2.0.6",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-vue": "^9.21.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('accessToken')
|
||||
router.push('/login')
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
} else if (err.response?.data?.message) {
|
||||
ElMessage.error(err.response.data.message)
|
||||
} else {
|
||||
ElMessage.error('请求失败')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export const get = (url: string, params?: Record<string, any>, headers?: Record<string, string>) =>
|
||||
client.get(url, { params, headers })
|
||||
|
||||
export const post = (url: string, data?: any, headers?: Record<string, string>) =>
|
||||
client.post(url, data, { headers })
|
||||
|
||||
export const put = (url: string, data?: any, headers?: Record<string, string>) =>
|
||||
client.put(url, data, { headers })
|
||||
|
||||
export const del = (url: string, headers?: Record<string, string>) =>
|
||||
client.delete(url, { headers })
|
||||
|
||||
export default client
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<el-container class="main-layout">
|
||||
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
|
||||
<div class="logo">
|
||||
<span v-if="!isCollapse">丹阳公益婚恋</span>
|
||||
<span v-else>丹阳</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" :collapse="isCollapse" :router="true"
|
||||
background-color="#304156" text-color="#bfcbd9" active-text-color="#E96B6B" class="sidebar-menu">
|
||||
<el-menu-item index="/dashboard" :disabled="false">
|
||||
<el-icon><DataBoard /></el-icon><template #title>工作台</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('review.view')" index="/review">
|
||||
<el-icon><DocumentChecked /></el-icon><template #title>资料审核</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('user.view')" index="/users">
|
||||
<el-icon><User /></el-icon><template #title>用户管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('organization.manage')" index="/organizations">
|
||||
<el-icon><OfficeBuilding /></el-icon><template #title>机构管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('permission.manage')" index="/operators">
|
||||
<el-icon><Setting /></el-icon><template #title>人员管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('activity.review_registration')" index="/activities/interests">
|
||||
<el-icon><Calendar /></el-icon><template #title>活动互选</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('risk.report.view')" index="/risk/reports">
|
||||
<el-icon><Warning /></el-icon><template #title>举报中心</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('risk.report.process')" index="/appeals">
|
||||
<el-icon><ChatDotRound /></el-icon><template #title>申诉管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('analytics.view')" index="/analytics">
|
||||
<el-icon><TrendCharts /></el-icon><template #title>数据统计</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('user.view')" index="/audit">
|
||||
<el-icon><Document /></el-icon><template #title>审计日志</template>
|
||||
</el-menu-item>
|
||||
<!-- V1.1 新增菜单 -->
|
||||
<el-menu-item v-if="hasPerm('banner.manage')" index="/banner">
|
||||
<el-icon><Promotion /></el-icon><template #title>Banner管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('content.manage')" index="/content">
|
||||
<el-icon><FolderOpened /></el-icon><template #title>内容管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('service_location.manage')" index="/service-station">
|
||||
<el-icon><Location /></el-icon><template #title>善缘服务站</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('service_appointment.manage')" index="/service-appointment">
|
||||
<el-icon><Memo /></el-icon><template #title>服务预约</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('service_program.manage')" index="/service-program">
|
||||
<el-icon><Service /></el-icon><template #title>专项服务项目</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('counseling.manage')" index="/counseling">
|
||||
<el-icon><ChatDotRound /></el-icon><template #title>婚恋咨询</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="hasPerm('referral.manage')" index="/referral">
|
||||
<el-icon><Bell /></el-icon><template #title>转介管理</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
|
||||
<Fold v-if="!isCollapse" /><Expand v-else />
|
||||
</el-icon>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ currentTitle }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
<el-avatar :size="32" style="background:#E96B6B">{{ (operator?.name || '管')[0] }}</el-avatar>
|
||||
<span class="user-name">{{ operator?.name || '工作人员' }}</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive><component :is="Component" /></keep-alive>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Fold, Expand, DataBoard, DocumentChecked, User, OfficeBuilding, Setting, Calendar, Warning, ChatDotRound, TrendCharts, Document, Service, Location, Memo, Promotion, FolderOpened, Bell } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const isCollapse = ref(false)
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const currentTitle = computed(() => (route.meta?.title as string) || '页面')
|
||||
const operator = computed(() => authStore.operator)
|
||||
|
||||
function hasPerm(permission: string): boolean {
|
||||
return (authStore.permissions || []).includes(permission)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-layout { height: 100vh; width: 100%; }
|
||||
.sidebar { background-color: #304156; transition: width 0.3s; overflow: hidden; }
|
||||
.logo { height: 60px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 16px; font-weight: bold; border-bottom: 1px solid #3a4a5e; }
|
||||
.sidebar-menu { border-right: none; }
|
||||
.header { background: #fff; border-bottom: 1px solid #e4e7ed; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.collapse-btn { cursor: pointer; font-size: 20px; color: #666; }
|
||||
.collapse-btn:hover { color: #E96B6B; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.user-name { font-size: 14px; color: #333; }
|
||||
.main-content { background: #f0f2f5; padding: 20px; overflow: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './theme/index.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { size: 'default', zIndex: 3000 })
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>活动互选管理</span>
|
||||
<el-input v-model="searchActivityId" placeholder="活动ID搜索" 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="activityTitle" label="活动名称" min-width="180" />
|
||||
<el-table-column label="双向互选对数" width="140">
|
||||
<template #default="{row}">{{ row.mutualPairs }} 对</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="反馈提交数" width="120">
|
||||
<template #default="{row}">{{ row.feedbackCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="viewMutuals(row.activityId)">查看互选</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 互选详情抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" title="活动互选详情" size="480px">
|
||||
<div v-if="currentActivity">
|
||||
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
|
||||
<el-table :data="mutualList" border size="small" style="margin-top:16px">
|
||||
<el-table-column prop="sourceName" label="表达者" width="120" />
|
||||
<el-table-column prop="targetName" label="被表达者" width="120" />
|
||||
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
|
||||
</el-table>
|
||||
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const searchActivityId = ref('')
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
const currentActivity = ref<any>(null)
|
||||
const mutualList = ref<any[]>([])
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewMutuals(activityId: string) {
|
||||
drawerVisible.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const [actRes, mutualRes]: any = await Promise.all([
|
||||
get(`/activities/${activityId}`, {}, { Authorization: `Bearer ${token}` }),
|
||||
get(`/activities/${activityId}/interests/mutual`, {}, { Authorization: `Bearer ${token}` })
|
||||
])
|
||||
currentActivity.value = actRes
|
||||
mutualList.value = mutualRes || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.card-header { display: flex; align-items: center; gap: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<!-- 日期选择 -->
|
||||
<el-card shadow="never" style="margin-bottom:16px">
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadData">查询</el-button>
|
||||
<el-button @click="loadDailySummary">每日汇总</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 用户漏斗 -->
|
||||
<el-card shadow="never" style="margin-bottom:16px">
|
||||
<template #header><span>用户转化漏斗</span></template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6" v-for="(stage) in funnelStages" :key="stage.key">
|
||||
<div class="funnel-stage" :style="{ backgroundColor: stage.color + '15', borderColor: stage.color }">
|
||||
<div class="funnel-label">{{ stage.label }}</div>
|
||||
<div class="funnel-value">{{ stage.value }}</div>
|
||||
<div class="funnel-rate" v-if="stage.rate !== null">{{ stage.rate }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-descriptions :column="4" border size="small" style="margin-top:16px">
|
||||
<el-descriptions-item label="累计注册">{{ funnelStats.totalRegistered }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实名用户">{{ funnelStats.verifiedCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核通过">{{ funnelStats.profileApproved }}</el-descriptions-item>
|
||||
<el-descriptions-item label="7日活跃">{{ funnelStats.activeUsers }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- Match漏斗 -->
|
||||
<el-card shadow="never" style="margin-bottom:16px">
|
||||
<template #header><span>Match转化漏斗</span></template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4" v-for="stage in matchStages" :key="stage.key">
|
||||
<div class="funnel-stage" :style="{ backgroundColor: stage.color + '15', borderColor: stage.color }">
|
||||
<div class="funnel-label">{{ stage.label }}</div>
|
||||
<div class="funnel-value">{{ stage.value }}</div>
|
||||
<div class="funnel-rate">{{ stage.rate }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 安全统计 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px">
|
||||
<el-col :span="6" v-for="card in safetyCards" :key="card.key">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 每日汇总表 -->
|
||||
<el-card shadow="never" v-if="dailySummary.length > 0">
|
||||
<template #header><span>每日汇总(最近{{ dailySummary.length }}天)</span></template>
|
||||
<el-table :data="dailySummary" border stripe size="small">
|
||||
<el-table-column prop="date" label="日期" width="120" />
|
||||
<el-table-column label="新增用户" width="100"><template #default="{row}">{{ row.newUsers }}</template></el-table-column>
|
||||
<el-table-column label="新增Match" width="100"><template #default="{row}">{{ row.newMatches }}</template></el-table-column>
|
||||
<el-table-column label="新增聊天" width="100"><template #default="{row}">{{ row.newConversations }}</template></el-table-column>
|
||||
<el-table-column label="新增举报" width="100"><template #default="{row}">{{ row.newReports }}</template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<[string, string]>([
|
||||
new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10),
|
||||
new Date().toISOString().slice(0, 10)
|
||||
])
|
||||
const funnelStats = ref<any>({})
|
||||
const matchFunnelStats = ref<any>({})
|
||||
const safetyStats = ref<any>({})
|
||||
const dailySummary = ref<any[]>([])
|
||||
|
||||
const funnelStages = [
|
||||
{ key: 'registered', label: '累计注册', value: 0, rate: null, color: '#409EFF' },
|
||||
{ key: 'verified', label: '已实名', value: 0, rate: null, color: '#67C23A' },
|
||||
{ key: 'approved', label: '审核通过', value: 0, rate: null, color: '#E59A32' },
|
||||
{ key: 'active', label: '7日活跃', value: 0, rate: null, color: '#E96B6B' }
|
||||
]
|
||||
|
||||
const matchStages = [
|
||||
{ key: 'recommended', label: '推荐', value: 0, rate: '-', color: '#409EFF' },
|
||||
{ key: 'liked', label: '喜欢', value: 0, rate: '-', color: '#67C23A' },
|
||||
{ key: 'matches', label: 'Match', value: 0, rate: '-', color: '#E59A32' },
|
||||
{ key: 'chats', label: '聊天', value: 0, rate: '-', color: '#909399' },
|
||||
{ key: 'knowing', label: '了解中', value: 0, rate: '-', color: '#E96B6B' },
|
||||
{ key: 'success', label: '成功牵手', value: 0, rate: '-', color: '#F56C6C' }
|
||||
]
|
||||
|
||||
const safetyCards = [
|
||||
{ key: 'pending', label: '待处理举报', value: 0, color: '#E96B6B' },
|
||||
{ key: 'resolved', label: '已处理举报', value: 0, color: '#67C23A' },
|
||||
{ key: 'highRisk', label: '高风险用户', value: 0, color: '#E59A32' },
|
||||
{ key: 'banned', label: '封禁用户', value: 0, color: '#F56C6C' }
|
||||
]
|
||||
|
||||
async function loadFunnel() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (dateRange.value[0] && dateRange.value[1]) {
|
||||
const [userRes, matchRes, safetyRes]: any[] = await Promise.all([
|
||||
get('/operator/analytics/user-funnel', {
|
||||
since: dateRange.value[0], until: dateRange.value[1]
|
||||
}, { Authorization: `Bearer ${token}` }),
|
||||
get('/operator/analytics/match-funnel', {
|
||||
since: dateRange.value[0], until: dateRange.value[1]
|
||||
}, { Authorization: `Bearer ${token}` }),
|
||||
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` })
|
||||
])
|
||||
funnelStats.value = userRes
|
||||
matchFunnelStats.value = matchRes
|
||||
safetyStats.value = safetyRes
|
||||
|
||||
// 更新漏斗显示
|
||||
funnelStages[0].value = userRes.totalRegistered || 0
|
||||
funnelStages[1].value = userRes.verifiedCount || 0
|
||||
funnelStages[1].rate = userRes.verifyRate || '-'
|
||||
funnelStages[2].value = userRes.profileApproved || 0
|
||||
funnelStages[2].rate = userRes.approvalRate || '-'
|
||||
funnelStages[3].value = userRes.activeUsers || 0
|
||||
funnelStages[3].rate = userRes.activeRate || '-'
|
||||
|
||||
matchStages[0].value = matchRes.totalRecommended || 0
|
||||
matchStages[1].value = matchRes.totalLiked || 0
|
||||
matchStages[1].rate = matchRes.likeRate || '-'
|
||||
matchStages[2].value = matchRes.totalMatches || 0
|
||||
matchStages[2].rate = matchRes.matchRate || '-'
|
||||
matchStages[3].value = matchRes.chatsStarted || 0
|
||||
matchStages[3].rate = matchRes.chatRate || '-'
|
||||
matchStages[4].value = matchRes.gettingToKnow || 0
|
||||
matchStages[5].value = matchRes.successPairs || 0
|
||||
matchStages[5].rate = matchRes.successRate || '-'
|
||||
|
||||
safetyCards[0].value = safetyRes.pendingReports || 0
|
||||
safetyCards[1].value = safetyRes.resolvedReports || 0
|
||||
safetyCards[2].value = safetyRes.highRiskUsers || 0
|
||||
safetyCards[3].value = safetyRes.bannedUsers || 0
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadDailySummary() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${token}` })
|
||||
dailySummary.value = res || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try { await Promise.all([loadFunnel(), loadDailySummary()]) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.funnel-stage {
|
||||
border: 2px solid;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.funnel-label { font-size: 14px; color: #666; margin-bottom: 8px; }
|
||||
.funnel-value { font-size: 28px; font-weight: bold; color: #333; }
|
||||
.funnel-rate { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 32px; font-weight: bold; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4" v-for="s in stats" :key="s.key">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ s.value }}</div>
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" style="margin-top:16px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>申诉管理</span>
|
||||
<el-tag type="warning" v-if="stats.find(s=>s.key==='pending')?.value">待处理 {{ stats.find(s=>s.key==='pending')?.value }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="userName" label="用户" width="120" />
|
||||
<el-table-column prop="appealTypeText" label="申诉类型" width="120" />
|
||||
<el-table-column prop="reason" label="申诉原因" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="contactMethod" label="联系方式" width="140" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="提交时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="row.status==='pending'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
|
||||
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 申诉详情抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" title="申诉详情" size="520px">
|
||||
<div v-if="detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户">{{ detail.userName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申诉类型">{{ detail.appealTypeText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTagType(detail.status)">{{ statusText(detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="联系方式">{{ detail.contactMethod || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申诉原因" :span="2">{{ detail.reason }}</el-descriptions-item>
|
||||
<el-descriptions-item label="证据" :span="2">
|
||||
<span v-if="detail.evidence">见原始证据文件</span>
|
||||
<span v-else class="text-muted">未上传</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处置备注" :span="2" v-if="detail.decisionNote">
|
||||
{{ detail.decisionNote }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'">
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
<el-select v-model="decision" style="width:100%">
|
||||
<el-option label="通过申诉,解除限制" value="approve" />
|
||||
<el-option label="驳回申诉,维持原处置" value="reject" />
|
||||
<el-option label="升级人工进一步调查" value="escalate" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核说明">
|
||||
<el-input v-model="decisionNote" type="textarea" :rows="3" placeholder="请输入审核意见" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="processing" @click="handleProcess">确认提交</el-button>
|
||||
<el-button @click="drawerVisible=false">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const processing = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const stats = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
const currentId = ref('')
|
||||
const detail = ref<any>(null)
|
||||
const decision = ref('approve')
|
||||
const decisionNote = ref('')
|
||||
|
||||
const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
pending: { text: '待审核', type: 'warning' },
|
||||
reviewing: { text: '审核中', type: 'info' },
|
||||
approved: { text: '已通过', type: 'success' },
|
||||
rejected: { text: '已驳回', type: 'danger' },
|
||||
escalated: { text: '已升级', type: 'warning' }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res || []
|
||||
loadStats()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
stats.value = [
|
||||
{ key: 'pending', label: '待审核', value: res.pendingCount || 0, color: '#E96B6B' },
|
||||
{ key: 'reviewing', label: '审核中', value: res.reviewingCount || 0, color: '#E59A32' },
|
||||
{ key: 'approved', label: '已通过', value: res.approvedCount || 0, color: '#67C23A' },
|
||||
{ key: 'rejected', label: '已驳回', value: res.rejectedCount || 0, color: '#909399' },
|
||||
{ key: 'escalated', label: '已升级', value: res.escalatedCount || 0, color: '#F56C6C' }
|
||||
]
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
currentId.value = id
|
||||
drawerVisible.value = true
|
||||
decision.value = 'approve'
|
||||
decisionNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function handleProcess() {
|
||||
if (!decision.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
await post(`/operator/appeals/${currentId.value}/process`,
|
||||
{ decision: decision.value, decisionNote: decisionNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
drawerVisible.value = false
|
||||
detail.value = null
|
||||
loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { processing.value = false }
|
||||
}
|
||||
|
||||
function statusTagType(s: string) { return STATUS_MAP[s]?.type || 'info' }
|
||||
function statusText(s: string) { return STATUS_MAP[s]?.text || s }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.text-muted { color: #BBB; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>审计日志</span>
|
||||
<el-radio-group v-model="resourceFilter" size="small" @change="loadData">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="user">用户</el-radio-button>
|
||||
<el-radio-button label="operator">Operator</el-radio-button>
|
||||
<el-radio-button label="report">举报</el-radio-button>
|
||||
<el-radio-button label="appeal">申诉</el-radio-button>
|
||||
<el-radio-button label="activity">活动</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="operatorId" label="操作人ID" width="140" />
|
||||
<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="ip" label="IP地址" width="140" />
|
||||
<el-table-column prop="createdAt" label="时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right"
|
||||
layout="total, prev, pager, next" :total="total" :page-size="pageSize"
|
||||
:current-page="page" @current-change="handlePageChange" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const pageSize = ref(20)
|
||||
const resourceFilter = ref('')
|
||||
|
||||
const ACTION_TEXT: Record<string, string> = {
|
||||
CREATE_OPERATOR: '创建操作员',
|
||||
DISABLE_OPERATOR: '停用操作员',
|
||||
ENABLE_OPERATOR: '启用操作员',
|
||||
RESET_PASSWORD: '重置密码',
|
||||
VIEW_USER_DETAIL: '查看用户详情',
|
||||
UPDATE_USER_STATUS: '修改用户状态',
|
||||
SUBMIT_REPORT: '提交举报',
|
||||
PROCESS_REPORT: '处理举报',
|
||||
SUBMIT_APPEAL: '提交申诉',
|
||||
PROCESS_APPEAL: '处理申诉',
|
||||
CREATE_ORGANIZATION: '创建机构',
|
||||
DISABLE_ORGANIZATION: '停用机构',
|
||||
ENABLE_ORGANIZATION: '启用机构',
|
||||
CREATE_ACTIVITY: '创建活动',
|
||||
APPROVE_REGISTRATION: '审核报名',
|
||||
REJECT_REGISTRATION: '拒绝报名'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/audit/logs', {
|
||||
resourceType: resourceFilter.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function handlePageChange(p: number) { page.value = p - 1; loadData() }
|
||||
function actionText(a: string) { return ACTION_TEXT[a] || a }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="banner-management">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>首页 Banner 管理</span>
|
||||
<el-button type="primary" @click="openCreate">新建 Banner</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="banners" v-loading="loading" stripe>
|
||||
<el-table-column prop="title" label="标题" width="150" />
|
||||
<el-table-column prop="target_type" label="跳转类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ bannerTargetTypeLabel(row.target_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="audience_scope" label="展示范围" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="audienceScopeTag(row.audience_scope)">{{ audienceScopeLabel(row.audience_scope) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column label="时间范围" width="200">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDateTime(row.start_at) }} ~ {{ formatDateTime(row.end_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="toggleStatus(row)">
|
||||
{{ row.status === 'active' ? '下架' : '上架' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建/编辑 Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑 Banner' : '新建 Banner'" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="副标题"><el-input v-model="form.subtitle" /></el-form-item>
|
||||
<el-form-item label="跳转类型">
|
||||
<el-select v-model="form.target_type">
|
||||
<el-option label="公益活动" value="activity" />
|
||||
<el-option label="公益红娘" value="matchmaker" />
|
||||
<el-option label="婚恋咨询" value="counseling" />
|
||||
<el-option label="婚前辅导" value="premarital" />
|
||||
<el-option label="善缘服务站" value="service_location" />
|
||||
<el-option label="文章内容" value="article" />
|
||||
<el-option label="内部页面" value="internal_page" />
|
||||
<el-option label="无" value="none" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标值"><el-input v-model="form.target_value" placeholder="活动ID/文章ID等" /></el-form-item>
|
||||
<el-form-item label="展示范围">
|
||||
<el-select v-model="form.audience_scope">
|
||||
<el-option label="全部用户" value="all" />
|
||||
<el-option label="未注册" value="onboarding" />
|
||||
<el-option label="正在寻找" value="searching" />
|
||||
<el-option label="初步了解" value="getting_to_know" />
|
||||
<el-option label="成功牵手" value="successful" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sort_order" :min="0" /></el-form-item>
|
||||
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" /></el-form-item>
|
||||
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveBanner">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const banners = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const form = ref({ title: '', subtitle: '', target_type: 'none', target_value: '', audience_scope: 'all', sort_order: 0, start_at: '', end_at: '' })
|
||||
|
||||
const TARGET_TYPE_LABEL: Record<string, string> = { activity: '活动', matchmaker: '红娘', counseling: '咨询', premarital: '婚前辅导', service_location: '服务站', article: '文章', internal_page: '内部页', none: '无' }
|
||||
const AUDIENCE_SCOPE_LABEL: Record<string, string> = { all: '全部', onboarding: '未注册', searching: '寻找中', getting_to_know: '初步了解', successful: '成功牵手' }
|
||||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', active: '生效中', inactive: '已下架', expired: '已过期' }
|
||||
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', active: 'success', inactive: '', expired: 'danger' }
|
||||
|
||||
function bannerTargetTypeLabel(t: string) { return TARGET_TYPE_LABEL[t] || t }
|
||||
function audienceScopeLabel(s: string) { return AUDIENCE_SCOPE_LABEL[s] || s }
|
||||
function audienceScopeTag(s: string) { return s === 'all' ? 'success' : 'info' }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function loadBanners() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/home/banners')
|
||||
banners.value = res.data || []
|
||||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() { editingId.value = null; Object.assign(form.value, { title: '', subtitle: '', target_type: 'none', target_value: '', audience_scope: 'all', sort_order: 0, start_at: '', end_at: '' }); dialogVisible.value = true }
|
||||
function openEdit(row: any) { editingId.value = row.id; Object.assign(form.value, { title: row.title, subtitle: row.subtitle, target_type: row.target_type, target_value: row.target_value, audience_scope: row.audience_scope, sort_order: row.sort_order, start_at: row.start_at, end_at: row.end_at }); dialogVisible.value = true }
|
||||
|
||||
async function saveBanner() {
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await put(`/home/banners/${editingId.value}`, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await post('/home/banners', form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadBanners()
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const newStatus = row.status === 'active' ? 'inactive' : 'active'
|
||||
await put(`/home/banners/${row.id}`, { ...row, status: newStatus })
|
||||
await loadBanners()
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
onMounted(loadBanners)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="content-management">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="内容分类" name="categories">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>内容分类</span><el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button></div></template>
|
||||
<el-table :data="categories" stripe>
|
||||
<el-table-column prop="name" label="分类名称" />
|
||||
<el-table-column prop="code" label="编码" width="150" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':''">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editCategory(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCategory(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="文章内容" name="articles">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>文章内容</span><el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button></div></template>
|
||||
<el-table :data="articles" stripe v-loading="loading">
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column label="分类" width="120">
|
||||
<template #default="{ row }">{{ categoryLabel(row.category_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="view_count" label="阅读量" width="80" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editArticle(row)">编辑</el-button>
|
||||
<el-button v-if="row.status==='draft'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
|
||||
<el-button v-if="row.status==='published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 分类Dialog -->
|
||||
<el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新建分类'" width="500px">
|
||||
<el-form :model="categoryForm" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="categoryForm.name" /></el-form-item>
|
||||
<el-form-item label="编码"><el-input v-model="categoryForm.code" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="categoryForm.sortOrder" :min="0" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCategory">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 文章Dialog -->
|
||||
<el-dialog v-model="articleDialogVisible" :title="editingArticleId ? '编辑文章' : '新建文章'" width="700px">
|
||||
<el-form :model="articleForm" label-width="80px">
|
||||
<el-form-item label="标题"><el-input v-model="articleForm.title" /></el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="articleForm.categoryId">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="内容"><el-input v-model="articleForm.content" type="textarea" :rows="8" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="articleDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveArticle">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { get, post, put, del } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('categories')
|
||||
const loading = ref(false)
|
||||
const categories = ref<any[]>([])
|
||||
const articles = ref<any[]>([])
|
||||
|
||||
const categoryDialogVisible = ref(false)
|
||||
const editingCategoryId = ref<string | null>(null)
|
||||
const categoryForm = ref({ name: '', code: '', sortOrder: 0 })
|
||||
|
||||
const articleDialogVisible = ref(false)
|
||||
const editingArticleId = ref<string | null>(null)
|
||||
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '' })
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { draft: '草稿', published: '已发布', offline: '已下架' }
|
||||
const STATUS_TAG: Record<string, string> = { draft: 'info', published: 'success', offline: 'danger' }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function categoryLabel(id: string) { const c = categories.value.find(x => x.id === id); return c ? c.name : '-' }
|
||||
|
||||
async function loadCategories() {
|
||||
const res: any = await get('/content/categories')
|
||||
categories.value = res.data || []
|
||||
}
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/content/articles')
|
||||
articles.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function saveCategory() {
|
||||
try {
|
||||
if (editingCategoryId.value) await put(`/content/operator/categories/${editingCategoryId.value}`, categoryForm.value)
|
||||
else await post('/content/operator/categories', categoryForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
categoryDialogVisible.value = false
|
||||
loadCategories()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
}
|
||||
async function editCategory(row: any) { editingCategoryId.value = row.id; Object.assign(categoryForm.value, row); categoryDialogVisible.value = true }
|
||||
async function deleteCategory(row: any) {
|
||||
try { await del(`/content/operator/categories/${row.id}`); ElMessage.success('删除成功'); loadCategories() }
|
||||
catch (e) { ElMessage.error('删除失败') }
|
||||
}
|
||||
async function saveArticle() {
|
||||
try {
|
||||
if (editingArticleId.value) await put(`/content/operator/articles/${editingArticleId.value}`, articleForm.value)
|
||||
else await post('/content/operator/articles', articleForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
articleDialogVisible.value = false
|
||||
loadArticles()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
}
|
||||
async function editArticle(row: any) { editingArticleId.value = row.id; Object.assign(articleForm.value, { title: row.title, categoryId: row.category_id, summary: row.summary, content: row.content }); articleDialogVisible.value = true }
|
||||
async function publishArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
|
||||
catch (e) { ElMessage.error('发布失败') }
|
||||
}
|
||||
async function offlineArticle(row: any) {
|
||||
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
|
||||
catch (e) { ElMessage.error('下架失败') }
|
||||
}
|
||||
|
||||
function openCategoryDialog() { editingCategoryId.value = null; Object.assign(categoryForm.value, { name: '', code: '', sortOrder: 0 }); categoryDialogVisible.value = true }
|
||||
function openArticleDialog() { editingArticleId.value = null; Object.assign(articleForm.value, { title: '', categoryId: '', summary: '', content: '' }); articleDialogVisible.value = true }
|
||||
|
||||
onMounted(() => { loadCategories(); loadArticles() })
|
||||
</script>
|
||||
<style
|
||||
scoped></style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="counseling-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益婚恋咨询管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="待分配队列" name="queue">
|
||||
<el-table :data="pendingQueue" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="serviceType" label="服务类型" width="150">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="requestSource" label="来源" width="140">
|
||||
<template #default="{ row }"><el-tag size="small">{{ requestSourceLabel(row.requestSource) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userRequestSummary" label="问题描述" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="申请时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="openAssign(row)">分配咨询师</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="我的咨询(Counselor)" name="assigned">
|
||||
<el-table :data="assignedServices" stripe v-loading="loading">
|
||||
<el-table-column prop="serviceType" label="类型" width="140">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scheduledStartAt" label="预约时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.scheduledStartAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='assigned'" size="small" @click="scheduleCounseling(row)">排期</el-button>
|
||||
<el-button v-if="row.status==='scheduled'" size="small" type="success" @click="startCounseling(row)">开始</el-button>
|
||||
<el-button v-if="row.status==='in_progress'" size="small" type="primary" @click="showFollowup(row)">跟进</el-button>
|
||||
<el-button v-if="row.status==='in_progress'" size="small" type="warning" @click="completeCounseling(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.counselorId" placeholder="选择咨询师">
|
||||
<el-option label="张咨询师" value="counselor01" />
|
||||
<el-option label="李咨询师" value="counselor02" />
|
||||
</el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doAssign">确认分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="500px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="跟进类型"><el-select v-model="followupForm.type">
|
||||
<el-option label="电话回访" value="phone_call" />
|
||||
<el-option label="面谈" value="face_to_face" />
|
||||
<el-option label="线上沟通" value="online_chat" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="跟进内容"><el-input v-model="followupForm.content" type="textarea" :rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="followupDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doFollowup">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('queue')
|
||||
const loading = ref(false)
|
||||
const pendingQueue = ref<any[]>([])
|
||||
const assignedServices = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const followupDialogVisible = ref(false)
|
||||
const currentId = ref('')
|
||||
const assignForm = ref({ counselorId: '' })
|
||||
const followupForm = ref({ type: 'phone_call', content: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
relationship_guidance: '恋爱困惑指导', emotional_support: '情感支持',
|
||||
premarital_guidance: '婚前辅导', anti_fraud_guidance: '婚恋反诈指导'
|
||||
}
|
||||
const REQUEST_SOURCE_LABEL: Record<string, string> = {
|
||||
user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助'
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待分配', assigned: '已分配', scheduled: '已排期',
|
||||
in_progress: '咨询中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
|
||||
}
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
pending: 'warning', assigned: 'info', scheduled: 'primary',
|
||||
in_progress: 'success', completed: '', cancelled: 'danger', closed: 'info'
|
||||
}
|
||||
|
||||
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
|
||||
function requestSourceLabel(s: string) { return REQUEST_SOURCE_LABEL[s] || s }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function loadQueue() {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/operator/counseling/services/queue'); pendingQueue.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadAssigned() {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/operator/counseling/services/operator/assigned'); assignedServices.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function openAssign(row: any) { currentId.value = row.id; assignForm.value.counselorId = ''; assignDialogVisible.value = true }
|
||||
async function doAssign() {
|
||||
try { await post(`/operator/counseling/services/${currentId.value}/assign`, { counselorId: assignForm.value.counselorId }); ElMessage.success('分配成功'); assignDialogVisible.value = false; loadQueue() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function scheduleCounseling(row: any) {
|
||||
const now = new Date(); now.setHours(now.getHours() + 2)
|
||||
const end = new Date(now); end.setHours(end.getHours() + 1)
|
||||
try { await post(`/operator/counseling/services/${row.id}/schedule`, { startAt: now.toISOString(), endAt: end.toISOString() }); ElMessage.success('排期成功'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function startCounseling(row: any) {
|
||||
try { await post(`/operator/counseling/services/${row.id}/start`, {}); ElMessage.success('已开始'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
function showFollowup(row: any) { currentId.value = row.id; followupForm.value = { type: 'phone_call', content: '' }; followupDialogVisible.value = true }
|
||||
async function doFollowup() {
|
||||
try { await post(`/operator/counseling/services/${currentId.value}/followup`, { followupType: followupForm.value.type, content: followupForm.value.content }); ElMessage.success('跟进记录已保存'); followupDialogVisible.value = false; loadAssigned() }
|
||||
}
|
||||
}
|
||||
async function completeCounseling(row: any) {
|
||||
try { await post(, {}); ElMessage.success('已完成'); loadAssigned() }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
onMounted(() => { loadQueue(); loadAssigned() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Operator 管理</span>
|
||||
<el-button type="primary" @click="openCreate">新增人员</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="organizationId" label="所属机构" width="160">
|
||||
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.status==='normal'?'success':'danger'">{{ row.status==='normal'?'正常':'停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastLoginAt" label="最近登录" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{row}">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right"
|
||||
layout="total, prev, pager, next" :total="total" :page-size="pageSize"
|
||||
:current-page="page" @current-change="handlePageChange" />
|
||||
</el-card>
|
||||
|
||||
<!-- 新增对话框 -->
|
||||
<el-dialog v-model="formVisible" title="新增Operator" width="480px">
|
||||
<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-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-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible=false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const pageSize = ref(20)
|
||||
const formVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({ username: '', name: '', organizationId: '', password: '' })
|
||||
const orgList = ref<any[]>([])
|
||||
const orgMap = ref<Record<string, string>>({})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||
orgList.value = res || []
|
||||
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = { username: '', name: '', organizationId: '', password: '' }
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.username || !form.value.password) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
|
||||
loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function resetPwd(id: string) {
|
||||
const pwd = prompt('请输入新密码(至少8位)')
|
||||
if (!pwd || pwd.length < 8) return
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function handlePageChange(p: number) { page.value = p - 1; loadData() }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(() => { loadData(); loadOrgs() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>机构管理</span>
|
||||
<el-button type="primary" @click="openCreate">新增机构</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="name" label="机构名称" width="200" />
|
||||
<el-table-column prop="type" label="机构类型" width="140">
|
||||
<template #default="{row}">{{ orgTypeText(row.type) }}</template>
|
||||
</el-table-column>
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="formVisible" :title="editId ? '编辑机构' : '新增机构'" width="480px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="机构名称">
|
||||
<el-input v-model="form.name" />
|
||||
</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="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="form.contactName" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible=false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const formVisible = ref(false)
|
||||
const editId = ref('')
|
||||
const form = ref({ name: '', type: '', contactName: '' })
|
||||
const saving = ref(false)
|
||||
|
||||
const ORG_TYPE_TEXT: Record<string, string> = {
|
||||
fulian: '妇联', gonghui: '工会', public_service: '公益服务中心',
|
||||
volunteer: '志愿者团队', other: '其他合作机构'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res || []
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId.value = ''
|
||||
form.value = { name: '', type: 'fulian', contactName: '' }
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editId.value = row.id
|
||||
form.value = { name: row.name, type: row.type, contactName: row.contactName || '' }
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (editId.value) {
|
||||
await put(`/operator/organizations/${editId.value}`, form.value, {
|
||||
Authorization: `Bearer ${token}`
|
||||
})
|
||||
} else {
|
||||
await post('/operator/organizations', form.value, {
|
||||
Authorization: `Bearer ${token}`
|
||||
})
|
||||
}
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
||||
Authorization: `Bearer ${token}`
|
||||
})
|
||||
loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function orgTypeText(t?: string) { return t && ORG_TYPE_TEXT[t] ? ORG_TYPE_TEXT[t] : (t || '-') }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="referral-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>红娘转介记录</span></div></template>
|
||||
<el-table :data="referrals" stripe v-loading="loading">
|
||||
<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="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userConfirmedAt" label="用户确认时间" width="160">
|
||||
<template #default="{ row }">{{ row.userConfirmedAt ? formatDateTime(row.userConfirmedAt) : '未确认' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
const loading = ref(false)
|
||||
const referrals = ref<any[]>([])
|
||||
function typeLabel(t: string) { return t === 'service_station' ? '服务站' : t === 'activity' ? '活动' : t }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/referrals/my-history'); referrals.value = res.data || [] } finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<el-container class="review-page">
|
||||
<el-header class="header">
|
||||
<h2>资料审核</h2>
|
||||
<el-tag type="warning" v-if="stats.pending > 0">待审核 {{ stats.pending }}</el-tag>
|
||||
<el-tag type="info" v-if="stats.processing > 0">审核中 {{ stats.processing }}</el-tag>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="待审核队列" name="pending">
|
||||
<el-table :data="pendingTasks" v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="reviewType" label="审核类型" width="120" />
|
||||
<el-table-column prop="submittedAt" label="提交时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.submittedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="handleClaim(row)">领取</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && pendingTasks.length === 0" description="暂无待审核任务" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="我的审核" name="my">
|
||||
<el-table :data="myTasks" v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completedAt" label="完成时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.completedAt ? formatTime(row.completedAt) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleView(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-main>
|
||||
|
||||
<!-- 审核详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="审核详情" size="500px">
|
||||
<template v-if="currentTask">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户ID">{{ currentTask.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核类型">{{ currentTask.reviewType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="statusType(currentTask.status)">{{ statusText(currentTask.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider>用户资料</el-divider>
|
||||
<el-descriptions :column="2" border v-if="currentTask.userProfile">
|
||||
<el-descriptions-item label="姓名">{{ currentTask.userProfile.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ currentTask.userProfile.gender }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年龄">{{ currentTask.userProfile.age }}岁</el-descriptions-item>
|
||||
<el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm }}cm</el-descriptions-item>
|
||||
<el-descriptions-item label="婚姻状况">{{ currentTask.userProfile.maritalStatus }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自我介绍" :span="2">
|
||||
{{ currentTask.userProfile.selfIntro || '暂未填写' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider>审核操作</el-divider>
|
||||
<el-form :model="reviewForm" label-width="80px">
|
||||
<el-form-item label="审核意见">
|
||||
<el-input v-model="reviewForm.comment" type="textarea" :rows="3" placeholder="请输入审核意见" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核原因">
|
||||
<el-select v-model="reviewForm.reasonCodes" multiple placeholder="可选" style="width:100%">
|
||||
<el-option
|
||||
v-for="reason in reasonOptions"
|
||||
:key="reason.code"
|
||||
:label="reason.name"
|
||||
:value="reason.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="handleApprove" :loading="submitting">通过</el-button>
|
||||
<el-button type="warning" @click="handleNeedEdit" :loading="submitting">需修改</el-button>
|
||||
<el-button type="danger" @click="handleReject" :loading="submitting">拒绝</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('pending')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const stats = ref({ pending: 0, processing: 0 })
|
||||
const pendingTasks = ref<any[]>([])
|
||||
const myTasks = ref<any[]>([])
|
||||
const currentTask = ref<any>(null)
|
||||
const reasonOptions = ref<any[]>([])
|
||||
|
||||
const reviewForm = ref({
|
||||
comment: '',
|
||||
reasonCodes: [] as string[]
|
||||
})
|
||||
|
||||
async function loadStats() {
|
||||
// TODO: Phase 05 实现真实API调用
|
||||
stats.value = { pending: 12, processing: 3 }
|
||||
}
|
||||
|
||||
function formatTime(ts?: string) {
|
||||
if (!ts) return '-'
|
||||
return new Date(ts).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
approved: 'success',
|
||||
need_edit: 'warning',
|
||||
rejected: 'danger'
|
||||
}
|
||||
return map[status] || ''
|
||||
}
|
||||
|
||||
function statusText(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
processing: '审核中',
|
||||
approved: '已通过',
|
||||
need_edit: '需修改',
|
||||
rejected: '已拒绝'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function handleClaim(_row: any) {
|
||||
// TODO: 调用 API 领取
|
||||
ElMessage.success('领取成功')
|
||||
}
|
||||
|
||||
function handleView(row: any) {
|
||||
currentTask.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
submitting.value = true
|
||||
try {
|
||||
// TODO: 调用 API
|
||||
ElMessage.success('审核通过')
|
||||
detailVisible.value = false
|
||||
await loadStats()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNeedEdit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
ElMessage.success('已标记需修改')
|
||||
detailVisible.value = false
|
||||
await loadStats()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
submitting.value = true
|
||||
try {
|
||||
ElMessage.success('已拒绝')
|
||||
detailVisible.value = false
|
||||
await loadStats()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-row :gutter="16">
|
||||
<!-- 统计卡片 -->
|
||||
<el-col :span="6" v-for="s in stats" :key="s.key">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value" :style="{ color: s.color }">{{ s.value }}</div>
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" style="margin-top:16px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>举报列表</span>
|
||||
<el-radio-group v-model="statusFilter" size="small" @change="loadData">
|
||||
<el-radio-button label="pending">待处理</el-radio-button>
|
||||
<el-radio-button label="processing">处理中</el-radio-button>
|
||||
<el-radio-button label="resolved">已处理</el-radio-button>
|
||||
<el-radio-button label="dismissed">已驳回</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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="description" label="说明" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="提交时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{row}">
|
||||
<el-button
|
||||
v-if="row.status==='pending'||row.status==='processing'"
|
||||
type="primary" size="small"
|
||||
@click="openDetail(row.id)">处理</el-button>
|
||||
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && tableData.length===0" description="暂无举报记录" />
|
||||
</el-card>
|
||||
|
||||
<!-- 举报详情抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" :title="`举报详情 #${currentReportId?.slice(0,8)||''}`" size="560px">
|
||||
<div v-if="detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="举报原因">{{ detail.report?.reasonText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTagType(detail.report?.status)">{{ statusText(detail.report?.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">被举报人风险画像</h4>
|
||||
<el-descriptions :column="2" border v-if="detail.riskProfile">
|
||||
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">
|
||||
<el-tag :type="riskLevelTag(detail.riskProfile.riskLevel)">
|
||||
{{ riskLevelText(detail.riskProfile.riskLevel) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">处置操作</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
<el-select v-model="processResult" style="width:100%">
|
||||
<el-option label="举报不成立(无处置)" value="no_action" />
|
||||
<el-option label="警告" value="warning" />
|
||||
<el-option label="限制功能(7天)" value="feature_limit" />
|
||||
<el-option label="暂停账号(30天)" value="temporary_suspend" />
|
||||
<el-option label="封禁账号" value="ban" />
|
||||
<el-option label="转人工进一步调查" value="manual_review" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理备注">
|
||||
<el-input v-model="processNote" type="textarea" :rows="3" placeholder="请输入处理说明" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="processing" @click="handleProcess">确认提交</el-button>
|
||||
<el-button @click="drawerVisible=false">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const processing = ref(false)
|
||||
const statusFilter = ref('pending')
|
||||
const tableData = ref<any[]>([])
|
||||
const stats = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
const currentReportId = ref('')
|
||||
const detail = ref<any>(null)
|
||||
const processResult = ref('no_action')
|
||||
const processNote = ref('')
|
||||
|
||||
const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
pending: { text: '待处理', type: 'warning' },
|
||||
processing: { text: '处理中', type: 'warning' },
|
||||
resolved: { text: '已处理', type: 'success' },
|
||||
rejected: { text: '未受理', type: 'info' },
|
||||
dismissed: { text: '已驳回', type: 'default' }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res || []
|
||||
loadStats()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
stats.value = [
|
||||
{ key: 'pending', label: '待处理', value: res.pendingCount || 0, color: '#E96B6B' },
|
||||
{ key: 'processing', label: '处理中', value: res.processingCount || 0, color: '#E59A32' },
|
||||
{ key: 'resolved', label: '已处理', value: res.resolvedCount || 0, color: '#67C23A' },
|
||||
{ key: 'dismissed', label: '已驳回', value: res.dismissedCount || 0, color: '#909399' }
|
||||
]
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function openDetail(reportId: string) {
|
||||
currentReportId.value = reportId
|
||||
drawerVisible.value = true
|
||||
processResult.value = 'no_action'
|
||||
processNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function handleProcess() {
|
||||
if (!processResult.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
await post(`/operator/risk/reports/${currentReportId.value}/process`,
|
||||
{ processResult: processResult.value, processNote: processNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
drawerVisible.value = false
|
||||
detail.value = null
|
||||
loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { processing.value = false }
|
||||
}
|
||||
|
||||
function statusTagType(s: string) { return STATUS_MAP[s]?.type || 'info' }
|
||||
function statusText(s: string) { return STATUS_MAP[s]?.text || s }
|
||||
function riskLevelTag(l: string) {
|
||||
return { normal: 'info', watch: '', high: 'warning', critical: 'danger' }[l] || 'info'
|
||||
}
|
||||
function riskLevelText(l: string) {
|
||||
return { normal: '正常', watch: '关注', high: '高风险', critical: '极高风险' }[l] || l
|
||||
}
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 36px; font-weight: bold; }
|
||||
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="appointment-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>服务预约管理</span>
|
||||
<el-radio-group v-model="filterStatus" size="small" @change="loadAppointments">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="submitted">待确认</el-radio-button>
|
||||
<el-radio-button label="confirmed">已确认</el-radio-button>
|
||||
<el-radio-button label="completed">已完成</el-radio-button>
|
||||
<el-radio-button label="cancelled">已取消</el-radio-button>
|
||||
</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="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>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<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="createdAt" label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='submitted'||row.status==='pending_confirmation'" size="small" type="success" @click="confirmAppointment(row)">确认</el-button>
|
||||
<el-button v-if="row.status==='confirmed'" size="small" type="primary" @click="completeAppointment(row)">完成</el-button>
|
||||
<el-button v-if="row.status==='submitted'||row.status==='pending_confirmation'" size="small" type="danger" @click="cancelAppointment(row)">取消</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const appointments = ref<any[]>([])
|
||||
const filterStatus = ref('all')
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = { user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助', activity_followup: '活动跟进', service_station: '服务站推荐' }
|
||||
const STATUS_LABEL: Record<string, string> = { submitted: '已提交', pending_confirmation: '待确认', confirmed: '已确认', completed: '已完成', cancelled: '已取消' }
|
||||
const STATUS_TAG: Record<string, string> = { submitted: 'warning', pending_confirmation: 'warning', confirmed: 'success', completed: 'info', cancelled: 'danger' }
|
||||
function sourceLabel(s: string) { return SOURCE_LABEL[s] || s }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || '' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function loadAppointments() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/operator/service-appointments', { status: filterStatus.value === 'all' ? undefined : filterStatus.value })
|
||||
appointments.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function confirmAppointment(row: any) {
|
||||
try { await post(`/service-appointments/operator/${row.id}/confirm`, {}); ElMessage.success('确认成功'); loadAppointments() }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function completeAppointment(row: any) {
|
||||
try { await post(`/service-appointments/operator/${row.id}/complete`, {}); ElMessage.success('完成成功'); loadAppointments() }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function cancelAppointment(row: any) {
|
||||
try { await post(`/service-appointments/operator/${row.id}/cancel`, { reason: '运营取消' }); ElMessage.success('取消成功'); loadAppointments() }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
onMounted(loadAppointments)
|
||||
</script>
|
||||
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="service-program">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>专项公益服务项目</span><el-button type="primary" size="small" @click="openCreate">新建项目</el-button></div></template>
|
||||
<el-table :data="programs" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="项目名称" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='draft'?'info':'danger'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间范围" width="220">
|
||||
<template #default="{ row }">{{ formatDateTime(row.start_at) }} ~ {{ formatDateTime(row.end_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成员数" width="80">
|
||||
<template #default="{ row }">{{ row.memberCount || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='draft'" size="small" type="success" @click="activateProgram(row)">激活</el-button>
|
||||
<el-button size="small" @click="viewMembers(row)">查看成员</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 成员Dialog -->
|
||||
<el-dialog v-model="memberDialogVisible" title="项目成员" width="700px">
|
||||
<el-table :data="members" stripe v-loading="memberLoading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='approved'?'success':row.status==='pending'?'warning':'info'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appliedAt" label="申请时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.appliedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='pending'" size="small" type="success" @click="approveMember(row.id)">通过</el-button>
|
||||
<el-button v-if="row.status==='pending'" size="small" type="danger" @click="rejectMember(row.id)">拒绝</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const programs = ref<any[]>([])
|
||||
const memberDialogVisible = ref(false)
|
||||
const currentProgramId = ref('')
|
||||
const members = ref<any[]>([])
|
||||
const memberLoading = ref(false)
|
||||
|
||||
async function loadPrograms() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/service-programs')
|
||||
programs.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function viewMembers(row: any) {
|
||||
currentProgramId.value = row.id
|
||||
memberDialogVisible.value = true
|
||||
memberLoading.value = true
|
||||
try {
|
||||
const res: any = await get(`/service-programs/operator/${row.id}/members`)
|
||||
members.value = res.data || []
|
||||
} finally { memberLoading.value = false }
|
||||
}
|
||||
|
||||
async function activateProgram(row: any) {
|
||||
try { await post(`/service-programs/operator/${row.id}/activate`, {}); ElMessage.success('激活成功'); loadPrograms() }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function approveMember(memberId: string) {
|
||||
try { await post(`/service-programs/operator/members/${memberId}/approve`, {}); ElMessage.success('已通过'); viewMembers({ id: currentProgramId.value }); }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function rejectMember(memberId: string) {
|
||||
try { await post(`/service-programs/operator/members/${memberId}/reject`, { reason: '不符合条件' }); ElMessage.success('已拒绝'); viewMembers({ id: currentProgramId.value }); }
|
||||
catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(loadPrograms)
|
||||
</script>
|
||||
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="service-station">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>善缘服务站管理</span><el-button type="primary" size="small" @click="openCreate">新建服务站</el-button></div></template>
|
||||
<el-table :data="stations" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="short_name" label="简称" width="120" />
|
||||
<el-table-column prop="address" label="地址" show-overflow-tooltip />
|
||||
<el-table-column prop="contact_phone_display" label="联系电话" width="130" />
|
||||
<el-table-column prop="opening_hours_text" label="营业时间" width="180">
|
||||
<template #default="{ row }">{{ row.opening_hours_text || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='closed'?'danger':'info'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="编辑服务站" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="简称"><el-input v-model="form.short_name" /></el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
||||
<el-form-item label="电话显示"><el-input v-model="form.contact_phone_display" /></el-form-item>
|
||||
<el-form-item label="营业时间"><el-input v-model="form.opening_hours_text" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="营业中" value="active" />
|
||||
<el-option label="休息中" value="inactive" />
|
||||
<el-option label="关闭" value="closed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveStation">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, put } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const stations = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref<any>({})
|
||||
|
||||
async function loadStations() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await get('/operator/service-locations')
|
||||
stations.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openEdit(row: any) { Object.assign(form.value, row); dialogVisible.value = true }
|
||||
function openCreate() { Object.assign(form.value, { name: '', short_name: '', address: '', contact_phone_display: '', opening_hours_text: '', status: 'active' }); dialogVisible.value = true }
|
||||
|
||||
async function saveStation() {
|
||||
try {
|
||||
if (form.value.id) await put(`/operator/service-locations/${form.value.id}`, form.value)
|
||||
else await post('/operator/service-locations', form.value)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadStations()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
|
||||
}
|
||||
|
||||
onMounted(loadStations)
|
||||
</script>
|
||||
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<el-card shadow="never" v-if="user">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户详情</span>
|
||||
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
|
||||
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 基础信息 -->
|
||||
<el-tab-pane label="基础信息" name="basic">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户ID">{{ user.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="显示名称">{{ user.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ genderText(user.gender) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年龄">{{ user.age || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">
|
||||
<template v-if="user.sensitiveMasked">
|
||||
<span class="masked">{{ user.phoneMasked }}</span>
|
||||
<el-button size="small" link @click="requestSensitive">需要查看</el-button>
|
||||
</template>
|
||||
<template v-else>{{ user.phone }}</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ user.regionName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatTime(user.registeredAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近登录">{{ formatTime(user.lastLoginAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 婚恋资料 -->
|
||||
<el-tab-pane label="婚恋资料" name="profile">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="婚姻状态">{{ maritalText(user.maritalStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="子女情况">{{ childrenText(user.childrenStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学历">{{ eduText(user.educationCode) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="职业">{{ occText(user.occupationCode) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自我介绍" :span="2">{{ user.selfIntro || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资料完整度">{{ user.profileCompletion }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="资料状态">{{ profileStatusText(user.profileStatus) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 风险记录 -->
|
||||
<el-tab-pane label="风险记录" name="risk">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="风险分数">{{ user.totalRiskScore ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">
|
||||
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="历史举报数">{{ user.totalReportsCount ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前处置数">{{ user.activeActionsCount ?? 0 }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div style="margin-top:16px">
|
||||
<el-button type="primary" size="small" @click="showStatusDialog = true">修改账号状态</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 状态记录 -->
|
||||
<el-tab-pane label="状态记录" name="status">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="账号状态">
|
||||
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="婚恋状态">{{ relationshipText(user.relationshipStatus) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 修改状态对话框 -->
|
||||
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="400px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="新状态">
|
||||
<el-select v-model="newStatus" style="width:100%">
|
||||
<el-option label="正常" value="normal" />
|
||||
<el-option label="风险审核中" value="reviewing_risk" />
|
||||
<el-option label="功能受限" value="limited" />
|
||||
<el-option label="临时暂停" value="suspended" />
|
||||
<el-option label="封禁" value="banned" />
|
||||
<el-option label="已注销" value="closed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因">
|
||||
<el-input v-model="statusNote" type="textarea" :rows="3" placeholder="请输入修改原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showStatusDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="updating" @click="updateStatus">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router' // eslint-disable-next-line
|
||||
import { get, put } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const user = ref<any>(null)
|
||||
const activeTab = ref('basic')
|
||||
const showStatusDialog = ref(false)
|
||||
const newStatus = ref('normal')
|
||||
const statusNote = ref('')
|
||||
const updating = ref(false)
|
||||
|
||||
const accountId = route.query.id as string
|
||||
|
||||
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
|
||||
normal: { text: '正常', type: 'success' },
|
||||
banned: { text: '封禁', type: 'danger' },
|
||||
suspended: { text: '暂停', type: 'warning' },
|
||||
reviewing_risk: { text: '风险审核', type: 'danger' },
|
||||
limited: { text: '受限', type: 'info' }
|
||||
}
|
||||
|
||||
const RISK_MAP: Record<string, { text: string; type: string }> = {
|
||||
normal: { text: '正常', type: 'info' },
|
||||
watch: { text: '关注', type: '' },
|
||||
high: { text: '高风险', type: 'warning' },
|
||||
critical: { text: '极高风险', type: 'danger' }
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!accountId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get(`/operator/users/${accountId}`, {}, { Authorization: `Bearer ${token}` })
|
||||
user.value = res
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
if (!newStatus.value) return
|
||||
updating.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }, {
|
||||
Authorization: `Bearer ${token}`
|
||||
})
|
||||
showStatusDialog.value = false
|
||||
loadDetail()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { updating.value = false }
|
||||
}
|
||||
|
||||
function requestSensitive() { /* TODO: 需要敏感权限的申请流程 */ }
|
||||
|
||||
function genderText(g?: string) { return g === 'male' ? '男' : g === 'female' ? '女' : '-' }
|
||||
function maritalText(m?: string) { const m2 = m as string | undefined; return m2 && { single: '未婚', divorced: '离异', widowed: '丧偶' }[m2] || '-' }
|
||||
function childrenText(c?: string) { const c2 = c as string | undefined; return c2 && { none: '无子女', with_children: '有子女' }[c2] || '-' }
|
||||
function eduText(e?: string) { return e || '-' }
|
||||
function occText(o?: string) { return o || '-' }
|
||||
function profileStatusText(s?: string) { const s2 = s as string | undefined; return s2 && { approved: '已审核', pending: '待审核', need_edit: '需修改' }[s2] || s }
|
||||
function relationshipText(r?: string) { const r2 = r as string | undefined; return r2 && { searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[r2] || r || '-' }
|
||||
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
|
||||
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
|
||||
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
|
||||
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.card-header { display: flex; align-items: center; gap: 12px; }
|
||||
.masked { color: #999; font-style: italic; }
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户管理</span>
|
||||
<el-input v-model="keyword" placeholder="搜索用户ID" clearable style="width:200px" />
|
||||
<el-select v-model="accountStatus" placeholder="账号状态" clearable style="width:140px">
|
||||
<el-option label="正常" value="normal" />
|
||||
<el-option label="封禁" value="banned" />
|
||||
<el-option label="暂停" value="suspended" />
|
||||
<el-option label="风险审核中" value="reviewing_risk" />
|
||||
</el-select>
|
||||
<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="id" label="用户ID" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="displayName" label="姓名" width="120" />
|
||||
<el-table-column prop="gender" label="性别" width="60">
|
||||
<template #default="{row}">{{ row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="age" label="年龄" width="60" />
|
||||
<el-table-column prop="phoneMasked" label="手机尾号" width="120" />
|
||||
<el-table-column prop="regionName" label="区域" width="100" />
|
||||
<el-table-column prop="accountStatus" label="账号状态" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="accountStatusType(row.accountStatus)">{{ accountStatusText(row.accountStatus) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="riskLevel" label="风险等级" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="riskLevelType(row.riskLevel)">{{ riskLevelText(row.riskLevel) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="registeredAt" label="注册时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.registeredAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="goDetail(row.id)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right"
|
||||
layout="total, prev, pager, next" :total="total" :page-size="pageSize"
|
||||
:current-page="page" @current-change="handlePageChange" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const accountStatus = ref('')
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
|
||||
normal: { text: '正常', type: 'success' },
|
||||
banned: { text: '封禁', type: 'danger' },
|
||||
suspended: { text: '暂停', type: 'warning' },
|
||||
reviewing_risk: { text: '风险审核', type: 'danger' },
|
||||
limited: { text: '受限', type: 'info' }
|
||||
}
|
||||
|
||||
const RISK_MAP: Record<string, { text: string; type: string }> = {
|
||||
normal: { text: '正常', type: 'info' },
|
||||
watch: { text: '关注', type: '' },
|
||||
high: { text: '高风险', type: 'warning' },
|
||||
critical: { text: '极高风险', type: 'danger' }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/users', {
|
||||
keyword: keyword.value || undefined,
|
||||
accountStatus: accountStatus.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.list || []
|
||||
total.value = res.total || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function handlePageChange(p: number) {
|
||||
page.value = p - 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
router.push({ path: '/users/detail', query: { id } })
|
||||
}
|
||||
|
||||
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
|
||||
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
|
||||
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
|
||||
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.card-header { display: flex; align-items: center; gap: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 路由权限映射:path prefix → 需要的最小权限码
|
||||
const ROUTE_PERMISSIONS: Record<string, string[]> = {
|
||||
'/users/detail': ['user.sensitive.view'],
|
||||
'/operators': ['permission.manage'],
|
||||
'/organizations': ['organization.manage'],
|
||||
'/analytics': ['analytics.view'],
|
||||
'/audit': ['user.view'],
|
||||
'/review': ['review.view'],
|
||||
'/risk/reports': ['risk.report.view'],
|
||||
'/appeals': ['risk.report.process'],
|
||||
'/activities/interests': ['activity.review_registration'],
|
||||
'/banner': ['banner.manage'],
|
||||
'/content': ['content.manage'],
|
||||
'/service-station': ['service_location.manage'],
|
||||
'/service-appointment': ['service_appointment.manage'],
|
||||
'/service-program': ['service_program.manage'],
|
||||
'/counseling': ['counseling.manage'],
|
||||
'/referral': ['referral.manage'],
|
||||
}
|
||||
|
||||
function hasPermission(requiredPerms: string[]): boolean {
|
||||
if (!requiredPerms.length) return true
|
||||
const userPerms = useAuthStore().permissions || []
|
||||
return requiredPerms.every((perm) => userPerms.includes(perm))
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/login', name: 'Login', component: () => import('@/views/LoginView.vue'), meta: { title: '登录' } },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { title: '工作台' } },
|
||||
{ path: 'review', name: 'Review', component: () => import('@/modules/review/ReviewView.vue'), meta: { title: '资料审核', permissions: ['review.view'] } },
|
||||
{ path: 'activities/interests', name: 'ActivityInterests', component: () => import('@/modules/activity/ActivityInterestView.vue'), meta: { title: '活动互选管理', permissions: ['activity.review_registration'] } },
|
||||
{ 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/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: '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 新增模块
|
||||
{ path: 'banner', name: 'BannerManagement', component: () => import('@/modules/banner/BannerManagementView.vue'), meta: { title: 'Banner管理', permissions: ['banner.manage'] } },
|
||||
{ path: 'content', name: 'ContentManagement', component: () => import('@/modules/content/ContentManagementView.vue'), meta: { title: '内容管理', permissions: ['content.manage'] } },
|
||||
{ path: 'service-station', name: 'ServiceStation', component: () => import('@/modules/service-station/ServiceStationView.vue'), meta: { title: '善缘服务站', permissions: ['service_location.manage'] } },
|
||||
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.manage'] } },
|
||||
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
|
||||
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore()
|
||||
const requiredPerms = (to.meta.permissions as string[]) || []
|
||||
|
||||
// 1. 登录检查
|
||||
if (to.path !== '/login' && !authStore.isLoggedIn) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
// 2. 权限检查
|
||||
if (requiredPerms.length > 0 && !hasPermission(requiredPerms)) {
|
||||
ElMessage.warning('您没有访问该页面的权限')
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
|
||||
document.title = to.meta.title ? `${to.meta.title} - 丹阳公益婚恋` : '丹阳公益婚恋'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { post } from '@/api/client'
|
||||
|
||||
interface OperatorInfo {
|
||||
operatorId: string
|
||||
name: string
|
||||
username: string
|
||||
permissions: string[]
|
||||
organizationId: string | null
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string>(localStorage.getItem('accessToken') || '')
|
||||
const operator = ref<OperatorInfo | null>(
|
||||
JSON.parse(localStorage.getItem('operator') || 'null')
|
||||
)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const permissions = computed(() => operator.value?.permissions || [])
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const res: any = await post('/auth/operator/login', { username, password })
|
||||
if (res.success) {
|
||||
token.value = res.data.token
|
||||
operator.value = {
|
||||
operatorId: res.data.operatorId,
|
||||
name: res.data.name,
|
||||
username: res.data.username,
|
||||
permissions: res.data.permissions || [],
|
||||
organizationId: null,
|
||||
}
|
||||
localStorage.setItem('accessToken', res.data.token)
|
||||
localStorage.setItem('operator', JSON.stringify(operator.value))
|
||||
return res.data
|
||||
}
|
||||
throw new Error(res.message || '登录失败')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
operator.value = null
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('operator')
|
||||
}
|
||||
|
||||
return { token, operator, isLoggedIn, permissions, login, logout }
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--color-primary: #E96B6B;
|
||||
--color-primary-light: #FFF3F2;
|
||||
--color-success: #2E9B68;
|
||||
--color-warning: #E59A32;
|
||||
--color-danger: #DC3545;
|
||||
--color-info: #17A2B8;
|
||||
|
||||
/* Text Colors */
|
||||
--color-text-primary: #1A1A1A;
|
||||
--color-text-secondary: #666666;
|
||||
--color-text-tertiary: #999999;
|
||||
|
||||
/* Background */
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-secondary: #F5F7FA;
|
||||
--color-bg-page: #F0F2F5;
|
||||
|
||||
/* Border */
|
||||
--color-border: #E4E7ED;
|
||||
--color-border-light: #EBEEF5;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 220px;
|
||||
--header-height: 60px;
|
||||
--border-radius: 6px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-page);
|
||||
}
|
||||
|
||||
/* Override Element Plus primary color */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: #E96B6B;
|
||||
--el-button-border-color: #E96B6B;
|
||||
--el-button-hover-bg-color: #d45a5a;
|
||||
--el-button-hover-border-color: #d45a5a;
|
||||
}
|
||||
|
||||
.el-radio-group .el-radio-button__inner:checked {
|
||||
background-color: #E96B6B;
|
||||
border-color: #E96B6B;
|
||||
}
|
||||
|
||||
/* Common utility */
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.text-primary { color: var(--color-text-primary); }
|
||||
.text-secondary { color: var(--color-text-secondary); }
|
||||
.text-tertiary { color: var(--color-text-tertiary); }
|
||||
|
||||
.mt-sm { margin-top: 8px; }
|
||||
.mt-md { margin-top: 16px; }
|
||||
.mt-lg { margin-top: 24px; }
|
||||
|
||||
.mb-sm { margin-bottom: 8px; }
|
||||
.mb-md { margin-bottom: 16px; }
|
||||
.mb-lg { margin-bottom: 24px; }
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6" v-for="card in statCards" :key="card.key">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>用户统计</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="累计注册">{{ stats.totalUsers }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已实名">{{ stats.verifiedUsers }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活跃用户(7天)">{{ stats.activeUsers }}</el-descriptions-item>
|
||||
<el-descriptions-item label="待审核举报">{{ stats.pendingReports }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>婚恋服务</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总喜欢数">{{ stats.totalLikes }}</el-descriptions-item>
|
||||
<el-descriptions-item label="双向Match">{{ stats.totalMatches }}</el-descriptions-item>
|
||||
<el-descriptions-item label="正在了解">{{ stats.gettingToKnow }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成功牵手">{{ stats.successPairs }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>安全风险</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="封禁账号">{{ stats.bannedUsers }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理中举报">{{ stats.processingReports }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>公益红娘</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="红娘服务申请">{{ stats.matchmakerServices }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const stats = ref<any>({})
|
||||
const loading = ref(false)
|
||||
|
||||
const statCards = [
|
||||
{ key: 'totalUsers', label: '累计注册', value: 0, color: '#409EFF' },
|
||||
{ key: 'verifiedUsers', label: '已实名', value: 0, color: '#67C23A' },
|
||||
{ key: 'activeUsers', label: '近7天活跃', value: 0, color: '#E59A32' },
|
||||
{ key: 'successPairs', label: '成功牵手', value: 0, color: '#E96B6B' }
|
||||
]
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/analytics/dashboard', {}, { Authorization: `Bearer ${token}` })
|
||||
stats.value = res || {}
|
||||
statCards[0].value = res.totalUsers || 0
|
||||
statCards[1].value = res.verifiedUsers || 0
|
||||
statCards[2].value = res.activeUsers || 0
|
||||
statCards[3].value = res.successPairs || 0
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 36px; font-weight: bold; }
|
||||
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h1>丹阳公益婚恋服务平台</h1>
|
||||
<p>工作人员登录</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" class="login-form" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock"
|
||||
size="large" show-password @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" style="width:100%;background:#E96B6B;border-color:#E96B6B"
|
||||
@click="handleLogin">登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="login-footer">
|
||||
<p>丹阳公益婚恋服务平台 · 运营管理后台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(form.username, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '登录失败,请检查用户名和密码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container { width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #FFF3F2 0%, #FFF8F7 100%); }
|
||||
.login-card { width: 400px; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(233,107,107,0.1); }
|
||||
.login-header { text-align: center; margin-bottom: 32px; }
|
||||
.login-header h1 { font-size: 24px; color: #E96B6B; margin-bottom: 8px; }
|
||||
.login-header p { font-size: 14px; color: #999; }
|
||||
.login-form { margin-top: 24px; }
|
||||
.login-footer { text-align: center; margin-top: 24px; font-size: 12px; color: #bbb; }
|
||||
</style>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8085',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache maven
|
||||
COPY pom.xml .
|
||||
COPY maven-settings.xml /root/.m2/settings.xml
|
||||
COPY src ./src
|
||||
RUN mvn compile -B -s /root/.m2/settings.xml && mvn package -DskipTests -Dmaven.test.skip=true -B -s /root/.m2/settings.xml
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar", "--spring.profiles.active=${SPRING_PROFILES_ACTIVE:docker}"]
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.danyang.match</groupId>
|
||||
<artifactId>temp-dependencies</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</parent>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>3.5.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
<version>9.22.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>1.5.5.Final</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<url>https://repo1.maven.org/maven2</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
</project>
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import urllib.request
|
||||
import ssl
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
|
||||
REPO_URL = "https://repo1.maven.org/maven2"
|
||||
LOCAL_REPO = "E:/develop/公益婚恋服务平台/backend/local-maven"
|
||||
|
||||
# Dependencies to download (groupId:artifactId:version)
|
||||
DEPS = [
|
||||
("org.springframework.boot", "spring-boot-starter-web", "3.2.5"),
|
||||
("org.springframework.boot", "spring-boot-starter-validation", "3.2.5"),
|
||||
("org.springframework.boot", "spring-boot-starter-security", "3.2.5"),
|
||||
("org.springframework.boot", "spring-boot-starter-data-redis", "3.2.5"),
|
||||
("org.springframework.boot", "spring-boot-starter-websocket", "3.2.5"),
|
||||
("org.postgresql", "postgresql", "42.7.1"),
|
||||
("com.baomidou", "mybatis-plus-spring-boot3-starter", "3.5.5"),
|
||||
("org.flywaydb", "flyway-core", "9.22.3"),
|
||||
("io.jsonwebtoken", "jjwt-api", "0.12.5"),
|
||||
("io.jsonwebtoken", "jjwt-impl", "0.12.5"),
|
||||
("io.jsonwebtoken", "jjwt-jackson", "0.12.5"),
|
||||
("org.projectlombok", "lombok", "1.18.30"),
|
||||
("org.mapstruct", "mapstruct", "1.5.5.Final"),
|
||||
("org.mapstruct", "mapstruct-processor", "1.5.5.Final"),
|
||||
("org.springdoc", "springdoc-openapi-starter-webmvc-ui", "2.3.0"),
|
||||
]
|
||||
|
||||
def download_artifact(group_id, artifact_id, version, classifier=""):
|
||||
rel_path = group_id.replace(".", "/")
|
||||
filename = f"{artifact_id}-{version}"
|
||||
if classifier:
|
||||
filename += f"-{classifier}"
|
||||
filename += ".jar"
|
||||
|
||||
url = f"{REPO_URL}/{rel_path}/{artifact_id}/{version}/{filename}"
|
||||
local_path = f"{LOCAL_REPO}/{rel_path}/{artifact_id}/{version}/{filename}"
|
||||
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
|
||||
try:
|
||||
print(f"Downloading {filename}...")
|
||||
urllib.request.urlretrieve(url, local_path)
|
||||
print(f" Saved to {local_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" Failed: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Downloading Maven dependencies...")
|
||||
for gid, aid, ver in DEPS:
|
||||
download_artifact(gid, aid, ver)
|
||||
print("Done!")
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
-- Fix matches table
|
||||
ALTER TABLE matches DROP CONSTRAINT IF EXISTS matches_user_a_id_fkey;
|
||||
ALTER TABLE matches DROP CONSTRAINT IF EXISTS matches_user_b_id_fkey;
|
||||
ALTER TABLE matches ALTER COLUMN user_a_id TYPE VARCHAR(36) USING user_a_id::text;
|
||||
ALTER TABLE matches ALTER COLUMN user_b_id TYPE VARCHAR(36) USING user_b_id::text;
|
||||
ALTER TABLE matches ADD CONSTRAINT matches_user_a_id_fkey FOREIGN KEY (user_a_id) REFERENCES users(id);
|
||||
ALTER TABLE matches ADD CONSTRAINT matches_user_b_id_fkey FOREIGN KEY (user_b_id) REFERENCES users(id);
|
||||
|
||||
-- Fix user_relations table
|
||||
ALTER TABLE user_relations DROP CONSTRAINT IF EXISTS user_relations_user_id_fkey;
|
||||
ALTER TABLE user_relations DROP CONSTRAINT IF EXISTS user_relations_target_user_id_fkey;
|
||||
ALTER TABLE user_relations ALTER COLUMN user_id TYPE VARCHAR(36) USING user_id::text;
|
||||
ALTER TABLE user_relations ALTER COLUMN target_user_id TYPE VARCHAR(36) USING target_user_id::text;
|
||||
ALTER TABLE user_relations ADD CONSTRAINT user_relations_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
ALTER TABLE user_relations ADD CONSTRAINT user_relations_target_user_id_fkey FOREIGN KEY (target_user_id) REFERENCES users(id);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>central</id>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
<name>Maven Central</name>
|
||||
<url>https://repo1.maven.org/maven2</url>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
</settings>
|
||||
Binary file not shown.
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Copyright 2002-2019 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Maven Start Up Batch script
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
while [ -h "$PRG" ]; do
|
||||
ls=$(ls -ld "$PRG")
|
||||
link=$(expr "$ls" : '.*-> \(.*\)$')
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=$(dirname "$PRG")/"$link"
|
||||
fi
|
||||
done
|
||||
APP_HOME=$(cd "$(dirname "$PRG")" > /dev/null; pwd)
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support.
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$(uname)" in
|
||||
CYGWIN*) cygwin=true ;;
|
||||
Darwin*) darwin=true ;;
|
||||
MSYS* | MINGW*) msys=true ;;
|
||||
NONSTOP*) nonstop=true ;;
|
||||
esac
|
||||
|
||||
# For Cygwin, ensure paths are in UNIX format before anything is touched.
|
||||
if $cygwin; then
|
||||
[ -n "$MAVEN_HOME" ] && MAVEN_HOME=$(cygpath --unix "$MAVEN_HOME")
|
||||
[ -n "$CLASSPATH" ] && CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
|
||||
fi
|
||||
|
||||
# Attempt to find MAVEN_HOME
|
||||
if [ -z "$MAVEN_HOME" ]; then
|
||||
MAVEN_HOME=/usr/local/maven
|
||||
fi
|
||||
|
||||
if [ -e "$MAVEN_HOME/bin/mvn" ]; then
|
||||
exec "$MAVEN_HOME/bin/mvn" "$@"
|
||||
elif [ -x /usr/bin/mvn ]; then
|
||||
exec /usr/bin/mvn "$@"
|
||||
elif [ -x /usr/local/bin/mvn ]; then
|
||||
exec /usr/local/bin/mvn "$@"
|
||||
else
|
||||
die "ERROR: Could not find Maven installation. Please install Maven or set JAVA_HOME and MAVEN_HOME."
|
||||
fi
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.danyang.match</groupId>
|
||||
<artifactId>danyang-match-platform</artifactId>
|
||||
<version>1.0.0-RC.1</version>
|
||||
<name>Danyang Match Platform</name>
|
||||
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<mybatis-plus.version>3.5.5</mybatis-plus.version>
|
||||
<jjwt.version>0.12.5</jjwt.version>
|
||||
<flyway.version>9.22.3</flyway.version>
|
||||
<mapstruct.version>1.5.5.Final</mapstruct.version>
|
||||
<lombok.version>1.18.34</lombok.version>
|
||||
<bcprov.version>1.77</bcprov.version>
|
||||
<resilience4j.version>2.1.0</resilience4j.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
<version>${flyway.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>${bcprov.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-spring-boot3</artifactId>
|
||||
<version>${resilience4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-circuitbreaker</artifactId>
|
||||
<version>${resilience4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>${mapstruct.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>${mapstruct.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||
<version>0.2.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-aws</artifactId>
|
||||
<version>2.2.6.RELEASE</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<version>8.5.9</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<source>${java.version}</source>
|
||||
<target>${java.version}</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>${mapstruct.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||
<version>0.2.0</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-maven-plugin</artifactId>
|
||||
<version>${flyway.version}</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.danyang.match.platform.shared.dto;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用配置控制器
|
||||
* 提供小程序启动时所需的全局配置信息
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/app")
|
||||
public class AppController {
|
||||
|
||||
/**
|
||||
* 获取应用配置
|
||||
* GET /api/v1/app/config
|
||||
*/
|
||||
@GetMapping("/config")
|
||||
public ApiResponse<Map<String, Object>> getConfig() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
|
||||
// 平台基本信息
|
||||
config.put("platformName", "丹阳公益婚恋服务平台");
|
||||
config.put("version", "1.0.0");
|
||||
config.put("buildTime", "2026-09-01");
|
||||
|
||||
// 功能开关
|
||||
Map<String, Boolean> features = new HashMap<>();
|
||||
features.put("activityEnabled", true);
|
||||
features.put("matchmakerEnabled", true);
|
||||
features.put("imageChatEnabled", true);
|
||||
features.put("appealEnabled", true);
|
||||
features.put("checkinEnabled", true);
|
||||
config.put("features", features);
|
||||
|
||||
// 业务限制参数
|
||||
Map<String, Object> businessLimits = new HashMap<>();
|
||||
businessLimits.put("dailyRecommendationLimit", 6);
|
||||
businessLimits.put("maxProfilePhotos", 6);
|
||||
businessLimits.put("maxInterestTags", 8);
|
||||
businessLimits.put("selfIntroMaxLength", 500);
|
||||
businessLimits.put("recommendationCooldownDays", 180);
|
||||
businessLimits.put("matchScoreThreshold", 60);
|
||||
config.put("businessLimits", businessLimits);
|
||||
|
||||
// 审核相关
|
||||
Map<String, Object> reviewConfig = new HashMap<>();
|
||||
reviewConfig.put("autoApproveAfterDays", 7);
|
||||
reviewConfig.put("maxResubmitCount", 3);
|
||||
config.put("reviewConfig", reviewConfig);
|
||||
|
||||
return ApiResponse.ok(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
* GET /api/v1/app/health
|
||||
*/
|
||||
@GetMapping("/health")
|
||||
public ApiResponse<Map<String, String>> getHealth() {
|
||||
Map<String, String> health = new HashMap<>();
|
||||
health.put("status", "healthy");
|
||||
health.put("service", "danyang-match-platform");
|
||||
health.put("version", "1.0.0");
|
||||
return ApiResponse.ok(health);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.danyang.match.platform;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class DanyangMatchPlatformApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DanyangMatchPlatformApplication.class, args);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.danyang.match.platform.account.controller;
|
||||
|
||||
import com.danyang.match.platform.account.service.AccountLifecycleService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 账号生命周期控制器(用户端)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/account")
|
||||
@RequiredArgsConstructor
|
||||
public class AccountController {
|
||||
|
||||
private final AccountLifecycleService accountService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
/**
|
||||
* 退出婚恋服务(暂停推荐)
|
||||
* POST /api/v1/account/withdraw
|
||||
*/
|
||||
@PostMapping("/withdraw")
|
||||
public ApiResponse<Map<String, Object>> withdrawService(
|
||||
@RequestHeader("Authorization") String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(accountService.withdrawDatingService(userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新开启婚恋服务
|
||||
* POST /api/v1/account/resume
|
||||
*/
|
||||
@PostMapping("/resume")
|
||||
public ApiResponse<Map<String, Object>> resumeService(
|
||||
@RequestHeader("Authorization") String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(accountService.resumeDatingService(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销前检查
|
||||
* GET /api/v1/account/can-withdraw
|
||||
*/
|
||||
@GetMapping("/can-withdraw")
|
||||
public ApiResponse<Map<String, Object>> canWithdraw(
|
||||
@RequestHeader("Authorization") String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(accountService.checkCanWithdraw(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行注销
|
||||
* POST /api/v1/account/close
|
||||
* Body: { "reason": "..." }
|
||||
*/
|
||||
@PostMapping("/close")
|
||||
public ApiResponse<Map<String, Object>> closeAccount(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = extractUserId(authHeader);
|
||||
String reason = body.getOrDefault("reason", "");
|
||||
return ApiResponse.ok(accountService.closeAccount(userId, userId, reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取隐私设置
|
||||
* GET /api/v1/account/privacy-settings
|
||||
*/
|
||||
@GetMapping("/privacy-settings")
|
||||
public ApiResponse<Map<String, Object>> getPrivacySettings(
|
||||
@RequestHeader("Authorization") String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(accountService.getPrivacySettings(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新隐私设置
|
||||
* PUT /api/v1/account/privacy-settings
|
||||
*/
|
||||
@PutMapping("/privacy-settings")
|
||||
public ApiResponse<Map<String, Object>> updatePrivacySettings(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@RequestBody Map<String, Object> settings) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(accountService.updatePrivacySettings(userId, settings));
|
||||
}
|
||||
|
||||
private String extractUserId(String authHeader) {
|
||||
String token = extractToken(authHeader);
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
|
||||
private String extractToken(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.danyang.match.platform.account.controller;
|
||||
|
||||
import com.danyang.match.platform.account.service.AccountLifecycleService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 账号生命周期管理控制器(Operator端)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/account")
|
||||
@RequiredArgsConstructor
|
||||
public class AccountOperatorController {
|
||||
|
||||
private final AccountLifecycleService accountService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
/**
|
||||
* 强制注销用户账号(管理端)
|
||||
* POST /api/v1/operator/account/{userId}/close
|
||||
*/
|
||||
@PostMapping("/{userId}/close")
|
||||
public ApiResponse<Map<String, Object>> closeAccount(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String userId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
String reason = body.getOrDefault("reason", "管理员强制注销");
|
||||
return ApiResponse.ok(accountService.closeAccount(userId, operatorId, reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否可以注销
|
||||
* GET /api/v1/operator/account/{userId}/can-withdraw
|
||||
*/
|
||||
@GetMapping("/{userId}/can-withdraw")
|
||||
public ApiResponse<Map<String, Object>> canWithdraw(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String userId) {
|
||||
return ApiResponse.ok(accountService.checkCanWithdraw(userId));
|
||||
}
|
||||
|
||||
private String extractOperatorId(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
throw new RuntimeException("未认证");
|
||||
}
|
||||
String token = authHeader.substring(7);
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 同意记录
|
||||
* 记录用户对隐私政策的同意状态
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("consent_records")
|
||||
public class ConsentRecord extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
private String consentType; // privacy_policy, user_agreement, marketing_consent
|
||||
private Boolean agreed;
|
||||
private String ipAddress;
|
||||
private String userAgent;
|
||||
private java.time.Instant agreedAt;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.danyang.match.platform.account.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.account.entity.ConsentRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface ConsentRecordMapper extends BaseMapper<ConsentRecord> {
|
||||
|
||||
@Select("SELECT * FROM consent_records WHERE user_id = #{userId} AND consent_type = #{type} AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
||||
Optional<ConsentRecord> findByUserAndType(@Param("userId") String userId,
|
||||
@Param("type") String type);
|
||||
}
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
package com.danyang.match.platform.account.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.account.entity.ConsentRecord;
|
||||
import com.danyang.match.platform.account.mapper.ConsentRecordMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 账号生命周期服务
|
||||
*
|
||||
* 核心 Invariant:
|
||||
* - 注销必须在线便捷,不设置障碍(不需要打电话/邮寄身份证)
|
||||
* - 注销后资料停止展示,显示"用户已注销"
|
||||
* - 注销前检查是否有进行中的业务(红娘服务/活动报名/Match进行中)
|
||||
* - 敏感数据脱敏后保留必要审计记录
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccountLifecycleService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AccountLifecycleService.class);
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final ConsentRecordMapper consentRecordMapper;
|
||||
private final ServiceAppointmentMapper serviceAppointmentMapper;
|
||||
private final CounselingServiceMapper counselingServiceMapper;
|
||||
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
|
||||
// ==================== 退出婚恋服务(暂停推荐) ====================
|
||||
|
||||
/**
|
||||
* 退出婚恋服务:保留账号,关闭所有婚恋相关功能
|
||||
* 用户可随时重新开启
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> withdrawDatingService(String userId, String operatorId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 检查是否有进行中的Match(不能直接退出)
|
||||
// 仅当没有 active match 时允许退出
|
||||
// (Match进行中需双方解除后才能退出)
|
||||
|
||||
// 停用推荐和资料展示
|
||||
user.setRecommendationEnabled(false);
|
||||
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_PAUSED);
|
||||
userMapper.updateById(user);
|
||||
|
||||
// 通知
|
||||
notificationService.send(userId, Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||
"退出婚恋服务",
|
||||
"您的婚恋服务已暂停,资料将不再展示给他人。您可随时重新开启。",
|
||||
"account", null,
|
||||
"withdraw:" + userId + ":" + System.currentTimeMillis());
|
||||
|
||||
saveAuditLog(operatorId, "WITHDRAW_DATING_SERVICE", "user", userId, null, null);
|
||||
log.info("用户退出婚恋服务: userId={}", userId);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("withdrawn", true);
|
||||
result.put("recommendationEnabled", false);
|
||||
result.put("relationshipStatus", Constants.RELATIONSHIP_STATUS_PAUSED);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 重新开启服务 ====================
|
||||
|
||||
/**
|
||||
* 重新开启婚恋服务
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> resumeDatingService(String userId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
user.setRecommendationEnabled(true);
|
||||
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
userMapper.updateById(user);
|
||||
|
||||
notificationService.send(userId, Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||
"重新开启婚恋服务",
|
||||
"您已重新开启婚恋服务,资料将重新进入推荐池。",
|
||||
"account", null,
|
||||
"resume:" + userId + ":" + System.currentTimeMillis());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("resumed", true);
|
||||
result.put("recommendationEnabled", true);
|
||||
result.put("relationshipStatus", Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 注销账号 ====================
|
||||
|
||||
/**
|
||||
* 注销前检查:是否有进行中的业务不能注销
|
||||
*/
|
||||
public Map<String, Object> checkCanWithdraw(String userId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
Map<String, Object> blockers = new HashMap<>();
|
||||
|
||||
// 检查是否有进行中的红娘服务
|
||||
long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper
|
||||
if (activeMatchmakerServices > 0) {
|
||||
blockers.put("matchmakerService", "您有进行中的红娘服务,请先完成或取消");
|
||||
}
|
||||
|
||||
// 检查是否有进行中的活动报名(pending/approved 且未取消)
|
||||
long activeRegistrations = registrationMapper.selectCount(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getUserId, userId)
|
||||
.in(ActivityRegistration::getStatus,
|
||||
Constants.REGISTRATION_STATUS_PENDING,
|
||||
Constants.REGISTRATION_STATUS_APPROVED));
|
||||
if (activeRegistrations > 0) {
|
||||
blockers.put("activityRegistration", "您有进行中的活动报名,请先完成或取消");
|
||||
}
|
||||
|
||||
// V1.1: 检查是否有待确认的服务预约
|
||||
long activeAppointments = serviceAppointmentMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.eq(ServiceAppointment::getUserId, userId)
|
||||
.in(ServiceAppointment::getStatus,
|
||||
Constants.APPOINTMENT_STATUS_SUBMITTED,
|
||||
Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION));
|
||||
if (activeAppointments > 0) {
|
||||
blockers.put("serviceAppointment", "您有已提交但未确认的服务预约,请先完成或取消");
|
||||
}
|
||||
|
||||
// V1.1: 检查是否有进行中的咨询
|
||||
long activeCounselings = counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingService>()
|
||||
.eq(CounselingService::getUserId, userId)
|
||||
.in(CounselingService::getStatus,
|
||||
Constants.COUNSELING_STATUS_PENDING,
|
||||
Constants.COUNSELING_STATUS_ASSIGNED,
|
||||
Constants.COUNSELING_STATUS_SCHEDULED,
|
||||
Constants.COUNSELING_STATUS_IN_PROGRESS));
|
||||
if (activeCounselings > 0) {
|
||||
blockers.put("counselingService", "您有进行中的心理咨询服务,请先完成或取消");
|
||||
}
|
||||
|
||||
// V1.1: 检查是否有进行中的专项服务项目
|
||||
long activePrograms = serviceProgramMemberMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getUserId, userId)
|
||||
.eq(ServiceProgramMember::getStatus, "approved"));
|
||||
if (activePrograms > 0) {
|
||||
blockers.put("serviceProgram", "您有进行中的专项服务项目,请先退出或完成");
|
||||
}
|
||||
|
||||
// V1.1: 检查本地资格核验状态
|
||||
Optional<LocalEligibility> localElig = localEligibilityMapper.findByUserId(userId);
|
||||
if (localElig.isPresent() &&
|
||||
(Constants.VERIFICATION_STATUS_SUBMITTED.equals(localElig.get().getVerificationStatus()) ||
|
||||
Constants.VERIFICATION_STATUS_PENDING.equals(localElig.get().getVerificationStatus()))) {
|
||||
blockers.put("localEligibility", "您的本地资格审核正在进行中,请耐心等待");
|
||||
}
|
||||
|
||||
// 检查是否有 pending 的成功牵手请求
|
||||
// 简化处理
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("canWithdraw", blockers.isEmpty());
|
||||
result.put("blockers", blockers);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行账号注销
|
||||
* 核心:注销后立即停止所有业务,敏感数据脱敏
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> closeAccount(String userId, String operatorId, String reason) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 1. 检查是否可以注销
|
||||
Map<String, Object> checkResult = checkCanWithdraw(userId);
|
||||
if (!(boolean) checkResult.get("canWithdraw")) {
|
||||
throw new RuntimeException("存在进行中的业务,无法注销:" + checkResult.get("blockers"));
|
||||
}
|
||||
|
||||
// 2. 停止所有进行中的业务
|
||||
// 2a. 停止推荐
|
||||
user.setRecommendationEnabled(false);
|
||||
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SERVICE_EXITED);
|
||||
user.setAccountStatus(Constants.ACCOUNT_STATUS_CLOSED);
|
||||
userMapper.updateById(user);
|
||||
|
||||
// 2b. 通知所有活跃 Match 对方
|
||||
notifyActiveMatches(user.getId(), "account_closed");
|
||||
|
||||
// 2c. 通知红娘(如果有)
|
||||
notifyMatchmaker(user.getId());
|
||||
|
||||
// 2d. 取消所有未开始的活动报名
|
||||
cancelPendingActivityRegistrations(userId);
|
||||
|
||||
// 2e. V1.1: 取消服务预约
|
||||
cancelServiceAppointments(userId);
|
||||
|
||||
// 2f. V1.1: 取消咨询记录
|
||||
cancelCounselingServices(userId);
|
||||
|
||||
// 2g. V1.1: 退出专项服务项目
|
||||
exitServicePrograms(userId);
|
||||
|
||||
// 2h. V1.1: 失效本地资格
|
||||
invalidateLocalEligibility(userId);
|
||||
|
||||
// 3. 敏感数据脱敏(保留审计所需的最小信息)
|
||||
sanitizeUserData(user.getId());
|
||||
|
||||
// 4. 记录退出时间
|
||||
Instant now = Instant.now();
|
||||
user.setUpdatedAt(now);
|
||||
userMapper.updateById(user);
|
||||
|
||||
// 5. 审计
|
||||
String afterJson = reason != null
|
||||
? "{\"reason\":" + escapeJsonString(reason) + "}"
|
||||
: "{\"reason\":null}";
|
||||
saveAuditLog(operatorId, "CLOSE_ACCOUNT", "user", userId, null, afterJson);
|
||||
log.info("用户注销账号: userId={}, reason={}", userId, reason);
|
||||
|
||||
// 6. 通知用户
|
||||
notificationService.send(userId, Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||
"账号已注销",
|
||||
"您的账号已成功注销。根据平台规则,部分必要信息将被脱敏保留以符合法律要求。",
|
||||
"account", userId,
|
||||
"account_closed:" + userId + ":" + System.currentTimeMillis());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("accountClosed", true);
|
||||
result.put("closedAt", now);
|
||||
result.put("dataRetentionNote", "必要信息已脱敏保留,其他数据已删除");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回同意
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> withdrawConsent(String userId, String consentType) {
|
||||
ConsentRecord record = consentRecordMapper.findByUserAndType(userId, consentType).orElse(null);
|
||||
if (record == null) {
|
||||
throw new RuntimeException("未找到该同意记录");
|
||||
}
|
||||
record.setAgreed(false);
|
||||
consentRecordMapper.updateById(record);
|
||||
|
||||
saveAuditLog(userId, "WITHDRAW_CONSENT", "consent", record.getId(), null, consentType);
|
||||
log.info("用户撤回同意: userId={}, type={}", userId, consentType);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("withdrawn", true);
|
||||
result.put("consentType", consentType);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 隐私设置 ====================
|
||||
|
||||
/**
|
||||
* 获取隐私设置
|
||||
*/
|
||||
public Map<String, Object> getPrivacySettings(String userId) {
|
||||
// TODO: Phase 21 实现 user_privacy_settings 查询
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
settings.put("visibleInRecommendations", true);
|
||||
settings.put("allowActivityInvitation", true);
|
||||
settings.put("showLastOnline", false);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新隐私设置
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> updatePrivacySettings(String userId, Map<String, Object> settings) {
|
||||
// TODO: Phase 21 实现 user_privacy_settings 更新
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("updated", true);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 通知所有活跃 Match 对方账号已关闭
|
||||
*/
|
||||
private void notifyActiveMatches(String userId, String reason) {
|
||||
// TODO: 查询该用户的所有 active match,通知对方
|
||||
log.info("通知 Match 对方账号关闭: userId={}, reason={}", userId, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知红娘服务已终止
|
||||
*/
|
||||
private void notifyMatchmaker(String userId) {
|
||||
// TODO: 查询该用户的红娘服务,通知相关红娘
|
||||
log.info("通知红娘服务终止: userId={}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有未开始的活动报名
|
||||
*/
|
||||
private void cancelPendingActivityRegistrations(String userId) {
|
||||
// TODO: 查询 activity_registrations 中 pending/approved 且活动未开始的记录,将其状态设为 cancelled
|
||||
log.info("取消用户活动报名: userId={}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感数据脱敏
|
||||
* 核心:身份证/手机号脱敏,保留审计所需的最小信息
|
||||
*/
|
||||
private void sanitizeUserData(String userId) {
|
||||
// 脱敏手机号
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user != null && user.getPhone() != null) {
|
||||
// 保留后4位,其余替换为 *
|
||||
String phone = user.getPhone();
|
||||
if (phone.length() >= 4) {
|
||||
user.setPhone(phone.substring(0, phone.length() - 4) + "****");
|
||||
} else {
|
||||
user.setPhone("****");
|
||||
}
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
// TODO: 同步更新 user_identities 中的身份证号码
|
||||
log.info("敏感数据脱敏: userId={}", userId);
|
||||
}
|
||||
|
||||
private String escapeJsonString(String s) {
|
||||
if (s == null) return "null";
|
||||
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\"";
|
||||
}
|
||||
|
||||
private void cancelServiceAppointments(String userId) {
|
||||
List<ServiceAppointment> appointments = serviceAppointmentMapper.findByUserId(userId);
|
||||
int cancelled = 0;
|
||||
for (ServiceAppointment appt : appointments) {
|
||||
if (Constants.APPOINTMENT_STATUS_SUBMITTED.equals(appt.getStatus()) ||
|
||||
Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION.equals(appt.getStatus())) {
|
||||
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
|
||||
appt.setUpdatedAt(Instant.now());
|
||||
serviceAppointmentMapper.updateById(appt);
|
||||
cancelled++;
|
||||
}
|
||||
}
|
||||
log.info("取消用户服务预约: userId={}, cancelled={}", userId, cancelled);
|
||||
}
|
||||
|
||||
private void cancelCounselingServices(String userId) {
|
||||
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
|
||||
int cancelled = 0;
|
||||
for (CounselingService cs : services) {
|
||||
if (Constants.COUNSELING_STATUS_PENDING.equals(cs.getStatus()) ||
|
||||
Constants.COUNSELING_STATUS_ASSIGNED.equals(cs.getStatus()) ||
|
||||
Constants.COUNSELING_STATUS_SCHEDULED.equals(cs.getStatus()) ||
|
||||
Constants.COUNSELING_STATUS_IN_PROGRESS.equals(cs.getStatus())) {
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_CANCELLED);
|
||||
cs.setUpdatedAt(Instant.now());
|
||||
counselingServiceMapper.updateById(cs);
|
||||
cancelled++;
|
||||
}
|
||||
}
|
||||
log.info("取消用户咨询服务: userId={}, cancelled={}", userId, cancelled);
|
||||
}
|
||||
|
||||
private void exitServicePrograms(String userId) {
|
||||
List<ServiceProgramMember> members = serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getUserId, userId)
|
||||
.eq(ServiceProgramMember::getStatus, "approved"));
|
||||
int exited = 0;
|
||||
for (ServiceProgramMember member : members) {
|
||||
member.setStatus("withdrawn");
|
||||
serviceProgramMemberMapper.updateById(member);
|
||||
exited++;
|
||||
}
|
||||
log.info("用户退出服务项目: userId={}, exited={}", userId, exited);
|
||||
}
|
||||
|
||||
private void invalidateLocalEligibility(String userId) {
|
||||
Optional<LocalEligibility> elig = localEligibilityMapper.findByUserId(userId);
|
||||
if (elig.isPresent()) {
|
||||
LocalEligibility le = elig.get();
|
||||
le.setVerificationStatus(Constants.VERIFICATION_STATUS_FAILED);
|
||||
localEligibilityMapper.updateById(le);
|
||||
log.info("失效用户本地资格: userId={}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
private void anonymizeServiceHistory(String userId) {
|
||||
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
|
||||
for (CounselingService cs : services) {
|
||||
if (cs.getUserRequestSummary() != null && cs.getUserRequestSummary().length() > 10) {
|
||||
cs.setUserRequestSummary("***已脱敏***");
|
||||
counselingServiceMapper.updateById(cs);
|
||||
}
|
||||
}
|
||||
log.info("服务历史记录脱敏完成: userId={}", userId);
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setBeforeData(beforeData);
|
||||
auditLog.setAfterData(afterData);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.danyang.match.platform.activity.controller;
|
||||
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.activity.service.ActivityService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户端活动控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/activities")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityController {
|
||||
|
||||
private final ActivityService activityService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<?> getActivities(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(activityService.getActivities(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/upcoming")
|
||||
public ApiResponse<?> getUpcoming(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(activityService.getUpcomingActivities(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<Map<String, Object>> getActivityDetail(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String id) {
|
||||
String userId = extractUserId(authHeader);
|
||||
Map<String, Object> detail = activityService.getActivityDetail(id, userId);
|
||||
if (detail == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
||||
}
|
||||
return ApiResponse.ok(detail);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/register")
|
||||
public ApiResponse<Map<String, Object>> register(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = extractUserId(authHeader);
|
||||
String emergencyContactName = body.get("emergencyContactName");
|
||||
String emergencyContactPhone = body.get("emergencyContactPhone");
|
||||
return ApiResponse.ok(activityService.registerActivity(userId, id, emergencyContactName, emergencyContactPhone));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/register")
|
||||
public ApiResponse<Void> cancelRegistration(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String id) {
|
||||
String userId = extractUserId(authHeader);
|
||||
activityService.cancelRegistration(userId, id);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/my")
|
||||
public ApiResponse<List<Map<String, Object>>> getMyActivities(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(activityService.getMyActivities(userId));
|
||||
}
|
||||
|
||||
// ---- V1.1 Phase 14: 多类型活动扩展 ----
|
||||
|
||||
@GetMapping("/types/{activityType}")
|
||||
public ApiResponse<?> getActivitiesByType(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String activityType) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(activityService.getActivitiesByType(userId, activityType));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/organizations")
|
||||
public ApiResponse<List<Map<String, Object>>> getActivityOrganizations(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(activityService.getActivityOrganizations(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/capacity-groups")
|
||||
public ApiResponse<List<Map<String, Object>>> getActivityCapacityGroups(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(activityService.getActivityCapacityGroups(id));
|
||||
}
|
||||
|
||||
@PostMapping("/operator")
|
||||
public ApiResponse<?> createActivityOperator(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/operator/{id}/organizations")
|
||||
public ApiResponse<?> addActivityOrganization(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(activityService.addActivityOrganization(id, body.get("organizationId"), body.get("role"), operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/operator/{id}/capacity-groups")
|
||||
public ApiResponse<?> addCapacityGroup(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(activityService.addCapacityGroup(id, body, operatorId));
|
||||
}
|
||||
|
||||
private String extractUserId(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
String token = authHeader.substring(7);
|
||||
if (!jwtTokenService.validateToken(token)) {
|
||||
return null;
|
||||
}
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.danyang.match.platform.activity.controller;
|
||||
|
||||
import com.danyang.match.platform.activity.dto.ActivityFeedbackDTO;
|
||||
import com.danyang.match.platform.activity.dto.ActivityMatchItemDTO;
|
||||
import com.danyang.match.platform.activity.service.ActivityInterestService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.relationship.service.RelationshipService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 活动反馈与互选控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/activities")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityInterestController {
|
||||
|
||||
private final ActivityInterestService interestService;
|
||||
private final RelationshipService relationshipService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
/**
|
||||
* 提交活动反馈 + 表达意向
|
||||
* POST /api/v1/activities/{id}/feedback
|
||||
*/
|
||||
@PostMapping("/{id}/feedback")
|
||||
public ApiResponse<Map<String, Object>> submitFeedback(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id,
|
||||
@RequestBody ActivityFeedbackDTO body) {
|
||||
String userId = extractUserId(authHeader);
|
||||
Map<String, Object> result = interestService.submitFeedback(
|
||||
userId,
|
||||
id,
|
||||
body.getOverallExperience(),
|
||||
body.getWillingNextTime(),
|
||||
body.getMetSomeoneInteresting(),
|
||||
body.getInterestTargetUserIds()
|
||||
);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动中的互选结果
|
||||
* GET /api/v1/activities/{id}/interests/mutual
|
||||
*/
|
||||
@GetMapping("/{id}/interests/mutual")
|
||||
public ApiResponse<List<ActivityMatchItemDTO>> getMutualMatches(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(interestService.getMutualMatches(userId, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询与某人在活动中的互选状态
|
||||
* GET /api/v1/activities/{id}/interests/mutual/{partnerId}
|
||||
*/
|
||||
@GetMapping("/{id}/interests/mutual/{partnerId}")
|
||||
public ApiResponse<Map<String, Object>> checkMutualInterest(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id,
|
||||
@PathVariable String partnerId) {
|
||||
String userId = extractUserId(authHeader);
|
||||
boolean mutual = interestService.hasMutualInterest(userId, partnerId, id);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("hasMutual", mutual);
|
||||
result.put("canConfirm", mutual);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认开启线上沟通(V1.1 Phase 08)
|
||||
* POST /api/v1/activities/{id}/interests/confirm
|
||||
* 双方确认后创建 Match Episode,不自动创建 Conversation
|
||||
*/
|
||||
@PostMapping("/{id}/interests/confirm")
|
||||
public ApiResponse<Map<String, Object>> confirmInterest(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = extractUserId(authHeader);
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动互选统计(管理端)
|
||||
* GET /api/v1/operator/activities/{id}/interest-stats
|
||||
*/
|
||||
@GetMapping("/operator/{id}/interest-stats")
|
||||
public ApiResponse<Map<String, Object>> getActivityInterestStats(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(interestService.getActivityInterestStats(id));
|
||||
}
|
||||
|
||||
private String extractUserId(String authHeader) {
|
||||
String token = extractToken(authHeader);
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
|
||||
private String extractToken(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.danyang.match.platform.activity.controller;
|
||||
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.activity.service.ActivityService;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import java.util.Map;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
|
||||
/**
|
||||
* 后台活动管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/activities")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityOperatorController {
|
||||
|
||||
private final ActivityService activityService;
|
||||
private final ActivityMapper activityMapper;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
/**
|
||||
* 创建活动
|
||||
* POST /api/v1/operator/activities
|
||||
*/
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public ApiResponse<Activity> createActivity(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
Activity activity = new Activity();
|
||||
activity.setTitle((String) body.get("title"));
|
||||
activity.setDescription((String) body.get("description"));
|
||||
activity.setLocationName((String) body.get("locationName"));
|
||||
activity.setLocationAddress((String) body.get("locationAddress"));
|
||||
activity.setOrganizerOrgId(operatorId);
|
||||
activity.setTotalLimit((Integer) body.getOrDefault("totalLimit", 50));
|
||||
activity.setMaleLimit((Integer) body.getOrDefault("maleLimit", 25));
|
||||
activity.setFemaleLimit((Integer) body.getOrDefault("femaleLimit", 25));
|
||||
activity.setMinAge(((Number) body.getOrDefault("minAge", 18)).shortValue());
|
||||
activity.setMaxAge(((Number) body.getOrDefault("maxAge", 60)).shortValue());
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
|
||||
activity.setCreatedBy(operatorId);
|
||||
// 默认时间:明天开始,持续3小时
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
activity.setStartAt(now.plusDays(1).withHour(14).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
|
||||
activity.setEndAt(now.plusDays(1).withHour(17).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
|
||||
activityMapper.insert(activity);
|
||||
return ApiResponse.ok(activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布活动
|
||||
* PUT /api/v1/operator/activities/{id}/publish
|
||||
*/
|
||||
@PutMapping("/{id}/publish")
|
||||
public ApiResponse<?> publishActivity(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
return ApiResponse.ok(activityService.publishActivity(id, operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消活动
|
||||
* PUT /api/v1/operator/activities/{id}/cancel
|
||||
*/
|
||||
@PutMapping("/{id}/cancel")
|
||||
public ApiResponse<?> cancelActivity(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
return ApiResponse.ok(activityService.cancelActivity(id, operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动列表
|
||||
* GET /api/v1/operator/activities
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<List<Map<String, Object>>> getActivities(
|
||||
@RequestHeader("Authorization") String authHeader) {
|
||||
List<Activity> activities = activityMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Activity>()
|
||||
.eq(Activity::getDeletedAt, 0)
|
||||
.orderByDesc(Activity::getCreatedAt)
|
||||
);
|
||||
return ApiResponse.ok(activities.stream().map(a -> {
|
||||
Map<String, Object> m = new java.util.HashMap<>();
|
||||
m.put("id", a.getId());
|
||||
m.put("title", a.getTitle());
|
||||
m.put("status", a.getStatus());
|
||||
m.put("startAt", a.getStartAt());
|
||||
m.put("endAt", a.getEndAt());
|
||||
m.put("locationName", a.getLocationName());
|
||||
m.put("totalLimit", a.getTotalLimit());
|
||||
m.put("createdAt", a.getCreatedAt());
|
||||
return m;
|
||||
}).collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动报名列表
|
||||
* GET /api/v1/operator/activities/{id}/registrations
|
||||
*/
|
||||
@GetMapping("/{id}/registrations")
|
||||
public ApiResponse<List<Map<String, Object>>> getRegistrations(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(activityService.getRegistrations(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过报名
|
||||
* POST /api/v1/operator/activities/registrations/{rid}/approve
|
||||
*/
|
||||
@PostMapping("/registrations/{rid}/approve")
|
||||
public ApiResponse<?> approveRegistration(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String rid) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
return ApiResponse.ok(activityService.approveRegistration(rid, operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核拒绝报名
|
||||
* POST /api/v1/operator/activities/registrations/{rid}/reject
|
||||
* Body: { "reason": "..." }
|
||||
*/
|
||||
@PostMapping("/registrations/{rid}/reject")
|
||||
public ApiResponse<?> rejectRegistration(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String rid,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
String reason = body.get("reason");
|
||||
return ApiResponse.ok(activityService.rejectRegistration(rid, operatorId, reason));
|
||||
}
|
||||
|
||||
private String extractOperatorId(String authHeader) {
|
||||
String token = extractToken(authHeader);
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
|
||||
private String extractToken(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.danyang.match.platform.activity.controller;
|
||||
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.activity.service.CheckinService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 签到控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/activities")
|
||||
@RequiredArgsConstructor
|
||||
public class CheckinController {
|
||||
|
||||
private final CheckinService checkinService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
/**
|
||||
* 生成签到码
|
||||
* POST /api/v1/activities/{id}/checkin-token
|
||||
*/
|
||||
@PostMapping("/{id}/checkin-token")
|
||||
public ApiResponse<Map<String, Object>> generateCheckinToken(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(checkinService.generateCheckinToken(userId, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作人员扫码签到
|
||||
* POST /api/v1/operator/activities/checkin/scan
|
||||
* Body: { "token": "..." }
|
||||
*/
|
||||
@PostMapping("/operator/checkin/scan")
|
||||
public ApiResponse<Map<String, Object>> scanCheckin(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
String token = body.get("token");
|
||||
if (token == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "token不能为空");
|
||||
}
|
||||
return ApiResponse.ok(checkinService.scanCheckin(token, operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工签到
|
||||
* POST /api/v1/operator/activities/{id}/checkin/manual
|
||||
* Body: { "userId": "..." }
|
||||
*/
|
||||
@PostMapping("/operator/{id}/checkin/manual")
|
||||
public ApiResponse<Map<String, Object>> manualCheckin(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
String userId = body.get("userId");
|
||||
if (userId == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "userId不能为空");
|
||||
}
|
||||
return ApiResponse.ok(checkinService.manualCheckin(userId, id, operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到统计
|
||||
* GET /api/v1/operator/activities/{id}/checkin-stats
|
||||
*/
|
||||
@GetMapping("/operator/{id}/checkin-stats")
|
||||
public ApiResponse<Map<String, Object>> getCheckinStats(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(checkinService.getCheckinStats(id));
|
||||
}
|
||||
|
||||
private String extractUserId(String authHeader) {
|
||||
String token = extractToken(authHeader);
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
|
||||
private String extractOperatorId(String authHeader) {
|
||||
String token = extractToken(authHeader);
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
|
||||
private String extractToken(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.danyang.match.platform.activity.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 活动反馈 DTO
|
||||
*/
|
||||
public class ActivityFeedbackDTO {
|
||||
|
||||
private String activityId;
|
||||
private String overallExperience;
|
||||
private String willingNextTime;
|
||||
private Boolean metSomeoneInteresting;
|
||||
private List<String> interestTargetUserIds;
|
||||
|
||||
public String getActivityId() { return activityId; }
|
||||
public void setActivityId(String activityId) { this.activityId = activityId; }
|
||||
public String getOverallExperience() { return overallExperience; }
|
||||
public void setOverallExperience(String overallExperience) { this.overallExperience = overallExperience; }
|
||||
public String getWillingNextTime() { return willingNextTime; }
|
||||
public void setWillingNextTime(String willingNextTime) { this.willingNextTime = willingNextTime; }
|
||||
public Boolean getMetSomeoneInteresting() { return metSomeoneInteresting; }
|
||||
public void setMetSomeoneInteresting(Boolean metSomeoneInteresting) { this.metSomeoneInteresting = metSomeoneInteresting; }
|
||||
public List<String> getInterestTargetUserIds() { return interestTargetUserIds; }
|
||||
public void setInterestTargetUserIds(List<String> interestTargetUserIds) { this.interestTargetUserIds = interestTargetUserIds; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.danyang.match.platform.activity.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 活动列表项DTO
|
||||
*/
|
||||
@Data
|
||||
public class ActivityListItemDTO {
|
||||
private String activityId;
|
||||
private String title;
|
||||
private String coverUrl;
|
||||
private Instant startAt;
|
||||
private Instant endAt;
|
||||
private String locationName;
|
||||
private String locationAddress;
|
||||
private int maleCount;
|
||||
private int femaleCount;
|
||||
private int totalLimit;
|
||||
private int remainingSeats;
|
||||
private boolean registered;
|
||||
private String registrationStatus;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.danyang.match.platform.activity.dto;
|
||||
|
||||
/**
|
||||
* 活动互选结果 DTO
|
||||
*/
|
||||
public class ActivityMatchItemDTO {
|
||||
|
||||
private String targetUserId;
|
||||
private String name;
|
||||
private String fileKey;
|
||||
private String age;
|
||||
private String locationName;
|
||||
private java.time.Instant mutualConfirmedAt;
|
||||
|
||||
public String getTargetUserId() { return targetUserId; }
|
||||
public void setTargetUserId(String targetUserId) { this.targetUserId = targetUserId; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getFileKey() { return fileKey; }
|
||||
public void setFileKey(String fileKey) { this.fileKey = fileKey; }
|
||||
public String getAge() { return age; }
|
||||
public void setAge(String age) { this.age = age; }
|
||||
public String getLocationName() { return locationName; }
|
||||
public void setLocationName(String locationName) { this.locationName = locationName; }
|
||||
public java.time.Instant getMutualConfirmedAt() { return mutualConfirmedAt; }
|
||||
public void setMutualConfirmedAt(java.time.Instant mutualConfirmedAt) { this.mutualConfirmedAt = mutualConfirmedAt; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.danyang.match.platform.activity.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;
|
||||
|
||||
/**
|
||||
* 公益活动实体
|
||||
* V1.1 Phase 14: 新增 activity_type / registration_policy / target_group / industry_scope / post_event_interest_enabled
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("activities")
|
||||
public class Activity extends BaseEntity {
|
||||
|
||||
private String title;
|
||||
private String coverFileId;
|
||||
private String description;
|
||||
private String organizerOrgId;
|
||||
private String coorganizerText;
|
||||
private Instant startAt;
|
||||
private Instant endAt;
|
||||
private String locationName;
|
||||
private String locationAddress;
|
||||
private Integer maleLimit;
|
||||
private Integer femaleLimit;
|
||||
private Integer totalLimit;
|
||||
private Short minAge;
|
||||
private Short maxAge;
|
||||
private Instant registrationStartAt;
|
||||
private Instant registrationEndAt;
|
||||
private Boolean needReview;
|
||||
private String status;
|
||||
private String createdBy;
|
||||
|
||||
// V1.1 Phase 14 新增字段
|
||||
/** 活动类型:matchmaking / interest_social / relationship_education / premarital_guidance / marriage_custom_culture / anti_fraud */
|
||||
private String activityType;
|
||||
/** 报名政策(JSON snapshot: requires_identity_verified, requires_local_eligibility 等) */
|
||||
private String registrationPolicy;
|
||||
/** 目标人群 */
|
||||
private String targetGroup;
|
||||
/** 行业范围 */
|
||||
private String industryScope;
|
||||
/** 是否启用活动后意向采集 */
|
||||
private Boolean postEventInterestEnabled;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.danyang.match.platform.activity.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 活动容量分组
|
||||
* V1.1 Phase 14
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("activity_capacity_groups")
|
||||
public class ActivityCapacityGroup extends BaseEntity {
|
||||
private String activityId;
|
||||
private String groupCode; // male / female / general
|
||||
private String ruleDefinition;
|
||||
private Integer capacity;
|
||||
private Integer approvedCount;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.danyang.match.platform.activity.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 活动互选记录
|
||||
* 核心 Invariant: 单向意向保密,仅双方确认后才进入 mutual 状态
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("activity_interests")
|
||||
public class ActivityInterest extends BaseEntity {
|
||||
|
||||
private String sourceUserId;
|
||||
private String targetUserId;
|
||||
private String activityId;
|
||||
private String status; // expressed(单方向,保密), mutual(双向确认)
|
||||
private java.time.Instant confirmedAt;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.danyang.match.platform.activity.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;
|
||||
|
||||
/**
|
||||
* 活动多组织关联
|
||||
* V1.1 Phase 14
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("activity_organizations")
|
||||
public class ActivityOrganization extends BaseEntity {
|
||||
private String activityId;
|
||||
private String organizationId;
|
||||
private String role; // host, organizer, co_organizer, partner, venue_partner
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.danyang.match.platform.activity.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;
|
||||
|
||||
/**
|
||||
* 活动报名表
|
||||
* V1.1 Phase 13: 新增转介追踪字段 source/request_source/created_by_operator_id/user_confirmed_at
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("activity_registrations")
|
||||
public class ActivityRegistration extends BaseEntity {
|
||||
|
||||
private String activityId;
|
||||
private String userId;
|
||||
private String status; // pending, approved, rejected, cancelled
|
||||
private Instant registeredAt;
|
||||
private String reviewedBy;
|
||||
private Instant reviewedAt;
|
||||
private String rejectReason;
|
||||
private String checkinStatus; // not_checked_in, checked_in
|
||||
private Instant checkinAt;
|
||||
private String emergencyContactName;
|
||||
private String emergencyContactPhoneEnc;
|
||||
/** V1.1: 来源类型(user_self/matchmaker_referral/operator_assisted等) */
|
||||
private String source;
|
||||
/** V1.1: 同source,用于兼容性保留 */
|
||||
private String requestSource;
|
||||
/** V1.1: 协助创建的工作人员ID(operator_assisted时填写) */
|
||||
private String createdByOperatorId;
|
||||
/** V1.1: 用户确认时间(转介场景需用户确认后激活) */
|
||||
private Instant userConfirmedAt;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.danyang.match.platform.activity.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityCapacityGroupMapper extends BaseMapper<ActivityCapacityGroup> {
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.danyang.match.platform.activity.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityInterest;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityInterestMapper extends BaseMapper<ActivityInterest> {
|
||||
|
||||
/**
|
||||
* 查询 A 对 B 是否已表达过意向
|
||||
*/
|
||||
@Select("SELECT * FROM activity_interests WHERE source_user_id = #{sourceUserId} " +
|
||||
"AND target_user_id = #{targetUserId} AND activity_id = #{activityId} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<ActivityInterest> findByActivityPair(@Param("sourceUserId") String sourceUserId,
|
||||
@Param("targetUserId") String targetUserId,
|
||||
@Param("activityId") String activityId);
|
||||
|
||||
/**
|
||||
* 查询 A 对本活动所有其他人的意向记录
|
||||
*/
|
||||
@Select("SELECT * FROM activity_interests WHERE source_user_id = #{userId} " +
|
||||
"AND activity_id = #{activityId} AND status != 'mutual' AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<ActivityInterest> findExpressedByUser(@Param("userId") String userId,
|
||||
@Param("activityId") String activityId);
|
||||
|
||||
/**
|
||||
* 查询谁对目标用户表达了意向(仅 expressed,不含 mutual,保证保密性)
|
||||
*/
|
||||
@Select("SELECT ai.*, u.name AS source_name, u.phone_masked " +
|
||||
"FROM activity_interests ai " +
|
||||
"JOIN users u ON u.id = ai.source_user_id " +
|
||||
"WHERE ai.target_user_id = #{targetUserId} AND ai.activity_id = #{activityId} " +
|
||||
"AND ai.status = 'expressed' AND ai.deleted_at IS NULL")
|
||||
List<ActivityInterest> findExpressedToUser(@Param("targetUserId") String targetUserId,
|
||||
@Param("activityId") String activityId);
|
||||
|
||||
/**
|
||||
* 查询本活动中所有 mutual 状态记录
|
||||
*/
|
||||
@Select("SELECT * FROM activity_interests WHERE activity_id = #{activityId} " +
|
||||
"AND status = 'mutual' AND deleted_at IS NULL")
|
||||
List<ActivityInterest> findMutualsByActivity(@Param("activityId") String activityId);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.danyang.match.platform.activity.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityMapper extends BaseMapper<Activity> {
|
||||
int countRemainingCapacity(String activityId);
|
||||
|
||||
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL ORDER BY start_at ASC")
|
||||
List<Activity> findOpenActivities();
|
||||
|
||||
@Select("SELECT * FROM activities WHERE start_at > NOW() AND deleted_at IS NULL ORDER BY start_at ASC LIMIT 10")
|
||||
List<Activity> findUpcomingActivities();
|
||||
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
|
||||
int countApprovedRegistrations(@Param("activityId") String activityId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
|
||||
int countApprovedMaleRegistrations(@Param("activityId") String activityId, @Param("gender") String gender);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.danyang.match.platform.activity.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityOrganization;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityOrganizationMapper extends BaseMapper<ActivityOrganization> {
|
||||
@Select("SELECT * FROM activity_organizations WHERE activity_id = #{activityId}")
|
||||
List<ActivityOrganization> findByActivityId(String activityId);
|
||||
|
||||
@Select("SELECT * FROM activity_organizations WHERE organization_id = #{orgId}")
|
||||
List<ActivityOrganization> findByOrganizationId(String orgId);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.danyang.match.platform.activity.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistration> {
|
||||
|
||||
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<ActivityRegistration> findByActivityAndUser(@Param("activityId") String activityId,
|
||||
@Param("userId") String userId);
|
||||
|
||||
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
|
||||
List<ActivityRegistration> findApprovedByActivity(String activityId);
|
||||
|
||||
@Select("SELECT * FROM activity_registrations WHERE user_id = #{userId} ORDER BY registered_at DESC LIMIT 20")
|
||||
List<ActivityRegistration> findByUserId(String userId);
|
||||
|
||||
/**
|
||||
* 原子检查名额:返回剩余名额数
|
||||
*/
|
||||
@Select("SELECT COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 9999) - " +
|
||||
"(SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL) AS remaining")
|
||||
int countRemainingCapacity(@Param("activityId") String activityId);
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package com.danyang.match.platform.activity.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.activity.dto.ActivityMatchItemDTO;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.entity.ActivityInterest;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityInterestMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.photo.entity.UserPhoto;
|
||||
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
|
||||
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.relationship.service.RelationshipService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 活动互选服务
|
||||
*
|
||||
* 核心 Invariant:
|
||||
* - 单向意向保密(expressed 状态,目标用户不可见)
|
||||
* - 仅双方均表达后建立 mutual,通知双方
|
||||
* - 活动结束后才能反馈
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityInterestService {
|
||||
|
||||
private final ActivityInterestMapper interestMapper;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final ActivityMapper activityMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final UserPhotoMapper userPhotoMapper;
|
||||
private final RelationshipService relationshipService;
|
||||
|
||||
/**
|
||||
* 提交活动反馈 + 表达意向
|
||||
* 活动结束后开放,单用户对每个目标用户最多表达一次
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> submitFeedback(String userId, String activityId,
|
||||
String overallExperience,
|
||||
String willingNextTime,
|
||||
Boolean metSomeoneInteresting,
|
||||
List<String> interestTargetUserIds) {
|
||||
// 1. 验证活动已结束
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) {
|
||||
throw new RuntimeException("活动不存在");
|
||||
}
|
||||
if (!Constants.ACTIVITY_STATUS_ENDED.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("活动尚未结束,暂不支持反馈");
|
||||
}
|
||||
|
||||
// 2. 验证用户已报名且通过审核
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("报名未通过审核,无法提交反馈");
|
||||
}
|
||||
|
||||
// 3. 过滤有效的目标用户(必须是同活动的approved用户,排除自己)
|
||||
if (interestTargetUserIds == null) {
|
||||
interestTargetUserIds = List.of();
|
||||
}
|
||||
Set<String> validTargets = getApprovedParticipants(activityId).stream()
|
||||
.filter(uid -> !uid.equals(userId))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<String> validInterests = interestTargetUserIds.stream()
|
||||
.filter(validTargets::contains)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 4. 逐个创建/更新意向记录
|
||||
List<Map<String, Object>> createdMutuals = new ArrayList<>();
|
||||
for (String targetId : validInterests) {
|
||||
Optional<ActivityInterest> existing = interestMapper.findByActivityPair(userId, targetId, activityId);
|
||||
if (existing.isPresent()) {
|
||||
continue; // 已表达过,跳过(幂等)
|
||||
}
|
||||
|
||||
ActivityInterest interest = new ActivityInterest();
|
||||
interest.setSourceUserId(userId);
|
||||
interest.setTargetUserId(targetId);
|
||||
interest.setActivityId(activityId);
|
||||
interest.setStatus(Constants.ACTIVITY_INTEREST_STATUS_EXPRESSED);
|
||||
interestMapper.insert(interest);
|
||||
|
||||
// 检查是否双向 → 若双向则创建 mutual
|
||||
Optional<ActivityInterest> reverse = interestMapper.findByActivityPair(targetId, userId, activityId);
|
||||
if (reverse.isPresent()) {
|
||||
activateMutual(userId, targetId, activityId);
|
||||
createdMutuals.add(buildMutualDTO(userId, targetId, activityId));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 审计
|
||||
saveAuditLog(userId, "SUBMIT_ACTIVITY_FEEDBACK", "activity_interest", activityId,
|
||||
null, null);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("feedbackSubmitted", true);
|
||||
result.put("newMutuals", createdMutuals);
|
||||
result.put("totalInterestsExpressed", validInterests.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活双向互选
|
||||
* 将双方记录更新为 mutual 状态,并发送通知
|
||||
*/
|
||||
@Transactional
|
||||
public void activateMutual(String userIdA, String userIdB, String activityId) {
|
||||
ActivityInterest ai1 = interestMapper.selectOne(new LambdaQueryWrapper<ActivityInterest>()
|
||||
.eq(ActivityInterest::getSourceUserId, userIdA)
|
||||
.eq(ActivityInterest::getTargetUserId, userIdB)
|
||||
.eq(ActivityInterest::getActivityId, activityId)
|
||||
.eq(ActivityInterest::getStatus, Constants.ACTIVITY_INTEREST_STATUS_EXPRESSED));
|
||||
if (ai1 != null) {
|
||||
ai1.setStatus(Constants.ACTIVITY_INTEREST_STATUS_MUTUAL);
|
||||
ai1.setConfirmedAt(Instant.now());
|
||||
interestMapper.updateById(ai1);
|
||||
}
|
||||
ActivityInterest ai2 = interestMapper.selectOne(new LambdaQueryWrapper<ActivityInterest>()
|
||||
.eq(ActivityInterest::getSourceUserId, userIdB)
|
||||
.eq(ActivityInterest::getTargetUserId, userIdA)
|
||||
.eq(ActivityInterest::getActivityId, activityId)
|
||||
.eq(ActivityInterest::getStatus, Constants.ACTIVITY_INTEREST_STATUS_EXPRESSED));
|
||||
if (ai2 != null) {
|
||||
ai2.setStatus(Constants.ACTIVITY_INTEREST_STATUS_MUTUAL);
|
||||
ai2.setConfirmedAt(Instant.now());
|
||||
interestMapper.updateById(ai2);
|
||||
}
|
||||
|
||||
// 获取双方用户资料
|
||||
UserProfile profileA = userProfileMapper.findByUserId(userIdA).orElse(null);
|
||||
UserProfile profileB = userProfileMapper.findByUserId(userIdB).orElse(null);
|
||||
|
||||
String nameA = profileA != null ? profileA.getDisplayName() : "一位朋友";
|
||||
String nameB = profileB != null ? profileB.getDisplayName() : "一位朋友";
|
||||
|
||||
// 通知 A
|
||||
notificationService.send(userIdA, Constants.NOTIFICATION_TYPE_ACTIVITY,
|
||||
"活动双向匹配",
|
||||
"您在「" + getActivityTitle(activityId) + "」中与" + nameB + "互相表达了进一步了解意愿。"
|
||||
+ "双方确认后才可以开启线上沟通。",
|
||||
"activity_interest", activityId,
|
||||
"activity_mutual:" + activityId + ":" + userIdA + ":" + userIdB);
|
||||
|
||||
// 通知 B
|
||||
notificationService.send(userIdB, Constants.NOTIFICATION_TYPE_ACTIVITY,
|
||||
"活动双向匹配",
|
||||
"您在「" + getActivityTitle(activityId) + "」中与" + nameA + "互相表达了进一步了解意愿。"
|
||||
+ "双方确认后才可以开启线上沟通。",
|
||||
"activity_interest", activityId,
|
||||
"activity_mutual:" + activityId + ":" + userIdB + ":" + userIdA);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询A和B是否在活动中形成了双向互选
|
||||
* 用于前端判断是否显示"开启沟通"按钮
|
||||
*/
|
||||
public boolean hasMutualInterest(String userId, String partnerId, String activityId) {
|
||||
Optional<ActivityInterest> ai1 = interestMapper.findByActivityPair(userId, partnerId, activityId);
|
||||
Optional<ActivityInterest> ai2 = interestMapper.findByActivityPair(partnerId, userId, activityId);
|
||||
return ai1.isPresent() && Constants.ACTIVITY_INTEREST_STATUS_MUTUAL.equals(ai1.get().getStatus())
|
||||
&& ai2.isPresent() && Constants.ACTIVITY_INTEREST_STATUS_MUTUAL.equals(ai2.get().getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在某活动中的互选列表
|
||||
*/
|
||||
public List<ActivityMatchItemDTO> getMutualMatches(String userId, String activityId) {
|
||||
List<ActivityInterest> interests = interestMapper.findMutualsByActivity(activityId);
|
||||
return interests.stream()
|
||||
.filter(i -> userId.equals(i.getSourceUserId()) || userId.equals(i.getTargetUserId()))
|
||||
.map(i -> {
|
||||
String partnerId = userId.equals(i.getSourceUserId()) ? i.getTargetUserId() : i.getSourceUserId();
|
||||
return toMatchDTO(partnerId, i.getConfirmedAt());
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某活动中所有 mutual 统计
|
||||
*/
|
||||
public Map<String, Object> getActivityInterestStats(String activityId) {
|
||||
List<ActivityInterest> mutuals = interestMapper.findMutualsByActivity(activityId);
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("activityId", activityId);
|
||||
stats.put("mutualCount", mutuals.size());
|
||||
stats.put("mutualPairs", mutuals.size() / 2);
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
private List<String> getApprovedParticipants(String activityId) {
|
||||
return registrationMapper.findApprovedByActivity(activityId).stream()
|
||||
.map(ActivityRegistration::getUserId)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ActivityMatchItemDTO toMatchDTO(String userId, Instant confirmedAt) {
|
||||
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||
UserPhoto primaryPhoto = userPhotoMapper.findAvatar(userId);
|
||||
ActivityMatchItemDTO dto = new ActivityMatchItemDTO();
|
||||
dto.setTargetUserId(userId);
|
||||
dto.setName(profile != null ? profile.getDisplayName() : "");
|
||||
dto.setFileKey(primaryPhoto != null ? primaryPhoto.getFileKey() : null);
|
||||
dto.setAge(profile != null && profile.getBirthDate() != null
|
||||
? String.valueOf(java.time.Year.now().getValue() - profile.getBirthDate().getYear()) : "");
|
||||
dto.setLocationName(profile != null ? profile.getRegionName() : "");
|
||||
dto.setMutualConfirmedAt(confirmedAt);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildMutualDTO(String userIdA, String userIdB, String activityId) {
|
||||
UserProfile profileB = userProfileMapper.findByUserId(userIdB).orElse(null);
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("userId", userIdB);
|
||||
item.put("name", profileB != null ? profileB.getDisplayName() : "");
|
||||
item.put("confirmedAt", Instant.now());
|
||||
return item;
|
||||
}
|
||||
|
||||
private String getActivityTitle(String activityId) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
return activity != null ? activity.getTitle() : activityId;
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
+526
@@ -0,0 +1,526 @@
|
||||
package com.danyang.match.platform.activity.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.danyang.match.platform.activity.dto.ActivityListItemDTO;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.activity.entity.ActivityOrganization;
|
||||
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper;
|
||||
import com.danyang.match.platform.organization.entity.Organization;
|
||||
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ActivityService.class);
|
||||
|
||||
private final ActivityMapper activityMapper;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final ActivityOrganizationMapper activityOrganizationMapper;
|
||||
private final ActivityCapacityGroupMapper capacityGroupMapper;
|
||||
private final OrganizationMapper organizationMapper;
|
||||
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
|
||||
|
||||
/**
|
||||
* 创建活动(后台)
|
||||
*/
|
||||
@Transactional
|
||||
public Activity createActivity(Activity activity, String operatorId) {
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
|
||||
activity.setCreatedBy(operatorId);
|
||||
activityMapper.insert(activity);
|
||||
saveAuditLog(operatorId, "CREATE_ACTIVITY", "activity", activity.getId(), null, null);
|
||||
log.info("创建活动: activityId={}, operatorId={}", activity.getId(), operatorId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布活动
|
||||
*/
|
||||
@Transactional
|
||||
public Activity publishActivity(String activityId, String operatorId) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) throw new RuntimeException("活动不存在");
|
||||
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许发布");
|
||||
}
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN);
|
||||
if (activity.getRegistrationStartAt() == null) {
|
||||
activity.setRegistrationStartAt(Instant.now());
|
||||
}
|
||||
activityMapper.updateById(activity);
|
||||
saveAuditLog(operatorId, "PUBLISH_ACTIVITY", "activity", activityId, null, null);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消活动
|
||||
*/
|
||||
@Transactional
|
||||
public Activity cancelActivity(String activityId, String operatorId) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) throw new RuntimeException("活动不存在");
|
||||
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus()) &&
|
||||
!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许取消");
|
||||
}
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_CANCELLED);
|
||||
activityMapper.updateById(activity);
|
||||
|
||||
// 通知所有已审核通过的用户
|
||||
registrationMapper.findApprovedByActivity(activityId).forEach(reg -> {
|
||||
if (Constants.REGISTRATION_STATUS_APPROVED.equals(reg.getStatus())) {
|
||||
notificationService.sendAsync(reg.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
|
||||
"活动已取消", "您报名的「" + activity.getTitle() + "」已取消,如有疑问请联系工作人员。",
|
||||
"activity_cancel", activityId);
|
||||
}
|
||||
});
|
||||
|
||||
saveAuditLog(operatorId, "CANCEL_ACTIVITY", "activity", activityId, null, null);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动列表
|
||||
*/
|
||||
public List<ActivityListItemDTO> getActivities(String userId) {
|
||||
List<Activity> activities = activityMapper.findOpenActivities();
|
||||
return activities.stream().map(a -> toListItemDTO(userId, a)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取即将开始的活动
|
||||
*/
|
||||
public List<ActivityListItemDTO> getUpcomingActivities(String userId) {
|
||||
List<Activity> activities = activityMapper.findUpcomingActivities();
|
||||
return activities.stream().map(a -> toListItemDTO(userId, a)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动详情
|
||||
*/
|
||||
public Map<String, Object> getActivityDetail(String activityId, String userId) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) return null;
|
||||
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
detail.put("activityId", activity.getId());
|
||||
detail.put("title", activity.getTitle());
|
||||
detail.put("description", activity.getDescription());
|
||||
detail.put("startAt", activity.getStartAt());
|
||||
detail.put("endAt", activity.getEndAt());
|
||||
detail.put("locationName", activity.getLocationName());
|
||||
detail.put("locationAddress", activity.getLocationAddress());
|
||||
detail.put("maleLimit", activity.getMaleLimit());
|
||||
detail.put("femaleLimit", activity.getFemaleLimit());
|
||||
detail.put("totalLimit", activity.getTotalLimit());
|
||||
detail.put("minAge", activity.getMinAge());
|
||||
detail.put("maxAge", activity.getMaxAge());
|
||||
detail.put("needReview", activity.getNeedReview());
|
||||
detail.put("status", activity.getStatus());
|
||||
detail.put("organizerOrgId", activity.getOrganizerOrgId());
|
||||
detail.put("coorganizerText", activity.getCoorganizerText());
|
||||
|
||||
// 报名统计
|
||||
int approvedCount = activityMapper.countApprovedRegistrations(activityId);
|
||||
int remaining = activityMapper.countRemainingCapacity(activityId);
|
||||
detail.put("approvedCount", approvedCount);
|
||||
detail.put("remainingSeats", Math.max(0, remaining));
|
||||
detail.put("isFull", remaining <= 0);
|
||||
|
||||
// 用户报名状态
|
||||
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(reg -> {
|
||||
detail.put("registered", true);
|
||||
detail.put("registrationStatus", reg.getStatus());
|
||||
detail.put("checkinStatus", reg.getCheckinStatus());
|
||||
});
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报名活动(含名额并发控制)
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> registerActivity(String userId, String activityId,
|
||||
String emergencyContactName,
|
||||
String emergencyContactPhoneEnc) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) throw new RuntimeException("活动不存在");
|
||||
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("活动未开放报名或已结束");
|
||||
}
|
||||
|
||||
// 检查是否已报名
|
||||
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(existing -> {
|
||||
if (!Constants.REGISTRATION_STATUS_REJECTED.equals(existing.getStatus()) &&
|
||||
!Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.getStatus())) {
|
||||
throw new RuntimeException("已经报名过该活动");
|
||||
}
|
||||
});
|
||||
|
||||
// 检查名额(事务内原子检查)
|
||||
int remaining = activityMapper.countRemainingCapacity(activityId);
|
||||
if (remaining <= 0) {
|
||||
throw new RuntimeException("活动名额已满");
|
||||
}
|
||||
|
||||
// 检查年龄范围
|
||||
if (activity.getMinAge() != null) {
|
||||
// TODO: 获取用户年龄并校验
|
||||
}
|
||||
|
||||
// 创建报名记录
|
||||
ActivityRegistration registration = new ActivityRegistration();
|
||||
registration.setActivityId(activityId);
|
||||
registration.setUserId(userId);
|
||||
registration.setStatus(activity.getNeedReview() ?
|
||||
Constants.REGISTRATION_STATUS_PENDING : Constants.REGISTRATION_STATUS_APPROVED);
|
||||
registration.setRegisteredAt(Instant.now());
|
||||
registration.setEmergencyContactName(emergencyContactName);
|
||||
registration.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
|
||||
registrationMapper.insert(registration);
|
||||
|
||||
// P17-T03: 线下活动风险检测
|
||||
try {
|
||||
String riskLevel = riskReferralService.detectActivityRisk(userId, emergencyContactName, null);
|
||||
if (riskLevel != null) {
|
||||
log.warn("活动报名触发风险: userId={}, activityId={}, riskLevel={}", userId, activityId, riskLevel);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("活动风险检测异常: {}", e.getMessage());
|
||||
}
|
||||
|
||||
log.info("用户报名活动: userId={}, activityId={}, status={}", userId, activityId, registration.getStatus());
|
||||
saveAuditLog(userId, "ACTIVITY_REGISTER", "activity", activityId, null, null);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("registrationId", registration.getId());
|
||||
result.put("status", registration.getStatus());
|
||||
result.put("needReview", activity.getNeedReview());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过报名
|
||||
*/
|
||||
@Transactional
|
||||
public ActivityRegistration approveRegistration(String registrationId, String operatorId) {
|
||||
ActivityRegistration registration = registrationMapper.selectById(registrationId);
|
||||
if (registration == null) throw new RuntimeException("报名记录不存在");
|
||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许审核通过");
|
||||
}
|
||||
|
||||
// 再次检查名额(防止并发超卖)
|
||||
int remaining = activityMapper.countRemainingCapacity(registration.getActivityId());
|
||||
if (remaining <= 0) {
|
||||
throw new RuntimeException("活动名额已满,无法通过审核");
|
||||
}
|
||||
|
||||
registration.setStatus(Constants.REGISTRATION_STATUS_APPROVED);
|
||||
registration.setReviewedBy(operatorId);
|
||||
registration.setReviewedAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
|
||||
// 通知用户
|
||||
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
|
||||
"活动报名审核通过", "您报名的活动已通过审核,请留意活动通知。",
|
||||
"activity_approval", registrationId);
|
||||
|
||||
log.info("审核通过报名: registrationId={}, operatorId={}", registrationId, operatorId);
|
||||
saveAuditLog(operatorId, "APPROVE_REGISTRATION", "activity_registration", registrationId, null, null);
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核拒绝报名
|
||||
*/
|
||||
@Transactional
|
||||
public ActivityRegistration rejectRegistration(String registrationId, String operatorId, String reason) {
|
||||
ActivityRegistration registration = registrationMapper.selectById(registrationId);
|
||||
if (registration == null) throw new RuntimeException("报名记录不存在");
|
||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许审核");
|
||||
}
|
||||
|
||||
registration.setStatus(Constants.REGISTRATION_STATUS_REJECTED);
|
||||
registration.setRejectReason(reason);
|
||||
registration.setReviewedBy(operatorId);
|
||||
registration.setReviewedAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
|
||||
// 通知用户
|
||||
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
|
||||
"活动报名未通过", "您报名的活动未通过审核:" + reason,
|
||||
"activity_rejection", registrationId);
|
||||
|
||||
log.info("审核拒绝报名: registrationId={}, operatorId={}", registrationId, operatorId);
|
||||
saveAuditLog(operatorId, "REJECT_REGISTRATION", "activity_registration", registrationId, null, null);
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户取消报名
|
||||
*/
|
||||
@Transactional
|
||||
public ActivityRegistration cancelRegistration(String userId, String activityId) {
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
|
||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
|
||||
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("当前状态不能取消");
|
||||
}
|
||||
registration.setStatus(Constants.REGISTRATION_STATUS_CANCELLED);
|
||||
registrationMapper.updateById(registration);
|
||||
log.info("用户取消报名: userId={}, activityId={}", userId, activityId);
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的活动列表
|
||||
*/
|
||||
public List<Map<String, Object>> getMyActivities(String userId) {
|
||||
return registrationMapper.findByUserId(userId).stream()
|
||||
.map(reg -> {
|
||||
Activity activity = activityMapper.selectById(reg.getActivityId());
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("activityId", activity.getId());
|
||||
map.put("title", activity.getTitle());
|
||||
map.put("startAt", activity.getStartAt());
|
||||
map.put("locationName", activity.getLocationName());
|
||||
map.put("status", reg.getStatus());
|
||||
map.put("checkinStatus", reg.getCheckinStatus());
|
||||
map.put("registeredAt", reg.getRegisteredAt());
|
||||
map.put("reviewedAt", reg.getReviewedAt());
|
||||
return map;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动报名列表(后台)
|
||||
*/
|
||||
public List<Map<String, Object>> getRegistrations(String activityId) {
|
||||
return registrationMapper.findApprovedByActivity(activityId).stream()
|
||||
.map(reg -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("registrationId", reg.getId());
|
||||
map.put("userId", reg.getUserId());
|
||||
map.put("status", reg.getStatus());
|
||||
map.put("checkinStatus", reg.getCheckinStatus());
|
||||
map.put("registeredAt", reg.getRegisteredAt());
|
||||
map.put("reviewedAt", reg.getReviewedAt());
|
||||
map.put("emergencyContactName", reg.getEmergencyContactName());
|
||||
// 获取用户资料
|
||||
userProfileMapper.findByUserId(reg.getUserId()).ifPresent(profile -> {
|
||||
map.put("displayName", profile.getDisplayName());
|
||||
map.put("gender", profile.getGender());
|
||||
});
|
||||
return map;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ActivityListItemDTO toListItemDTO(String userId, Activity activity) {
|
||||
ActivityListItemDTO dto = new ActivityListItemDTO();
|
||||
dto.setActivityId(activity.getId());
|
||||
dto.setTitle(activity.getTitle());
|
||||
dto.setStartAt(activity.getStartAt());
|
||||
dto.setEndAt(activity.getEndAt());
|
||||
dto.setLocationName(activity.getLocationName());
|
||||
dto.setTotalLimit(activity.getTotalLimit());
|
||||
long approvedCount = registrationMapper.selectCount(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getActivityId, activity.getId())
|
||||
.eq(ActivityRegistration::getStatus, "approved"));
|
||||
// 注:性别在 user_profiles 中,此处简化处理(男女各半)
|
||||
dto.setMaleCount((int)(approvedCount / 2));
|
||||
dto.setFemaleCount((int)(approvedCount - approvedCount / 2));
|
||||
dto.setRemainingSeats(Math.max(0, activity.getTotalLimit() != null ?
|
||||
activity.getTotalLimit() - dto.getMaleCount() - dto.getFemaleCount() : 0));
|
||||
dto.setRegistered(false);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** V1.1 Phase 14: 按活动类型过滤 */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> getActivitiesByType(String userId, String activityType) {
|
||||
List<Activity> activities = activityMapper.findOpenActivities();
|
||||
return activities.stream()
|
||||
.filter(a -> activityType != null && activityType.equals(a.getActivityType()))
|
||||
.map(a -> (Map<String, Object>) convertToMap(toListItemDTO(userId, a)))
|
||||
.toList();
|
||||
}
|
||||
private Map<String, Object> convertToMap(ActivityListItemDTO dto) {
|
||||
Map<String, Object> m = new java.util.HashMap<>();
|
||||
m.put("activityId", dto.getActivityId());
|
||||
m.put("title", dto.getTitle());
|
||||
m.put("startAt", dto.getStartAt());
|
||||
m.put("endAt", dto.getEndAt());
|
||||
m.put("locationName", dto.getLocationName());
|
||||
m.put("totalLimit", dto.getTotalLimit());
|
||||
m.put("maleCount", dto.getMaleCount());
|
||||
m.put("femaleCount", dto.getFemaleCount());
|
||||
m.put("remainingSeats", dto.getRemainingSeats());
|
||||
m.put("registered", dto.isRegistered());
|
||||
return m;
|
||||
}
|
||||
|
||||
/** V1.1 Phase 14: 获取活动组织列表 */
|
||||
public List<Map<String, Object>> getActivityOrganizations(String activityId) {
|
||||
List<ActivityOrganization> orgs = activityOrganizationMapper.findByActivityId(activityId);
|
||||
return orgs.stream().map(o -> {
|
||||
Map<String, Object> m = new java.util.HashMap<>();
|
||||
m.put("organizationId", o.getOrganizationId());
|
||||
m.put("role", o.getRole());
|
||||
Organization org = organizationMapper.selectById(o.getOrganizationId());
|
||||
if (org != null) m.put("name", org.getName());
|
||||
return m;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** V1.1 Phase 14: 获取活动容量分组 */
|
||||
public List<Map<String, Object>> getActivityCapacityGroups(String activityId) {
|
||||
List<ActivityCapacityGroup> groups = capacityGroupMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityCapacityGroup>()
|
||||
.eq(ActivityCapacityGroup::getActivityId, activityId)
|
||||
);
|
||||
return groups.stream().map(g -> {
|
||||
Map<String, Object> m = new java.util.HashMap<>();
|
||||
m.put("groupCode", g.getGroupCode());
|
||||
m.put("capacity", g.getCapacity());
|
||||
m.put("approvedCount", g.getApprovedCount());
|
||||
m.put("ruleDefinition", g.getRuleDefinition());
|
||||
return m;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** V1.1 Phase 14: 从后台创建带多组织和类型的活动 */
|
||||
@Transactional
|
||||
public Map<String, Object> createActivityFromAdmin(Map<String, Object> body, String operatorId) {
|
||||
Activity activity = new Activity();
|
||||
activity.setTitle((String) body.get("title"));
|
||||
activity.setDescription((String) body.get("description"));
|
||||
activity.setCoverFileId((String) body.get("coverFileId"));
|
||||
activity.setStartAt(Instant.parse((String) body.get("startAt")));
|
||||
activity.setEndAt(Instant.parse((String) body.get("endAt")));
|
||||
activity.setLocationName((String) body.get("locationName"));
|
||||
activity.setLocationAddress((String) body.get("locationAddress"));
|
||||
activity.setTotalLimit(((Number) body.get("totalLimit")).intValue());
|
||||
activity.setMinAge(((Number) body.get("minAge")).shortValue());
|
||||
activity.setMaxAge(((Number) body.get("maxAge")).shortValue());
|
||||
Object regStart = body.get("registrationStartAt");
|
||||
Object regEnd = body.get("registrationEndAt");
|
||||
activity.setRegistrationStartAt(regStart != null ? Instant.parse((String) regStart) : null);
|
||||
activity.setRegistrationEndAt(regEnd != null ? Instant.parse((String) regEnd) : null);
|
||||
activity.setNeedReview(Boolean.parseBoolean(body.getOrDefault("needReview", "true").toString()));
|
||||
activity.setActivityType((String) body.getOrDefault("activityType", "matchmaking"));
|
||||
activity.setRegistrationPolicy((String) body.get("registrationPolicy"));
|
||||
activity.setTargetGroup((String) body.get("targetGroup"));
|
||||
activity.setIndustryScope((String) body.get("industryScope"));
|
||||
activity.setPostEventInterestEnabled(Boolean.parseBoolean(body.getOrDefault("postEventInterestEnabled", "false").toString()));
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
|
||||
activity.setCreatedBy(operatorId);
|
||||
activityMapper.insert(activity);
|
||||
|
||||
// 保存主组织
|
||||
String orgId = (String) body.get("organizerOrgId");
|
||||
if (orgId != null) {
|
||||
ActivityOrganization hostOrg = new ActivityOrganization();
|
||||
hostOrg.setActivityId(activity.getId());
|
||||
hostOrg.setOrganizationId(orgId);
|
||||
hostOrg.setRole(Constants.ORG_ROLE_HOST);
|
||||
activityOrganizationMapper.insert(hostOrg);
|
||||
}
|
||||
|
||||
// 保存协作组织
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<java.util.Map<String, String>> orgList = (java.util.List<java.util.Map<String, String>>) body.get("partnerOrganizations");
|
||||
if (orgList != null) {
|
||||
for (java.util.Map<String, String> orgEntry : orgList) {
|
||||
ActivityOrganization org = new ActivityOrganization();
|
||||
org.setActivityId(activity.getId());
|
||||
org.setOrganizationId(orgEntry.get("organizationId"));
|
||||
org.setRole(orgEntry.getOrDefault("role", Constants.ORG_ROLE_PARTNER));
|
||||
activityOrganizationMapper.insert(org);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("后台创建活动(V1.1): activityId={}, type={}, operatorId={}", activity.getId(), activity.getActivityType(), operatorId);
|
||||
saveAuditLog(operatorId, "CREATE_ACTIVITY_V11", "activity", activity.getId(), null, null);
|
||||
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
result.put("id", activity.getId());
|
||||
result.put("activityType", activity.getActivityType());
|
||||
result.put("status", activity.getStatus());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** V1.1 Phase 14: 添加活动组织关联 */
|
||||
@Transactional
|
||||
public Map<String, Object> addActivityOrganization(String activityId, String organizationId, String role, String operatorId) {
|
||||
ActivityOrganization org = new ActivityOrganization();
|
||||
org.setActivityId(activityId);
|
||||
org.setOrganizationId(organizationId);
|
||||
org.setRole(role != null ? role : Constants.ORG_ROLE_PARTNER);
|
||||
activityOrganizationMapper.insert(org);
|
||||
log.info("添加活动组织: activityId={}, orgId={}, role={}", activityId, organizationId, role);
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
result.put("activityId", activityId);
|
||||
result.put("organizationId", organizationId);
|
||||
result.put("role", org.getRole());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** V1.1 Phase 14: 添加容量分组 */
|
||||
@Transactional
|
||||
public Map<String, Object> addCapacityGroup(String activityId, Map<String, Object> body, String operatorId) {
|
||||
ActivityCapacityGroup group = new ActivityCapacityGroup();
|
||||
group.setActivityId(activityId);
|
||||
group.setGroupCode((String) body.get("groupCode"));
|
||||
group.setCapacity(((Number) body.get("capacity")).intValue());
|
||||
group.setRuleDefinition((String) body.get("ruleDefinition"));
|
||||
group.setApprovedCount(0);
|
||||
capacityGroupMapper.insert(group);
|
||||
log.info("添加容量分组: activityId={}, groupCode={}", activityId, group.getGroupCode());
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
result.put("id", group.getId());
|
||||
result.put("groupCode", group.getGroupCode());
|
||||
result.put("capacity", group.getCapacity());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package com.danyang.match.platform.activity.service;
|
||||
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 签到服务
|
||||
* 使用短期签名Token,二维码不包含敏感明文
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CheckinService {
|
||||
|
||||
@Value("${checkin.token.secret:danyang-match-checkin-secret-change-in-production}")
|
||||
private String tokenSecret;
|
||||
|
||||
@Value("${checkin.token.ttl-minutes:30}")
|
||||
private int tokenTtlMinutes;
|
||||
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final ActivityMapper activityMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
/**
|
||||
* 生成签到Token(短时有效,二维码内容)
|
||||
* Token格式: base64(userId:activityId:timestamp: HMAC)
|
||||
*/
|
||||
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
|
||||
// 检查是否有有效的报名记录
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
|
||||
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("报名尚未审核通过,无法签到");
|
||||
}
|
||||
|
||||
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
|
||||
throw new RuntimeException("您已经签到过了");
|
||||
}
|
||||
|
||||
// 检查活动是否在进行中
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) {
|
||||
throw new RuntimeException("活动不存在");
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) {
|
||||
throw new RuntimeException("活动未开始或已结束,无法签到");
|
||||
}
|
||||
|
||||
// 生成签名Token
|
||||
String payload = userId + ":" + activityId + ":" + now.toEpochMilli();
|
||||
String signature = sign(payload);
|
||||
String token = Base64.getEncoder().encodeToString((payload + ":" + signature).getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// 缓存Token(防止重放攻击)
|
||||
String cacheKey = "checkin:token:" + token;
|
||||
redisTemplate.opsForValue().set(cacheKey, "used", tokenTtlMinutes, TimeUnit.MINUTES);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", token);
|
||||
result.put("expiresAt", now.plusSeconds(tokenTtlMinutes * 60L).toString());
|
||||
result.put("activityTitle", activity.getTitle());
|
||||
result.put("locationName", activity.getLocationName());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作人员扫码签到
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> scanCheckin(String token, String staffOperatorId) {
|
||||
// 1. 解码Token
|
||||
String decoded;
|
||||
try {
|
||||
decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("无效的签到码");
|
||||
}
|
||||
|
||||
String[] parts = decoded.split(":");
|
||||
if (parts.length != 4) {
|
||||
throw new RuntimeException("无效的签到码格式");
|
||||
}
|
||||
|
||||
String userId = parts[0];
|
||||
String activityId = parts[1];
|
||||
long timestamp = Long.parseLong(parts[2]);
|
||||
String signature = parts[3];
|
||||
|
||||
// 2. 验证签名
|
||||
String expectedPayload = userId + ":" + activityId + ":" + timestamp;
|
||||
String expectedSignature = sign(expectedPayload);
|
||||
if (!expectedSignature.equals(signature)) {
|
||||
throw new RuntimeException("签到码签名验证失败");
|
||||
}
|
||||
|
||||
// 3. 检查Token是否已使用(防重放)
|
||||
String cacheKey = "checkin:token:" + token;
|
||||
String cached = redisTemplate.opsForValue().get(cacheKey);
|
||||
if ("used".equals(cached)) {
|
||||
throw new RuntimeException("该签到码已使用过");
|
||||
}
|
||||
|
||||
// 4. 检查Token是否过期
|
||||
Instant tokenTime = Instant.ofEpochMilli(timestamp);
|
||||
if (Instant.now().isAfter(tokenTime.plusSeconds(tokenTtlMinutes * 60L))) {
|
||||
throw new RuntimeException("签到码已过期");
|
||||
}
|
||||
|
||||
// 5. 检查报名记录
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("未找到报名记录"));
|
||||
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("报名未审核通过,无法签到");
|
||||
}
|
||||
|
||||
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
|
||||
throw new RuntimeException("该用户已经签到");
|
||||
}
|
||||
|
||||
// 6. 执行签到
|
||||
registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN);
|
||||
registration.setCheckinAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
|
||||
// 7. 标记Token为已使用
|
||||
redisTemplate.opsForValue().set(cacheKey, "used", 1, TimeUnit.HOURS);
|
||||
|
||||
// 8. 审计
|
||||
saveAuditLog(staffOperatorId, "CHECKIN", "activity_registration", registration.getId(), null, null);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("userId", userId);
|
||||
result.put("activityId", activityId);
|
||||
result.put("checkedInAt", Instant.now());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工签到(工作人员手动输入)
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> manualCheckin(String userId, String activityId, String staffOperatorId) {
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("未找到报名记录"));
|
||||
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("报名未审核通过");
|
||||
}
|
||||
|
||||
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
|
||||
throw new RuntimeException("该用户已经签到");
|
||||
}
|
||||
|
||||
registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN);
|
||||
registration.setCheckinAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
|
||||
saveAuditLog(staffOperatorId, "MANUAL_CHECKIN", "activity_registration", registration.getId(), null, null);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("checkedInAt", Instant.now());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动签到统计
|
||||
*/
|
||||
public Map<String, Object> getCheckinStats(String activityId) {
|
||||
ActivityRegistrationMapper regMapper = registrationMapper;
|
||||
// 统计已签到人数
|
||||
long checkedInCount = registrationMapper.findApprovedByActivity(activityId).stream()
|
||||
.filter(r -> Constants.CHECKIN_STATUS_CHECKED_IN.equals(r.getCheckinStatus()))
|
||||
.count();
|
||||
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("activityId", activityId);
|
||||
stats.put("totalApproved", registrationMapper.findApprovedByActivity(activityId).size());
|
||||
stats.put("checkedInCount", checkedInCount);
|
||||
stats.put("remainingCount", Math.max(0, (activity.getTotalLimit() != null ? activity.getTotalLimit() : 0) - checkedInCount));
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算HMAC-SHA256签名
|
||||
*/
|
||||
private String sign(String payload) {
|
||||
try {
|
||||
Mac hmac = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec keySpec = new SecretKeySpec(tokenSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
hmac.init(keySpec);
|
||||
byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(hash);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("签名生成失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.danyang.match.platform.activity_mutual.controller;
|
||||
|
||||
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
|
||||
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 活动互选二阶段同意控制器
|
||||
* V1.1 Phase 16
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/activities/{activityId}/mutual")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityMutualConsentController {
|
||||
|
||||
private final ActivityMutualConsentService consentService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
/**
|
||||
* P16-T03: 检测双向互选结果
|
||||
* GET /api/v1/activities/{id}/mutual/results
|
||||
*/
|
||||
@GetMapping("/results")
|
||||
public ApiResponse<List<MutualInterestResultDTO>> getMutualResults(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String activityId) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(consentService.detectMutualResults(activityId));
|
||||
}
|
||||
|
||||
/**
|
||||
* P16-T04: 创建互选同意记录
|
||||
* POST /api/v1/activities/{id}/mutual/consent
|
||||
*/
|
||||
@PostMapping("/consent")
|
||||
public ApiResponse<?> createConsent(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String activityId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = extractUserId(authHeader);
|
||||
String partnerId = body.get("partnerId");
|
||||
return ApiResponse.ok(consentService.createMutualConsent(activityId, userId, partnerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* P16-T05/T06: 用户确认/拒绝开启沟通
|
||||
* POST /api/v1/activities/{id}/mutual/confirm
|
||||
*/
|
||||
@PostMapping("/confirm")
|
||||
public ApiResponse<Map<String, Object>> confirmConnection(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String activityId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = extractUserId(authHeader);
|
||||
String partnerId = body.get("partnerId");
|
||||
return ApiResponse.ok(consentService.confirmConnection(userId, activityId, partnerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* P16-T12: 用户拒绝开启沟通
|
||||
* POST /api/v1/activities/{id}/mutual/decline
|
||||
*/
|
||||
@PostMapping("/decline")
|
||||
public ApiResponse<Map<String, Object>> declineConnection(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String activityId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = extractUserId(authHeader);
|
||||
String partnerId = body.get("partnerId");
|
||||
return ApiResponse.ok(consentService.declineConnection(userId, activityId, partnerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询我的互选状态
|
||||
* GET /api/v1/activities/{id}/mutual/status/{partnerId}
|
||||
*/
|
||||
@GetMapping("/status/{partnerId}")
|
||||
public ApiResponse<Map<String, Object>> getMyConsentStatus(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String activityId,
|
||||
@PathVariable String partnerId) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(consentService.getMyConsentStatus(userId, activityId, partnerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的活动互选结果列表
|
||||
* GET /api/v1/mutual/my-results
|
||||
*/
|
||||
@GetMapping("/my-results")
|
||||
public ApiResponse<List<MutualInterestResultDTO>> getMyMutualResults(
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(consentService.getMyMutualResults(userId));
|
||||
}
|
||||
|
||||
private String extractUserId(String authHeader) {
|
||||
String token = extractToken(authHeader);
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
|
||||
private String extractToken(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.danyang.match.platform.activity_mutual.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
public class MutualConsentDTO {
|
||||
private String id;
|
||||
private String activityId;
|
||||
private String userAId;
|
||||
private String userBId;
|
||||
private String userAStatus;
|
||||
private String userBStatus;
|
||||
private Instant bothConfirmedAt;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.danyang.match.platform.activity_mutual.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 活动互选结果DTO(供Miniapp展示)
|
||||
*/
|
||||
@Data
|
||||
public class MutualInterestResultDTO {
|
||||
private String partnerId;
|
||||
private String partnerName;
|
||||
private String partnerAvatarFileId;
|
||||
private String activityId;
|
||||
private Instant bothConfirmedAt;
|
||||
/** 是否双方都已确认 */
|
||||
private boolean bothConfirmed;
|
||||
/** 当前用户是否需要确认 */
|
||||
private boolean needsMyConfirmation;
|
||||
/** 对方是否已拒绝 */
|
||||
private boolean partnerDeclined;
|
||||
/** 是否已有活跃Match */
|
||||
private boolean hasActiveMatch;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.danyang.match.platform.activity_mutual.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;
|
||||
|
||||
/**
|
||||
* 活动互选二阶段同意记录
|
||||
* V1.1 Phase 16: 每次互选结果只记录一对,独立追踪双方同意状态
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("activity_mutual_connection_consents")
|
||||
public class ActivityMutualConsent extends BaseEntity {
|
||||
|
||||
private String activityId;
|
||||
private String userAId;
|
||||
private String userBId;
|
||||
/** A的同意状态:pending / accepted / declined */
|
||||
private String userAStatus;
|
||||
/** B的同意状态:pending / accepted / declined */
|
||||
private String userBStatus;
|
||||
/** 双方都同意时的时间戳 */
|
||||
private Instant bothConfirmedAt;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.danyang.match.platform.activity_mutual.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityMutualConsentMapper extends BaseMapper<ActivityMutualConsent> {
|
||||
|
||||
@Select("SELECT * FROM activity_mutual_connection_consents " +
|
||||
"WHERE activity_id = #{activityId} " +
|
||||
"AND ((user_a_id = #{userId} AND user_b_id = #{partnerId}) " +
|
||||
"OR (user_a_id = #{partnerId} AND user_b_id = #{userId})) " +
|
||||
"LIMIT 1")
|
||||
Optional<ActivityMutualConsent> findByActivityPair(@Param("activityId") String activityId,
|
||||
@Param("userId") String userId,
|
||||
@Param("partnerId") String partnerId);
|
||||
|
||||
@Select("SELECT * FROM activity_mutual_connection_consents " +
|
||||
"WHERE activity_id = #{activityId} AND user_a_status = 'accepted' AND user_b_status = 'accepted'")
|
||||
List<ActivityMutualConsent> findBothConfirmedByActivity(String activityId);
|
||||
|
||||
@Select("SELECT * FROM activity_mutual_connection_consents " +
|
||||
"WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND user_a_status = 'accepted' AND user_b_status = 'accepted'")
|
||||
List<ActivityMutualConsent> findBothConfirmedByUser(String userId);
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package com.danyang.match.platform.activity_mutual.service;
|
||||
|
||||
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
|
||||
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
|
||||
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityMutualConsentService {
|
||||
|
||||
private final ActivityMutualConsentMapper consentMapper;
|
||||
private final com.danyang.match.platform.relationship.mapper.MatchMapper matchMapper;
|
||||
|
||||
public List<MutualInterestResultDTO> detectMutualResults(String activityId) {
|
||||
List<ActivityMutualConsent> bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId);
|
||||
return bothConfirmed.stream().map(c -> toResultDTO(c, activityId)).toList();
|
||||
}
|
||||
|
||||
public ActivityMutualConsent createMutualConsent(String activityId, String userId, String partnerId) {
|
||||
Optional<ActivityMutualConsent> existing = consentMapper.findByActivityPair(activityId, userId, partnerId);
|
||||
ActivityMutualConsent consent;
|
||||
if (existing.isPresent()) {
|
||||
consent = existing.get();
|
||||
} else {
|
||||
consent = new ActivityMutualConsent();
|
||||
consent.setId(UUID.randomUUID().toString());
|
||||
consent.setActivityId(activityId);
|
||||
if (userId.compareTo(partnerId) < 0) {
|
||||
consent.setUserAId(userId);
|
||||
consent.setUserBId(partnerId);
|
||||
} else {
|
||||
consent.setUserAId(partnerId);
|
||||
consent.setUserBId(userId);
|
||||
}
|
||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
|
||||
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
|
||||
consentMapper.insert(consent);
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
||||
if (userId.equals(consent.getUserAId())) {
|
||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
|
||||
} else {
|
||||
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
|
||||
}
|
||||
consent.setUpdatedAt(Instant.now());
|
||||
consentMapper.updateById(consent);
|
||||
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
|
||||
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("myStatus", userId.equals(consent.getUserAId()) ? (aAccepted ? "accepted" : "pending") : (bAccepted ? "accepted" : "pending"));
|
||||
result.put("partnerStatus", userId.equals(consent.getUserAId()) ? (bAccepted ? "accepted" : "pending") : (aAccepted ? "accepted" : "pending"));
|
||||
if (aAccepted && bAccepted) {
|
||||
consent.setBothConfirmedAt(Instant.now());
|
||||
consentMapper.updateById(consent);
|
||||
result.put("bothConfirmed", true);
|
||||
log.info("双方确认活动互选: activityId={}, userA={}, userB={}", activityId, consent.getUserAId(), consent.getUserBId());
|
||||
} else {
|
||||
result.put("bothConfirmed", false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
||||
if (userId.equals(consent.getUserAId())) {
|
||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
||||
} else {
|
||||
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
||||
}
|
||||
consent.setUpdatedAt(Instant.now());
|
||||
consentMapper.updateById(consent);
|
||||
log.info("用户拒绝活动互选: userId={}, activityId={}, partnerId={}", userId, activityId, partnerId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("myStatus", "declined");
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean hasActiveMatch(String userId, String partnerId) {
|
||||
return matchMapper.findActiveMatch(userId, partnerId).isPresent();
|
||||
}
|
||||
|
||||
public List<MutualInterestResultDTO> getMyMutualResults(String userId) {
|
||||
List<ActivityMutualConsent> consents = consentMapper.findBothConfirmedByUser(userId);
|
||||
return consents.stream().map(c -> toResultDTO(c, c.getActivityId())).toList();
|
||||
}
|
||||
|
||||
public Map<String, Object> getMyConsentStatus(String userId, String activityId, String partnerId) {
|
||||
Optional<ActivityMutualConsent> consentOpt = consentMapper.findByActivityPair(activityId, userId, partnerId);
|
||||
if (consentOpt.isEmpty()) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("exists", false);
|
||||
return result;
|
||||
}
|
||||
ActivityMutualConsent consent = consentOpt.get();
|
||||
boolean myAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()))
|
||||
|| (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()));
|
||||
boolean partnerAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()))
|
||||
|| (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()));
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("exists", true);
|
||||
result.put("myStatus", myAccepted ? "accepted" : "pending");
|
||||
result.put("partnerStatus", partnerAccepted ? "accepted" : "pending");
|
||||
result.put("bothConfirmed", myAccepted && partnerAccepted);
|
||||
result.put("bothConfirmedAt", consent.getBothConfirmedAt());
|
||||
return result;
|
||||
}
|
||||
|
||||
private MutualInterestResultDTO toResultDTO(ActivityMutualConsent consent, String activityId) {
|
||||
MutualInterestResultDTO dto = new MutualInterestResultDTO();
|
||||
dto.setActivityId(activityId);
|
||||
dto.setBothConfirmedAt(consent.getBothConfirmedAt());
|
||||
dto.setBothConfirmed(consent.getBothConfirmedAt() != null);
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.danyang.match.platform.analytics;
|
||||
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据概览控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/analytics")
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsController {
|
||||
|
||||
private final AnalyticsService analyticsService;
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
public ApiResponse<Map<String, Object>> getDashboard() {
|
||||
return ApiResponse.ok(analyticsService.getDashboardStats());
|
||||
}
|
||||
|
||||
@GetMapping("/user-funnel")
|
||||
public ApiResponse<Map<String, Object>> getUserFunnel(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate since,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate until) {
|
||||
return ApiResponse.ok(analyticsService.getUserFunnel(since, until));
|
||||
}
|
||||
|
||||
@GetMapping("/match-funnel")
|
||||
public ApiResponse<Map<String, Object>> getMatchFunnel(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate since,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate until) {
|
||||
return ApiResponse.ok(analyticsService.getMatchFunnel(since, until));
|
||||
}
|
||||
|
||||
@GetMapping("/daily-summary")
|
||||
public ApiResponse<List<Map<String, Object>>> getDailySummary(
|
||||
@RequestParam(defaultValue = "30") int days) {
|
||||
return ApiResponse.ok(analyticsService.getDailySummary(days));
|
||||
}
|
||||
|
||||
@GetMapping("/safety")
|
||||
public ApiResponse<Map<String, Object>> getSafetyStats() {
|
||||
return ApiResponse.ok(analyticsService.getSafetyStats());
|
||||
}
|
||||
|
||||
// ==================== V1.1 公益项目统计 ====================
|
||||
|
||||
@GetMapping("/v11-dashboard")
|
||||
public ApiResponse<Map<String, Object>> getV11ServiceDashboard() {
|
||||
return ApiResponse.ok(analyticsService.getV11ServiceDashboard());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/banners")
|
||||
public ApiResponse<Map<String, Object>> getBannerStats() {
|
||||
return ApiResponse.ok(analyticsService.getBannerStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/service-stations")
|
||||
public ApiResponse<Map<String, Object>> getServiceStationStats() {
|
||||
return ApiResponse.ok(analyticsService.getServiceStationStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/appointments")
|
||||
public ApiResponse<Map<String, Object>> getServiceAppointmentStats() {
|
||||
return ApiResponse.ok(analyticsService.getServiceAppointmentStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/counseling")
|
||||
public ApiResponse<Map<String, Object>> getCounselingStats() {
|
||||
return ApiResponse.ok(analyticsService.getCounselingStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/premarital")
|
||||
public ApiResponse<Map<String, Object>> getPremaritalStats() {
|
||||
return ApiResponse.ok(analyticsService.getPremaritalStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/content")
|
||||
public ApiResponse<Map<String, Object>> getContentStats() {
|
||||
return ApiResponse.ok(analyticsService.getContentStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/local-eligibility")
|
||||
public ApiResponse<Map<String, Object>> getLocalEligibilityStats() {
|
||||
return ApiResponse.ok(analyticsService.getLocalEligibilityStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/service-programs")
|
||||
public ApiResponse<Map<String, Object>> getServiceProgramStats() {
|
||||
return ApiResponse.ok(analyticsService.getServiceProgramStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/registration-sources")
|
||||
public ApiResponse<Map<String, Object>> getRegistrationSourceStats() {
|
||||
return ApiResponse.ok(analyticsService.getRegistrationSourceStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/unique-service-users")
|
||||
public ApiResponse<Map<String, Object>> getUniqueServiceUsersStats() {
|
||||
return ApiResponse.ok(analyticsService.getUniqueServiceUsersStats());
|
||||
}
|
||||
|
||||
@GetMapping("/v11/effective-connections")
|
||||
public ApiResponse<Map<String, Object>> getEffectiveConnectionStats() {
|
||||
return ApiResponse.ok(analyticsService.getEffectiveConnectionStats());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package com.danyang.match.platform.analytics;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.match.entity.RecommendationRecord;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.messaging.entity.Conversation;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.entity.Match;
|
||||
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
|
||||
import com.danyang.match.platform.risk.entity.Report;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
import com.danyang.match.platform.risk.entity.RiskAction;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
|
||||
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.content.entity.ContentArticle;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgram;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Analytics 服务
|
||||
* 提供:Dashboard / 用户漏斗 / Match漏斗 / 每日汇总 / 趋势图数据
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsService {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final RelationshipSuccessRequestMapper successRequestMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
private final HomeBannerMapper homeBannerMapper;
|
||||
private final CounselingServiceMapper counselingServiceMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
private final ServiceAppointmentMapper serviceAppointmentMapper;
|
||||
private final ActivityMutualConsentMapper activityMutualConsentMapper;
|
||||
private final ContentArticleMapper contentArticleMapper;
|
||||
private final ServiceProgramMapper serviceProgramMapper;
|
||||
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
|
||||
private final ActivityRegistrationMapper activityRegistrationMapper;
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
|
||||
public Map<String, Object> getDashboardStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.putAll(userBasicStats());
|
||||
stats.putAll(matchFunnelStats());
|
||||
stats.putAll(safetyStats());
|
||||
stats.putAll(activityFunnelStats());
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ==================== 用户漏斗 ====================
|
||||
|
||||
public Map<String, Object> getUserFunnel(LocalDate since, LocalDate until) {
|
||||
if (since == null) since = LocalDate.now().minusMonths(1);
|
||||
if (until == null) until = LocalDate.now();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
// 各阶段总量
|
||||
long totalRegistered = userMapper.selectCount(null);
|
||||
long verifiedCount = userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.ne(UserProfile::getProfileStatus, "rejected"));
|
||||
long profileApproved = userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.eq(UserProfile::getProfileStatus, "approved"));
|
||||
long activeUsers = userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant()));
|
||||
|
||||
result.put("totalRegistered", totalRegistered);
|
||||
result.put("verifiedCount", verifiedCount);
|
||||
result.put("profileApproved", profileApproved);
|
||||
result.put("activeUsers", activeUsers);
|
||||
|
||||
// 转化率
|
||||
result.put("verifyRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * verifiedCount / totalRegistered) : "0%");
|
||||
result.put("approvalRate", verifiedCount > 0 ? String.format("%.1f%%", 100.0 * profileApproved / verifiedCount) : "0%");
|
||||
result.put("activeRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * activeUsers / totalRegistered) : "0%");
|
||||
|
||||
// 每日新增趋势
|
||||
List<Map<String, Object>> dailyTrend = buildDailyNewUsers(since, until);
|
||||
result.put("dailyTrend", dailyTrend);
|
||||
|
||||
// 男女比例
|
||||
Map<String, Long> genderRatio = userProfileMapper.selectList(
|
||||
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getGender))
|
||||
.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
UserProfile::getGender, Collectors.counting()));
|
||||
result.put("genderRatio", genderRatio);
|
||||
|
||||
// 年龄分布
|
||||
List<UserProfile> profiles = userProfileMapper.selectList(
|
||||
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getBirthDate));
|
||||
List<Integer> breaks = Arrays.asList(18, 25, 30, 35, 40, 45, 100);
|
||||
Map<String, Long> ageDist = new LinkedHashMap<>();
|
||||
for (int i = 0; i < breaks.size() - 1; i++) {
|
||||
int min = breaks.get(i);
|
||||
int max = breaks.get(i + 1);
|
||||
long count = profiles.stream()
|
||||
.filter(p -> p.getBirthDate() != null)
|
||||
.filter(p -> {
|
||||
int age = LocalDate.now().getYear() - p.getBirthDate().getYear();
|
||||
return age >= min && age < max;
|
||||
})
|
||||
.count();
|
||||
ageDist.put(min + "-" + (max - 1), count);
|
||||
}
|
||||
result.put("ageDistribution", ageDist);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Match漏斗 ====================
|
||||
|
||||
public Map<String, Object> getMatchFunnel(LocalDate since, LocalDate until) {
|
||||
if (since == null) since = LocalDate.now().minusMonths(1);
|
||||
if (until == null) until = LocalDate.now();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
// 总量
|
||||
long totalRecommended = recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>()
|
||||
.isNotNull(RecommendationRecord::getShownAt));
|
||||
long totalLiked = recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>()
|
||||
.eq(RecommendationRecord::getAction, "liked"));
|
||||
long totalMatches = matchMapper.selectCount(null);
|
||||
long chatsStarted = conversationMapper.selectCount(null);
|
||||
long gettingToKnow = matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>()
|
||||
.eq(Match::getStatus, "getting_to_know"));
|
||||
long successPairs = successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>()
|
||||
.eq(RelationshipSuccessRequest::getStatus, "confirmed"));
|
||||
|
||||
result.put("totalRecommended", totalRecommended);
|
||||
result.put("totalLiked", totalLiked);
|
||||
result.put("totalMatches", totalMatches);
|
||||
result.put("chatsStarted", chatsStarted);
|
||||
result.put("gettingToKnow", gettingToKnow);
|
||||
result.put("successPairs", successPairs);
|
||||
|
||||
// 各阶段转化率
|
||||
result.put("likeRate", totalRecommended > 0 ? String.format("%.1f%%", 100.0 * totalLiked / totalRecommended) : "0%");
|
||||
result.put("matchRate", totalLiked > 0 ? String.format("%.1f%%", 100.0 * totalMatches / totalLiked) : "0%");
|
||||
result.put("chatRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * chatsStarted / totalMatches) : "0%");
|
||||
result.put("successRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * successPairs / totalMatches) : "0%");
|
||||
|
||||
// 每日新增匹配趋势
|
||||
List<Map<String, Object>> dailyTrend = buildDailyNewMatches(since, until);
|
||||
result.put("dailyTrend", dailyTrend);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 活动漏斗 ====================
|
||||
|
||||
public Map<String, Object> getActivityFunnel() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
// 活动数量(简化:统计 distinct activity_id)
|
||||
long activityCount = userMapper.selectCount(
|
||||
new LambdaQueryWrapper<>() {}); // placeholder
|
||||
// 报名数、通过数、签到数需要从 activity_registrations 统计
|
||||
// 简化处理
|
||||
result.put("totalActivities", 0);
|
||||
result.put("totalRegistrations", 0);
|
||||
result.put("approvedRegistrations", 0);
|
||||
result.put("checkedInCount", 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 安全统计 ====================
|
||||
|
||||
public Map<String, Object> getSafetyStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long pendingReports = reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING);
|
||||
long resolvedReports = reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED);
|
||||
long highRiskUsers = riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.in(RiskAction::getActionType,
|
||||
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active"));
|
||||
long bannedUsers = riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active"));
|
||||
|
||||
stats.put("pendingReports", pendingReports);
|
||||
stats.put("resolvedReports", resolvedReports);
|
||||
stats.put("highRiskUsers", highRiskUsers);
|
||||
stats.put("bannedUsers", bannedUsers);
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ==================== 每日汇总 ====================
|
||||
|
||||
public List<Map<String, Object>> getDailySummary(int days) {
|
||||
List<Map<String, Object>> summary = new ArrayList<>();
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
for (int i = 0; i < days; i++) {
|
||||
LocalDate date = today.minusDays(days - 1 - i);
|
||||
Instant start = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
|
||||
Map<String, Object> day = new HashMap<>();
|
||||
day.put("date", date.toString());
|
||||
day.put("newUsers", countEntityByDateRange(start, end));
|
||||
day.put("newMatches", countMatchByDateRange(start, end));
|
||||
day.put("newConversations", countConversationByDateRange(start, end));
|
||||
day.put("newReports", countReportByDateRange(start, end));
|
||||
summary.add(day);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
private Map<String, Object> userBasicStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalUsers", userMapper.selectCount(null));
|
||||
s.put("verifiedUsers", userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>().ne(UserProfile::getProfileStatus, "rejected")));
|
||||
s.put("approvedProfiles", userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getProfileStatus, "approved")));
|
||||
s.put("activeUsers7d", userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> matchFunnelStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalLikes", recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>().eq(RecommendationRecord::getAction, "liked")));
|
||||
s.put("totalMatches", matchMapper.selectCount(null));
|
||||
s.put("gettingToKnow", matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "getting_to_know")));
|
||||
s.put("successPairs", successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed")));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> safetyStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("pendingReports", reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING));
|
||||
s.put("resolvedReports", reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED));
|
||||
s.put("highRiskUsers", riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.in(RiskAction::getActionType, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active")));
|
||||
s.put("bannedUsers", riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active")));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> activityFunnelStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalActivities", 0);
|
||||
s.put("totalRegistrations", 0);
|
||||
return s;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
LocalDate current = since;
|
||||
while (!current.isAfter(until)) {
|
||||
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.toString());
|
||||
point.put("value", countEntityByDateRange(start, end));
|
||||
trend.add(point);
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
return trend;
|
||||
}
|
||||
|
||||
// ==================== V1.1 公益项目统计 ====================
|
||||
|
||||
public Map<String, Object> getBannerStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long totalBanners = homeBannerMapper.selectCount(null);
|
||||
Instant now = Instant.now();
|
||||
Instant finalNow = now;
|
||||
long activeBanners = homeBannerMapper.selectCount(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.danyang.match.platform.banner.entity.HomeBanner>()
|
||||
.eq(com.danyang.match.platform.banner.entity.HomeBanner::getStatus, "active")
|
||||
.le(com.danyang.match.platform.banner.entity.HomeBanner::getStartAt, finalNow)
|
||||
.ge(com.danyang.match.platform.banner.entity.HomeBanner::getEndAt, finalNow)
|
||||
);
|
||||
stats.put("totalBanners", totalBanners);
|
||||
stats.put("activeBanners", activeBanners);
|
||||
stats.put("impressions", 0);
|
||||
stats.put("clicks", 0);
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceStationStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceAppointment> appointments = serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNotNull(ServiceAppointment::getServiceLocationId)
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
);
|
||||
Set<String> uniqueLocations = new HashSet<>();
|
||||
for (ServiceAppointment a : appointments) {
|
||||
if (a.getServiceLocationId() != null) uniqueLocations.add(a.getServiceLocationId());
|
||||
}
|
||||
stats.put("activeStations", uniqueLocations.size());
|
||||
stats.put("totalAppointments", appointments.size());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceAppointmentStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceAppointment> all = serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt)
|
||||
);
|
||||
stats.put("total", all.size());
|
||||
stats.put("submitted", all.stream().filter(a -> "submitted".equals(a.getStatus())).count());
|
||||
stats.put("confirmed", all.stream().filter(a -> "confirmed".equals(a.getStatus())).count());
|
||||
stats.put("completed", all.stream().filter(a -> "completed".equals(a.getStatus())).count());
|
||||
stats.put("cancelled", all.stream().filter(a -> "cancelled".equals(a.getStatus())).count());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCounselingStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingService> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt)
|
||||
);
|
||||
stats.put("total", all.size());
|
||||
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
|
||||
stats.put("assigned", all.stream().filter(c -> "assigned".equals(c.getStatus())).count());
|
||||
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
|
||||
stats.put("cancelled", all.stream().filter(c -> "cancelled".equals(c.getStatus())).count());
|
||||
stats.put("premaritalGuidance", all.stream().filter(c -> "premarital_guidance".equals(c.getServiceType())).count());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getPremaritalStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingService> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingService>()
|
||||
.eq(CounselingService::getServiceType, "premarital_guidance")
|
||||
.isNull(CounselingService::getDeletedAt)
|
||||
);
|
||||
stats.put("totalRequests", all.size());
|
||||
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
|
||||
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
|
||||
stats.put("withCompanion", all.stream().filter(c -> c.getCompanionUserId() != null).count());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getContentStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ContentArticle> articles = contentArticleMapper.selectList(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt)
|
||||
);
|
||||
long totalViews = articles.stream()
|
||||
.mapToInt(a -> a.getViewCount() != null ? a.getViewCount() : 0).sum();
|
||||
stats.put("totalPublishedArticles", articles.size());
|
||||
stats.put("totalViews", totalViews);
|
||||
articles.sort((a, b) -> Integer.compare(
|
||||
b.getViewCount() != null ? b.getViewCount() : 0,
|
||||
a.getViewCount() != null ? a.getViewCount() : 0));
|
||||
List<Map<String, Object>> top = articles.stream().limit(5)
|
||||
.map(a -> { Map<String, Object> m = new HashMap<>(); m.put("id", a.getId()); m.put("title", a.getTitle()); m.put("views", a.getViewCount()); return m; })
|
||||
.toList();
|
||||
stats.put("topArticles", top);
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getLocalEligibilityStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<LocalEligibility> all = localEligibilityMapper.selectList(new LambdaQueryWrapper<>());
|
||||
stats.put("total", all.size());
|
||||
stats.put("verified", all.stream().filter(l -> "verified".equals(l.getVerificationStatus())).count());
|
||||
stats.put("pending", all.stream().filter(l -> "pending_review".equals(l.getVerificationStatus()) || "pending".equals(l.getVerificationStatus())).count());
|
||||
stats.put("rejected", all.stream().filter(l -> "rejected".equals(l.getVerificationStatus())).count());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceProgramStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceProgram> programs = serviceProgramMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
|
||||
List<ServiceProgramMember> members = serviceProgramMemberMapper.selectList(null);
|
||||
stats.put("totalPrograms", programs.size());
|
||||
stats.put("activePrograms", programs.stream().filter(p -> "active".equals(p.getStatus())).count());
|
||||
stats.put("totalMembers", members.stream().filter(m -> "approved".equals(m.getStatus())).count());
|
||||
stats.put("pendingApplications", members.stream().filter(m -> "pending".equals(m.getStatus())).count());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getRegistrationSourceStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityRegistration>().ne(ActivityRegistration::getStatus, "cancelled"));
|
||||
Map<String, Long> bySource = regs.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(
|
||||
r -> r.getSource() != null ? r.getSource() : "unknown",
|
||||
java.util.stream.Collectors.counting()));
|
||||
stats.put("totalRegistrations", regs.size());
|
||||
stats.put("bySource", bySource);
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getUniqueServiceUsersStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Set<String> cu = counselingServiceMapper.selectList(new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt))
|
||||
.stream().map(CounselingService::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> au = serviceAppointmentMapper.selectList(new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt))
|
||||
.stream().map(ServiceAppointment::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> pu = serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>().eq(ServiceProgramMember::getStatus, "approved"))
|
||||
.stream().map(ServiceProgramMember::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> eu = localEligibilityMapper.selectList(new LambdaQueryWrapper<>())
|
||||
.stream().map(LocalEligibility::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
stats.put("counselingUniqueUsers", cu.size());
|
||||
stats.put("appointmentUniqueUsers", au.size());
|
||||
stats.put("programUniqueUsers", pu.size());
|
||||
stats.put("eligibilityUniqueUsers", eu.size());
|
||||
Set<String> all = new HashSet<>(); all.addAll(cu); all.addAll(au); all.addAll(pu); all.addAll(eu);
|
||||
stats.put("totalUniqueServiceUsers", all.size());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getEffectiveConnectionStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long activeMatches = matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "active"));
|
||||
long matchesWithChat = conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().in(Conversation::getStatus, "active", "muted"));
|
||||
long successRequests = successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed"));
|
||||
stats.put("activeMatches", activeMatches);
|
||||
stats.put("matchesWithChat", matchesWithChat);
|
||||
stats.put("successConnections", successRequests);
|
||||
double rate = activeMatches > 0 ? (double) matchesWithChat / activeMatches : 0;
|
||||
stats.put("effectiveConnectionRate", Math.round(rate * 1000.0) / 10.0);
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getV11ServiceDashboard() {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("banners", getBannerStats());
|
||||
d.put("serviceStations", getServiceStationStats());
|
||||
d.put("serviceAppointments", getServiceAppointmentStats());
|
||||
d.put("counseling", getCounselingStats());
|
||||
d.put("premaritalGuidance", getPremaritalStats());
|
||||
d.put("content", getContentStats());
|
||||
d.put("localEligibility", getLocalEligibilityStats());
|
||||
d.put("servicePrograms", getServiceProgramStats());
|
||||
d.put("registrationSources", getRegistrationSourceStats());
|
||||
d.put("uniqueServiceUsers", getUniqueServiceUsersStats());
|
||||
d.put("effectiveConnections", getEffectiveConnectionStats());
|
||||
return d;
|
||||
}
|
||||
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
LocalDate current = since;
|
||||
while (!current.isAfter(until)) {
|
||||
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.toString());
|
||||
point.put("value", countMatchByDateRange(start, end));
|
||||
trend.add(point);
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
return trend;
|
||||
}
|
||||
|
||||
private long countEntityByDateRange(Instant start, Instant end) {
|
||||
return userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getCreatedAt, start)
|
||||
.le(User::getCreatedAt, end));
|
||||
}
|
||||
|
||||
private long countMatchByDateRange(Instant start, Instant end) {
|
||||
return matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>()
|
||||
.ge(Match::getMatchedAt, start)
|
||||
.le(Match::getMatchedAt, end));
|
||||
}
|
||||
|
||||
private long countConversationByDateRange(Instant start, Instant end) {
|
||||
return conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>()
|
||||
.ge(Conversation::getCreatedAt, start)
|
||||
.le(Conversation::getCreatedAt, end));
|
||||
}
|
||||
|
||||
private long countReportByDateRange(Instant start, Instant end) {
|
||||
return reportMapper.selectCount(
|
||||
new LambdaQueryWrapper<Report>()
|
||||
.ge(Report::getCreatedAt, start)
|
||||
.le(Report::getCreatedAt, end));
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.danyang.match.platform.appeal.controller;
|
||||
|
||||
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
|
||||
import com.danyang.match.platform.appeal.dto.AppealSubmitDTO;
|
||||
import com.danyang.match.platform.appeal.service.AppealService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 申诉控制器(用户端)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/appeals")
|
||||
@RequiredArgsConstructor
|
||||
public class AppealController {
|
||||
|
||||
private final AppealService appealService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
/**
|
||||
* 获取账号状态(含是否可申诉)
|
||||
* GET /api/v1/appeals/account-status
|
||||
*/
|
||||
@GetMapping("/account-status")
|
||||
public ApiResponse<Map<String, Object>> getAccountStatus(
|
||||
@RequestHeader("Authorization") String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(appealService.getAccountStatus(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交申诉
|
||||
* POST /api/v1/appeals
|
||||
*/
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, Object>> submitAppeal(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@RequestBody AppealSubmitDTO body) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(appealService.submitAppeal(userId, body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的申诉列表
|
||||
* GET /api/v1/appeals/my
|
||||
*/
|
||||
@GetMapping("/my")
|
||||
public ApiResponse<Object> getMyAppeals(@RequestHeader("Authorization") String authHeader) {
|
||||
String userId = extractUserId(authHeader);
|
||||
return ApiResponse.ok(appealService.getMyAppeals(userId));
|
||||
}
|
||||
|
||||
private String extractUserId(String authHeader) {
|
||||
String token = extractToken(authHeader);
|
||||
return jwtTokenService.getUserIdFromToken(token);
|
||||
}
|
||||
|
||||
private String extractToken(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.danyang.match.platform.appeal.controller;
|
||||
|
||||
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
|
||||
import com.danyang.match.platform.appeal.service.AppealService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 申诉管理控制器(Operator端)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/appeals")
|
||||
@RequiredArgsConstructor
|
||||
public class AppealOperatorController {
|
||||
|
||||
private final AppealService appealService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
/**
|
||||
* 获取待处理申诉
|
||||
* GET /api/v1/operator/appeals/pending
|
||||
*/
|
||||
@GetMapping("/pending")
|
||||
public ApiResponse<Object> getPendingAppeals(@RequestHeader("Authorization") String authHeader) {
|
||||
return ApiResponse.ok(appealService.getPendingAppeals());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取申诉详情
|
||||
* GET /api/v1/operator/appeals/{id}
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<Object> getAppealDetail(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(appealService.getAppealDetail(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理申诉
|
||||
* POST /api/v1/operator/appeals/{id}/process
|
||||
* Body: { "decision": "approve|reject|escalate", "decisionNote": "..." }
|
||||
*/
|
||||
@PostMapping("/{id}/process")
|
||||
public ApiResponse<Map<String, Object>> processAppeal(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String id,
|
||||
@RequestBody AppealProcessDTO body) {
|
||||
String operatorId = extractOperatorId(authHeader);
|
||||
return ApiResponse.ok(appealService.processAppeal(id, body, operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取申诉统计
|
||||
* GET /api/v1/operator/appeals/stats
|
||||
*/
|
||||
@GetMapping("/stats")
|
||||
public ApiResponse<Map<String, Object>> getAppealStats(@RequestHeader("Authorization") String authHeader) {
|
||||
return ApiResponse.ok(appealService.getAppealStats());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有申诉
|
||||
* GET /api/v1/operator/appeals/users/{userId}
|
||||
*/
|
||||
@GetMapping("/users/{userId}")
|
||||
public ApiResponse<Object> getUserAppeals(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@PathVariable String userId) {
|
||||
return ApiResponse.ok(appealService.getUserAppeals(userId));
|
||||
}
|
||||
|
||||
private String extractOperatorId(String authHeader) {
|
||||
String token = extractToken(authHeader);
|
||||
return token;
|
||||
}
|
||||
|
||||
private String extractToken(String authHeader) {
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.danyang.match.platform.appeal.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 申诉列表项 DTO
|
||||
*/
|
||||
public class AppealItemDTO {
|
||||
private String id;
|
||||
private String userId;
|
||||
private String userName;
|
||||
private String appealType;
|
||||
private String appealTypeText;
|
||||
private String relatedActionId;
|
||||
private String reason;
|
||||
private String evidence;
|
||||
private String contactMethod;
|
||||
private String status;
|
||||
private Instant createdAt;
|
||||
private String decisionNote;
|
||||
private String reviewedBy;
|
||||
private Instant reviewedAt;
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getUserId() { return userId; }
|
||||
public void setUserId(String userId) { this.userId = userId; }
|
||||
public String getUserName() { return userName; }
|
||||
public void setUserName(String userName) { this.userName = userName; }
|
||||
public String getAppealType() { return appealType; }
|
||||
public void setAppealType(String appealType) { this.appealType = appealType; }
|
||||
public String getAppealTypeText() { return appealTypeText; }
|
||||
public void setAppealTypeText(String appealTypeText) { this.appealTypeText = appealTypeText; }
|
||||
public String getRelatedActionId() { return relatedActionId; }
|
||||
public void setRelatedActionId(String relatedActionId) { this.relatedActionId = relatedActionId; }
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
public String getEvidence() { return evidence; }
|
||||
public void setEvidence(String evidence) { this.evidence = evidence; }
|
||||
public String getContactMethod() { return contactMethod; }
|
||||
public void setContactMethod(String contactMethod) { this.contactMethod = contactMethod; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
public String getDecisionNote() { return decisionNote; }
|
||||
public void setDecisionNote(String decisionNote) { this.decisionNote = decisionNote; }
|
||||
public String getReviewedBy() { return reviewedBy; }
|
||||
public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; }
|
||||
public Instant getReviewedAt() { return reviewedAt; }
|
||||
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.danyang.match.platform.appeal.dto;
|
||||
|
||||
/**
|
||||
* 申诉处理请求 DTO
|
||||
*/
|
||||
public class AppealProcessDTO {
|
||||
private String decision; // approve, reject, escalate
|
||||
private String decisionNote;
|
||||
|
||||
public String getDecision() { return decision; }
|
||||
public void setDecision(String decision) { this.decision = decision; }
|
||||
public String getDecisionNote() { return decisionNote; }
|
||||
public void setDecisionNote(String decisionNote) { this.decisionNote = decisionNote; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.danyang.match.platform.appeal.dto;
|
||||
|
||||
/**
|
||||
* 申诉提交 DTO
|
||||
*/
|
||||
public class AppealSubmitDTO {
|
||||
private String appealType; // ban_appeal, suspend_appeal
|
||||
private String relatedActionId;
|
||||
private String reason;
|
||||
private String evidence; // JSON array of file keys
|
||||
private String contactMethod;
|
||||
|
||||
public String getAppealType() { return appealType; }
|
||||
public void setAppealType(String appealType) { this.appealType = appealType; }
|
||||
public String getRelatedActionId() { return relatedActionId; }
|
||||
public void setRelatedActionId(String relatedActionId) { this.relatedActionId = relatedActionId; }
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
public String getEvidence() { return evidence; }
|
||||
public void setEvidence(String evidence) { this.evidence = evidence; }
|
||||
public String getContactMethod() { return contactMethod; }
|
||||
public void setContactMethod(String contactMethod) { this.contactMethod = contactMethod; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.danyang.match.platform.appeal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 申诉记录
|
||||
* 核心 Invariant: 申诉仅针对 ban/temporary_suspend 类处置,不支持申诉为 warning/feature_limit
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("appeals")
|
||||
public class Appeal extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
private String appealType; // ban_appeal, suspend_appeal
|
||||
private String relatedActionId; // 关联的 risk_action id
|
||||
private String reason;
|
||||
private String evidence; // JSON array of file keys
|
||||
private String contactMethod; // 用户可提供的联系方式
|
||||
private String status; // pending, reviewing, approved, rejected, escalated
|
||||
private String decisionNote;
|
||||
private String reviewedBy;
|
||||
private java.time.Instant reviewedAt;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.danyang.match.platform.appeal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.appeal.entity.Appeal;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface AppealMapper extends BaseMapper<Appeal> {
|
||||
|
||||
@Select("SELECT * FROM appeals WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT 20")
|
||||
List<Appeal> findByUserId(String userId);
|
||||
|
||||
@Select("SELECT * FROM appeals WHERE status = 'pending' ORDER BY created_at ASC")
|
||||
List<Appeal> findPending();
|
||||
|
||||
@Select("SELECT * FROM appeals WHERE status = #{status} ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset}")
|
||||
List<Appeal> findByStatusPaged(@Param("status") String status,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM appeals WHERE status = #{status} AND deleted_at IS NULL")
|
||||
long countByStatus(@Param("status") String status);
|
||||
|
||||
/**
|
||||
* 防重:同一用户对同一 action 只能有一次 pending/reviewing 申诉
|
||||
*/
|
||||
@Select("SELECT * FROM appeals WHERE user_id = #{userId} AND related_action_id = #{actionId} " +
|
||||
"AND status IN ('pending', 'reviewing') AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<Appeal> findActiveByUserAndAction(@Param("userId") String userId,
|
||||
@Param("actionId") String actionId);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.danyang.match.platform.appeal.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.appeal.dto.AppealItemDTO;
|
||||
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
|
||||
import com.danyang.match.platform.appeal.dto.AppealSubmitDTO;
|
||||
import com.danyang.match.platform.appeal.entity.Appeal;
|
||||
import com.danyang.match.platform.appeal.mapper.AppealMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
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.shared.config.FeatureFlagConfig;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 申诉服务
|
||||
*
|
||||
* 核心 Invariant:
|
||||
* - 申诉功能受 FeatureFlag 控制,默认关闭
|
||||
* - 仅 ban / temporary_suspend 类处置可申诉,warning/feature_limit 不可申诉
|
||||
* - 同一用户对同一 action 只能有一次 pending 申诉(幂等)
|
||||
* - 申诉通过 → 解除对应风险处置
|
||||
* - 申诉被拒 → 维持原处置
|
||||
* - 升级人工 → 标记 escalated,通知风控员
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppealService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AppealService.class);
|
||||
|
||||
private final AppealMapper appealMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final FeatureFlagConfig featureFlagConfig;
|
||||
|
||||
// ==================== 用户端 ====================
|
||||
|
||||
/**
|
||||
* 检查是否支持申诉(FeatureFlag + 账号状态)
|
||||
*/
|
||||
public Map<String, Object> getAccountStatus(String userId) {
|
||||
List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(userId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (activeActions.isEmpty()) {
|
||||
result.put("hasRestriction", false);
|
||||
result.put("canAppeal", false);
|
||||
result.put("appealEnabled", featureFlagConfig.isAppealEnabled());
|
||||
result.put("accountStatus", "normal");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查是否有可申诉的处置
|
||||
boolean hasAppealable = activeActions.stream()
|
||||
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType()) ||
|
||||
Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
|
||||
|
||||
result.put("hasRestriction", true);
|
||||
result.put("canAppeal", hasAppealable && featureFlagConfig.isAppealEnabled());
|
||||
result.put("appealEnabled", featureFlagConfig.isAppealEnabled());
|
||||
result.put("accountStatus", calculateAccountStatus(activeActions));
|
||||
result.put("actions", activeActions.stream().map(a -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("actionType", a.getActionType());
|
||||
m.put("reason", a.getReason());
|
||||
m.put("effectiveAt", a.getEffectiveAt());
|
||||
m.put("expiresAt", a.getExpiresAt());
|
||||
m.put("canAppeal", Constants.RISK_ACTION_BAN.equals(a.getActionType()) ||
|
||||
Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
|
||||
return m;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交申诉
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> submitAppeal(String userId, AppealSubmitDTO dto) {
|
||||
// FeatureFlag 检查
|
||||
if (!featureFlagConfig.isAppealEnabled()) {
|
||||
throw new RuntimeException("申诉功能暂未开放");
|
||||
}
|
||||
|
||||
// 验证关联的 risk_action 存在且仍有效
|
||||
RiskAction targetAction = riskActionMapper.selectById(dto.getRelatedActionId());
|
||||
if (targetAction == null) {
|
||||
throw new RuntimeException("关联的处置记录不存在");
|
||||
}
|
||||
if (!Constants.RISK_ACTION_STATUS_ACTIVE.equals(targetAction.getStatus())) {
|
||||
throw new RuntimeException("该处置已失效,无法申诉");
|
||||
}
|
||||
if (!userId.equals(targetAction.getUserId())) {
|
||||
throw new RuntimeException("无权对此处置提交申诉");
|
||||
}
|
||||
|
||||
// 必须是 ban 或 suspend 类型
|
||||
if (!Constants.RISK_ACTION_BAN.equals(targetAction.getActionType()) &&
|
||||
!Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(targetAction.getActionType())) {
|
||||
throw new RuntimeException("当前处置类型不支持申诉");
|
||||
}
|
||||
|
||||
// 防重:同一用户对同一 action 只能有一个 pending/reviewing 申诉
|
||||
Optional<Appeal> existing = appealMapper.findActiveByUserAndAction(userId, dto.getRelatedActionId());
|
||||
if (existing.isPresent()) {
|
||||
throw new RuntimeException("您已就此处置提交过申诉,请等待处理结果");
|
||||
}
|
||||
|
||||
Appeal appeal = new Appeal();
|
||||
appeal.setUserId(userId);
|
||||
appeal.setAppealType(dto.getAppealType());
|
||||
appeal.setRelatedActionId(dto.getRelatedActionId());
|
||||
appeal.setReason(dto.getReason());
|
||||
appeal.setEvidence(dto.getEvidence());
|
||||
appeal.setContactMethod(dto.getContactMethod());
|
||||
appeal.setStatus(Constants.APPEAL_STATUS_PENDING);
|
||||
appealMapper.insert(appeal);
|
||||
|
||||
saveAuditLog(userId, "SUBMIT_APPEAL", "appeal", appeal.getId(), null, null);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("appealId", appeal.getId());
|
||||
result.put("status", appeal.getStatus());
|
||||
result.put("submittedAt", Instant.now());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的申诉列表
|
||||
*/
|
||||
public List<AppealItemDTO> getMyAppeals(String userId) {
|
||||
return appealMapper.findByUserId(userId).stream()
|
||||
.map(this::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 管理端 ====================
|
||||
|
||||
/**
|
||||
* 获取待处理申诉列表
|
||||
*/
|
||||
public List<AppealItemDTO> getPendingAppeals() {
|
||||
return appealMapper.findPending().stream()
|
||||
.map(this::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取申诉详情
|
||||
*/
|
||||
public AppealItemDTO getAppealDetail(String appealId) {
|
||||
Appeal appeal = appealMapper.selectById(appealId);
|
||||
if (appeal == null) {
|
||||
throw new RuntimeException("申诉不存在");
|
||||
}
|
||||
return toDTO(appeal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理申诉
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> processAppeal(String appealId, AppealProcessDTO dto, String operatorId) {
|
||||
Appeal appeal = appealMapper.selectById(appealId);
|
||||
if (appeal == null) {
|
||||
throw new RuntimeException("申诉不存在");
|
||||
}
|
||||
if (!Constants.APPEAL_STATUS_PENDING.equals(appeal.getStatus()) &&
|
||||
!Constants.APPEAL_STATUS_REVIEWING.equals(appeal.getStatus())) {
|
||||
throw new RuntimeException("该申诉已处理完毕");
|
||||
}
|
||||
|
||||
appeal.setStatus(Constants.APPEAL_STATUS_REVIEWING);
|
||||
appealMapper.updateById(appeal);
|
||||
|
||||
// 根据决策执行
|
||||
switch (dto.getDecision()) {
|
||||
case "approve" -> approveAppeal(appeal, dto.getDecisionNote(), operatorId);
|
||||
case "reject" -> rejectAppeal(appeal, dto.getDecisionNote(), operatorId);
|
||||
case "escalate" -> escalateAppeal(appeal, dto.getDecisionNote(), operatorId);
|
||||
default -> throw new RuntimeException("无效的申诉决策");
|
||||
}
|
||||
|
||||
saveAuditLog(operatorId, "PROCESS_APPEAL", "appeal", appealId, null, null);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("appealId", appealId);
|
||||
result.put("decision", dto.getDecision());
|
||||
result.put("processedAt", Instant.now());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取申诉统计
|
||||
*/
|
||||
public Map<String, Object> getAppealStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("pendingCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_PENDING));
|
||||
stats.put("reviewingCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_REVIEWING));
|
||||
stats.put("approvedCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_APPROVED));
|
||||
stats.put("rejectedCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_REJECTED));
|
||||
stats.put("escalatedCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_ESCALATED));
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有申诉(含已处理的)
|
||||
*/
|
||||
public List<AppealItemDTO> getUserAppeals(String userId) {
|
||||
return appealMapper.findByUserId(userId).stream()
|
||||
.map(this::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
private void approveAppeal(Appeal appeal, String note, String operatorId) {
|
||||
appeal.setStatus(Constants.APPEAL_STATUS_APPROVED);
|
||||
appeal.setDecisionNote(note);
|
||||
appeal.setReviewedBy(operatorId);
|
||||
appeal.setReviewedAt(Instant.now());
|
||||
appealMapper.updateById(appeal);
|
||||
|
||||
// 解除对应风险处置
|
||||
if (appeal.getRelatedActionId() != null) {
|
||||
RiskAction ra = riskActionMapper.selectById(appeal.getRelatedActionId());
|
||||
if (ra != null) {
|
||||
ra.setStatus("resolved");
|
||||
ra.setUpdatedAt(Instant.now());
|
||||
riskActionMapper.updateById(ra);
|
||||
}
|
||||
|
||||
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
|
||||
}
|
||||
|
||||
// 通知用户
|
||||
UserProfile profile = userProfileMapper.findByUserId(appeal.getUserId()).orElse(null);
|
||||
String userName = profile != null ? profile.getDisplayName() : "用户";
|
||||
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||
"申诉审核通过",
|
||||
"您的申诉已审核通过,相关限制已解除。",
|
||||
"appeal", appeal.getId(),
|
||||
"appeal_approved:" + appeal.getId());
|
||||
}
|
||||
|
||||
private void rejectAppeal(Appeal appeal, String note, String operatorId) {
|
||||
appeal.setStatus(Constants.APPEAL_STATUS_REJECTED);
|
||||
appeal.setDecisionNote(note);
|
||||
appeal.setReviewedBy(operatorId);
|
||||
appeal.setReviewedAt(Instant.now());
|
||||
appealMapper.updateById(appeal);
|
||||
|
||||
// 维持原处置,不变更 risk_action
|
||||
log.info("申诉驳回,维持原处置: appealId={}", appeal.getId());
|
||||
|
||||
// 通知用户
|
||||
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||
"申诉审核结果",
|
||||
"您的申诉未通过审核,原处置维持。如有疑问请联系客服。",
|
||||
"appeal", appeal.getId(),
|
||||
"appeal_rejected:" + appeal.getId());
|
||||
}
|
||||
|
||||
private void escalateAppeal(Appeal appeal, String note, String operatorId) {
|
||||
appeal.setStatus(Constants.APPEAL_STATUS_ESCALATED);
|
||||
appeal.setDecisionNote("升级人工处理:" + note);
|
||||
appeal.setReviewedBy(operatorId);
|
||||
appeal.setReviewedAt(Instant.now());
|
||||
appealMapper.updateById(appeal);
|
||||
|
||||
log.info("申诉升级人工处理: appealId={}", appeal.getId());
|
||||
|
||||
// 通知风控员
|
||||
notificationService.send(operatorId, Constants.NOTIFICATION_TYPE_SYSTEM,
|
||||
"申诉升级处理",
|
||||
"用户申诉已升级人工处理,请核实后决策。",
|
||||
"appeal_escalated", appeal.getId(),
|
||||
"appeal_escalated:" + appeal.getId());
|
||||
}
|
||||
|
||||
private AppealItemDTO toDTO(Appeal appeal) {
|
||||
AppealItemDTO dto = new AppealItemDTO();
|
||||
dto.setId(appeal.getId());
|
||||
dto.setUserId(appeal.getUserId());
|
||||
dto.setAppealType(appeal.getAppealType());
|
||||
dto.setAppealTypeText(getAppealTypeText(appeal.getAppealType()));
|
||||
dto.setRelatedActionId(appeal.getRelatedActionId());
|
||||
dto.setReason(appeal.getReason());
|
||||
dto.setEvidence(appeal.getEvidence());
|
||||
dto.setContactMethod(appeal.getContactMethod());
|
||||
dto.setStatus(appeal.getStatus());
|
||||
dto.setCreatedAt(appeal.getCreatedAt());
|
||||
dto.setDecisionNote(appeal.getDecisionNote());
|
||||
dto.setReviewedBy(appeal.getReviewedBy());
|
||||
dto.setReviewedAt(appeal.getReviewedAt());
|
||||
|
||||
// 填充用户名
|
||||
UserProfile profile = userProfileMapper.findByUserId(appeal.getUserId()).orElse(null);
|
||||
dto.setUserName(profile != null ? profile.getDisplayName() : "");
|
||||
return dto;
|
||||
}
|
||||
|
||||
private String getAppealTypeText(String type) {
|
||||
return "ban_appeal".equals(type) ? "封禁申诉" : "暂停申诉";
|
||||
}
|
||||
|
||||
private String calculateAccountStatus(List<RiskAction> actions) {
|
||||
boolean hasBan = actions.stream().anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType()));
|
||||
if (hasBan) return "banned";
|
||||
boolean hasSuspend = actions.stream().anyMatch(a -> Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
|
||||
if (hasSuspend) return "suspended";
|
||||
return "limited";
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user