chore: establish V1.1 recovery baseline

This commit is contained in:
2026-09-09 22:05:00 +08:00
commit b46b48eb33
947 changed files with 155471 additions and 0 deletions
+93
View File
@@ -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
View File
@@ -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/
+166
View File
@@ -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`
+54
View File
@@ -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
+70
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
婚恋服务小程序
+242
View File
@@ -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`。
---
## 许可证
本项目为公益项目,服务于丹阳市单身人士婚恋匹配。
+322
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
1.1.0-RC.1
+13
View File
@@ -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;"]
+13
View File
@@ -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>
+42
View File
@@ -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;
}
}
+30
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false
+13
View File
@@ -0,0 +1,13 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
#app {
width: 100%;
height: 100%;
}
</style>
+46
View File
@@ -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
+137
View File
@@ -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>
+22
View File
@@ -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>
+230
View File
@@ -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>
+81
View File
@@ -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
+48
View File
@@ -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 }
})
+80
View File
@@ -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;
}
+95
View File
@@ -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>
+73
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+25
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -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,
},
},
},
})
Binary file not shown.
+2
View File
@@ -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
+13
View File
@@ -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}"]
+77
View File
@@ -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>
+56
View File
@@ -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!")
+16
View File
@@ -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);
+11
View File
@@ -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>
BIN
View File
Binary file not shown.
+82
View File
@@ -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
View File
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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; }
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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
}
@@ -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;
}
@@ -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> {
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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