Browse Source

feat: 首次导入青核后端项目

codex/gitea-initial-20261026
zzy_0123 1 day ago
commit
5bb2bb81c7
  1. 58
      .gitignore
  2. 220
      README.md
  3. 9
      campus-server/.dockerignore
  4. 51
      campus-server/.env.example
  5. 8
      campus-server/.gitignore
  6. 17
      campus-server/Dockerfile
  7. 76
      campus-server/Start-Local.ps1
  8. 122
      campus-server/Test-LocalDeployment.ps1
  9. 91
      campus-server/compose.local.yml
  10. 264
      campus-server/pom.xml
  11. 16
      campus-server/src/main/java/com/campus/CampusApplication.java
  12. 43
      campus-server/src/main/java/com/campus/common/BaseEntity.java
  13. 16
      campus-server/src/main/java/com/campus/common/BusinessException.java
  14. 9
      campus-server/src/main/java/com/campus/common/ForbiddenException.java
  15. 116
      campus-server/src/main/java/com/campus/common/GlobalExceptionHandler.java
  16. 37
      campus-server/src/main/java/com/campus/common/PageResult.java
  17. 44
      campus-server/src/main/java/com/campus/common/Result.java
  18. 13
      campus-server/src/main/java/com/campus/common/ServiceUnavailableException.java
  19. 9
      campus-server/src/main/java/com/campus/common/TooManyRequestsException.java
  20. 20
      campus-server/src/main/java/com/campus/config/AchievementInitializer.java
  21. 30
      campus-server/src/main/java/com/campus/config/ActivityLocationProperties.java
  22. 67
      campus-server/src/main/java/com/campus/config/AdminBootstrapInitializer.java
  23. 23
      campus-server/src/main/java/com/campus/config/AliyunPushConfig.java
  24. 39
      campus-server/src/main/java/com/campus/config/AsyncConfig.java
  25. 43
      campus-server/src/main/java/com/campus/config/AuthDataIntegrityInitializer.java
  26. 97
      campus-server/src/main/java/com/campus/config/CommerceCatalogInitializer.java
  27. 228
      campus-server/src/main/java/com/campus/config/DictInitializer.java
  28. 58
      campus-server/src/main/java/com/campus/config/EasemobConfig.java
  29. 101
      campus-server/src/main/java/com/campus/config/EasemobGroupInitializer.java
  30. 59
      campus-server/src/main/java/com/campus/config/GiftInitializer.java
  31. 52
      campus-server/src/main/java/com/campus/config/HttpRequestLoggingFilter.java
  32. 156
      campus-server/src/main/java/com/campus/config/JwtAuthenticationFilter.java
  33. 75
      campus-server/src/main/java/com/campus/config/JwtConfig.java
  34. 49
      campus-server/src/main/java/com/campus/config/OcrProperties.java
  35. 29
      campus-server/src/main/java/com/campus/config/OssConfig.java
  36. 37
      campus-server/src/main/java/com/campus/config/OssProperties.java
  37. 109
      campus-server/src/main/java/com/campus/config/PointRuleInitializer.java
  38. 96
      campus-server/src/main/java/com/campus/config/RabbitMQConfig.java
  39. 115
      campus-server/src/main/java/com/campus/config/RedisConfig.java
  40. 30
      campus-server/src/main/java/com/campus/config/RedisConnectionConfig.java
  41. 82
      campus-server/src/main/java/com/campus/config/SecurityConfig.java
  42. 81
      campus-server/src/main/java/com/campus/config/SensitiveWordInitializer.java
  43. 23
      campus-server/src/main/java/com/campus/config/StsProperties.java
  44. 29
      campus-server/src/main/java/com/campus/config/VodProperties.java
  45. 19
      campus-server/src/main/java/com/campus/config/WebConfig.java
  46. 17
      campus-server/src/main/java/com/campus/controller/AccountDeletionReviewController.java
  47. 16
      campus-server/src/main/java/com/campus/controller/AchievementController.java
  48. 269
      campus-server/src/main/java/com/campus/controller/ActivityController.java
  49. 19
      campus-server/src/main/java/com/campus/controller/ActivityLocationController.java
  50. 136
      campus-server/src/main/java/com/campus/controller/ActivityRegistrationController.java
  51. 184
      campus-server/src/main/java/com/campus/controller/AdminController.java
  52. 349
      campus-server/src/main/java/com/campus/controller/AnnouncementController.java
  53. 78
      campus-server/src/main/java/com/campus/controller/AreaController.java
  54. 124
      campus-server/src/main/java/com/campus/controller/AuthController.java
  55. 184
      campus-server/src/main/java/com/campus/controller/ChatController.java
  56. 32
      campus-server/src/main/java/com/campus/controller/CollegeController.java
  57. 40
      campus-server/src/main/java/com/campus/controller/CommentController.java
  58. 42
      campus-server/src/main/java/com/campus/controller/ContentAppealController.java
  59. 93
      campus-server/src/main/java/com/campus/controller/DictController.java
  60. 38
      campus-server/src/main/java/com/campus/controller/DraftController.java
  61. 183
      campus-server/src/main/java/com/campus/controller/FileUploadController.java
  62. 44
      campus-server/src/main/java/com/campus/controller/FollowController.java
  63. 149
      campus-server/src/main/java/com/campus/controller/FriendshipController.java
  64. 89
      campus-server/src/main/java/com/campus/controller/GiftController.java
  65. 201
      campus-server/src/main/java/com/campus/controller/GroupController.java
  66. 61
      campus-server/src/main/java/com/campus/controller/GroupInvitationController.java
  67. 23
      campus-server/src/main/java/com/campus/controller/HomeController.java
  68. 26
      campus-server/src/main/java/com/campus/controller/MajorController.java
  69. 63
      campus-server/src/main/java/com/campus/controller/MatchingController.java
  70. 176
      campus-server/src/main/java/com/campus/controller/MockCommerceController.java
  71. 325
      campus-server/src/main/java/com/campus/controller/MomentController.java
  72. 128
      campus-server/src/main/java/com/campus/controller/NotificationController.java
  73. 37
      campus-server/src/main/java/com/campus/controller/NotificationSseController.java
  74. 148
      campus-server/src/main/java/com/campus/controller/OcrController.java
  75. 118
      campus-server/src/main/java/com/campus/controller/OssController.java
  76. 113
      campus-server/src/main/java/com/campus/controller/PointsController.java
  77. 31
      campus-server/src/main/java/com/campus/controller/PrivacySettingsController.java
  78. 148
      campus-server/src/main/java/com/campus/controller/ProfileController.java
  79. 67
      campus-server/src/main/java/com/campus/controller/PushController.java
  80. 35
      campus-server/src/main/java/com/campus/controller/QrScanController.java
  81. 75
      campus-server/src/main/java/com/campus/controller/RadarController.java
  82. 54
      campus-server/src/main/java/com/campus/controller/ReportController.java
  83. 81
      campus-server/src/main/java/com/campus/controller/ReviewController.java
  84. 49
      campus-server/src/main/java/com/campus/controller/SensitiveWordController.java
  85. 33
      campus-server/src/main/java/com/campus/controller/ShareController.java
  86. 24
      campus-server/src/main/java/com/campus/controller/SocialGraphController.java
  87. 178
      campus-server/src/main/java/com/campus/controller/TopicController.java
  88. 97
      campus-server/src/main/java/com/campus/controller/TopicDiscussionController.java
  89. 72
      campus-server/src/main/java/com/campus/controller/UniversityController.java
  90. 190
      campus-server/src/main/java/com/campus/controller/UserController.java
  91. 28
      campus-server/src/main/java/com/campus/controller/UserProfileController.java
  92. 214
      campus-server/src/main/java/com/campus/controller/VodController.java
  93. 10
      campus-server/src/main/java/com/campus/dto/request/ActivityCheckInRequest.java
  94. 15
      campus-server/src/main/java/com/campus/dto/request/ActivityLocationRequest.java
  95. 30
      campus-server/src/main/java/com/campus/dto/request/ActivityQueryRequest.java
  96. 27
      campus-server/src/main/java/com/campus/dto/request/ActivityRegistrationRequest.java
  97. 83
      campus-server/src/main/java/com/campus/dto/request/ActivityRequest.java
  98. 15
      campus-server/src/main/java/com/campus/dto/request/ActivityReviewRequest.java
  99. 26
      campus-server/src/main/java/com/campus/dto/request/ActivityTicketRequest.java
  100. 24
      campus-server/src/main/java/com/campus/dto/request/AddFriendRequest.java

58
.gitignore

@ -0,0 +1,58 @@
# ============================================================
# 这个交付物含真实凭证(数据库密码、云存储密钥)和数据库快照。
# 规则:默认只提交 Java 源码与构建配置,其余一律排除。
# ============================================================
# ---- 敏感配置:绝不入库 ----
application.yml
application-dev.yml
*.local.yml
!/campus-server/compose.local.yml
.env
.env.*
!.env.example
# ---- 数据库快照与迁移脚本:不入库 ----
*.sql
*.sql.gz
*.dump
青核数据库-*.zip
# ---- 大体积二进制:不入库 ----
*.jar
*.zip
*.tar
*.tar.gz
*.tar.xz
*.xz
*.7z
# ---- 构建产物 ----
target/
build/
out/
*.class
*.log
# ---- IDE ----
.idea/
*.iml
.vscode/
*.iws
*.code-workspace
# ---- 临时目录 ----
.rewrite-backup/
# Windows 沙箱文件权限诊断的备份与报告(diagnose-windows-sandbox-acl 产出)
.acl-recovery/
.acl-reports/
.dsh-acl-recovery/
# ---- 临时排查文件:不入库 ----
topic-check.txt
campus-server/cp.txt
.dump-class.py
.scan-classes.py
# ---- 对外发布用的干净快照(独立 git 仓库)----
gitea-upload/

220
README.md

@ -0,0 +1,220 @@
# 青核后端(qinghe-backend)
青核是一个校园社交与服务平台,提供用户、动态、话题讨论、组局活动、好友与群聊、通知、认证和内容审核等能力。本仓库是青核项目的后端与工程协作仓库,不包含 App / Web 前端源码。
首次独立部署请阅读 [本地部署说明](方案分析与实现(md)文件/部署说明-本地部署.md),使用脱敏模板与专用启动器。下文普通 IDEA/JAR 启动用于已有安全配置的开发环境。
## 当前交付基线
| 项目 | 当前状态 |
| --- | --- |
| 后端模块 | `campus-server`,包根 `com.campus` |
| 默认端口 | `8009` |
| 业务接口前缀 | `/api` |
| 构建产物 | `campus-server/target/campus-server-1.0.0.jar` |
| 数据库迁移 | `campus-server/src/main/resources/db/migration/`;当前 Flyway 关闭,脚本不会自动执行 |
| 接口契约 | 运行时 Swagger / OpenAPI;内部专题接口资料由维护者单独提供 |
| 自动化测试 | 以当前源码执行 `mvn test` 或 `mvn clean verify` 的结果为准 |
| 部署与验收边界 | 见本仓库的本地部署说明;内部工作记录和开发规范不随源码公开 |
2026-10-21 部署材料验证:`mvn -Pportable clean verify` 通过,496 用例 / 0 失败 / 0 错误 / 0 跳过;启动器离线检查及 Compose 静态校验通过。尚未执行 Docker 实机启动、全新空库或第三方服务验收,详细边界见本地部署说明。
## 仓库结构
```text
qinghe-backend/
├─ campus-server/ Spring Boot 后端模块
│ ├─ pom.xml Maven 构建配置
│ ├─ .env.example 环境变量占位示例
│ ├─ compose.local.yml 独立本地基础设施及可选后端容器
│ ├─ Start-Local.ps1 独立配置启动器
│ └─ src/
│ ├─ main/java/com/campus/ Controller、Service、实体、持久层与配置
│ ├─ main/resources/ 应用配置与 db/migration/ 迁移脚本
│ └─ test/java/com/campus/ 后端测试
├─ 方案分析与实现(md)文件/
│ └─ 部署说明-本地部署.md 随源码提供的部署与验收说明
└─ README.md 项目入口与启动、联调说明
```
目录位置沿用现有工作区。其他内部方案、工作记录、`AGENTS.md`、真实应用配置和 `gitea-upload/` 保留在维护者本地,不随本次源码发布。
## 技术栈
- Java 8 编译目标,本地可使用 JDK 17 / 21 构建运行。
- Spring Boot **2.7.18**。
- Spring MVC、Spring Security、Spring Data JPA / Hibernate,使用 `javax.*`。
- MySQL、Redis、RabbitMQ;Flyway 依赖已引入,当前配置关闭自动迁移。
- 环信 IM,阿里云 OSS、VOD、推送、OCR 和短信。
- Maven、JUnit 5、Mockito。
- SpringDoc OpenAPI UI 1.7.0。
## 环境准备
开发机需要安装 JDK 和 Maven,并可在 PowerShell 中执行:
```powershell
java -version
mvn -version
```
后端启动需要数据库、Redis、RabbitMQ、JWT 等配置,以及对应业务使用的第三方服务配置。使用团队通过安全渠道提供的配置或在运行环境中注入对应 Spring 配置项。不要将真实密码、JWT secret、Token 或云服务密钥写入 README、脚本、日志或 Git。
仓库存在两份应用配置:
- `campus-server/src/main/resources/application.yml`:模块内的应用配置,IDEA 启动时加载,构建时打入 JAR。
- 根目录 `application.yml`:外部应用配置;按下文从仓库根目录运行 JAR 时默认加载,同名配置项优先于 JAR 内配置。
两份配置涉及共同配置项时必须同步维护。启动前确认工作目录和实际连接环境;配置文件被 Git 忽略,拉取源码后需单独准备。
另提供不含真实凭据的 [application.example.yml](campus-server/src/main/resources/application.example.yml) 和 [.env.example](campus-server/.env.example)。不要覆盖现有真实配置;新部署者按部署说明复制私有 `.env` 并填写自己的配置。万能验证码在模板中默认关闭,未接入云服务时相关功能不可用。
## 基础设施准备
独立开发环境使用现有模块目录下的 [compose.local.yml](campus-server/compose.local.yml),提供 MySQL、Redis、RabbitMQ,不调整其他文件位置。先准备私有 `.env` 并启动 Docker Desktop,再在 `campus-server` 目录执行:
```powershell
docker compose --env-file .env -f compose.local.yml up -d mysql redis rabbitmq
docker compose --env-file .env -f compose.local.yml ps
```
等待三个服务 healthy 后启动后端。宿主机默认端口分别为 `13306/16379/15672`,RabbitMQ 管理页端口为 `15673`。数据保留、首次初始化及可选后端容器步骤见本地部署说明。当前尚未完成 Docker 实机与全新空库验收。
## 启动后端
### 构建并验证
以下命令从仓库根目录执行,进入模块后完成编译、测试、打包与 Maven 验证:
```powershell
cd .\campus-server
mvn clean verify
```
需要快速打包且已经完成对应测试时,可在 `campus-server` 目录执行:
```powershell
mvn clean package -DskipTests
```
该命令跳过测试,不能作为测试通过的依据。构建产物为 `campus-server/target/campus-server-1.0.0.jar`。
独立部署/对外分发使用 `mvn -Pportable clean verify`,产物为 `target/portable/campus-server-1.0.0.jar`。该 profile 排除真实应用配置;普通开发产物可能包含本机配置,不应直接分发。独立启动器 [Start-Local.ps1](campus-server/Start-Local.ps1) 会采用该 profile 并显式加载脱敏模板。
### IDEA 启动
1. 打开 `campus-server/pom.xml`,等待 Maven 依赖加载完成。
2. 选择 JDK 17 或 21,运行配置的工作目录设为 `campus-server`。
3. 运行 `com.campus.CampusApplication`,等待日志出现 `Started CampusApplication`。
### 使用最新 JAR 启动
完成构建后,在 `campus-server` 目录执行:
```powershell
java -jar .\target\campus-server-1.0.0.jar
```
需要使用根目录外部配置时,另开终端并从仓库根目录执行以下命令,选择一种方式启动即可:
```powershell
java -jar .\campus-server\target\campus-server-1.0.0.jar
```
默认监听 `8009`。遇到端口占用先确认是否已有后端实例;需要另开实例时可在启动命令末尾添加 `--server.port=8010`,并相应调整联调地址。当前工作区采用上述源码或 JAR 启动方式。
### 启动验证
在另一个 PowerShell 窗口执行:
```powershell
Invoke-RestMethod 'http://127.0.0.1:8009/actuator/health'
```
预期返回 `{"status":"UP"}`。接口文档入口为 [Swagger UI](http://127.0.0.1:8009/swagger-ui.html)。进入联调前应处理导致启动失败或健康状态异常的问题;第三方业务能力仍需按对应功能单独验收。
## 测试与接口校验
以下命令均在 `campus-server` 目录执行:
```powershell
# 快速回归
mvn test
# 发布前完整验证
mvn clean verify
# 只运行指定测试类
mvn -Dtest=GroupAnnouncementServiceTest test
```
测试报告位于 `campus-server/target/surefire-reports/`。验证记录需注明执行时间、代码版本、使用环境及结果。
启动后检查 [Swagger UI](http://127.0.0.1:8009/swagger-ui.html) 与 [运行时 OpenAPI](http://127.0.0.1:8009/v3/api-docs),并按功能对接文档核对路径、参数、鉴权、响应字段、状态码和错误文案。Markdown 接口清单是文档快照,发现与运行时不一致时需核对源码并同步更新。
## 接口文档与前端联调
随仓库提供的部署说明及运行时接口入口:
| 文档 | 内容 |
| --- | --- |
| [本地部署说明](方案分析与实现(md)文件/部署说明-本地部署.md) | 配置、基础设施、数据库初始化、升级与验收 |
| [Swagger UI](http://127.0.0.1:8009/swagger-ui.html) | 后端启动后查看接口和参数 |
| [运行时 OpenAPI](http://127.0.0.1:8009/v3/api-docs) | 后端启动后获取接口契约 |
其他专题接口清单与前端对接资料由维护者单独提供;需要联调某个功能时,先取得对应版本的契约说明。
本地联调的 API Base URL 为:
```text
http://127.0.0.1:8009/api
```
接口清单写出的完整路径已包含 `/api`;拼接时避免重复添加。手机联调使用手机可访问的电脑地址和端口。使用内网穿透时,将前端与 Apifox 的 Base URL 统一替换为实际隧道地址并保留 `/api` 前缀。
需要认证的接口使用请求头:
```http
access_token: <登录返回的令牌>
```
同名 Query 参数作为兜底方式,日常联调优先使用 Header。测试 Token、验证码和临时公网地址不要写入提交文件或公开文档。
接口统一返回 `{code,message,data}`;成功但没有内容时使用 `code=200, data=null`。分页参数从第 1 页开始,默认每页 20 条,上限 100 条;时间格式为 `yyyy-MM-dd HH:mm:ss`(GMT+8)。具体权限、参数、错误文案和状态码以对应功能文档为准。
## 数据库迁移与上线
当前配置为 `spring.flyway.enabled=false`,`db/migration/*.sql` **不会随启动自动执行**。上线某项功能前,阅读对应前端对接文档的迁移要求,核对目标数据库已执行的脚本,再人工执行所需迁移。
执行数据清理或包含 DDL 的脚本前先备份。MySQL DDL 可能隐式提交,不能假设整份脚本可以回滚。当前 Hibernate 使用 `ddl-auto: update`,它不能替代迁移脚本中的数据清理、外键及约束校验。
全新独立库与已有数据库升级的步骤不同,不能把历史迁移全部重跑。具体建表、唯一约束和外键核验流程见 [本地部署说明](方案分析与实现(md)文件/部署说明-本地部署.md) §6。
## 开发约定
仓库维护者另有内部开发规范。每个新功能按以下顺序交付:
1. 核对需求、现有实现、实体、权限及接口语义,确认影响范围。
2. 明确设计与兼容性要求,必要时在现有方案目录记录实现方案。
3. 实现 DTO、Controller、Service、Repository 和必要迁移,遵循统一响应、异常、事务及防重复提交规则。
4. 补齐单元测试,覆盖精确中文文案、权限、边界及失败时不落库;按风险补充集成验证。
5. 执行 `mvn test`,发布前执行 `mvn clean verify`,并完成相应运行时接口和依赖验证。
6. 在 `方案分析与实现(md)文件/` 交付 `前端对接-<功能名>.md`,说明兼容性、前端改造点、上线顺序及人工迁移要求。
7. 记录本次改动、验证结果与未完成事项;内部工作台账由维护者单独保管。
根目录的 Markdown 入口为 `AGENTS.md` 和 `README.md`;实现方案、分析及前端对接文档统一保留在 `方案分析与实现(md)文件/`。参考其他项目时按本仓库实际结构填写文档,不据此调整现有文件位置。
提交前复核差异,使用精确文件清单加入版本控制。迁移 SQL 经复核后单独纳入,共享文件发生冲突时先核对双方改动及基线,再合并验证。
## 数据与安全边界
- 不提交含真实凭据的应用配置、Token、密钥、数据库快照、JAR、压缩包、日志和 `target/` 等产物。
- 迁移 SQL 是受控例外:经复核后按文件精确加入版本控制。
- 日志、错误响应和调试输出不记录密码、JWT、验证码及不必要的个人信息。
- 测试与联调使用明确授权的环境和账号,不删除真实业务数据,不为联调放宽权限校验。
## 相关项目边界
本仓库只维护青核后端和后端文档。App / Web 前端源码位于其他仓库,所需前端改造通过对接文档交付;本文不假定前端项目在开发机上的具体路径。
`gitea-upload/` 为对外发布用的独立快照目录,已被主仓库忽略。主工作区的修改不会自动同步到该目录,发布时需单独核对源码、配置排除规则和文档。

9
campus-server/.dockerignore

@ -0,0 +1,9 @@
**
!Dockerfile
!target/
!target/portable/
!target/portable/campus-server-1.0.0.jar
!src/
!src/main/
!src/main/resources/
!src/main/resources/application.example.yml

51
campus-server/.env.example

@ -0,0 +1,51 @@
# 复制成同目录 .env 后修改。仅包含示例值,不包含任何现有环境凭据。
# Compose 会读取 --env-file;宿主机 Java 不会自动读取 .env,请用 Start-Local.ps1。
# 值为单行原文,不加引号,不写行尾注释,不引用其他变量。
COMPOSE_PROJECT_NAME=qinghe-local
DB_HOST=127.0.0.1
DB_PORT=13306
DB_NAME=qinghe_local
DB_USERNAME=qinghe
DB_PASSWORD=replace-me-db-password
MYSQL_ROOT_PASSWORD=replace-me-root-password
REDIS_HOST=127.0.0.1
REDIS_PORT=16379
REDIS_PASSWORD=replace-me-redis-password
RABBITMQ_HOST=127.0.0.1
RABBITMQ_PORT=15672
RABBITMQ_MANAGEMENT_PORT=15673
RABBITMQ_USERNAME=qinghe
RABBITMQ_PASSWORD=replace-me-rabbitmq-password
JWT_SECRET=replace-me-with-a-random-secret-of-at-least-32-bytes
SERVER_PORT=8009
# 首次独立空库用 update 建表;旧库升级先按文档审核迁移,验收后可用 validate。
DB_DDL_AUTO=update
# 以下均为可选云服务配置;留空仅供受限的本地基础验证,不能使用对应云功能。
OSS_ENDPOINT=http://127.0.0.1:19000
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET_NAME=qinghe-local-placeholder
OSS_DOMAIN=http://127.0.0.1:19000
SMS_ACCESS_KEY_ID=
SMS_ACCESS_KEY_SECRET=
SMS_SIGN_NAME=
SMS_TEMPLATE_CODE=
SMS_REGION_ID=cn-hangzhou
PUSH_ENABLED=false
PUSH_ACCESS_KEY_ID=
PUSH_ACCESS_KEY_SECRET=
PUSH_ANDROID_APP_KEY=
PUSH_IOS_APP_KEY=
OCR_ACCESS_KEY_ID=
OCR_ACCESS_KEY_SECRET=
VOD_ACCESS_KEY_ID=
VOD_ACCESS_KEY_SECRET=
VOD_TRANSCODE_TEMPLATE_GROUP_ID=
VOD_CALLBACK_URL=
EASEMOB_APP_KEY=
EASEMOB_CLIENT_ID=
EASEMOB_CLIENT_SECRET=
EASEMOB_ORG_NAME=
EASEMOB_APP_NAME=
EASEMOB_BASE_URL=https://a1.easemob.com
DISCUSSION_ALLOWED_MEDIA_HOSTS=localhost,127.0.0.1

8
campus-server/.gitignore

@ -0,0 +1,8 @@
target/
*.log
.idea/
*.iml
.vscode/
# This private delivery contains actual credentials. Never add these files to Git.
src/main/resources/application.yml
src/main/resources/application-dev.yml

17
campus-server/Dockerfile

@ -0,0 +1,17 @@
# 在 campus-server 目录先执行 mvn -Pportable clean verify。
# 从软件源安装 FFmpeg,构建不再依赖开发机的离线压缩包。
FROM eclipse-temurin:17-jre-jammy
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg fontconfig fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system campus \
&& useradd --system --gid campus --home-dir /app campus
WORKDIR /app
COPY target/portable/campus-server-1.0.0.jar /app/app.jar
COPY src/main/resources/application.example.yml /app/config/application.example.yml
USER campus
EXPOSE 8009
ENTRYPOINT ["java", "-Duser.timezone=GMT+08", "-jar", "/app/app.jar", "--spring.config.location=file:/app/config/application.example.yml"]

76
campus-server/Start-Local.ps1

@ -0,0 +1,76 @@
[CmdletBinding()]
param(
[string]$EnvFile = '.env',
[switch]$SkipBuild,
[switch]$CheckOnly
)
$ErrorActionPreference = 'Stop'
# 仅载入此脚本允许的配置名,不把 .env 当成可执行脚本。
$examplePath = Join-Path $PSScriptRoot '.env.example'
$allowedNames = @{}
foreach ($line in Get-Content -LiteralPath $examplePath -Encoding UTF8) {
if ($line -match '^([A-Z][A-Z0-9_]*)=') { $allowedNames[$Matches[1]] = $true }
}
$envPath = if ([IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $PSScriptRoot $EnvFile }
if (-not (Test-Path -LiteralPath $envPath -PathType Leaf)) {
throw '缺少本地 .env;请从 .env.example 复制到 .env 并填写,不要覆盖现有文件。'
}
$settings = @{}
foreach ($line in Get-Content -LiteralPath $envPath -Encoding UTF8) {
if ([string]::IsNullOrWhiteSpace($line) -or $line.TrimStart().StartsWith('#')) { continue }
if ($line -notmatch '^([A-Z][A-Z0-9_]*)=(.*)$') { throw '环境文件格式错误:请使用 KEY=value,不使用 export 或引号。' }
$settingName = $Matches[1]
$settingValue = $Matches[2]
if (-not $allowedNames.ContainsKey($settingName)) { throw "环境文件包含未知配置名:$settingName" }
if ($settings.ContainsKey($settingName)) { throw "环境文件包含重复配置名:$settingName" }
if ($settingValue -match '^["'']' -or $settingValue -match '\s#' -or $settingValue.Contains('$')) {
throw "配置 $settingName 必须使用未加引号的单行原文,不能包含变量引用或行尾注释。"
}
$settings[$settingName] = $settingValue
}
foreach ($requiredName in @('DB_PASSWORD','MYSQL_ROOT_PASSWORD','REDIS_PASSWORD','RABBITMQ_PASSWORD','JWT_SECRET')) {
if (-not $settings.ContainsKey($requiredName) -or [string]::IsNullOrWhiteSpace($settings[$requiredName]) -or $settings[$requiredName].StartsWith('replace-me-')) {
throw "请为 $requiredName 设置自己的非示例值。"
}
}
if ([Text.Encoding]::UTF8.GetByteCount($settings['JWT_SECRET']) -lt 32) { throw 'JWT_SECRET 至少需要 32 字节。' }
if ($CheckOnly) {
Write-Host '本地配置格式与必填项检查通过(未连接任何服务、未构建或启动)。'
return
}
$savedEnvironment = @{}
foreach ($settingName in $allowedNames.Keys) {
$savedEnvironment[$settingName] = [Environment]::GetEnvironmentVariable($settingName, 'Process')
}
Push-Location $PSScriptRoot
try {
# 清除继承的同名云凭据,只接受该 .env 中填写的值;空项回落到模板的占位值。
foreach ($settingName in $allowedNames.Keys) {
$settingValue = if ($settings.ContainsKey($settingName) -and $settings[$settingName] -ne '') { $settings[$settingName] } else { $null }
if ($null -eq $settingValue) {
Remove-Item -LiteralPath "Env:$settingName" -ErrorAction SilentlyContinue
} else {
[Environment]::SetEnvironmentVariable($settingName, $settingValue, 'Process')
}
}
if (-not $SkipBuild) {
& mvn -Pportable clean verify
if ($LASTEXITCODE -ne 0) { throw '构建或测试失败,未启动后端。' }
}
$jarPath = Join-Path $PSScriptRoot 'target/portable/campus-server-1.0.0.jar'
if (-not (Test-Path -LiteralPath $jarPath -PathType Leaf)) { throw '缺少 portable JAR,请先执行 mvn -Pportable clean verify。' }
$configPath = (Join-Path $PSScriptRoot 'src/main/resources/application.example.yml').Replace('\', '/')
& java -Duser.timezone=GMT+08 -jar $jarPath "--spring.config.location=file:$configPath"
if ($LASTEXITCODE -ne 0) { throw '后端退出异常,请检查启动日志。' }
} finally {
foreach ($settingName in $allowedNames.Keys) {
if ($null -eq $savedEnvironment[$settingName]) {
Remove-Item -LiteralPath "Env:$settingName" -ErrorAction SilentlyContinue
} else {
[Environment]::SetEnvironmentVariable($settingName, $savedEnvironment[$settingName], 'Process')
}
}
Pop-Location
}

122
campus-server/Test-LocalDeployment.ps1

@ -0,0 +1,122 @@
[CmdletBinding()]
param()
# 仅做离线检查,不启动 Java、Maven 或 Docker,不连接数据库和云服务。
$ErrorActionPreference = 'Stop'
$launcher = Join-Path $PSScriptRoot 'Start-Local.ps1'
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile($launcher, [ref]$tokens, [ref]$parseErrors) | Out-Null
if ($parseErrors.Count) { throw 'Start-Local.ps1 语法检查失败。' }
$fixtureDirectory = Join-Path ([IO.Path]::GetTempPath()) ('qinghe-deployment-check-' + [Guid]::NewGuid().ToString('N'))
[void](New-Item -ItemType Directory -Path $fixtureDirectory)
$fixtureFile = Join-Path $fixtureDirectory '.env'
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
$baseFixture = @'
DB_PASSWORD=test-db-value
MYSQL_ROOT_PASSWORD=test-root-value
REDIS_PASSWORD=test-redis-value
RABBITMQ_PASSWORD=test-rabbit-value
JWT_SECRET=test-only-secret-with-at-least-32-bytes-000
'@
$passedCases = 0
function Assert-ConfigurationCase {
param([string]$Name, [string]$Content, [bool]$ShouldPass, [string]$ExpectedMessage)
[IO.File]::WriteAllText($fixtureFile, $Content, $utf8Bom)
$didPass = $true
$failureMessage = ''
try { & $launcher -EnvFile $fixtureFile -CheckOnly } catch {
$didPass = $false
$failureMessage = $_.Exception.Message
}
if ($didPass -ne $ShouldPass) { throw "用例失败:$Name,结果不符合预期。" }
if (-not $ShouldPass -and -not $failureMessage.Contains($ExpectedMessage)) {
throw "用例失败:$Name,未得到预期的配置错误提示。"
}
Write-Host "PASS: $Name"
}
try {
Assert-ConfigurationCase '合法配置' $baseFixture $true ''
$passedCases++
Assert-ConfigurationCase '拒绝短 JWT secret' ($baseFixture.Replace('test-only-secret-with-at-least-32-bytes-000', 'short')) $false '至少需要 32 字节'
$passedCases++
Assert-ConfigurationCase '拒绝未修改的示例密码' ($baseFixture.Replace('test-db-value', 'replace-me-db-password')) $false 'DB_PASSWORD'
$passedCases++
Assert-ConfigurationCase '拒绝缺失密码' ($baseFixture.Replace('REDIS_PASSWORD=test-redis-value', 'REDIS_PASSWORD=')) $false 'REDIS_PASSWORD'
$passedCases++
Assert-ConfigurationCase '拒绝重复变量' ($baseFixture + "`nDB_PASSWORD=second-value") $false '重复配置名'
$passedCases++
Assert-ConfigurationCase '拒绝未知变量' ($baseFixture + "`nPATH=unknown") $false '未知配置名'
$passedCases++
Assert-ConfigurationCase '拒绝脚本表达式' ($baseFixture + "`nOSS_DOMAIN=" + '$(Get-Process)') $false '不能包含变量引用'
$passedCases++
Assert-ConfigurationCase '拒绝带引号的值' ($baseFixture + "`nOSS_DOMAIN=" + '"localhost"') $false '未加引号'
$passedCases++
$examplePath = Join-Path $PSScriptRoot '.env.example'
$exampleContent = [IO.File]::ReadAllText($examplePath)
Assert-ConfigurationCase '原始示例必须先填写' $exampleContent $false 'DB_PASSWORD'
$passedCases++
# 用局部 Java 替身检查启动参数及环境恢复,不启动 JVM 或访问任何服务。
if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'target/portable/campus-server-1.0.0.jar')) {
$javaObservation = @{ Arguments = @(); DatabasePassword = $null }
function java {
$javaObservation.Arguments = @($args)
$javaObservation.DatabasePassword = [Environment]::GetEnvironmentVariable('DB_PASSWORD', 'Process')
$global:LASTEXITCODE = 0
}
$previousPassword = [Environment]::GetEnvironmentVariable('DB_PASSWORD', 'Process')
try {
[Environment]::SetEnvironmentVariable('DB_PASSWORD', 'outer-test-sentinel', 'Process')
[IO.File]::WriteAllText($fixtureFile, $baseFixture, $utf8Bom)
& $launcher -EnvFile $fixtureFile -SkipBuild
if ($javaObservation.DatabasePassword -ne 'test-db-value') { throw '启动时未正确注入本地变量。' }
$locationArguments = @($javaObservation.Arguments | Where-Object { $_ -like '--spring.config.location=file:*application.example.yml' })
if ($locationArguments.Count -ne 1) { throw '未显式指定独立模板。' }
if ([Environment]::GetEnvironmentVariable('DB_PASSWORD', 'Process') -ne 'outer-test-sentinel') {
throw '退出后没有恢复原进程环境变量。'
}
Write-Host 'PASS: 独立配置参数、环境注入及退出恢复(Java 替身,无外部连接)'
$passedCases++
} finally {
if ($null -eq $previousPassword) {
Remove-Item -LiteralPath Env:DB_PASSWORD -ErrorAction SilentlyContinue
} else {
[Environment]::SetEnvironmentVariable('DB_PASSWORD', $previousPassword, 'Process')
}
Remove-Item -LiteralPath Function:\java
}
}
$jarPath = Join-Path $PSScriptRoot 'target/portable/campus-server-1.0.0.jar'
if (Test-Path -LiteralPath $jarPath -PathType Leaf) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($jarPath)
try {
$privateEntries = @($archive.Entries | Where-Object {
$_.FullName -match '^BOOT-INF/classes/(.*/)?(application[^/]*\.(yml|yaml|properties)|\.env[^/]*)$'
})
if ($privateEntries.Count) { throw '部署 JAR 混入应用配置或 .env 文件,禁止分发。' }
if (-not $archive.GetEntry('BOOT-INF/classes/com/campus/CampusApplication.class')) {
throw '部署 JAR 缺少应用主类。'
}
} finally { $archive.Dispose() }
Write-Host 'PASS: 部署 JAR 不含约定的私有应用配置,且存在主类'
$passedCases++
} else {
Write-Host 'SKIP: 未找到 portable JAR,打包内容尚未检查。'
}
Write-Host "离线检查通过:$passedCases 项。未连接外部服务。"
} finally {
# 只清理本脚本刚创建、路径已核对的临时夹;不触及用户环境文件。
$resolvedFixture = [IO.Path]::GetFullPath($fixtureDirectory)
$resolvedTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
if (-not $resolvedFixture.StartsWith($resolvedTemp, [StringComparison]::OrdinalIgnoreCase) -or
-not ([IO.Path]::GetFileName($resolvedFixture)).StartsWith('qinghe-deployment-check-')) {
throw '临时检查目录超出预期路径,未清理。'
}
Remove-Item -LiteralPath $resolvedFixture -Recurse -Force
}

91
campus-server/compose.local.yml

@ -0,0 +1,91 @@
# 在 campus-server 目录执行;仅默认启动基础设施,backend 需显式启用 app profile。
services:
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请先设置 MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME:-qinghe_local}
MYSQL_USER: ${DB_USERNAME:-qinghe}
MYSQL_PASSWORD: ${DB_PASSWORD:?请先设置 DB_PASSWORD}
TZ: Asia/Shanghai
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
ports:
- "127.0.0.1:${DB_PORT:-13306}:3306"
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "MYSQL_PWD=\"$$MYSQL_PASSWORD\" mysql --protocol=TCP -h 127.0.0.1 -u\"$$MYSQL_USER\" \"$$MYSQL_DATABASE\" -e 'SELECT 1' >/dev/null"]
interval: 10s
timeout: 5s
retries: 20
start_period: 40s
redis:
image: redis:7.4-alpine
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?请先设置 REDIS_PASSWORD}
command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\""]
ports:
- "127.0.0.1:${REDIS_PORT:-16379}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 10
rabbitmq:
image: rabbitmq:4.1-management
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-qinghe}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?请先设置 RABBITMQ_PASSWORD}
ports:
- "127.0.0.1:${RABBITMQ_PORT:-15672}:5672"
- "127.0.0.1:${RABBITMQ_MANAGEMENT_PORT:-15673}:15672"
volumes:
- rabbitmq-data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 10s
retries: 20
start_period: 30s
backend:
profiles: ["app"]
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
environment:
DB_HOST: mysql
DB_PORT: "3306"
REDIS_HOST: redis
REDIS_PORT: "6379"
RABBITMQ_HOST: rabbitmq
RABBITMQ_PORT: "5672"
SERVER_PORT: "8009"
JWT_SECRET: ${JWT_SECRET:?请先设置 JWT_SECRET}
# env_file 中空值不会触发 Spring 的默认值,这里统一回落到明确的本地占位值。
OSS_ENDPOINT: ${OSS_ENDPOINT:-http://127.0.0.1:19000}
OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-your-local-placeholder}
OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-your-local-placeholder}
OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-qinghe-local-placeholder}
OSS_DOMAIN: ${OSS_DOMAIN:-http://127.0.0.1:19000}
SMS_ACCESS_KEY_ID: ${SMS_ACCESS_KEY_ID:-your-local-placeholder}
SMS_ACCESS_KEY_SECRET: ${SMS_ACCESS_KEY_SECRET:-your-local-placeholder}
SMS_SIGN_NAME: ${SMS_SIGN_NAME:-local-placeholder}
SMS_TEMPLATE_CODE: ${SMS_TEMPLATE_CODE:-local-placeholder}
SMS_REGION_ID: ${SMS_REGION_ID:-cn-hangzhou}
ports:
- "127.0.0.1:${SERVER_PORT:-8009}:8009"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
volumes:
mysql-data:
redis-data:
rabbitmq-data:

264
campus-server/pom.xml

@ -0,0 +1,264 @@
<?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>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.campus</groupId>
<artifactId>campus-server</artifactId>
<version>1.0.0</version>
<name>campus-server</name>
<description>Campus Comprehensive Service Platform</description>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</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-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
<version>8.5.13</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- 阿里云短信 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.0</version>
</dependency>
<!-- 环信IM SDK -->
<dependency>
<groupId>com.easemob.im</groupId>
<artifactId>im-sdk-core</artifactId>
<version>0.8.56</version>
</dependency>
<!-- Reactor依赖(环信SDK需要) -->
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.4.29</version>
</dependency>
<!-- 阿里云OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<!-- 阿里云STS SDK(临时凭证) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-sts</artifactId>
<version>3.1.2</version>
</dependency>
<!-- 阿里云VOD SDK(视频点播) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-vod</artifactId>
<version>2.16.5</version>
</dependency>
<!-- 阿里云推送 SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-push</artifactId>
<version>3.13.10</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.5.25</version>
</dependency>
<!-- 阿里云OCR SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>ocr_api20210707</artifactId>
<version>3.1.2</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>darabonba-stream</artifactId>
<version>0.0.1</version>
</dependency>
<!-- OCR SDK 依赖的 Tea 基础库(版本与 ocr_api20210707:3.1.2 POM 保持一致) -->
<!-- 注意:dysmsapi20170525 也依赖 tea-util/tea-rpc,可能拉入低版本导致 NoSuchFieldError -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea-openapi</artifactId>
<version>0.3.6</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea-util</artifactId>
<version>0.2.23</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>openapiutil</artifactId>
<version>0.2.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<!-- 二维码解码(ZXing):从上传图片中识别学信网报告二维码文本 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- HTML解析(Jsoup):从学信网在线查验返回页提取报告字段 -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<!-- PDF解析与首页渲染:2.x 仍支持Java 8,与当前编译级别兼容 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.33</version>
</dependency>
<!-- Test -->
<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>
</plugins>
</build>
<!-- 本地部署/对外分发专用产物,使用独立目录,避免混入开发机真实配置。 -->
<profiles>
<profile>
<id>portable</id>
<build>
<directory>${project.basedir}/target/portable</directory>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>**/application*.yml</exclude>
<exclude>**/application*.yaml</exclude>
<exclude>**/application*.properties</exclude>
<exclude>**/.env*</exclude>
</excludes>
</resource>
</resources>
</build>
</profile>
</profiles>
</project>

16
campus-server/src/main/java/com/campus/CampusApplication.java

@ -0,0 +1,16 @@
package com.campus;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
public class CampusApplication {
public static void main(String[] args) {
SpringApplication.run(CampusApplication.class, args);
}
}

43
campus-server/src/main/java/com/campus/common/BaseEntity.java

@ -0,0 +1,43 @@
package com.campus.common;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.hibernate.annotations.Where;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
import java.util.UUID;
@Data
@MappedSuperclass
@Where(clause = "deleted = false")
public abstract class BaseEntity implements Serializable {
@Id
@Column(name = "id", nullable = false, updatable = false, length = 36)
private String id;
/** 逻辑删除标记:true-已删除,false-未删除 */
@Column(name = "deleted", nullable = false)
private Boolean deleted = false;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
@PrePersist
public void prePersist() {
if (this.id == null) {
this.id = UUID.randomUUID().toString();
}
}
}

16
campus-server/src/main/java/com/campus/common/BusinessException.java

@ -0,0 +1,16 @@
package com.campus.common;
/**
* 业务异常,用于可预期的业务校验失败(如手机号已注册、密码错误等)。
* 通过 GlobalExceptionHandler 返回 HTTP 400,而非 500。
*/
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
}
}

9
campus-server/src/main/java/com/campus/common/ForbiddenException.java

@ -0,0 +1,9 @@
package com.campus.common;
/** 权限不足时返回 HTTP 403,避免把鉴权失败误报为业务参数错误或服务器错误。 */
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) {
super(message);
}
}

116
campus-server/src/main/java/com/campus/common/GlobalExceptionHandler.java

@ -0,0 +1,116 @@
package com.campus.common;
import lombok.extern.slf4j.Slf4j;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleMessageNotReadableException(HttpMessageNotReadableException ex) {
// 不输出请求原文,避免将个人资料或其他敏感参数写入日志。
Throwable cause = ex;
while (cause != null) {
if (cause instanceof InvalidFormatException) {
InvalidFormatException invalid = (InvalidFormatException) cause;
if (java.util.Date.class.equals(invalid.getTargetType())
&& !invalid.getPath().isEmpty()
&& "birthday".equals(invalid.getPath().get(invalid.getPath().size() - 1).getFieldName())) {
return Result.error(400, "生日格式不正确,请使用 yyyy-MM-dd(例如 2000-06-01)");
}
}
cause = cause.getCause();
}
return Result.error(400, "请求体格式不正确,请检查参数类型和 JSON 格式");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("Validation failed: {}", message);
return Result.error(400, message);
}
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleBindException(BindException ex) {
String message = ex.getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("Binding failed: {}", message);
return Result.error(400, message);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex) {
log.warn("File size exceeded: {}", ex.getMessage());
return Result.error(400, "文件大小超过限制,请压缩后重新上传(最大100MB)");
}
@ExceptionHandler(MultipartException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleMultipartException(MultipartException ex) {
log.error("Multipart parsing failed: {}", ex.getMessage());
return Result.error(400, "文件上传失败,请检查网络连接或文件格式后重试");
}
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleBusinessException(BusinessException ex) {
log.warn("Business exception: {}", ex.getMessage());
return Result.error(400, ex.getMessage());
}
@ExceptionHandler(ForbiddenException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<Void> handleForbiddenException(ForbiddenException ex) {
log.warn("Forbidden operation: {}", ex.getMessage());
return Result.error(403, ex.getMessage());
}
@ExceptionHandler(ServiceUnavailableException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public Result<Void> handleServiceUnavailableException(ServiceUnavailableException ex) {
log.error("Service unavailable: {}", ex.getMessage());
return Result.error(503, ex.getMessage());
}
@ExceptionHandler(TooManyRequestsException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public Result<Void> handleTooManyRequestsException(TooManyRequestsException ex) {
log.warn("Too many requests: {}", ex.getMessage());
return Result.error(429, ex.getMessage());
}
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleRuntimeException(RuntimeException ex) {
log.error("Runtime exception: ", ex);
return Result.error(500, "服务器内部错误");
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception ex) {
log.error("Unexpected exception: ", ex);
return Result.error("服务器内部错误");
}
}

37
campus-server/src/main/java/com/campus/common/PageResult.java

@ -0,0 +1,37 @@
package com.campus.common;
import lombok.Data;
import org.springframework.data.domain.Page;
import java.util.Collections;
import java.util.List;
@Data
public class PageResult<T> {
private List<T> items;
private Long total;
private Integer page;
private Integer pageSize;
public static <T> PageResult<T> from(Page<T> page) {
PageResult<T> result = new PageResult<>();
result.setItems(page.getContent());
result.setTotal(page.getTotalElements());
result.setPage(page.getNumber() + 1);
result.setPageSize(page.getSize());
return result;
}
public static <T> PageResult<T> of(List<T> items, long total, int page, int pageSize) {
PageResult<T> result = new PageResult<>();
result.setItems(items);
result.setTotal(total);
result.setPage(page);
result.setPageSize(pageSize);
return result;
}
public static <T> PageResult<T> empty(int page, int pageSize) {
return of(Collections.emptyList(), 0, page, pageSize);
}
}

44
campus-server/src/main/java/com/campus/common/Result.java

@ -0,0 +1,44 @@
package com.campus.common;
import lombok.Data;
@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}
public static <T> Result<T> success() {
return success(null);
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
public static <T> Result<T> tokenExpired() {
return error(401, "token已失效,请重新登录");
}
public static <T> Result<T> unauthorized(String message) {
return error(401, message);
}
}

13
campus-server/src/main/java/com/campus/common/ServiceUnavailableException.java

@ -0,0 +1,13 @@
package com.campus.common;
/** 外部基础服务暂时不可用,例如 Redis 会话存储连接失败。 */
public class ServiceUnavailableException extends RuntimeException {
public ServiceUnavailableException(String message) {
super(message);
}
public ServiceUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}

9
campus-server/src/main/java/com/campus/common/TooManyRequestsException.java

@ -0,0 +1,9 @@
package com.campus.common;
/** 同一用户短时间内重复操作同一目标;由全局异常处理映射为 HTTP 429。 */
public class TooManyRequestsException extends RuntimeException {
public TooManyRequestsException(String message) {
super(message);
}
}

20
campus-server/src/main/java/com/campus/config/AchievementInitializer.java

@ -0,0 +1,20 @@
package com.campus.config;
import com.campus.entity.AchievementDefinition;
import com.campus.repository.AchievementDefinitionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@RequiredArgsConstructor
public class AchievementInitializer implements CommandLineRunner {
private final AchievementDefinitionRepository repository;
@Override @Transactional
public void run(String... args) {
String[][] rows={{"ACTIVITY_EXPERT","活动达人","参与并发起10场校园活动","10"},{"MOMENT_EXPERT","动态达人","发布20条审核通过的普通动态","20"},{"CHECKIN_EXPERT","签到达人","连续签到30天","30"},{"FRIEND_EXPERT","交友达人","添加100位好友","100"},{"GIFT_EXPERT","送礼达人","送出50份礼物","50"},{"CAMPUS_EXPERT","校园达人","完成学信网认证,填写昵称、头像、学校、专业和年级","1"},{"POPULAR_EXPERT","人气达人","动态及青春号累计获得1000个有效赞(不含自赞和话题讨论)","1000"},{"SHARE_EXPERT","分享达人","成功发送50条站内分享卡片","50"}};
for(int i=0;i<rows.length;i++) {
String[] row=rows[i]; if(repository.findIncludingDeleted(row[0]).isPresent()) continue;
AchievementDefinition d=new AchievementDefinition();d.setCode(row[0]);d.setName(row[1]);d.setDescription(row[2]);d.setTargetValue(Long.valueOf(row[3]));d.setSort(i);repository.save(d);
}
}
}

30
campus-server/src/main/java/com/campus/config/ActivityLocationProperties.java

@ -0,0 +1,30 @@
package com.campus.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@org.springframework.validation.annotation.Validated
@ConfigurationProperties(prefix = "app.activity-location")
public class ActivityLocationProperties {
private boolean enabled = false;
private String key = "";
@javax.validation.constraints.Min(1)
private int connectTimeoutMs = 2000;
@javax.validation.constraints.Min(1)
private int readTimeoutMs = 3000;
@javax.validation.constraints.Min(1)
private int resolveTtlSeconds = 900;
@javax.validation.constraints.Min(1)
private int tokenTtlSeconds = 1800;
@javax.validation.constraints.Min(1)
private int requestsPerMinute = 20;
@javax.validation.constraints.Min(1)
private int globalRequestsPerMinute = 300;
@javax.validation.constraints.Min(1)
private int maxConcurrent = 8;
/** 未核实用户位置上报坐标系前,禁止雷达混算新旧坐标。 */
private boolean radarCoordinatesVerified = false;
}

67
campus-server/src/main/java/com/campus/config/AdminBootstrapInitializer.java

@ -0,0 +1,67 @@
package com.campus.config;
import com.campus.entity.User;
import com.campus.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
/**
* 通过环境变量 ADMIN_BOOTSTRAP_PHONES 显式指定初始管理员手机号,避免开放无鉴权的提权接口。
* 多个手机号使用英文逗号分隔;空值时不会自动提升任何普通用户。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AdminBootstrapInitializer implements ApplicationRunner {
private final UserRepository userRepository;
private final JdbcTemplate jdbcTemplate;
@Value("${app.admin.bootstrap-phones:}")
private String bootstrapPhones;
@Override
@Transactional
public void run(ApplicationArguments args) {
// Hibernate 为历史表新增字段后,显式回填旧账号,避免空字符串状态影响统计和权限判断。
jdbcTemplate.update("UPDATE `user` SET account_status = 'ACTIVE' "
+ "WHERE account_status IS NULL OR TRIM(account_status) = ''");
jdbcTemplate.update("UPDATE `user` SET is_admin = 0 WHERE is_admin IS NULL");
jdbcTemplate.update("UPDATE `user` SET is_reviewer = 0 WHERE is_reviewer IS NULL");
if (bootstrapPhones == null || bootstrapPhones.trim().isEmpty()) {
if (userRepository.countByIsAdminTrue() == 0) {
log.warn("系统当前没有管理员;请通过 ADMIN_BOOTSTRAP_PHONES 指定已有账号后重启完成初始化");
}
return;
}
for (String rawPhone : bootstrapPhones.split(",")) {
String phone = rawPhone.trim();
if (phone.isEmpty()) continue;
List<User> accounts = userRepository.findAllByPhoneIncludingDeleted(phone);
User active = accounts.stream()
.filter(user -> !Boolean.TRUE.equals(user.getDeleted()))
.findFirst().orElse(null);
if (active == null) {
log.warn("管理员初始化账号不存在,已跳过: phone={}", maskPhone(phone));
continue;
}
active.setIsAdmin(true);
active.setAccountStatus("ACTIVE");
userRepository.save(active);
log.info("已初始化管理员账号: userId={}, phone={}", active.getId(), maskPhone(phone));
}
}
private String maskPhone(String phone) {
if (phone == null || phone.length() < 7) return "***";
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
}

23
campus-server/src/main/java/com/campus/config/AliyunPushConfig.java

@ -0,0 +1,23 @@
package com.campus.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "aliyun.push")
public class AliyunPushConfig {
private Boolean enabled = true;
private String accessKeyId;
private String accessKeySecret;
private String regionId = "cn-hangzhou";
private String androidAppKey;
private String iosAppKey;
}

39
campus-server/src/main/java/com/campus/config/AsyncConfig.java

@ -0,0 +1,39 @@
package com.campus.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 异步线程池配置
*
* 核心策略:
* - 核心线程 4(应对正常流量)
* - 最大线程 8(高峰弹性伸缩)
* - 队列 100(缓冲突增任务)
* - CallerRunsPolicy(队列满时由调用线程执行,防止 OOM)
*/
@Configuration
@EnableAsync
public class AsyncConfig {
/** 通用异步任务线程池(推送通知、审核等) */
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}

43
campus-server/src/main/java/com/campus/config/AuthDataIntegrityInitializer.java

@ -0,0 +1,43 @@
package com.campus.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* 登录账号数据完整性防线。
*
* 历史数据库曾缺少 phone 唯一索引,导致并发注册产生两个有效账号,并让登录查询抛出
* NonUniqueResultException。启动时先拒绝带重复手机号的脏库,再确保数据库存在唯一索引。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthDataIntegrityInitializer implements ApplicationRunner {
private final JdbcTemplate jdbcTemplate;
@Override
public void run(ApplicationArguments args) {
Integer duplicateGroups = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM (SELECT phone FROM `user` GROUP BY phone HAVING COUNT(*) > 1) duplicates",
Integer.class);
if (duplicateGroups != null && duplicateGroups > 0) {
throw new IllegalStateException(
"用户表存在 " + duplicateGroups + " 组重复手机号,已拒绝启动;请先执行账号数据修复");
}
Integer uniquePhoneIndexes = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.statistics "
+ "WHERE table_schema = DATABASE() AND table_name = 'user' "
+ "AND column_name = 'phone' AND non_unique = 0",
Integer.class);
if (uniquePhoneIndexes == null || uniquePhoneIndexes == 0) {
jdbcTemplate.execute("ALTER TABLE `user` ADD CONSTRAINT `uk_user_phone` UNIQUE (`phone`)");
log.info("已创建用户手机号唯一索引 uk_user_phone");
}
}
}

97
campus-server/src/main/java/com/campus/config/CommerceCatalogInitializer.java

@ -0,0 +1,97 @@
package com.campus.config;
import com.campus.entity.LotteryPrize;
import com.campus.entity.MallProduct;
import com.campus.entity.TaskDefinition;
import com.campus.repository.LotteryPrizeRepository;
import com.campus.repository.MallProductRepository;
import com.campus.repository.TaskDefinitionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** 只补齐缺失的默认配置,不覆盖管理员已经调整过的数据。 */
@Configuration
@RequiredArgsConstructor
public class CommerceCatalogInitializer {
private final MallProductRepository productRepository;
private final TaskDefinitionRepository taskRepository;
private final LotteryPrizeRepository prizeRepository;
@Bean
public ApplicationRunner initializeCommerceCatalog() {
return args -> {
ensureProduct("demo-theme", "个性主题体验包", "展示用主题皮肤", 30, "THEME", 30, 1);
ensureProduct("demo-avatar-frame", "头像框体验包", "展示用头像框", 20, "AVATAR_FRAME", 30, 2);
ensureProduct("demo-boost", "匹配加速体验", "展示用匹配加速权益", 50, "MATCH_BOOST", 7, 3);
ensureTask("complete-profile", "完善个人资料", "完善家乡、个人介绍和兴趣标签", 10, "ONCE", 1);
ensureTask("daily-check-in", "每日签到", "完成今日签到", 5, "DAILY", 2);
ensureTask("publish-moment", "发布一条动态", "分享校园生活", 10, "ONCE", 3);
ensureTask("real-name-auth", "实名认证", "完成学信网实名认证", 100, "ONCE", 4);
ensureTask("first-login", "首次登录", "首次登录获得积分", 10, "ONCE", 0);
ensurePrize("NONE", "谢谢参与", 60, -1, 0, null, null, 1);
ensurePrize("BEAN_5", "青豆 5", 25, -1, 5, null, null, 2);
ensurePrize("AVATAR_FRAME", "头像框体验卡", 10, -1, 0, "AVATAR_FRAME", 7, 3);
ensurePrize("MATCH_BOOST", "匹配加速体验卡", 5, -1, 0, "MATCH_BOOST", 3, 4);
};
}
private void ensureProduct(String code, String name, String description, int price,
String entitlementType, Integer durationDays, int sort) {
if (productRepository.findByCode(code).isPresent()) return;
MallProduct product = new MallProduct();
product.setId(code);
product.setCode(code);
product.setName(name);
product.setDescription(description);
product.setPricePoints(price);
product.setStock(-1);
product.setEntitlementType(entitlementType);
product.setDurationDays(durationDays);
product.setSort(sort);
productRepository.save(product);
}
private void ensureTask(String code, String name, String description, int reward,
String cycle, int sort) {
java.util.Optional<TaskDefinition> existing = taskRepository.findByCode(code);
if (existing.isPresent()) {
TaskDefinition task = existing.get();
if ("complete-profile".equals(code)
&& "完成头像、昵称和兴趣资料".equals(task.getDescription())) {
task.setDescription(description);
taskRepository.save(task);
}
return;
}
TaskDefinition task = new TaskDefinition();
task.setId(code);
task.setCode(code);
task.setName(name);
task.setDescription(description);
task.setRewardPoints(reward);
task.setCycle(cycle);
task.setSort(sort);
taskRepository.save(task);
}
private void ensurePrize(String code, String name, int weight, int stock, int points,
String entitlementType, Integer durationDays, int sort) {
if (prizeRepository.findByCode(code).isPresent()) return;
LotteryPrize prize = new LotteryPrize();
prize.setId(code);
prize.setCode(code);
prize.setName(name);
prize.setWeight(weight);
prize.setStock(stock);
prize.setRewardPoints(points);
prize.setEntitlementType(entitlementType);
prize.setDurationDays(durationDays);
prize.setSort(sort);
prizeRepository.save(prize);
}
}

228
campus-server/src/main/java/com/campus/config/DictInitializer.java

@ -0,0 +1,228 @@
package com.campus.config;
import com.campus.entity.SysDict;
import com.campus.entity.SysDictItem;
import com.campus.repository.SysDictItemRepository;
import com.campus.repository.SysDictRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
/**
* 字典预置数据初始化器
* 项目启动时若字典主表为空则写入预置数据
*/
@Slf4j
@Component
@Order(1)
@RequiredArgsConstructor
public class DictInitializer implements CommandLineRunner {
private final SysDictRepository sysDictRepository;
private final SysDictItemRepository sysDictItemRepository;
@Override
@Transactional
public void run(String... args) {
log.info("开始初始化字典数据...");
// 公告类型
initDictIfNotExists("announcement_type", "公告类型", "系统公告、讲座、演出、招聘会等的分类",
Arrays.asList(
item("all", "全部", 1, null, null),
item("lecture", "讲座", 2, null, null),
item("concert", "演出", 3, null, null),
item("jobfair", "招聘会", 4, null, null),
item("meeting", "会议", 5, null, null),
item("activity", "活动", 6, null, null),
item("notice", "通知", 7, null, null),
item("general", "通用", 99, null, null)
));
// 活动类型
initDictIfNotExists("activity_type", "活动类型", "组局活动分类",
Arrays.asList(
item("sports", "运动", 1, null, "sports"),
item("study", "学习", 2, null, "study"),
item("party", "聚会", 3, null, "party"),
item("food", "美食", 4, null, "food"),
item("travel", "出行", 5, null, "travel"),
item("game", "游戏", 6, null, "game"),
item("volunteer", "志愿", 7, null, "volunteer"),
item("other", "其他", 99, null, null)
));
// 动态分类
initDictIfNotExists("moment_category", "动态分类", "校园动态分类",
Arrays.asList(
item("lost_found", "失物招领", 1, null, null),
item("second_hand", "二手市场", 2, null, null),
item("club", "社团活动", 3, null, null),
item("help", "校园求助", 6, null, null),
item("study", "学习交流", 7, null, null),
item("rent", "租房合租", 8, null, null),
item("friends", "交友约伴", 9, null, null),
item("skill", "技能交换", 10, null, null)
));
// 动态发布范围
initDictIfNotExists("moment_scope", "动态发布范围", "动态可见范围",
Arrays.asList(
item("personal", "个人动态", 1, null, null),
item("campus", "校园动态", 2, null, null)
));
// 学校类型
initDictIfNotExists("university_type", "学校类型", "高等院校分类",
Arrays.asList(
item("comprehensive", "综合类", 1, null, null),
item("science", "理工类", 2, null, null),
item("liberal_arts", "文史类", 3, null, null),
item("finance", "财经类", 4, null, null),
item("language", "语言类", 5, null, null),
item("medical", "医药类", 6, null, null),
item("agriculture", "农林类", 7, null, null),
item("normal", "师范类", 8, null, null),
item("art", "艺术类", 9, null, null),
item("nationalities", "民族类", 10, null, null),
item("military", "军事类", 11, null, null),
item("sport", "体育类", 12, null, null),
item("police", "政法类", 13, null, null)
));
// 积分交易类型
initDictIfNotExists("points_type", "积分类型", "积分交易类型",
Arrays.asList(
item("earn", "获得", 1, "#67C23A", null),
item("spend", "消费", 2, "#F56C6C", null),
item("freeze", "冻结", 3, "#E6A23C", null),
item("unfreeze", "解冻", 4, "#909399", null)
));
// 通知类型
initDictIfNotExists("notification_type", "通知类型", "系统通知类型",
Arrays.asList(
item("follow", "关注通知", 1, null, null),
item("friend_request", "好友申请", 2, null, null),
item("friend_accepted", "好友通过", 3, null, null),
item("like", "点赞通知", 4, null, null),
item("comment", "评论通知", 5, null, null),
item("mention", "@提醒", 6, null, null),
item("system", "系统通知", 7, null, null),
item("activity_review", "活动审核", 8, null, null),
item("moment_review", "动态审核", 9, null, null),
item("post_review", "帖子审核", 10, null, null)
));
// 审核状态
initDictIfNotExists("review_status", "审核状态", "内容审核状态",
Arrays.asList(
item("pending", "待审核", 1, "#E6A23C", null),
item("approved", "已通过", 2, "#67C23A", null),
item("rejected", "已拒绝", 3, "#F56C6C", null)
));
// 公告发布范围
initDictIfNotExists("announcement_scope", "公告发布范围", "公告可见范围",
Arrays.asList(
item("all", "全部学校", 1, null, null),
item("specified", "指定学校", 2, null, null)
));
// 视频转码状态
initDictIfNotExists("video_status", "视频转码状态", "视频转码任务状态",
Arrays.asList(
item("pending", "等待转码", 1, "#E6A23C", null),
item("processing", "转码中", 2, "#409EFF", null),
item("completed", "转码完成", 3, "#67C23A", null),
item("failed", "转码失败", 4, "#F56C6C", null)
));
// 青春号视频标签
initDictIfNotExists("youth_video_tag", "青春号视频标签", "青春号短视频分类标签",
Arrays.asList(
item("music_dance", "音乐舞蹈", 1, "#FF6B9D", "music"),
item("humor", "幽默趣事", 2, "#FFB84D", "smile"),
item("games_sports", "游戏体育", 3, "#5B8FF9", "gamepad-2"),
item("challenge_growth", "挑战成长", 4, "#B37FEB", "sparkles"),
item("beauty_fashion", "美妆时尚", 5, "#FF85C0", "lipstick"),
item("food_travel", "美食旅游", 6, "#FF7A45", "coffee"),
item("art_handicraft", "艺术手工", 7, "#F759AB", "gift"),
item("animals", "动物伙伴", 8, "#52C41A", "paw-print"),
item("tech_digital", "科技数码", 9, "#13C2C2", "vr-headset"),
item("knowledge_speech", "知识演讲", 10, "#36CFC9", "book-open"),
item("people_views", "人物观点", 11, "#9254DE", "user"),
item("workplace_business", "职场商业", 12, "#2F54EB", "briefcase")
));
// 好友状态
initDictIfNotExists("friendship_status", "好友状态", "好友关系状态",
Arrays.asList(
item("pending", "待确认", 1, "#E6A23C", null),
item("accepted", "已通过", 2, "#67C23A", null),
item("rejected", "已拒绝", 3, "#F56C6C", null),
item("blocked", "已拉黑", 4, "#909399", null)
));
// 兴趣爱好标签 - 强制更新确保数据最新
initDictIfNotExists("interest_tags", "兴趣爱好", "用户注册时选择的兴趣爱好标签",
Arrays.asList(
item("reading", "阅读", 1, "#6366F1", "📚"),
item("music", "音乐", 2, "#EC4899", "🎵"),
item("movie", "电影", 3, "#8B5CF6", "🎬"),
item("gaming", "游戏", 4, "#10B981", "🎮"),
item("sports", "运动", 5, "#F59E0B", "⚽"),
item("travel", "旅行", 6, "#06B6D4", "✈️"),
item("photography", "摄影", 7, "#84CC16", "📷"),
item("food", "美食", 8, "#EF4444", "🍜"),
item("programming", "编程", 9, "#2563EB", "💻"),
item("art", "艺术", 10, "#D946EF", "🎨"),
item("dance", "舞蹈", 11, "#F97316", "💃"),
item("fitness", "健身", 12, "#0EA5E9", "🏋️"),
item("pets", "宠物", 13, "#14B8A6", "🐕"),
item("coffee", "咖啡", 14, "#78350F", "☕"),
item("singing", "唱歌", 15, "#DC2626", "🎤"),
item("writing", "写作", 16, "#64748B", "✍️")
));
log.info("字典数据初始化完成");
}
private void initDictIfNotExists(String code, String name, String description, List<SysDictItem> items) {
if (sysDictRepository.findByCode(code).isPresent()) {
log.info("字典 [{}] {} 已存在,跳过", code, name);
return;
}
initDict(code, name, description, items);
}
private void initDict(String code, String name, String description, List<SysDictItem> items) {
SysDict dict = new SysDict();
dict.setCode(code);
dict.setName(name);
dict.setDescription(description);
dict.setStatus(true);
final String dictId = sysDictRepository.save(dict).getId();
items.forEach(item -> item.setDictId(dictId));
sysDictItemRepository.saveAll(items);
log.info("初始化字典 [{}] {} 共 {} 项", code, name, items.size());
}
private SysDictItem item(String value, String label, int sort, String color, String icon) {
SysDictItem item = new SysDictItem();
item.setValue(value);
item.setLabel(label);
item.setSortOrder(sort);
item.setStatus(true);
item.setColor(color);
item.setIcon(icon);
return item;
}
}

58
campus-server/src/main/java/com/campus/config/EasemobConfig.java

@ -0,0 +1,58 @@
package com.campus.config;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.easemob.im.server.EMProperties;
import com.easemob.im.server.EMService;
@Data
@Slf4j
@Configuration
@ConfigurationProperties(prefix = "easemob")
public class EasemobConfig {
private String appKey;
private String clientId;
private String clientSecret;
private String orgName;
private String appName;
private String baseUrl = "https://a1.easemob.com";
@Bean
public EMService emService() {
if (!isValidConfig()) {
log.warn("环信配置无效,将跳过EMService初始化。请在application.yml中正确配置环信参数(appKey格式应为:org_name#app_name)");
return null;
}
try {
EMProperties.Builder builder = EMProperties.builder()
.setAppkey(appKey)
.setClientId(clientId)
.setClientSecret(clientSecret);
// 关键:必须显式指定数据中心(baseUri),否则 SDK 默认连接国内1区 a1.easemob.com,
// 而 base-url 配置为国内2区时会导致发消息连错区、发送失败。
if (baseUrl != null && !baseUrl.isEmpty()) {
builder.setBaseUri(baseUrl);
}
EMProperties properties = builder.build();
EMService service = new EMService(properties);
log.info("环信EMService初始化成功, baseUri={}", baseUrl);
return service;
} catch (Exception e) {
log.error("环信EMService初始化失败: {}", e.getMessage());
return null;
}
}
private boolean isValidConfig() {
return appKey != null && !appKey.isEmpty() && appKey.contains("#")
&& clientId != null && !clientId.isEmpty()
&& clientSecret != null && !clientSecret.isEmpty();
}
}

101
campus-server/src/main/java/com/campus/config/EasemobGroupInitializer.java

@ -0,0 +1,101 @@
package com.campus.config;
import com.campus.entity.Group;
import com.campus.repository.GroupRepository;
import com.campus.repository.GroupMemberRepository;
import com.campus.service.EasemobService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
/**
* 环信群组补建初始化器
* 为存量 chat_group 中 easemob_group_id 为空的记录补建环信群组
*/
@Slf4j
@Component
@Order(10)
@RequiredArgsConstructor
public class EasemobGroupInitializer implements CommandLineRunner {
private final GroupRepository groupRepository;
private final GroupMemberRepository groupMemberRepository;
private final EasemobService easemobService;
@Override
public void run(String... args) {
log.info("开始检查是否需要补建环信群组...");
int pageSize = 50;
int page = 0;
int fixedCount = 0;
int failCount = 0;
while (true) {
Page<Group> groupPage = groupRepository.findAll(
PageRequest.of(page, pageSize));
for (Group group : groupPage.getContent()) {
if (group.getEasemobGroupId() != null) {
continue;
}
// 查找群成员列表
java.util.List<com.campus.entity.GroupMember> members =
groupMemberRepository.findByGroupId(group.getId());
java.util.List<String> memberIds = members.stream()
.filter(m -> !m.getUserId().equals(group.getOwnerId()))
.filter(m -> !"PENDING".equals(m.getRole()))
.map(com.campus.entity.GroupMember::getUserId)
.collect(java.util.stream.Collectors.toList());
try {
java.util.List<String> usersToRegister = new java.util.ArrayList<>();
usersToRegister.add(group.getOwnerId());
usersToRegister.addAll(memberIds);
boolean usersReady = true;
for (String userId : usersToRegister) {
if (!easemobService.registerUser(userId, "campus_" + userId)) {
usersReady = false;
log.warn("补建群组前注册环信用户失败: groupId={}, userId={}", group.getId(), userId);
}
}
if (!usersReady) {
failCount++;
continue;
}
String easemobGroupId = easemobService.createGroup(
group.getName(),
group.getDescription() != null ? group.getDescription() : "",
group.getOwnerId(),
memberIds);
if (easemobGroupId == null) {
failCount++;
log.warn("补建环信群组失败: groupId={}", group.getId());
continue;
}
group.setEasemobGroupId(easemobGroupId);
groupRepository.save(group);
fixedCount++;
log.info("补建环信群组成功: groupId={}, name={}, easemobGroupId={}",
group.getId(), group.getName(), easemobGroupId);
} catch (Exception e) {
failCount++;
log.error("补建环信群组失败: groupId={}, name={}", group.getId(), group.getName(), e);
}
}
if (!groupPage.hasNext()) {
break;
}
page++;
}
log.info("环信群组补建完成: 修复 {} 个, 失败 {} 个", fixedCount, failCount);
}
}

59
campus-server/src/main/java/com/campus/config/GiftInitializer.java

@ -0,0 +1,59 @@
package com.campus.config;
import com.campus.entity.Gift;
import com.campus.repository.GiftRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* 礼物初始化器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GiftInitializer implements CommandLineRunner {
private final GiftRepository giftRepository;
@Override
public void run(String... args) {
if (giftRepository.count() > 0) {
log.info("礼物已存在,跳过初始化");
return;
}
log.info("初始化礼物...");
List<Gift> gifts = Arrays.asList(
createGift("小花花", "https://oss.yoyoeco.com/gifts/flower.png", 1, 1, 1),
createGift("爱心", "https://oss.yoyoeco.com/gifts/heart.png", 5, 5, 2),
createGift("棒棒糖", "https://oss.yoyoeco.com/gifts/candy.png", 10, 10, 3),
createGift("蛋糕", "https://oss.yoyoeco.com/gifts/cake.png", 20, 20, 4),
createGift("奶茶", "https://oss.yoyoeco.com/gifts/milktea.png", 30, 30, 5),
createGift("音乐盒", "https://oss.yoyoeco.com/gifts/musicbox.png", 50, 50, 6),
createGift("玫瑰花束", "https://oss.yoyoeco.com/gifts/roses.png", 100, 100, 7),
createGift("限定皮肤", "https://oss.yoyoeco.com/gifts/skin.png", 200, 200, 8),
createGift("钻石戒指", "https://oss.yoyoeco.com/gifts/ring.png", 500, 500, 9),
createGift("超级火箭", "https://oss.yoyoeco.com/gifts/rocket.png", 1000, 1000, 10)
);
giftRepository.saveAll(gifts);
log.info("礼物初始化完成,共 {} 个礼物", gifts.size());
}
private Gift createGift(String name, String iconUrl, int points, int value, int sort) {
Gift gift = new Gift();
gift.setName(name);
gift.setIconUrl(iconUrl);
gift.setPoints(points);
gift.setValue(value);
gift.setSort(sort);
gift.setEnabled(true);
return gift;
}
}

52
campus-server/src/main/java/com/campus/config/HttpRequestLoggingFilter.java

@ -0,0 +1,52 @@
package com.campus.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/** Lightweight request log used by the internal test environment. */
@Slf4j
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class HttpRequestLoggingFilter extends OncePerRequestFilter {
@Value("${app.observability.slow-request-ms:1000}")
private long slowRequestMs;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long start = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} catch (IOException | ServletException | RuntimeException ex) {
log.error("[HTTP_ERROR] {} {} status={} duration={}ms",
request.getMethod(), request.getRequestURI(), response.getStatus(),
System.currentTimeMillis() - start, ex);
throw ex;
} finally {
long elapsed = System.currentTimeMillis() - start;
log.info("[HTTP] {} {} status={} duration={}ms remote={}",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
elapsed,
request.getRemoteAddr());
if (elapsed >= slowRequestMs) {
log.warn("[SLOW_HTTP] {} {} status={} duration={}ms threshold={}ms",
request.getMethod(), request.getRequestURI(), response.getStatus(), elapsed, slowRequestMs);
}
}
}
}

156
campus-server/src/main/java/com/campus/config/JwtAuthenticationFilter.java

@ -0,0 +1,156 @@
package com.campus.config;
import com.campus.common.Result;
import com.campus.entity.User;
import com.campus.repository.UserRepository;
import com.campus.service.UserRestrictionService;
import com.campus.service.RedisRetryExecutor;
import com.campus.service.UserSessionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtConfig jwtConfig;
private final UserRepository userRepository;
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
private final UserRestrictionService userRestrictionService;
private final RedisRetryExecutor redisRetryExecutor;
private final UserSessionService userSessionService;
private static final String TOKEN_PREFIX = "user:token:";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = extractJwtFromRequest(request);
if (StringUtils.hasText(jwt)) {
// 验证 JWT 签名是否有效
boolean isValidSignature = false;
try {
isValidSignature = !jwtConfig.isTokenExpired(jwt);
} catch (Exception e) {
// JWT 签名无效或已过期
}
String userId = jwtConfig.extractUserId(jwt);
String phone = jwtConfig.extractPhone(jwt);
// 从 Redis 中验证 token 不仅存在,而且确实属于 JWT 中的用户 ID。
String tokenKey = TOKEN_PREFIX + jwt;
Object sessionUserId = redisRetryExecutor.execute("session-token-read",
() -> redisTemplate.opsForValue().get(tokenKey));
boolean isInRedis = StringUtils.hasText(userId)
&& sessionUserId != null
&& userId.equals(sessionUserId.toString());
if (!isValidSignature || !isInRedis) {
// token 失效(签名无效/过期 或 Redis中不存在)
writeErrorResponse(response, Result.tokenExpired());
return;
}
Optional<User> userOptional = userRepository.findById(userId);
if (userOptional.isPresent()
&& !Boolean.TRUE.equals(userOptional.get().getDeleted())
&& !"DISABLED".equalsIgnoreCase(userOptional.get().getAccountStatus())
&& phone.equals(userOptional.get().getPhone())) {
User user = userOptional.get();
if (userRestrictionService.isRestricted(user.getId(), "LOGIN")) {
writeErrorResponse(response, Result.unauthorized("账号登录权限已被限制"));
return;
}
if ("DEACTIVATING".equalsIgnoreCase(user.getAccountStatus())) {
String path = request.getServletPath();
boolean available = user.getScheduledDeletionAt() != null && user.getScheduledDeletionAt().after(new java.util.Date());
boolean allowed = ("GET".equals(request.getMethod()) && ("/api/users/account-security".equals(path) || "/api/users/sessions".equals(path)))
|| ("POST".equals(request.getMethod()) && ("/api/users/recover".equals(path) || "/api/users/logout".equals(path)))
|| ("DELETE".equals(request.getMethod()) && ("/api/users/sessions".equals(path) || path.startsWith("/api/users/sessions/")));
if (!available || !allowed) {
response.setStatus(403);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(Result.error(403,
available ? "账号处于注销冷静期,请先恢复账号" : "账号注销冷静期已结束,无法恢复")));
return;
}
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
if (Boolean.TRUE.equals(user.getIsReviewer())) {
authorities.add(new SimpleGrantedAuthority("ROLE_REVIEWER"));
}
if (Boolean.TRUE.equals(user.getIsAdmin())) {
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
user,
null,
authorities
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
userSessionService.touch(jwt);
} else {
writeErrorResponse(response, Result.tokenExpired());
return;
}
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
writeErrorResponse(response, Result.unauthorized("认证失败"));
return;
}
filterChain.doFilter(request, response);
}
private void writeErrorResponse(HttpServletResponse response, Result<?> result) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(result));
}
private String extractJwtFromRequest(HttpServletRequest request) {
// 优先从 Header 获取
String bearerToken = request.getHeader("access_token");
if (StringUtils.hasText(bearerToken)) {
return bearerToken.replaceAll("\\s", "");
}
// SSE 等场景无法自定义 Header 时,从 Query 参数获取
String queryToken = request.getParameter("access_token");
if (StringUtils.hasText(queryToken)) {
return queryToken.replaceAll("\\s", "");
}
return null;
}
}

75
campus-server/src/main/java/com/campus/config/JwtConfig.java

@ -0,0 +1,75 @@
package com.campus.config;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class JwtConfig {
@Value("${jwt.secret:campus-platform-secret-key-must-be-at-least-256-bits-long}")
private String secret;
@Value("${jwt.expiration:86400000}")
private long expiration;
private Key getSigningKey() {
byte[] keyBytes = secret.getBytes();
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(String phone, String userId) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
return Jwts.builder()
.setClaims(claims)
.setSubject(phone)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String extractPhone(String token) {
return extractClaim(token, Claims::getSubject);
}
public String extractUserId(String token) {
return extractClaim(token, claims -> claims.get("userId", String.class));
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public boolean validateToken(String token, String phone) {
final String extractedPhone = extractPhone(token);
return (extractedPhone.equals(phone) && !isTokenExpired(token));
}
}

49
campus-server/src/main/java/com/campus/config/OcrProperties.java

@ -0,0 +1,49 @@
package com.campus.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 阿里云OCR配置属性
*/
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.ocr")
public class OcrProperties {
/**
* 是否启用OCR
*/
private boolean enabled = true;
/**
* AccessKey ID
*/
private String accessKeyId;
/**
* AccessKey Secret
*/
private String accessKeySecret;
/**
* 区域ID,默认华东1(杭州)
*/
private String regionId = "cn-hangzhou";
/**
* OCR服务Endpoint
*/
private String endpoint = "ocr-api.cn-hangzhou.aliyuncs.com";
/**
* 连接超时时间(毫秒)
*/
private int connectTimeout = 10000;
/**
* 读取超时时间(毫秒)
*/
private int readTimeout = 30000;
}

29
campus-server/src/main/java/com/campus/config/OssConfig.java

@ -0,0 +1,29 @@
package com.campus.config;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
@ConditionalOnProperty(prefix = "aliyun.oss", name = "enabled", havingValue = "true", matchIfMissing = true)
public class OssConfig {
@Autowired
private OssProperties ossProperties;
@Bean(destroyMethod = "shutdown")
public OSS ossClient() {
log.info("初始化阿里云OSS客户端,Endpoint: {}, Bucket: {}", ossProperties.getEndpoint(), ossProperties.getBucketName());
return new OSSClientBuilder()
.build(
ossProperties.getEndpoint(),
ossProperties.getAccessKeyId(),
ossProperties.getAccessKeySecret()
);
}
}

37
campus-server/src/main/java/com/campus/config/OssProperties.java

@ -0,0 +1,37 @@
package com.campus.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class OssProperties {
/** 是否启用OSS */
private Boolean enabled = true;
/** 阿里云OSS的endpoint */
private String endpoint;
/** 阿里云AccessKeyId */
private String accessKeyId;
/** 阿里云AccessKeySecret */
private String accessKeySecret;
/** OSS存储空间名称 */
private String bucketName;
/** 访问域名(自定义域名或默认域名) */
private String domain;
/** 最大文件大小(MB) */
private Long maxFileSize = 100L;
/** 允许的文件类型 */
private List<String> allowedFileTypes;
}

109
campus-server/src/main/java/com/campus/config/PointRuleInitializer.java

@ -0,0 +1,109 @@
package com.campus.config;
import com.campus.entity.PointRule;
import com.campus.repository.PointRuleRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* 积分规则初始化器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PointRuleInitializer implements CommandLineRunner {
private final PointRuleRepository pointRuleRepository;
@Override
public void run(String... args) {
log.info("初始化积分规则...");
List<PointRule> rules = Arrays.asList(
// ========== 获得积分规则 ==========
createRule("PUBLISH_MOMENT", "发布动态", "发布普通动态获得积分", "EARN", 5, 20, 0),
createRule("PUBLISH_VIDEO", "发布视频", "发布青春号视频获得积分", "EARN", 10, 20, 0),
createRule("CREATE_ACTIVITY", "创建活动", "创建组局活动获得积分", "EARN", 10, 5, 0),
createRule("JOIN_ACTIVITY", "参加活动", "参加组局活动获得积分", "EARN", 3, 10, 0),
createRule("LIKE_MOMENT", "点赞动态", "点赞他人动态获得积分", "EARN", 1, 20, 0),
createRule("LIKE_COMMENT", "点赞评论", "点赞他人评论获得积分", "EARN", 1, 20, 0),
createRule("COMMENT_MOMENT", "评论动态", "评论他人动态获得积分", "EARN", 2, 30, 0),
createRule("BE_LIKED", "被点赞", "动态被点赞获得积分", "EARN", 1, 100, 0),
createRule("BE_COMMENTED", "被评论", "动态被评论获得积分", "EARN", 2, 100, 0),
createRule("BE_SHARED", "被转发", "动态被转发获得积分", "EARN", 3, 50, 0),
createRule("DAILY_CHECK_IN", "每日签到", "每日签到获得积分", "EARN", 5, 1, 0),
createRule("FIRST_LOGIN", "首次登录", "首次登录获得积分", "EARN", 10, 1, 1),
createRule("COMPLETE_PROFILE", "完善资料", "完善个人资料获得积分", "EARN", 5, 1, 1),
createRule("REAL_NAME_AUTH", "实名认证", "完成实名认证获得积分", "EARN", 100, 1, 1),
createRule("RECEIVE_GIFT", "收到礼物", "收到礼物打赏获得积分", "EARN", 0, 0, 0), // 根据礼物价值计算
// ========== 消耗积分规则 ==========
createRule("SEND_GIFT", "送出礼物", "送礼物消耗积分", "SPEND", 0, 0, 0), // 根据礼物价值消耗
createRule("REDEEM_PRIZE", "兑换奖品", "积分商城兑换奖品", "SPEND", 0, 0, 0),
// ========== 扣除积分规则 ==========
createRule("PUNISH_CONTENT", "内容违规", "发布违规内容扣除积分", "SPEND", 50, 0, 0),
createRule("PUNISH_SPAM", "刷屏行为", "刷屏行为扣除积分", "SPEND", 20, 0, 0),
createRule("REPORT_CONTENT", "举报核实", "被举报内容核实后冻结积分", "SPEND", 30, 0, 0)
);
for (PointRule rule : rules) {
java.util.Optional<PointRule> existing = pointRuleRepository.findByCode(rule.getCode());
if (!existing.isPresent()) {
pointRuleRepository.save(rule);
} else if ("REAL_NAME_AUTH".equals(rule.getCode())
&& Integer.valueOf(10).equals(existing.get().getPoints())) {
PointRule current = existing.get();
current.setPoints(100);
pointRuleRepository.save(current);
}
}
log.info("积分规则初始化完成,共 {} 条规则", rules.size());
}
private PointRule createRule(String code, String name, String description,
String type, int points, int dailyLimit, int totalLimit) {
PointRule rule = new PointRule();
rule.setCode(code);
rule.setName(name);
rule.setDescription(description);
rule.setType(type);
rule.setPoints(points);
rule.setDailyLimit(dailyLimit);
rule.setTotalLimit(totalLimit);
rule.setEnabled(true);
rule.setSort(getSortOrder(code));
return rule;
}
private int getSortOrder(String code) {
switch (code) {
case "PUBLISH_MOMENT": return 1;
case "PUBLISH_VIDEO": return 2;
case "CREATE_ACTIVITY": return 3;
case "JOIN_ACTIVITY": return 4;
case "LIKE_MOMENT": return 5;
case "LIKE_COMMENT": return 6;
case "COMMENT_MOMENT": return 7;
case "BE_LIKED": return 8;
case "BE_COMMENTED": return 9;
case "BE_SHARED": return 10;
case "DAILY_CHECK_IN": return 11;
case "FIRST_LOGIN": return 12;
case "COMPLETE_PROFILE": return 13;
case "REAL_NAME_AUTH": return 14;
case "RECEIVE_GIFT": return 15;
case "SEND_GIFT": return 100;
case "REDEEM_PRIZE": return 101;
case "PUNISH_CONTENT": return 200;
case "PUNISH_SPAM": return 201;
case "REPORT_CONTENT": return 202;
default: return 999;
}
}
}

96
campus-server/src/main/java/com/campus/config/RabbitMQConfig.java

@ -0,0 +1,96 @@
package com.campus.config;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ 配置
* 交换机: video_upload_exchange (Topic)
* 队列: video_transcode_queue + video_transcode_dlq (死信)
*/
@Configuration
public class RabbitMQConfig {
// ===== 交换机 =====
public static final String VIDEO_UPLOAD_EXCHANGE = "video_upload_exchange";
// ===== 队列 =====
public static final String VIDEO_TRANSCODE_QUEUE = "video_transcode_queue";
public static final String VIDEO_TRANSCODE_DLQ = "video_transcode_dlq";
// ===== 路由键 =====
public static final String VIDEO_TRANSCODE_ROUTING_KEY = "video.transcode";
/**
* 主题交换机
*/
@Bean
public TopicExchange videoUploadExchange() {
return ExchangeBuilder.topicExchange(VIDEO_UPLOAD_EXCHANGE)
.durable(true)
.build();
}
/**
* 转码队列 — 绑定了死信交换机
* 消息消费失败后自动路由到 DLQ
*/
@Bean
public Queue videoTranscodeQueue() {
return QueueBuilder.durable(VIDEO_TRANSCODE_QUEUE)
.deadLetterExchange(VIDEO_UPLOAD_EXCHANGE)
.deadLetterRoutingKey(VIDEO_TRANSCODE_DLQ)
.build();
}
/**
* 死信队列
*/
@Bean
public Queue videoTranscodeDlq() {
return QueueBuilder.durable(VIDEO_TRANSCODE_DLQ).build();
}
/**
* 绑定: 转码队列 → 交换机 (routing-key = video.transcode)
*/
@Bean
public Binding bindTranscodeQueue() {
return BindingBuilder.bind(videoTranscodeQueue())
.to(videoUploadExchange())
.with(VIDEO_TRANSCODE_ROUTING_KEY);
}
/**
* 绑定: 死信队列 → 交换机 (routing-key = video_transcode_dlq)
*/
@Bean
public Binding bindDlq() {
return BindingBuilder.bind(videoTranscodeDlq())
.to(videoUploadExchange())
.with(VIDEO_TRANSCODE_DLQ);
}
/**
* JSON 消息转换器
*/
@Bean
public Jackson2JsonMessageConverter jackson2JsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
/**
* RabbitTemplate — 用于发送消息
*/
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory,
Jackson2JsonMessageConverter converter) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(converter);
return template;
}
}

115
campus-server/src/main/java/com/campus/config/RedisConfig.java

@ -0,0 +1,115 @@
package com.campus.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.CacheManager;
import org.springframework.cache.Cache;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
/** Redis 短暂断线时绕过缓存执行数据库逻辑,避免公开读接口直接返回 500。 */
@Override
public CacheErrorHandler errorHandler() {
return new CacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
warn("GET", cache, key, exception);
}
@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
warn("PUT", cache, key, exception);
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
warn("EVICT", cache, key, exception);
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
warn("CLEAR", cache, null, exception);
}
private void warn(String operation, Cache cache, Object key, RuntimeException exception) {
log.warn("Redis 缓存操作失败,已降级绕过缓存: operation={}, cache={}, key={}, cause={}",
operation, cache == null ? "unknown" : cache.getName(), key,
exception.getMessage());
}
};
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofDays(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues();
// 动态列表缓存仅 30 秒,平衡实时性和性能
RedisCacheConfiguration momentsConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues();
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
cacheConfigs.put("moments", momentsConfig);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}

30
campus-server/src/main/java/com/campus/config/RedisConnectionConfig.java

@ -0,0 +1,30 @@
package com.campus.config;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.SocketOptions;
import org.springframework.boot.autoconfigure.data.redis.LettuceClientConfigurationBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/** 提升云 Redis 空闲断线后的自愈能力。 */
@Configuration
public class RedisConnectionConfig {
@Bean
public LettuceClientConfigurationBuilderCustomizer lettuceConnectionCustomizer() {
return builder -> builder.clientOptions(ClientOptions.builder()
.autoReconnect(true)
.pingBeforeActivateConnection(true)
// Windows 默认 TCP keepalive 间隔通常以小时计,云端空闲连接可能早已被回收。
// 主动探测能让 Lettuce 在下一次业务命令到来前发现断线并重连。
.socketOptions(SocketOptions.builder().keepAlive(
SocketOptions.KeepAliveOptions.builder()
.enable()
.idle(Duration.ofSeconds(30))
.interval(Duration.ofSeconds(10))
.count(3)
.build()).build())
.build());
}
}

82
campus-server/src/main/java/com/campus/config/SecurityConfig.java

@ -0,0 +1,82 @@
package com.campus.config;
import com.campus.common.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final ObjectMapper objectMapper;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.antMatchers("/api/universities/search").permitAll()
.antMatchers("/api/dicts/**").permitAll()
.antMatchers("/api/oss/vod-callback").permitAll() // VOD 回调,由阿里云调用
.antMatchers("/api/oss/upload-callback").permitAll() // OSS 回调,由阿里云调用
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers("/api/sensitive-words/**").hasRole("ADMIN")
.antMatchers(HttpMethod.POST, "/api/campus/points/adjust").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/api/campus/points/account-other").hasRole("ADMIN")
.antMatchers(HttpMethod.PUT, "/api/profile/other").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/api/profile/points-other").hasRole("ADMIN")
.antMatchers(HttpMethod.POST, "/api/campus/notifications").hasRole("ADMIN")
.antMatchers("/api/campus/review/**").hasAnyRole("ADMIN", "REVIEWER")
.antMatchers(HttpMethod.PUT, "/api/campus/activities/review").hasAnyRole("ADMIN", "REVIEWER")
.antMatchers(HttpMethod.PUT, "/api/campus/announcements/review").hasAnyRole("ADMIN", "REVIEWER")
.antMatchers(HttpMethod.GET, "/api/reports/pending").hasAnyRole("ADMIN", "REVIEWER")
.antMatchers(HttpMethod.PUT, "/api/reports/*/review").hasAnyRole("ADMIN", "REVIEWER")
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(Result.unauthorized("请先登录")));
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(Result.error(403, "无权限访问")));
})
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.httpBasic().disable()
.formLogin().disable();
return http.build();
}
}

81
campus-server/src/main/java/com/campus/config/SensitiveWordInitializer.java

@ -0,0 +1,81 @@
package com.campus.config;
import com.campus.entity.SensitiveWord;
import com.campus.repository.SensitiveWordRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class SensitiveWordInitializer implements CommandLineRunner {
private final SensitiveWordRepository sensitiveWordRepository;
@Override
public void run(String... args) throws Exception {
if (sensitiveWordRepository.count() > 0) {
log.info("敏感词数据已存在,跳过初始化");
return;
}
log.info("开始初始化敏感词数据...");
List<SensitiveWord> sensitiveWords = Arrays.asList(
createWord("法轮功", "POLITICAL", "HIGH"),
createWord("法轮大法", "POLITICAL", "HIGH"),
createWord("全能神", "POLITICAL", "HIGH"),
createWord("门徒会", "POLITICAL", "HIGH"),
createWord("呼喊派", "POLITICAL", "HIGH"),
createWord("色情", "PORN", "HIGH"),
createWord("裸聊", "PORN", "HIGH"),
createWord("约炮", "PORN", "HIGH"),
createWord("一夜情", "PORN", "HIGH"),
createWord("援交", "PORN", "HIGH"),
createWord("包养", "PORN", "HIGH"),
createWord("杀人", "VIOLENCE", "HIGH"),
createWord("放火", "VIOLENCE", "HIGH"),
createWord("爆炸", "VIOLENCE", "HIGH"),
createWord("自杀", "VIOLENCE", "HIGH"),
createWord("暴力", "VIOLENCE", "HIGH"),
createWord("恐怖", "VIOLENCE", "HIGH"),
createWord("代开发票", "AD", "MEDIUM"),
createWord("代开票", "AD", "MEDIUM"),
createWord("刷单", "AD", "MEDIUM"),
createWord("兼职刷信誉", "AD", "MEDIUM"),
createWord("信用卡套现", "AD", "MEDIUM"),
createWord("赌博", "OTHER", "HIGH"),
createWord("博彩", "OTHER", "HIGH"),
createWord("吸毒", "OTHER", "HIGH"),
createWord("毒品", "OTHER", "HIGH"),
createWord("诈骗", "OTHER", "HIGH"),
createWord("传销", "OTHER", "HIGH"),
createWord("非法集资", "OTHER", "HIGH"),
createWord("傻逼", "OTHER", "LOW"),
createWord("草泥马", "OTHER", "LOW"),
createWord("尼玛", "OTHER", "LOW"),
createWord("操", "OTHER", "LOW"),
createWord("滚", "OTHER", "LOW"),
createWord("垃圾", "OTHER", "LOW"),
createWord("白痴", "OTHER", "LOW"),
createWord("脑残", "OTHER", "LOW")
);
sensitiveWordRepository.saveAll(sensitiveWords);
log.info("敏感词数据初始化完成,共加载 {} 条", sensitiveWords.size());
}
private SensitiveWord createWord(String word, String type, String level) {
SensitiveWord sensitiveWord = new SensitiveWord();
sensitiveWord.setWord(word);
sensitiveWord.setType(type);
sensitiveWord.setLevel(level);
sensitiveWord.setEnabled(true);
return sensitiveWord;
}
}

23
campus-server/src/main/java/com/campus/config/StsProperties.java

@ -0,0 +1,23 @@
package com.campus.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 阿里云 STS 临时凭证配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "oss.sts")
public class StsProperties {
/** RAM角色ARN */
private String roleArn;
/** 凭证有效期(秒) */
private Long durationSeconds = 3600L;
/** 允许上传的OSS路径前缀 */
private String uploadPrefix = "videos/";
}

29
campus-server/src/main/java/com/campus/config/VodProperties.java

@ -0,0 +1,29 @@
package com.campus.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "vod")
public class VodProperties {
/** VOD API 地域 */
private String regionId = "cn-shanghai";
/** 阿里云 AccessKeyId(VOD专用) */
private String accessKeyId;
/** 阿里云 AccessKeySecret(VOD专用) */
private String accessKeySecret;
/** 转码模板组ID */
private String transcodeTemplateGroupId;
/** VOD 处理完成回调地址 */
private String callbackUrl;
/** VOD 回调认证 */
private String callbackAuth;
}

19
campus-server/src/main/java/com/campus/config/WebConfig.java

@ -0,0 +1,19 @@
package com.campus.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

17
campus-server/src/main/java/com/campus/controller/AccountDeletionReviewController.java

@ -0,0 +1,17 @@
package com.campus.controller;
import com.campus.common.*;
import com.campus.service.AccountDeletionReviewService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/account-deletion/resources")
@RequiredArgsConstructor
public class AccountDeletionReviewController {
private final AccountDeletionReviewService service;
@GetMapping("/receipts") public Result<PageResult<Map<String,Object>>> receipts(@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size){return Result.success(service.receipts(page,size));}
@GetMapping public Result<PageResult<Map<String,Object>>> list(@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size){return Result.success(service.list(page,size));}
@PostMapping("/{id}/resolve") public Result<Void> resolve(@PathVariable String id,@RequestBody Decision request){service.resolve(id,request.getDecision(),request.getReason());return Result.success();}
@Data public static class Decision {private String decision;private String reason;}
}

16
campus-server/src/main/java/com/campus/controller/AchievementController.java

@ -0,0 +1,16 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.*;
import com.campus.service.AchievementService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/achievements")
@RequiredArgsConstructor
public class AchievementController {
private final AchievementService service;
@GetMapping("/me")
public Result<AchievementOverviewDTO> overview(@RequestParam(defaultValue="ALL") String status,@RequestParam(required=false) String categoryCode) {return Result.success(service.overview(status,categoryCode));}
@GetMapping("/{code}")
public Result<AchievementDTO> detail(@PathVariable String code) {return Result.success(service.detail(code));}
}

269
campus-server/src/main/java/com/campus/controller/ActivityController.java

@ -0,0 +1,269 @@
package com.campus.controller;
import com.campus.common.BusinessException;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.ActivityQueryRequest;
import com.campus.dto.request.ActivityRequest;
import com.campus.dto.request.ActivityReviewRequest;
import com.campus.dto.response.ActivityDTO;
import com.campus.dto.response.ActivityParticipantDTO;
import com.campus.service.ActivityService;
import com.campus.service.ClickGuardService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/api/campus/activities")
@RequiredArgsConstructor
@Log4j2
public class ActivityController {
private final ActivityService activityService;
private final ClickGuardService clickGuardService;
@GetMapping
public Result<PageResult<ActivityDTO>> list(
@RequestParam(required = false) String type,
@RequestParam(required = false) String category,
@RequestParam(required = false) String activityType,
@RequestParam(name = "activity_type", required = false) String activityTypeSnakeCase,
@RequestParam(required = false) String status,
@RequestParam(required = false) String reviewStatus,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String creatorId,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String period,
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "size", defaultValue = "20") Integer pageSize) {
String resolvedType = firstNonBlank(type, category, activityType, activityTypeSnakeCase);
String resolvedStatus = nullIfAll(status);
String resolvedReviewStatus = nullIfAll(reviewStatus);
String resolvedSortBy = nullIfAll(sortBy);
String resolvedPeriod = nullIfAll(period);
log.info("[ACTIVITY_LIST] type={} status={} reviewStatus={} sortBy={} period={} page={} size={}",
resolvedType, resolvedStatus, resolvedReviewStatus, resolvedSortBy, resolvedPeriod, page, pageSize);
String userId = UserContext.getCurrentUserId();
String userSchool = UserContext.getCurrentUser() != null
? UserContext.getCurrentUser().getUniversityName()
: null;
boolean hasAdvancedFilter = (resolvedStatus != null && !resolvedStatus.isEmpty())
|| (resolvedReviewStatus != null && !resolvedReviewStatus.isEmpty())
|| (keyword != null && !keyword.isEmpty())
|| (creatorId != null && !creatorId.isEmpty())
|| (resolvedSortBy != null && !resolvedSortBy.isEmpty())
|| (resolvedPeriod != null && !resolvedPeriod.isEmpty());
if (hasAdvancedFilter) {
ActivityQueryRequest request = new ActivityQueryRequest();
request.setType(resolvedType);
request.setStatus(resolvedStatus);
request.setReviewStatus(resolvedReviewStatus);
request.setKeyword(keyword);
request.setCreatorId(creatorId);
request.setSortBy(resolvedSortBy);
request.setPeriod(resolvedPeriod);
return Result.success(activityService.search(request, userSchool, page, pageSize, userId));
}
return Result.success(activityService.list(resolvedType, userSchool, page, pageSize, userId));
}
private static String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.trim().isEmpty()) {
return value.trim();
}
}
return null;
}
private static String nullIfAll(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty() || "all".equalsIgnoreCase(trimmed) || "全部".equals(trimmed)) {
return null;
}
return trimmed;
}
@PostMapping("/search")
public Result<PageResult<ActivityDTO>> search(
@RequestBody ActivityQueryRequest request,
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "size", defaultValue = "20") Integer pageSize) {
String userId = UserContext.getCurrentUserId();
String userSchool = UserContext.getCurrentUser() != null
? UserContext.getCurrentUser().getUniversityName()
: null;
return Result.success(activityService.search(request, userSchool, page, pageSize, userId));
}
@PostMapping("/view")
public Result<Void> view(@RequestParam String id) {
activityService.incrementViewCount(id);
return Result.success();
}
@GetMapping("/detail")
public Result<ActivityDTO> getById(@RequestParam String id) {
String userId = UserContext.getCurrentUserId();
String userSchool = UserContext.getCurrentUser() != null
? UserContext.getCurrentUser().getUniversityName()
: null;
return Result.success(activityService.getById(id, userId, userSchool));
}
/**
* 旧的「直接报名」入口。全局报名审核后不再使用:它会直接把人置为 CONFIRMED,绕过创建者审核。
* 报名统一改走 {@code POST /{activityId}/applications};票档活动内部走的
* {@code ActivityService.joinActivity}(免费票档报名)不受影响。
* 保留 id / remark 参数,让老前端拿到明确的业务 400 而不是参数校验错误。
*/
@PostMapping("/join")
public Result<ActivityDTO> join(
@RequestParam String id,
@RequestParam(required = false) String remark) {
throw new BusinessException("报名需发起人审核,请提交报名申请");
}
@DeleteMapping("/quit")
public Result<ActivityDTO> quit(@RequestParam String id) {
String userId = UserContext.getCurrentUserId();
clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_ACTIVITY_JOIN_QUIT, userId, id);
return Result.success(activityService.quitActivity(id, userId));
}
@GetMapping("/participants")
public Result<List<ActivityParticipantDTO>> getParticipants(@RequestParam String id) {
return Result.success(activityService.getParticipants(id));
}
@GetMapping("/{activityId}/participants/manage")
public Result<List<ActivityParticipantDTO>> getParticipantsForManagement(
@PathVariable String activityId) {
return Result.success(activityService.getParticipantsForManagement(activityId));
}
@PostMapping("/{activityId}/applications")
public Result<ActivityParticipantDTO> apply(
@PathVariable String activityId,
@RequestParam(required = false) String remark) {
String userId = UserContext.getCurrentUserId();
// 连点守卫:申请单独一个窗口,提交后立刻撤回不会被误拦
clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_ACTIVITY_APPLY, userId, activityId);
return Result.success(activityService.applyActivity(activityId, userId, remark));
}
@PutMapping("/{activityId}/participants/{participantId}/review")
public Result<ActivityParticipantDTO> reviewParticipant(
@PathVariable String activityId,
@PathVariable String participantId,
@RequestBody(required = false) ActivityReviewRequest request,
@RequestParam(required = false) String action,
@RequestParam(required = false) Boolean approved,
@RequestParam(required = false) String reason) {
ActivityReviewRequest body = request == null ? new ActivityReviewRequest() : request;
String resolvedAction = action != null ? action : body.getAction();
Boolean resolvedApproved = approved != null ? approved : body.getApproved();
if (resolvedAction == null && resolvedApproved != null) {
resolvedAction = resolvedApproved ? "APPROVE" : "REJECT";
}
String resolvedReason = reason != null ? reason : body.getReason();
return Result.success(activityService.reviewParticipant(
activityId, participantId, resolvedAction, resolvedReason));
}
@DeleteMapping("/{activityId}/participants/{participantId}")
public Result<ActivityParticipantDTO> removeParticipant(
@PathVariable String activityId,
@PathVariable String participantId,
@RequestParam(required = false) String reason) {
return Result.success(activityService.reviewParticipant(
activityId, participantId, "REMOVE", reason));
}
@GetMapping("/created")
public Result<PageResult<ActivityDTO>> listCreated(
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "size", defaultValue = "20") Integer pageSize) {
String userId = UserContext.getCurrentUserId();
return Result.success(activityService.listByCreatorIdWithPage(userId, page, pageSize));
}
@GetMapping("/joined")
public Result<PageResult<ActivityDTO>> listJoined(
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "size", defaultValue = "20") Integer pageSize) {
String userId = UserContext.getCurrentUserId();
return Result.success(activityService.listJoinedActivities(userId, page, pageSize));
}
@PostMapping
public Result<ActivityDTO> create(@Valid @RequestBody ActivityRequest request) {
String creatorId = UserContext.getCurrentUserId();
return Result.success(activityService.create(request, creatorId));
}
@PutMapping
public Result<ActivityDTO> update(@RequestParam String id, @Valid @RequestBody ActivityRequest request) {
return Result.success(activityService.update(id, request));
}
@DeleteMapping
public Result<Void> delete(@RequestParam String id) {
activityService.delete(id);
return Result.success();
}
@DeleteMapping("/mine")
public Result<Void> deleteMy(@RequestParam String id) {
String userId = UserContext.getCurrentUserId();
activityService.deleteMyActivity(id, userId);
return Result.success();
}
@PostMapping("/cancel")
public Result<Void> cancel(@RequestParam String id) {
String userId = UserContext.getCurrentUserId();
activityService.cancelMyActivity(id, userId);
return Result.success();
}
@PostMapping("/{id}/withdraw")
public Result<ActivityDTO> withdraw(@PathVariable String id) {
return Result.success(activityService.withdraw(id));
}
@PostMapping("/{id}/resubmit")
public Result<ActivityDTO> resubmit(@PathVariable String id) {
return Result.success(activityService.resubmit(id));
}
@GetMapping("/{id}/operation-logs")
public Result<List<com.campus.entity.ActivityOperationLog>> operationLogs(@PathVariable String id) {
return Result.success(activityService.operationLogs(id));
}
@PutMapping("/review")
public Result<ActivityDTO> review(
@RequestParam String id,
@RequestParam boolean approved,
@RequestParam(required = false) String reason) {
return Result.success(activityService.reviewActivity(id, approved, reason));
}
}

19
campus-server/src/main/java/com/campus/controller/ActivityLocationController.java

@ -0,0 +1,19 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.ActivityLocationRequest;
import com.campus.dto.response.ActivityLocationDTO;
import com.campus.service.location.ActivityLocationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/campus/activity-locations")
public class ActivityLocationController {
private final ActivityLocationService service;
@PostMapping("/resolve")
public Result<ActivityLocationDTO> resolve(@RequestBody ActivityLocationRequest request) { return Result.success(service.resolve(request)); }
@PostMapping("/confirm")
public Result<ActivityLocationDTO> confirm(@RequestBody ActivityLocationRequest request) { return Result.success(service.confirm(request)); }
}

136
campus-server/src/main/java/com/campus/controller/ActivityRegistrationController.java

@ -0,0 +1,136 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.ActivityRegistrationRequest;
import com.campus.dto.request.ActivityTicketRequest;
import com.campus.dto.request.ActivityReviewRequest;
import com.campus.dto.request.ActivityCheckInRequest;
import com.campus.dto.response.ActivityRegistrationDTO;
import com.campus.dto.response.ActivityPaymentOrderDTO;
import com.campus.dto.response.ActivityTicketDTO;
import com.campus.service.ActivityRegistrationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
import java.util.Collections;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
/** 活动票档和报名单接口;支付确认在后续支付步骤接入。 */
@RestController
@RequestMapping("/api/campus/activities")
@RequiredArgsConstructor
public class ActivityRegistrationController {
private final ActivityRegistrationService registrationService;
@GetMapping("/{activityId}/tickets")
public Result<List<ActivityTicketDTO>> listTickets(@PathVariable String activityId) {
return Result.success(registrationService.listTickets(activityId));
}
@PostMapping("/{activityId}/tickets")
public Result<ActivityTicketDTO> createTicket(
@PathVariable String activityId,
@Valid @RequestBody ActivityTicketRequest request) {
return Result.success(registrationService.createTicket(activityId, request));
}
@PostMapping("/{activityId}/registrations")
public Result<ActivityRegistrationDTO> register(
@PathVariable String activityId,
@Valid @RequestBody ActivityRegistrationRequest request) {
return Result.success(registrationService.register(activityId, request));
}
@GetMapping("/{activityId}/registrations")
public Result<List<ActivityRegistrationDTO>> listForActivity(@PathVariable String activityId) {
return Result.success(registrationService.listForActivity(activityId));
}
@GetMapping("/registrations/mine")
public Result<List<ActivityRegistrationDTO>> listMine() {
return Result.success(registrationService.listMine());
}
@GetMapping("/registrations/{registrationId}")
public Result<ActivityRegistrationDTO> getMine(@PathVariable String registrationId) {
return Result.success(registrationService.getMine(registrationId));
}
@DeleteMapping("/registrations/{registrationId}")
public Result<ActivityRegistrationDTO> cancel(@PathVariable String registrationId) {
return Result.success(registrationService.cancel(registrationId));
}
@PutMapping("/registrations/{registrationId}/review")
public Result<ActivityRegistrationDTO> review(
@PathVariable String registrationId,
@RequestBody(required = false) ActivityReviewRequest request,
@org.springframework.web.bind.annotation.RequestParam(required = false) String action,
@org.springframework.web.bind.annotation.RequestParam(required = false) Boolean approved,
@org.springframework.web.bind.annotation.RequestParam(required = false) String reason) {
ActivityReviewRequest body = request == null ? new ActivityReviewRequest() : request;
String resolvedAction = action != null ? action : body.getAction();
Boolean resolvedApproved = approved != null ? approved : body.getApproved();
if (resolvedAction == null && resolvedApproved != null) {
resolvedAction = resolvedApproved ? "APPROVE" : "REJECT";
}
return Result.success(registrationService.review(registrationId, resolvedAction,
reason != null ? reason : body.getReason()));
}
@DeleteMapping("/registrations/{registrationId}/remove")
public Result<ActivityRegistrationDTO> remove(
@PathVariable String registrationId,
@org.springframework.web.bind.annotation.RequestParam(required = false) String reason) {
return Result.success(registrationService.review(registrationId, "REMOVE", reason));
}
@PostMapping("/registrations/{registrationId}/payment-order")
public Result<ActivityPaymentOrderDTO> createPaymentOrder(@PathVariable String registrationId) {
return Result.success(registrationService.createPaymentOrder(registrationId));
}
@GetMapping("/registrations/{registrationId}/payment-order")
public Result<ActivityPaymentOrderDTO> getPaymentOrder(@PathVariable String registrationId) {
return Result.success(registrationService.getPaymentOrder(registrationId));
}
@DeleteMapping("/registrations/{registrationId}/payment-order")
public Result<ActivityPaymentOrderDTO> cancelPaymentOrder(@PathVariable String registrationId) {
return Result.success(registrationService.cancelPaymentOrder(registrationId));
}
@PostMapping("/{activityId}/check-in-code")
public Result<Map<String, String>> generateCheckInCode(@PathVariable String activityId) {
return Result.success(Collections.singletonMap("checkInCode",
registrationService.generateCheckInCode(activityId)));
}
@PostMapping("/registrations/{registrationId}/check-in")
public Result<ActivityRegistrationDTO> checkIn(@PathVariable String registrationId,
@Valid @RequestBody ActivityCheckInRequest request) {
return Result.success(registrationService.checkIn(registrationId, request.getCheckInCode()));
}
@GetMapping("/{activityId}/registrations/export")
public ResponseEntity<byte[]> export(@PathVariable String activityId) {
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=activity-" + activityId + "-registrations.csv")
.body(registrationService.exportRegistrations(activityId));
}
}

184
campus-server/src/main/java/com/campus/controller/AdminController.java

@ -0,0 +1,184 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.AdminAccountStatusRequest;
import com.campus.dto.request.AdminRoleUpdateRequest;
import com.campus.dto.request.AdminPasswordResetRequest;
import com.campus.dto.request.RevokeRestrictionRequest;
import com.campus.dto.request.UserRestrictionRequest;
import com.campus.dto.response.AdminUserDTO;
import com.campus.dto.response.AdminAuditLogDTO;
import com.campus.dto.response.UserRestrictionDTO;
import com.campus.service.AdminService;
import com.campus.service.AuditLogService;
import com.campus.service.UserRestrictionService;
import com.campus.service.ActivityService;
import com.campus.dto.response.ActivityDTO;
import com.campus.service.MomentCityBackfillService;
import com.campus.service.CommerceAdminService;
import com.campus.entity.MallProduct;
import com.campus.entity.TaskDefinition;
import com.campus.entity.LotteryPrize;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.List;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {
private final AdminService adminService;
private final AuditLogService auditLogService;
private final UserRestrictionService userRestrictionService;
private final ActivityService activityService;
private final MomentCityBackfillService momentCityBackfillService;
private final CommerceAdminService commerceAdminService;
@GetMapping("/me")
public Result<Map<String, Object>> me() {
AdminUserDTO user = adminService.getUser(UserContext.getCurrentUserId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("user", user);
result.put("permissions", new String[]{
"USER_MANAGE", "ROLE_MANAGE", "CONTENT_REVIEW", "POINTS_ADJUST",
"DICT_MANAGE", "SENSITIVE_WORD_MANAGE", "COMMERCE_MANAGE", "AUDIT_LOG_VIEW"
});
return Result.success(result);
}
@GetMapping("/dashboard")
public Result<Map<String, Object>> dashboard() {
return Result.success(adminService.dashboard());
}
@GetMapping("/users")
public Result<PageResult<AdminUserDTO>> users(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String accountStatus,
@RequestParam(required = false) Boolean isAdmin,
@RequestParam(required = false) Boolean isReviewer,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(adminService.listUsers(keyword, accountStatus, isAdmin, isReviewer, page, size));
}
@GetMapping("/users/{id}")
public Result<AdminUserDTO> user(@PathVariable String id) {
return Result.success(adminService.getUser(id));
}
@PutMapping("/users/{id}/roles")
public Result<AdminUserDTO> updateRoles(@PathVariable String id,
@Valid @RequestBody AdminRoleUpdateRequest request) {
return Result.success(adminService.updateRoles(id, request));
}
@PutMapping("/users/{id}/status")
public Result<AdminUserDTO> updateStatus(@PathVariable String id,
@Valid @RequestBody AdminAccountStatusRequest request) {
return Result.success(adminService.updateAccountStatus(id, request));
}
@PostMapping("/users/{id}/kickout")
public Result<Void> kickout(@PathVariable String id) {
adminService.kickout(id);
return Result.success();
}
@PostMapping("/users/{id}/reset-password")
public Result<Void> resetPassword(@PathVariable String id,
@Valid @RequestBody AdminPasswordResetRequest request) {
adminService.resetPassword(id, request);
return Result.success();
}
@GetMapping("/audit-logs")
public Result<PageResult<AdminAuditLogDTO>> auditLogs(
@RequestParam(required = false) String operatorId,
@RequestParam(required = false) String action,
@RequestParam(required = false) String targetType,
@RequestParam(required = false) String targetId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(auditLogService.list(operatorId, action, targetType, targetId, page, size));
}
@GetMapping("/users/{id}/restrictions")
public Result<PageResult<UserRestrictionDTO>> restrictions(@PathVariable String id,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(userRestrictionService.list(id, page, size));
}
@PostMapping("/users/{id}/restrictions")
public Result<UserRestrictionDTO> createRestriction(@PathVariable String id,
@Valid @RequestBody UserRestrictionRequest request) {
return Result.success(userRestrictionService.create(id, request));
}
@PostMapping("/restrictions/{id}/revoke")
public Result<UserRestrictionDTO> revokeRestriction(@PathVariable String id,
@RequestBody(required = false) RevokeRestrictionRequest request) {
return Result.success(userRestrictionService.revoke(id, request == null ? null : request.getReason()));
}
@PostMapping("/activities/{id}/cancel")
public Result<ActivityDTO> cancelActivity(@PathVariable String id,
@RequestParam(required = false) String reason) {
return Result.success(activityService.adminCancel(id, reason));
}
@PostMapping("/maintenance/moments/backfill-city")
public Result<Map<String, Object>> backfillMomentCity() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("updated", momentCityBackfillService.backfill());
return Result.success(result);
}
@GetMapping("/commerce/products")
public Result<List<MallProduct>> commerceProducts() {
return Result.success(commerceAdminService.products());
}
@PutMapping("/commerce/products/{code}")
public Result<MallProduct> saveCommerceProduct(@PathVariable String code,
@RequestBody MallProduct product) {
return Result.success(commerceAdminService.saveProduct(code, product));
}
@GetMapping("/commerce/tasks")
public Result<List<TaskDefinition>> commerceTasks() {
return Result.success(commerceAdminService.tasks());
}
@PutMapping("/commerce/tasks/{code}")
public Result<TaskDefinition> saveCommerceTask(@PathVariable String code,
@RequestBody TaskDefinition task) {
return Result.success(commerceAdminService.saveTask(code, task));
}
@GetMapping("/commerce/lottery-prizes")
public Result<List<LotteryPrize>> lotteryPrizes() {
return Result.success(commerceAdminService.prizes());
}
@PutMapping("/commerce/lottery-prizes/{code}")
public Result<LotteryPrize> saveLotteryPrize(@PathVariable String code,
@RequestBody LotteryPrize prize) {
return Result.success(commerceAdminService.savePrize(code, prize));
}
}

349
campus-server/src/main/java/com/campus/controller/AnnouncementController.java

@ -0,0 +1,349 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.AnnouncementRequest;
import com.campus.dto.request.AnnouncementUpdateRequest;
import com.campus.dto.response.AnnouncementDTO;
import com.campus.service.AnnouncementService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/campus/announcements")
@RequiredArgsConstructor
public class AnnouncementController {
private final AnnouncementService announcementService;
/**
* 分页查询公告列表
*
* @param type 公告类型(可选,例:general/event/notice)
* @param status 审核状态(可选,例:pending/approved/rejected)
* @param pageable 分页参数 ?page=1&size=20&sort=createdAt,desc
* @return 分页公告数据
* @api GET /api/campus/announcements
* @reqExample GET /api/campus/announcements?type=event&status=approved&page=1&size=20
* @resExample {
* "code": 200,
* "message": "success",
* "data": {
* "list": [
* {
* "id": "18292888123456789012",
* "type": "event",
* "title": "校园歌手大赛",
* "subtitle": "第十届校园歌手大赛报名开始",
* "description": "面向全校同学的歌唱比赛",
* "content": "活动详情正文...",
* "eventDate": "2026-07-01",
* "eventTime": "19:00-21:30",
* "location": "大学生活动中心",
* "organizer": "校学生会",
* "capacity": 200,
* "coverImage": "https://oss.example.com/cover/xxx.jpg",
* "images": ["https://oss.example.com/img/1.jpg"],
* "status": "approved",
* "reviewReason": null,
* "scopeType": "all",
* "universityIds": [],
* "viewCount": 1234,
* "registeredCount": 56,
* "creatorId": "17283940000000000001",
* "createdAt": "2026-06-20 10:00:00",
* "updatedAt": "2026-06-20 10:00:00"
* }
* ],
* "total": 1,
* "page": 1,
* "pageSize": 20
* }
* }
*/
@GetMapping
public Result<PageResult<AnnouncementDTO>> list(
@RequestParam(required = false) String type,
@RequestParam(required = false) String status,
@PageableDefault(size = 20) Pageable pageable) {
int page = pageable.getPageNumber() + 1;
int pageSize = pageable.getPageSize();
return Result.success(announcementService.list(type, status, page, pageSize));
}
/**
* 按学校分页查询公告(全校公告 + 指定学校的公告)
*
* @param universityId 学校ID
* @param type 公告类型(可选)
* @param pageable 分页参数 ?page=1&size=20
* @return 分页公告数据
* @api GET /api/campus/announcements/university
* @reqExample GET /api/campus/announcements/university?universityId=10001&type=event&page=1&size=20
* @resExample 同 list 接口
*/
@GetMapping("/university")
public Result<PageResult<AnnouncementDTO>> listByUniversity(
@RequestParam String universityId,
@RequestParam(required = false) String type,
@PageableDefault(size = 20) Pageable pageable) {
int page = pageable.getPageNumber() + 1;
int pageSize = pageable.getPageSize();
return Result.success(announcementService.listByUniversity(universityId, type, page, pageSize));
}
/**
* 获取公告详情(自动增加浏览数)
*
* @param id 公告ID
* @return 公告详情
* @api GET /api/campus/announcements/detail
* @reqExample GET /api/campus/announcements/detail?id=18292888123456789012
* @resExample {
* "code": 200,
* "message": "success",
* "data": {
* "id": "18292888123456789012",
* "type": "event",
* "title": "校园歌手大赛",
* "subtitle": "第十届校园歌手大赛报名开始",
* "description": "面向全校同学的歌唱比赛",
* "content": "活动详情正文...",
* "eventDate": "2026-07-01",
* "eventTime": "19:00-21:30",
* "location": "大学生活动中心",
* "organizer": "校学生会",
* "capacity": 200,
* "coverImage": "https://oss.example.com/cover/xxx.jpg",
* "images": ["https://oss.example.com/img/1.jpg"],
* "status": "approved",
* "reviewReason": null,
* "scopeType": "all",
* "universityIds": [],
* "viewCount": 1235,
* "registeredCount": 56,
* "creatorId": "17283940000000000001",
* "createdAt": "2026-06-20 10:00:00",
* "updatedAt": "2026-06-20 10:00:00"
* }
* }
*/
@GetMapping("/detail")
public Result<AnnouncementDTO> getById(@RequestParam String id) {
AnnouncementDTO dto = announcementService.getById(id);
announcementService.incrementViewCount(id);
return Result.success(dto);
}
/**
* 创建公告(提交后进入审核,状态=pending)
*
* @param request 公告请求体,title 必填
* @return 创建后的公告(含 id、status=pending 等)
* @api POST /api/campus/announcements
* @reqExample {
* "type": "event",
* "title": "校园歌手大赛",
* "subtitle": "第十届校园歌手大赛报名开始",
* "description": "面向全校同学的歌唱比赛",
* "content": "活动详情正文...",
* "eventDate": "2026-07-01",
* "eventTime": "19:00-21:30",
* "location": "大学生活动中心",
* "organizer": "校学生会",
* "capacity": 200,
* "coverImage": "https://oss.example.com/cover/xxx.jpg",
* "images": [
* "https://oss.example.com/img/1.jpg",
* "https://oss.example.com/img/2.jpg"
* ],
* "scopeType": "specified",
* "universityIds": ["10001", "10002"]
* }
* @resExample {
* "code": 200,
* "message": "success",
* "data": {
* "id": "18292888123456789012",
* "type": "event",
* "title": "校园歌手大赛",
* "status": "pending",
* "viewCount": 0,
* "registeredCount": 0,
* "creatorId": "17283940000000000001",
* "createdAt": "2026-06-21 09:00:00",
* "updatedAt": "2026-06-21 09:00:00"
* }
* }
*/
@PostMapping
public Result<AnnouncementDTO> create(
@Valid @RequestBody AnnouncementRequest request) {
return Result.success(announcementService.create(request));
}
/**
* 更新公告(部分更新,所有字段可选,非空才覆盖原数据)
*
* @param id 公告ID(URL参数,必填)
* @param request 仅传入需要修改的字段;省略字段保持原值
* @return 更新后的公告详情
* @api PUT /api/campus/announcements
* @reqExample PUT /api/campus/announcements?id=18292888123456789012
* @reqBody {
* "title": "校园歌手大赛(改)",
* "capacity": 300
* }
* @reqExample PUT /api/campus/announcements?id=18292888123456789012
* @reqBody {
* "type": "notice",
* "description": "面向全校同学的歌唱比赛",
* "content": "更新后的活动详情正文...",
* "eventDate": "2026-07-10",
* "eventTime": "19:30-22:00",
* "location": "大学生活动中心·三楼",
* "organizer": "校学生会文艺部",
* "coverImage": "https://oss.example.com/cover/xxx_v2.jpg",
* "images": [
* "https://oss.example.com/img/1.jpg",
* "https://oss.example.com/img/2.jpg"
* ],
* "scopeType": "specified",
* "universityIds": ["10001", "10002", "10003"]
* }
* @reqExample PUT /api/campus/announcements?id=18292888123456789012
* @reqBody {
* "images": [],
* "universityIds": []
* }
* @resExample {
* "code": 200,
* "message": "success",
* "data": {
* "id": "18292888123456789012",
* "type": "notice",
* "title": "校园歌手大赛(改)",
* "subtitle": "第十届校园歌手大赛报名开始",
* "description": "面向全校同学的歌唱比赛",
* "content": "更新后的活动详情正文...",
* "eventDate": "2026-07-10",
* "eventTime": "19:30-22:00",
* "location": "大学生活动中心·三楼",
* "organizer": "校学生会文艺部",
* "capacity": 300,
* "coverImage": "https://oss.example.com/cover/xxx_v2.jpg",
* "images": [
* "https://oss.example.com/img/1.jpg",
* "https://oss.example.com/img/2.jpg"
* ],
* "status": "pending",
* "reviewReason": null,
* "scopeType": "specified",
* "universityIds": ["10001", "10002", "10003"],
* "viewCount": 0,
* "registeredCount": 56,
* "creatorId": "17283940000000000001",
* "createdAt": "2026-06-21 09:00:00",
* "updatedAt": "2026-06-21 10:30:00"
* }
* }
* @errorExample {
* "code": 500,
* "message": "公告不存在"
* }
* @field type 公告类型(max 50,可选;不传保持原值)
* @field title 公告标题(max 200,可选;不传保持原值)
* @field subtitle 副标题(max 200,可选)
* @field description 公告简述(max 500,可选)
* @field content 公告正文(TEXT,可选)
* @field eventDate 活动日期 yyyy-MM-dd(可选)
* @field eventTime 活动时间/时分(max 50,可选,例:19:00-21:30)
* @field location 活动地点(max 200,可选)
* @field organizer 主办方(max 100,可选)
* @field capacity 容量限制(Integer,可选)
* @field coverImage 封面图片URL(max 500,可选)
* @field images 图片URL列表(可选:传非空数组覆盖,传 [] 清空,不传保持原值)
* @field scopeType 发布范围类型:all=全部学校,specified=指定学校(可选)
* @field universityIds 指定学校ID列表(可选:传非空数组覆盖,传 [] 清空,不传保持原值)
*/
@PutMapping
public Result<AnnouncementDTO> update(
@RequestParam String id,
@Valid @RequestBody AnnouncementUpdateRequest request) {
return Result.success(announcementService.update(id, request));
}
/**
* 删除公告(管理员删除,逻辑删除)
*
* @param id 公告ID
* @api DELETE /api/campus/announcements
* @reqExample DELETE /api/campus/announcements?id=18292888123456789012
* @resExample {
* "code": 200,
* "message": "success",
* "data": null
* }
*/
@DeleteMapping
public Result<Void> delete(@RequestParam String id) {
announcementService.delete(id);
return Result.success();
}
/**
* 删除自己创建的公告(仅创建者可操作)
*
* @param id 公告ID
* @api DELETE /api/campus/announcements/mine
* @reqExample DELETE /api/campus/announcements/mine?id=18292888123456789012
* @resExample {
* "code": 200,
* "message": "success",
* "data": null
* }
* @errorExample {
* "code": 500,
* "message": "无权删除他人创建的公告"
* }
*/
@DeleteMapping("/mine")
public Result<Void> deleteMy(@RequestParam String id) {
String userId = UserContext.getCurrentUserId();
announcementService.deleteMyAnnouncement(id, userId);
return Result.success();
}
/**
* 审核公告
*
* @param id 公告ID
* @param status 审核状态 approved=通过 / rejected=拒绝
* @param reason 审核原因(status=rejected 时建议填写)
* @return 审核后的公告
* @api PUT /api/campus/announcements/review
* @reqExample PUT /api/campus/announcements/review?id=18292888123456789012&status=approved
* @reqExample PUT /api/campus/announcements/review?id=18292888123456789012&status=rejected&reason=内容包含敏感词
* @resExample {
* "code": 200,
* "message": "success",
* "data": {
* "id": "18292888123456789012",
* "status": "approved",
* "reviewReason": null
* }
* }
*/
@PutMapping("/review")
public Result<AnnouncementDTO> review(
@RequestParam String id,
@RequestParam String status,
@RequestParam(required = false) String reason) {
return Result.success(announcementService.review(id, status, reason));
}
}

78
campus-server/src/main/java/com/campus/controller/AreaController.java

@ -0,0 +1,78 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.AreaDTO;
import com.campus.service.AreaService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 地区信息控制器
* 提供省市区查询接口
*/
@RestController
@RequestMapping("/api/areas")
@RequiredArgsConstructor
public class AreaController {
private final AreaService areaService;
/**
* 获取所有省份
*/
@GetMapping("/provinces")
public Result<List<AreaDTO>> getProvinces() {
return Result.success(areaService.getProvinces());
}
/**
* 根据父级ID获取子地区
* @param parentId 父级地区ID,0或不传表示获取省份
*/
@GetMapping("/children")
public Result<List<AreaDTO>> getChildren(@RequestParam Integer parentId) {
return Result.success(areaService.getChildrenByParentId(parentId));
}
/**
* 获取完整的省市区三级树形结构
*/
@GetMapping("/tree")
public Result<List<AreaDTO>> getAreaTree() {
return Result.success(areaService.getAreaTree());
}
/**
* 根据ID获取地区详情
*/
@GetMapping("/detail")
public Result<AreaDTO> getAreaById(@RequestParam Integer areaId) {
return Result.success(areaService.getAreaById(areaId));
}
/**
* 根据编码获取地区
*/
@GetMapping("/code")
public Result<AreaDTO> getAreaByCode(@RequestParam String code) {
return Result.success(areaService.getAreaByCode(code));
}
/**
* 获取热门地区
*/
@GetMapping("/hot")
public Result<List<AreaDTO>> getHotAreas() {
return Result.success(areaService.getHotAreas());
}
/**
* 搜索地区
*/
@GetMapping("/search")
public Result<List<AreaDTO>> searchAreas(@RequestParam String keyword) {
return Result.success(areaService.searchAreas(keyword));
}
}

124
campus-server/src/main/java/com/campus/controller/AuthController.java

@ -0,0 +1,124 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.LoginRequest;
import com.campus.dto.request.LoginWithCaptchaRequest;
import com.campus.dto.request.RegisterRequest;
import com.campus.dto.request.ResetPasswordRequest;
import com.campus.dto.response.LoginResponse;
import com.campus.entity.User;
import com.campus.service.CaptchaService;
import com.campus.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.servlet.http.HttpServletRequest;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
private final CaptchaService captchaService;
private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
/**
* 手机号+密码登录
*/
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
if (!PHONE_PATTERN.matcher(request.getPhone()).matches()) {
return Result.error("手机号格式不正确");
}
UserService.LoginResult loginResult = userService.login(request,
clientIp(httpRequest), httpRequest.getHeader("User-Agent"));
return Result.success(buildLoginResponse(loginResult.getToken(), loginResult.getUser()));
}
/**
* 手机号+验证码登录
*/
@PostMapping("/login-captcha")
public Result<LoginResponse> loginByCaptcha(
@Valid @RequestBody LoginWithCaptchaRequest request,
HttpServletRequest httpRequest) {
if (!PHONE_PATTERN.matcher(request.getPhone()).matches()) {
return Result.error("手机号格式不正确");
}
if (!captchaService.verifyLoginCaptcha(request.getPhone(), request.getCaptcha())) {
return Result.error("验证码错误或已过期");
}
UserService.LoginResult loginResult = userService.loginByPhone(request.getPhone(),
request.getDeviceId(), request.getDeviceName(), request.getPlatform(),
clientIp(httpRequest), httpRequest.getHeader("User-Agent"));
return Result.success(buildLoginResponse(loginResult.getToken(), loginResult.getUser()));
}
private LoginResponse buildLoginResponse(String token, User user) {
LoginResponse response = new LoginResponse();
response.setToken(token);
response.setUser(LoginResponse.toUserInfo(user));
return response;
}
@PostMapping("/register")
public Result<LoginResponse> register(@Valid @RequestBody RegisterRequest request) {
// 验证手机号格式
if (!PHONE_PATTERN.matcher(request.getPhone()).matches()) {
return Result.error("手机号格式不正确");
}
if (!captchaService.verifyLoginCaptcha(request.getPhone(), request.getCaptcha())) {
return Result.error("验证码错误或已过期");
}
UserService.LoginResult loginResult = userService.register(request);
return Result.success(buildLoginResponse(loginResult.getToken(), loginResult.getUser()));
}
/**
* 获取验证码
*/
@GetMapping("/captcha")
public Result<Void> getCaptcha(@RequestParam String phone) {
// 验证手机号格式
if (!PHONE_PATTERN.matcher(phone).matches()) {
return Result.error("手机号格式不正确");
}
boolean success = captchaService.sendCaptcha(phone);
if (success) {
return Result.success();
} else {
return Result.error("验证码发送失败,请稍后重试");
}
}
/**
* 手机号验证码重置密码。该接口不涉及真实支付或其他外部扣费。
*/
@PostMapping("/reset-password")
public Result<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
if (!PHONE_PATTERN.matcher(request.getPhone()).matches()) {
return Result.error("手机号格式不正确");
}
if (!captchaService.verifyLoginCaptcha(request.getPhone(), request.getCaptcha())) {
return Result.error("验证码错误或已过期");
}
userService.resetPassword(request.getPhone(), request.getNewPassword());
return Result.success();
}
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.trim().isEmpty()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

184
campus-server/src/main/java/com/campus/controller/ChatController.java

@ -0,0 +1,184 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.ChatMessageRequest;
import com.campus.dto.request.ConversationSettingsRequest;
import com.campus.dto.request.SendGroupMessageRequest;
import com.campus.dto.response.ChatMessageDTO;
import com.campus.dto.response.EasemobConfigDTO;
import com.campus.entity.ChatConversation;
import com.campus.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@RestController
@RequestMapping("/api/campus/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@GetMapping("/config")
public Result<EasemobConfigDTO> getConfig() {
return Result.success(chatService.getConfig());
}
@PostMapping("/register")
public Result<Boolean> register() {
return Result.success(chatService.registerCurrentUser());
}
// ===================== 消息发送 =====================
@PostMapping("/messages/private")
public Result<ChatMessageDTO> sendPrivateMessage(@Valid @RequestBody ChatMessageRequest request) {
return Result.success(chatService.sendPrivateMessage(request));
}
@PostMapping("/messages/group/{groupId}")
public Result<ChatMessageDTO> sendGroupMessage(@PathVariable String groupId, @Valid @RequestBody SendGroupMessageRequest request) {
return Result.success(chatService.sendGroupMessage(groupId, request.getContent()));
}
// ===================== 聊天记录 =====================
@GetMapping("/history/private/{peerId}")
public Result<PageResult<ChatMessageDTO>> getPrivateChatHistory(
@PathVariable String peerId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
return Result.success(chatService.getPrivateChatHistory(peerId, page, size));
}
@GetMapping("/search/private/{peerId}")
public Result<PageResult<ChatMessageDTO>> searchPrivate(
@PathVariable String peerId, @RequestParam String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(chatService.searchPrivateMessages(peerId, keyword, page, size));
}
@GetMapping("/export/private/{peerId}")
public ResponseEntity<byte[]> exportPrivate(@PathVariable String peerId) {
return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=chat-" + peerId + ".csv")
.body(chatService.exportPrivateMessages(peerId));
}
@PostMapping("/messages/{messageId}/report")
public Result<com.campus.entity.ChatReport> report(@PathVariable String messageId,
@RequestBody Map<String, String> body) {
return Result.success(chatService.reportPrivateMessage(messageId, body.get("reason")));
}
@GetMapping("/reports/pending")
public Result<PageResult<com.campus.entity.ChatReport>> pendingReports(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) {
return Result.success(chatService.pendingChatReports(page, size));
}
@PutMapping("/reports/{id}/review")
public Result<com.campus.entity.ChatReport> reviewReport(@PathVariable String id,
@RequestBody Map<String, Object> body) {
return Result.success(chatService.reviewChatReport(id, Boolean.TRUE.equals(body.get("accepted")),
body.get("note") == null ? null : body.get("note").toString()));
}
@GetMapping("/history/group/{groupId}")
public Result<PageResult<ChatMessageDTO>> getGroupChatHistory(
@PathVariable String groupId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
return Result.success(chatService.getGroupChatHistory(groupId, page, size));
}
// ===================== 增量同步(新增) =====================
/**
* 按会话增量同步:GET /api/chat/sync/{peerId}?since={lastSeqId}
* 只返回 seqId > since 的新消息,客户端收到后更新本地 lastSeqId
*/
@GetMapping("/sync/{peerId}")
public Result<List<ChatMessageDTO>> syncMessages(
@PathVariable String peerId,
@RequestParam(defaultValue = "0") Long since) {
return Result.success(chatService.syncMessages(peerId, since));
}
/**
* 全局增量同步:GET /api/chat/sync/global?since={lastSeqId}
* 拉取所有会话中 seqId > since 且与当前用户相关的消息
* 用于断线重连、App 从后台切回前台时补拉所有漏掉的消息
*/
@GetMapping("/sync/global")
public Result<List<ChatMessageDTO>> syncAllMessages(
@RequestParam(defaultValue = "0") Long since) {
return Result.success(chatService.syncAllMessages(since));
}
/**
* 获取服务端最大 seqId
*/
@GetMapping("/sync/max-seq")
public Result<Long> getMaxSeqId() {
return Result.success(chatService.getMaxSeqId());
}
// ===================== 已读标记 =====================
/**
* 标记会话已读。conversationId:私聊传对方用户ID,群聊传群ID。
*/
@PutMapping("/read/{conversationId}")
public Result<Void> markMessagesAsRead(@PathVariable String conversationId) {
chatService.markMessagesAsRead(conversationId);
return Result.success();
}
@PutMapping("/read/all")
public Result<Void> markAllAsRead() {
chatService.markAllAsRead();
return Result.success();
}
// ===================== 会话管理 =====================
@GetMapping("/conversations")
public Result<List<ChatConversation>> getConversations() {
return Result.success(chatService.getConversations());
}
@PutMapping("/conversations/settings")
public Result<ChatConversation> updateConversationSettings(
@Valid @RequestBody ConversationSettingsRequest request) {
return Result.success(chatService.updateConversationSettings(request));
}
@DeleteMapping("/conversations/{peerId}")
public Result<Void> deleteConversation(@PathVariable String peerId) {
chatService.deleteConversation(peerId);
return Result.success();
}
// ===================== 消息删除 =====================
@DeleteMapping("/messages/{messageId}")
public Result<Void> deleteMessage(@PathVariable String messageId) {
chatService.deleteMessage(messageId);
return Result.success();
}
@DeleteMapping("/messages/conversation/{conversationId}")
public Result<Void> clearConversationMessages(@PathVariable String conversationId) {
chatService.clearConversationMessages(conversationId);
return Result.success();
}
}

32
campus-server/src/main/java/com/campus/controller/CollegeController.java

@ -0,0 +1,32 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.CollegeDTO;
import com.campus.dto.response.MajorDTO;
import com.campus.service.UniversityService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 学院信息控制器
* 提供学院及下属专业的独立查询接口
*/
@RestController
@RequestMapping("/api/colleges")
@RequiredArgsConstructor
public class CollegeController {
private final UniversityService universityService;
@GetMapping
public Result<List<CollegeDTO>> getAllColleges() {
return Result.success(universityService.getAllColleges());
}
@GetMapping("/majors")
public Result<List<MajorDTO>> getMajorsByCollege(@RequestParam String collegeId) {
return Result.success(universityService.getMajorsByCollege(collegeId));
}
}

40
campus-server/src/main/java/com/campus/controller/CommentController.java

@ -0,0 +1,40 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.CommentRequest;
import com.campus.entity.Comment;
import com.campus.service.CommentService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/comments")
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
@GetMapping
public Result<PageResult<Comment>> getCommentsByPost(
@RequestParam String postId,
@PageableDefault(size = 20) Pageable pageable) {
return Result.success(commentService.getCommentsByPostId(postId, pageable));
}
@PostMapping
public Result<Comment> createComment(@Valid @RequestBody CommentRequest request) {
return Result.success(commentService.createComment(request));
}
@DeleteMapping
public Result<Void> deleteComment(@RequestParam String id) {
commentService.deleteComment(id);
return Result.success();
}
}

42
campus-server/src/main/java/com/campus/controller/ContentAppealController.java

@ -0,0 +1,42 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.ContentAppealRequest;
import com.campus.dto.request.ContentAppealReviewRequest;
import com.campus.dto.response.ContentAppealDTO;
import com.campus.service.ContentAppealService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/content-appeals")
@RequiredArgsConstructor
public class ContentAppealController {
private final ContentAppealService service;
@PostMapping
public Result<ContentAppealDTO> create(@Valid @RequestBody ContentAppealRequest request) {
return Result.success(service.create(request));
}
@GetMapping("/mine")
public Result<PageResult<ContentAppealDTO>> mine(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(service.mine(page, size));
}
@GetMapping("/pending")
public Result<PageResult<ContentAppealDTO>> pending(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(service.pending(page, size));
}
@PutMapping("/{id}/review")
public Result<ContentAppealDTO> review(@PathVariable String id,
@Valid @RequestBody ContentAppealReviewRequest request) {
return Result.success(service.review(id, request));
}
}

93
campus-server/src/main/java/com/campus/controller/DictController.java

@ -0,0 +1,93 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.SysDictItemRequest;
import com.campus.dto.request.SysDictRequest;
import com.campus.dto.response.DictItemDTO;
import com.campus.dto.response.SysDictDTO;
import com.campus.dto.response.SysDictItemDTO;
import com.campus.service.DictService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
/**
* 字典管理 Controller
* - 公开接口(/api/dicts/**)供前端动态获取字典项
* - 管理接口(/api/admin/dicts/**)用于字典和字典项的 CRUD
*
* 路径约定:所有参数(id、code、dictId)走 query 或 body,不出现在 URL 路径中
*/
@RestController
@RequiredArgsConstructor
public class DictController {
private final DictService dictService;
// ============== 公开读取接口 ==============
/**
* 根据字典编码获取启用的字典项(前端下拉框使用)
* 例:GET /api/dicts/items?code=announcement_type
*/
@GetMapping("/api/dicts/items")
public Result<List<DictItemDTO>> getDictItems(@RequestParam String code) {
return Result.success(dictService.getEnabledItems(code));
}
// ============== 管理接口:字典主表 ==============
@GetMapping("/api/admin/dicts")
public Result<List<SysDictDTO>> listDicts() {
return Result.success(dictService.listAllDicts());
}
@GetMapping("/api/admin/dicts/detail")
public Result<SysDictDTO> getDict(@RequestParam String id) {
return Result.success(dictService.getDictById(id));
}
@PostMapping("/api/admin/dicts")
public Result<SysDictDTO> createDict(@Valid @RequestBody SysDictRequest request) {
return Result.success(dictService.createDict(request));
}
@PutMapping("/api/admin/dicts")
public Result<SysDictDTO> updateDict(@RequestParam String id,
@Valid @RequestBody SysDictRequest request) {
return Result.success(dictService.updateDict(id, request));
}
@DeleteMapping("/api/admin/dicts")
public Result<Void> deleteDict(@RequestParam String id) {
dictService.deleteDict(id);
return Result.success();
}
// ============== 管理接口:字典项 ==============
@GetMapping("/api/admin/dicts/items")
public Result<List<SysDictItemDTO>> listItems(@RequestParam String dictId) {
return Result.success(dictService.listItemsByDictId(dictId));
}
@PostMapping("/api/admin/dicts/items")
public Result<SysDictItemDTO> createItem(@RequestParam String dictId,
@Valid @RequestBody SysDictItemRequest request) {
return Result.success(dictService.createItem(dictId, request));
}
@PutMapping("/api/admin/dicts/items")
public Result<SysDictItemDTO> updateItem(@RequestParam String itemId,
@Valid @RequestBody SysDictItemRequest request) {
return Result.success(dictService.updateItem(itemId, request));
}
@DeleteMapping("/api/admin/dicts/items")
public Result<Void> deleteItem(@RequestParam String itemId) {
dictService.deleteItem(itemId);
return Result.success();
}
}

38
campus-server/src/main/java/com/campus/controller/DraftController.java

@ -0,0 +1,38 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.service.ContentDraftService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/draft")
@RequiredArgsConstructor
public class DraftController {
private final ContentDraftService service;
@PostMapping("/save")
public Result<Map<String, Object>> saveDraft(@RequestBody Map<String, Object> draft) {
return Result.success(service.save(UserContext.getCurrentUserId(), draft));
}
@GetMapping({"", "/list"})
public Result<List<Map<String, Object>>> listDrafts() {
return Result.success(service.listLatest(UserContext.getCurrentUserId()));
}
@GetMapping("/{draftId}/versions")
public Result<List<Map<String, Object>>> versions(@PathVariable String draftId) {
return Result.success(service.versions(UserContext.getCurrentUserId(), draftId));
}
@DeleteMapping("/{draftId}")
public Result<Void> deleteDraft(@PathVariable String draftId) {
service.delete(UserContext.getCurrentUserId(), draftId);
return Result.success();
}
}

183
campus-server/src/main/java/com/campus/controller/FileUploadController.java

@ -0,0 +1,183 @@
package com.campus.controller;
import com.campus.dto.response.FileUploadResponse;
import com.campus.util.OssUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@RestController
@RequestMapping("/api/file")
@CrossOrigin
public class FileUploadController {
@Autowired
private OssUtil ossUtil;
/**
* 通用文件上传
*/
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public FileUploadResponse uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "folder", defaultValue = "images") String folder) {
log.info("上传文件: {}, 文件夹: {}", file.getOriginalFilename(), folder);
String url = ossUtil.uploadFile(file, folder);
return FileUploadResponse.builder()
.url(url)
.fileName(file.getOriginalFilename())
.fileSize(file.getSize())
.fileType(file.getContentType())
.build();
}
/**
* 上传图片
*/
@PostMapping(value = "/upload/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public FileUploadResponse uploadImage(@RequestParam("file") MultipartFile file) {
String url = ossUtil.uploadImage(file);
return FileUploadResponse.builder()
.url(url)
.fileName(file.getOriginalFilename())
.fileSize(file.getSize())
.fileType(file.getContentType())
.build();
}
/**
* 上传视频
*/
@PostMapping(value = "/upload/video", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public FileUploadResponse uploadVideo(@RequestParam("file") MultipartFile file) {
long startTime = System.currentTimeMillis();
String url = ossUtil.uploadVideo(file);
long elapsed = System.currentTimeMillis() - startTime;
log.info("视频上传完成: {}, 大小: {}MB, 总耗时: {}ms",
file.getOriginalFilename(),
String.format("%.1f", file.getSize() / 1024.0 / 1024.0),
elapsed);
return FileUploadResponse.builder()
.url(url)
.fileName(file.getOriginalFilename())
.fileSize(file.getSize())
.fileType(file.getContentType())
.build();
}
/**
* 上传文档
*/
@PostMapping(value = "/upload/document", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public FileUploadResponse uploadDocument(@RequestParam("file") MultipartFile file) {
String url = ossUtil.uploadDocument(file);
return FileUploadResponse.builder()
.url(url)
.fileName(file.getOriginalFilename())
.fileSize(file.getSize())
.fileType(file.getContentType())
.build();
}
/**
* 上传头像
*/
@PostMapping(value = "/upload/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public FileUploadResponse uploadAvatar(@RequestParam("file") MultipartFile file) {
String url = ossUtil.uploadAvatar(file);
return FileUploadResponse.builder()
.url(url)
.fileName(file.getOriginalFilename())
.fileSize(file.getSize())
.fileType(file.getContentType())
.build();
}
/**
* 批量上传文件
*/
@PostMapping(value = "/upload/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Map<String, Object> uploadFiles(
@RequestParam("files") MultipartFile[] files,
@RequestParam(value = "folder", defaultValue = "images") String folder) {
log.info("批量上传文件数: {}, 文件夹: {}", files.length, folder);
List<FileUploadResponse> responses = new ArrayList<>();
for (MultipartFile file : files) {
String url = ossUtil.uploadFile(file, folder);
responses.add(FileUploadResponse.builder()
.url(url)
.fileName(file.getOriginalFilename())
.fileSize(file.getSize())
.fileType(file.getContentType())
.build());
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", responses.size());
result.put("files", responses);
return result;
}
/**
* 批量上传图片
*/
@PostMapping(value = "/upload/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Map<String, Object> uploadImages(@RequestParam("files") MultipartFile[] files) {
List<FileUploadResponse> responses = new ArrayList<>();
for (MultipartFile file : files) {
String url = ossUtil.uploadImage(file);
responses.add(FileUploadResponse.builder()
.url(url)
.fileName(file.getOriginalFilename())
.fileSize(file.getSize())
.fileType(file.getContentType())
.build());
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", responses.size());
result.put("urls", responses.stream().map(FileUploadResponse::getUrl).collect(Collectors.toList()));
result.put("files", responses);
return result;
}
/**
* 删除文件
*/
@DeleteMapping("/delete")
public Map<String, String> deleteFile(@RequestParam("url") String url) {
log.info("删除文件: {}", url);
ossUtil.deleteFile(url);
Map<String, String> result = new HashMap<>();
result.put("message", "文件删除成功");
result.put("url", url);
return result;
}
/**
* 批量删除文件
*/
@DeleteMapping("/delete/batch")
public Map<String, Object> deleteFiles(@RequestBody List<String> urls) {
log.info("批量删除文件数: {}", urls.size());
ossUtil.deleteFiles(urls);
Map<String, Object> result = new HashMap<>();
result.put("message", "文件批量删除成功");
result.put("count", urls.size());
result.put("urls", urls);
return result;
}
}

44
campus-server/src/main/java/com/campus/controller/FollowController.java

@ -0,0 +1,44 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.response.FollowUserDTO;
import com.campus.service.FollowService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class FollowController {
private final FollowService followService;
@PostMapping("/follow")
public Result<Map<String, Object>> follow(@RequestParam String userId) {
return Result.success(followService.toggleFollow(userId));
}
@GetMapping("/followers")
public Result<PageResult<FollowUserDTO>> getFollowers(
@RequestParam String userId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(followService.listFollowers(userId, page, size));
}
@GetMapping("/following")
public Result<PageResult<FollowUserDTO>> getFollowing(
@RequestParam String userId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(followService.listFollowing(userId, page, size));
}
@GetMapping("/follow-status")
public Result<Map<String, Object>> getFollowStatus(@RequestParam String userId) {
return Result.success(followService.getFollowStatus(userId));
}
}

149
campus-server/src/main/java/com/campus/controller/FriendshipController.java

@ -0,0 +1,149 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.AddFriendRequest;
import com.campus.dto.request.FriendFilterRequest;
import com.campus.dto.request.FriendSearchRequest;
import com.campus.dto.request.GreetRequest;
import com.campus.dto.response.FriendDTO;
import com.campus.dto.response.UserProfileDTO;
import com.campus.entity.Friendship;
import com.campus.service.FriendService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
import java.util.Map;
/**
* 交友模块统一入口(保留旧 /api/friendships 路径)
*/
@RestController
@RequestMapping("/api/friendships")
@RequiredArgsConstructor
public class FriendshipController {
private final FriendService friendService;
// ===================== 关系管理 =====================
@GetMapping("/my")
public Result<List<FriendDTO>> getMyFriends() {
return Result.success(friendService.listMyFriendCards());
}
@GetMapping("/pending")
public Result<List<Map<String, Object>>> getPendingRequests() {
return Result.success(friendService.getMyReceivedPendingWithUser());
}
@GetMapping("/pending/sent")
public Result<List<Map<String, Object>>> getSentRequests() {
return Result.success(friendService.getMySentRequests());
}
@PostMapping("/add")
public Result<Friendship> addFriend(@Valid @RequestBody AddFriendRequest request) {
return Result.success(friendService.addFriend(request));
}
/** 打招呼:向推荐/陌生人发送默认问候语私聊消息 */
@PostMapping("/greet")
public Result<Void> greet(@Valid @RequestBody GreetRequest request) {
friendService.greetUser(request);
return Result.success();
}
@PostMapping
public Result<Friendship> sendFriendRequest(@RequestParam String friendId) {
AddFriendRequest req = new AddFriendRequest();
req.setFriendId(friendId);
return Result.success(friendService.addFriend(req));
}
@PutMapping("/accept")
public Result<Friendship> acceptRequest(@RequestParam String id) {
return Result.success(friendService.acceptFriendRequest(id));
}
@PutMapping("/acceptByUserId")
public Result<Friendship> acceptRequestByUserId(@RequestParam String userId) {
return Result.success(friendService.acceptFriendRequestByUserId(userId));
}
@DeleteMapping("/reject")
public Result<Void> rejectRequest(@RequestParam String id) {
friendService.rejectFriendRequest(id);
return Result.success();
}
/** 删除好友(双向,仅限已是好友) */
@DeleteMapping("/delete")
public Result<Void> deleteFriend(@RequestParam String friendId) {
friendService.deleteFriend(friendId);
return Result.success();
}
/** 撤回自己发起的好友申请(PENDING 状态) */
@DeleteMapping("/requests")
public Result<Void> cancelFriendRequest(@RequestParam String friendId) {
friendService.cancelFriendRequest(friendId);
return Result.success();
}
/** 拉黑好友(将 ACCEPTED 关系改为 BLOCKED) */
@PostMapping("/block")
public Result<Void> blockFriend(@RequestParam String friendId) {
friendService.blockFriend(friendId);
return Result.success();
}
/** 获取当前用户的黑名单。 */
@GetMapping("/blacklist")
public Result<List<FriendDTO>> getBlacklist() {
return Result.success(friendService.listBlacklist());
}
/** 移出黑名单;移出后不会自动恢复好友关系。 */
@DeleteMapping("/blacklist")
public Result<Void> unblockFriend(@RequestParam String friendId) {
friendService.unblockFriend(friendId);
return Result.success();
}
// ===================== 推荐 / 搜索 / 筛选 =====================
/**
* 智能推荐(首次 / 换一批)
*
* @param limit 本批数量(默认 20)
* @param batchToken 上一批返回的 token;首次不传或传空 → 开启新批次
*/
@GetMapping("/recommend")
public Result<com.campus.dto.response.RecommendBatchDTO> recommend(
@RequestParam(required = false, defaultValue = "20") Integer limit,
@RequestParam(required = false) String batchToken) {
return Result.success(friendService.recommendBatch(
limit == null || limit < 1 ? 20 : limit,
batchToken));
}
@GetMapping("/search")
public Result<PageResult<FriendDTO>> search(FriendSearchRequest request) {
return Result.success(friendService.search(request));
}
@GetMapping("/filter")
public Result<PageResult<FriendDTO>> filter(FriendFilterRequest request) {
return Result.success(friendService.filter(request));
}
// ===================== 资料 / 共同好友 =====================
@GetMapping("/mutual")
public Result<List<FriendDTO>> getMutualFriends(@RequestParam String userId) {
return Result.success(friendService.getMutualFriends(userId));
}
}

89
campus-server/src/main/java/com/campus/controller/GiftController.java

@ -0,0 +1,89 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.SendGiftRequest;
import com.campus.dto.response.GiftDTO;
import com.campus.dto.response.GiftRecordDTO;
import com.campus.service.GiftService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 礼物控制器
*/
@RestController
@RequestMapping("/api/campus/gifts")
@RequiredArgsConstructor
public class GiftController {
private final GiftService giftService;
/**
* 获取礼物列表
*/
@GetMapping
public Result<List<GiftDTO>> getGiftList() {
return Result.success(giftService.getGiftList());
}
/**
* 赠送积分礼物
*/
@PostMapping("/send")
public Result<GiftRecordDTO> sendGift(@Valid @RequestBody SendGiftRequest request) {
return Result.success(giftService.sendGift(request));
}
/**
* 获取我收到的礼物
*/
@GetMapping("/received")
public Result<PageResult<GiftRecordDTO>> getReceivedGifts(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(giftService.getReceivedGifts(page, size));
}
/**
* 获取我送出的礼物
*/
@GetMapping("/sent")
public Result<PageResult<GiftRecordDTO>> getSentGifts(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(giftService.getSentGifts(page, size));
}
/**
* 获取指定动态收到的礼物列表
*/
@GetMapping("/moment")
public Result<List<GiftRecordDTO>> getGiftsByMoment(@RequestParam String momentId) {
return Result.success(giftService.getGiftsByMoment(momentId));
}
/**
* 获取指定动态收到的礼物总积分
*/
@GetMapping("/moment/points")
public Result<Map<String, Object>> getMomentGiftPoints(@RequestParam String momentId) {
Integer points = giftService.getMomentGiftPoints(momentId);
Map<String, Object> result = new HashMap<>();
result.put("points", points != null ? points : 0);
return Result.success(result);
}
/**
* 获取用户礼物统计
*/
@GetMapping("/stats")
public Result<Map<String, Object>> getUserGiftStats(@RequestParam String userId) {
return Result.success(giftService.getUserGiftStats(userId));
}
}

201
campus-server/src/main/java/com/campus/controller/GroupController.java

@ -0,0 +1,201 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.CreateGroupRequest;
import com.campus.dto.request.AddGroupMembersRequest;
import com.campus.dto.request.CreateGroupInvitationRequest;
import com.campus.dto.request.UpdateGroupAnnouncementRequest;
import com.campus.dto.request.UpdateGroupNicknameRequest;
import com.campus.dto.response.GroupAnnouncementDTO;
import com.campus.dto.response.GroupInvitationDTO;
import com.campus.dto.response.GroupInviteResult;
import com.campus.entity.Group;
import com.campus.entity.GroupMember;
import com.campus.service.GroupAnnouncementService;
import com.campus.service.GroupInvitationService;
import com.campus.service.GroupService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/api/groups")
@RequiredArgsConstructor
public class GroupController {
private final GroupService groupService;
private final GroupAnnouncementService groupAnnouncementService;
private final GroupInvitationService groupInvitationService;
// ===================== 创建群组 =====================
@PostMapping
public Result<Group> createGroup(@Valid @RequestBody CreateGroupRequest request) {
return Result.success(groupService.createGroup(request.getName(), request.getDescription(),
request.getAvatar(), request.getMaxMembers(), request.getMemberIds()));
}
// ===================== 加入/退出群组 =====================
@PostMapping("/{groupId}/join")
public Result<GroupMember> joinGroup(@PathVariable String groupId) {
return Result.success(groupService.joinGroup(groupId));
}
@PostMapping("/{groupId}/leave")
public Result<Void> leaveGroup(@PathVariable String groupId) {
groupService.leaveGroup(groupId);
return Result.success();
}
// ===================== 群成员管理 =====================
@PostMapping("/{groupId}/approve")
public Result<Void> approveJoinRequest(@PathVariable String groupId, @RequestParam String userId) {
groupService.approveJoinRequest(groupId, userId);
return Result.success();
}
@PostMapping("/{groupId}/reject")
public Result<Void> rejectJoinRequest(@PathVariable String groupId, @RequestParam String userId) {
groupService.rejectJoinRequest(groupId, userId);
return Result.success();
}
@GetMapping("/{groupId}/requests")
public Result<List<GroupMember>> getJoinRequests(@PathVariable String groupId) {
return Result.success(groupService.getJoinRequests(groupId));
}
/**
* 邀请用户入群(原「添加群成员」)。
*
* <p>⚠️ 行为变更:已建群拉人不再直接入群,改为**发群邀请**,等对方点「允许」后才成为成员。
* 建群时带人({@code POST /api/groups} 的 memberIds)不受影响,仍是直接入群。
* 响应体由 {@code List<GroupMember>} 改为 {@link GroupInviteResult}(invited / failed)。
*/
@PostMapping("/{groupId}/members")
public Result<GroupInviteResult> addMembers(@PathVariable String groupId,
@Valid @RequestBody AddGroupMembersRequest request) {
return Result.success(groupInvitationService.invite(groupId, request.getUserIds(), null));
}
// ===================== 群邀请 =====================
/** 发起群邀请(与 /members 等价,语义更明确的新路径)。 */
@PostMapping("/{groupId}/invitations")
public Result<GroupInviteResult> inviteMembers(@PathVariable String groupId,
@Valid @RequestBody CreateGroupInvitationRequest request) {
return Result.success(groupInvitationService.invite(groupId, request.getUserIds(), request.getMessage()));
}
/** 群主/管理员查看本群已发出的邀请(谁还没点允许)。 */
@GetMapping("/{groupId}/invitations")
public Result<PageResult<GroupInvitationDTO>> listGroupInvitations(
@PathVariable String groupId,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
return Result.success(groupInvitationService.listByGroup(groupId, status, page, size));
}
@DeleteMapping("/{groupId}/members/{userId}")
public Result<Void> removeMember(@PathVariable String groupId, @PathVariable String userId) {
groupService.removeMember(groupId, userId);
return Result.success();
}
@PutMapping("/{groupId}/members/{userId}/mute")
public Result<Void> setMemberMute(@PathVariable String groupId, @PathVariable String userId,
@RequestParam boolean isMuted) {
groupService.setMemberMute(groupId, userId, isMuted);
return Result.success();
}
@PutMapping("/{groupId}/members/me/nickname")
public Result<GroupMember> updateMyGroupNickname(@PathVariable String groupId,
@RequestBody UpdateGroupNicknameRequest request) {
return Result.success(groupService.updateMyGroupNickname(groupId, request.getNickname()));
}
// ===================== 群组查询 =====================
@GetMapping("/my")
public Result<List<Group>> getMyGroups() {
return Result.success(groupService.getMyGroups());
}
@GetMapping("/search")
public Result<PageResult<Group>> searchGroups(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
return Result.success(groupService.searchGroups(keyword, page, size));
}
@GetMapping("/{groupId}")
public Result<Group> getGroupInfo(@PathVariable String groupId) {
return Result.success(groupService.getGroupInfo(groupId));
}
@GetMapping("/{groupId}/members")
public Result<List<GroupMember>> getGroupMembers(@PathVariable String groupId) {
return Result.success(groupService.getGroupMembers(groupId));
}
// ===================== 群公告 =====================
@GetMapping("/{groupId}/announcement")
public Result<GroupAnnouncementDTO> getAnnouncement(@PathVariable String groupId) {
return Result.success(groupAnnouncementService.getAnnouncement(groupId));
}
@PutMapping("/{groupId}/announcement")
public Result<GroupAnnouncementDTO> updateAnnouncement(
@PathVariable String groupId,
@Valid @RequestBody UpdateGroupAnnouncementRequest request) {
return Result.success(groupAnnouncementService.updateAnnouncement(groupId, request));
}
// ===================== 群主管理 =====================
@PostMapping("/{groupId}/transfer")
public Result<Void> transferOwner(@PathVariable String groupId, @RequestParam String newOwnerId) {
groupService.transferOwner(groupId, newOwnerId);
return Result.success();
}
@PutMapping("/{groupId}/admins/{userId}")
public Result<Void> appointAdmin(@PathVariable String groupId, @PathVariable String userId) {
groupService.appointAdmin(groupId, userId);
return Result.success();
}
@DeleteMapping("/{groupId}/admins/{userId}")
public Result<Void> revokeAdmin(@PathVariable String groupId, @PathVariable String userId) {
groupService.revokeAdmin(groupId, userId);
return Result.success();
}
// ===================== 群组资料修改 =====================
@PutMapping("/{groupId}")
public Result<Group> updateGroupInfo(
@PathVariable String groupId,
@RequestParam(required = false) String name,
@RequestParam(required = false) String description,
@RequestParam(required = false) String avatar) {
return Result.success(groupService.updateGroupInfo(groupId, name, description, avatar));
}
// ===================== 删除群组 =====================
@DeleteMapping("/{groupId}")
public Result<Void> deleteGroup(@PathVariable String groupId) {
groupService.deleteGroup(groupId);
return Result.success();
}
}

61
campus-server/src/main/java/com/campus/controller/GroupInvitationController.java

@ -0,0 +1,61 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.response.GroupInvitationDTO;
import com.campus.entity.GroupMember;
import com.campus.service.GroupInvitationService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 群邀请为中心的接口(被邀请人视角)。
*
* <p>路径刻意与 {@code /api/groups/{groupId}} 分开:邀请操作只需要邀请 ID,
* 挂在群路径下会与 {@code /api/groups/{groupId}} 的字面量段互相吞并
* (话题模块的 {@code /api/topics/my/discussions} 就是这么遮蔽掉名为 my 的话题的)。
* 以群为主体的(发起邀请、查看本群邀请)仍在 {@code GroupController} 里。
*/
@RestController
@RequestMapping("/api/group-invitations")
@RequiredArgsConstructor
public class GroupInvitationController {
private final GroupInvitationService groupInvitationService;
// ===================== 被邀请人:我的邀请 =====================
/**
* 我收到的群邀请。
*
* @param status 省略/PENDING = 只看待处理;ALL = 全部历史;也可传 ACCEPTED/CANCELLED/EXPIRED
*/
@GetMapping("/mine")
public Result<PageResult<GroupInvitationDTO>> listMine(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
return Result.success(groupInvitationService.listMine(status, page, size));
}
// ===================== 被邀请人:允许入群 =====================
@PostMapping("/{invitationId}/accept")
public Result<GroupMember> accept(@PathVariable String invitationId) {
return Result.success(groupInvitationService.accept(invitationId));
}
// ===================== 邀请人:撤回 =====================
@DeleteMapping("/{invitationId}")
public Result<Void> cancel(@PathVariable String invitationId) {
groupInvitationService.cancel(invitationId);
return Result.success();
}
}

23
campus-server/src/main/java/com/campus/controller/HomeController.java

@ -0,0 +1,23 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.HomeOverviewDTO;
import com.campus.service.HomeService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 首页首屏聚合接口。 */
@RestController
@RequestMapping("/api/home")
@RequiredArgsConstructor
public class HomeController {
private final HomeService homeService;
@GetMapping("/overview")
public Result<HomeOverviewDTO> overview() {
return Result.success(homeService.overview());
}
}

26
campus-server/src/main/java/com/campus/controller/MajorController.java

@ -0,0 +1,26 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.MajorDTO;
import com.campus.service.UniversityService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 专业信息控制器
* 提供专业独立查询接口
*/
@RestController
@RequestMapping("/api/majors")
@RequiredArgsConstructor
public class MajorController {
private final UniversityService universityService;
@GetMapping
public Result<List<MajorDTO>> getAllMajors() {
return Result.success(universityService.getAllMajors());
}
}

63
campus-server/src/main/java/com/campus/controller/MatchingController.java

@ -0,0 +1,63 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.MatchActionRequest;
import com.campus.dto.request.MatchPreferenceRequest;
import com.campus.dto.response.FriendDTO;
import com.campus.dto.response.MatchActionDTO;
import com.campus.dto.response.MatchPairDTO;
import com.campus.dto.response.MatchPreferenceDTO;
import com.campus.service.MatchingService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
/** 心动匹配接口。 */
@RestController
@RequestMapping("/api/matches")
@RequiredArgsConstructor
public class MatchingController {
private final MatchingService matchingService;
@GetMapping("/preferences")
public Result<MatchPreferenceDTO> getPreference() {
return Result.success(matchingService.getPreference());
}
@PutMapping("/preferences")
public Result<MatchPreferenceDTO> updatePreference(@RequestBody MatchPreferenceRequest request) {
return Result.success(matchingService.updatePreference(request));
}
@GetMapping("/candidates")
public Result<List<FriendDTO>> candidates(@RequestParam(required = false, defaultValue = "20") Integer limit) {
return Result.success(matchingService.candidates(limit));
}
@PostMapping("/actions")
public Result<MatchActionDTO> action(@Valid @RequestBody MatchActionRequest request) {
return Result.success(matchingService.act(request));
}
@GetMapping
public Result<List<MatchPairDTO>> listPairs() {
return Result.success(matchingService.listPairs());
}
@DeleteMapping("/{pairId}")
public Result<Void> unmatch(@PathVariable String pairId) {
matchingService.unmatch(pairId);
return Result.success();
}
}

176
campus-server/src/main/java/com/campus/controller/MockCommerceController.java

@ -0,0 +1,176 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.response.PointTransactionDTO;
import com.campus.entity.CommerceOrder;
import com.campus.entity.LotteryDraw;
import com.campus.entity.UserEntitlement;
import com.campus.repository.CommerceOrderRepository;
import com.campus.repository.LotteryDrawRepository;
import com.campus.repository.UserEntitlementRepository;
import com.campus.service.CommerceService;
import com.campus.service.PointsService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 会员、钱包、青豆商城、任务和转盘接口。
* 现金支付相关能力仍保持关闭;青豆兑换、任务和抽奖均为真实持久化业务。
*/
@RestController
@RequestMapping("/api/campus")
@RequiredArgsConstructor
public class MockCommerceController {
private final PointsService pointsService;
private final CommerceService commerceService;
private final LotteryDrawRepository lotteryDrawRepository;
private final CommerceOrderRepository commerceOrderRepository;
private final UserEntitlementRepository entitlementRepository;
@GetMapping({"/membership", "/mock/membership"})
public Result<Map<String, Object>> membership() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("mock", true);
result.put("paymentRequired", false);
result.put("level", "FREE");
result.put("levelName", "青核体验会员");
result.put("expireAt", null);
result.put("benefits", java.util.Arrays.asList("基础社交功能", "每日签到积分", "活动报名"));
result.put("message", "会员购买功能暂未接入真实支付");
return Result.success(result);
}
@GetMapping({"/wallet", "/mock/wallet"})
public Result<Map<String, Object>> wallet() {
String userId = UserContext.getCurrentUserId();
Map<String, Object> result = new LinkedHashMap<>();
result.put("mock", true);
result.put("paymentRequired", false);
result.put("currency", "青豆");
result.put("balance", pointsService.getAccountInfo(userId).getPoints());
result.put("frozenBalance", 0);
result.put("canWithdraw", false);
result.put("message", "当前不支持真实充值、提现或现金支付");
return Result.success(result);
}
@GetMapping({"/wallet/bills", "/mock/wallet/bills"})
public Result<Map<String, Object>> bills(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<PointTransactionDTO> transactions = pointsService
.getTransactions(UserContext.getCurrentUserId(), page, size);
List<Map<String, Object>> items = transactions.getItems().stream().map(transaction -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", transaction.getId());
item.put("type", transaction.getType());
item.put("amount", transaction.getPoints());
item.put("description", transaction.getRemark() != null
? transaction.getRemark() : transaction.getRuleName());
item.put("status", "SUCCESS");
item.put("createdAt", transaction.getCreatedAt());
item.put("mock", false);
return item;
}).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("mock", false);
result.put("items", items);
result.put("total", transactions.getTotal());
result.put("page", transactions.getPage());
result.put("pageSize", transactions.getPageSize());
result.put("message", "青豆账单来自积分流水,不代表真实支付账单");
return Result.success(result);
}
@GetMapping({"/mall/products", "/mock/mall/products"})
public Result<Map<String, Object>> products() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("mock", false);
result.put("paymentRequired", false);
result.put("items", commerceService.listProducts());
result.put("currency", "青豆");
result.put("message", "商品使用青豆兑换;现金支付尚未接入");
return Result.success(result);
}
@GetMapping({"/mall/orders", "/mock/mall/orders"})
public Result<List<Map<String, Object>>> orders() {
List<Map<String, Object>> items = commerceOrderRepository
.findByUserIdOrderByCreatedAtDesc(UserContext.getCurrentUserId()).stream().map(this::orderMap)
.collect(Collectors.toList());
return Result.success(items);
}
@GetMapping("/mall/entitlements")
public Result<List<UserEntitlement>> entitlements() {
return Result.success(entitlementRepository
.findByUserIdOrderByCreatedAtDesc(UserContext.getCurrentUserId()));
}
@PostMapping({"/mall/purchase", "/mock/mall/purchase"})
public Result<Map<String, Object>> purchase(
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
@RequestBody(required = false) Map<String, Object> body) {
String productId = body == null || body.get("productId") == null
? "demo-theme" : String.valueOf(body.get("productId"));
return Result.success(commerceService.purchase(UserContext.getCurrentUserId(), productId, idempotencyKey));
}
@GetMapping({"/tasks", "/mock/tasks"})
public Result<Map<String, Object>> tasks() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("mock", false);
result.put("items", commerceService.listTasks(UserContext.getCurrentUserId()));
result.put("message", "任务定义、进度和青豆奖励已持久化");
return Result.success(result);
}
@PostMapping({"/tasks/{taskId}/claim", "/mock/tasks/{taskId}/claim"})
public Result<Map<String, Object>> claimTask(@PathVariable String taskId) {
return Result.success(commerceService.claimTask(UserContext.getCurrentUserId(), taskId));
}
@GetMapping({"/lottery", "/mock/lottery"})
public Result<Map<String, Object>> lottery() {
return Result.success(commerceService.lotteryInfo(UserContext.getCurrentUserId()));
}
@GetMapping({"/lottery/history", "/mock/lottery/history"})
public Result<List<LotteryDraw>> lotteryHistory() {
String today = java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).toString();
return Result.success(lotteryDrawRepository
.findByUserIdAndDrawDateOrderByDrawNumberAsc(UserContext.getCurrentUserId(), today));
}
@PostMapping({"/lottery/draw", "/mock/lottery/draw"})
public Result<Map<String, Object>> drawLottery(
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) {
return Result.success(commerceService.draw(UserContext.getCurrentUserId(), idempotencyKey));
}
private Map<String, Object> orderMap(CommerceOrder order) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", order.getId());
item.put("productId", order.getProductId());
item.put("productName", order.getProductName());
item.put("pricePoints", order.getPricePoints());
item.put("status", order.getStatus());
item.put("paymentMethod", order.getPaymentMethod());
item.put("createdAt", order.getCreatedAt());
return item;
}
}

325
campus-server/src/main/java/com/campus/controller/MomentController.java

@ -0,0 +1,325 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.common.BusinessException;
import com.campus.dto.request.CommentRequest;
import com.campus.dto.request.MomentRequest;
import com.campus.dto.response.MomentCommentDTO;
import com.campus.dto.response.MomentDTO;
import com.campus.dto.response.MomentLikeUserDTO;
import com.campus.dto.response.InteractionLikeStateDTO;
import com.campus.service.MomentLikeCommentService;
import com.campus.service.MomentService;
import com.campus.service.MomentShareCountService;
import com.campus.service.VideoPlayService;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/campus/moments")
@RequiredArgsConstructor
public class MomentController {
private final MomentService momentService;
private final MomentLikeCommentService likeCommentService;
private final VideoPlayService videoPlayService;
private final MomentShareCountService momentShareCountService;
@PostMapping("/{id}/block")
public Result<Void> block(@PathVariable String id) {
momentService.blockMoment(id);
return Result.success();
}
@DeleteMapping("/{id}/block")
public Result<Void> unblock(@PathVariable String id) {
momentService.unblockMoment(id);
return Result.success();
}
/**
* 更新动态的视频URL(后台上传完成后调用)
*/
@PutMapping("/video-url")
public Result<Void> updateVideoUrl(
@RequestParam String id,
@RequestParam String videoUrl) {
momentService.updateVideoUrl(id, videoUrl);
return Result.success();
}
/**
* 更新动态的 VOD 视频ID(VOD 直传完成后的后台回调)
*/
@PutMapping("/vod-video-id")
public Result<Void> updateVodVideoId(
@RequestParam String id,
@RequestParam String vodVideoId) {
momentService.updateVodVideoId(id, vodVideoId);
return Result.success();
}
/**
* 图片后台上传完成后,更新动态的 mediaUrls
*/
@PutMapping("/media-urls")
public Result<Void> updateMediaUrls(
@RequestParam String id,
@RequestBody MomentRequest request) {
momentService.updateMediaUrls(id, request.getMediaUrls());
return Result.success();
}
/**
* 动态列表。传 {@code tag} 时按单个标签精确筛选(话题详情页使用),
* 不传则按 {@code scope} 正常分页。
*/
@GetMapping
public Result<PageResult<MomentDTO>> list(
@RequestParam(required = false) String scope,
@RequestParam(required = false) String tag,
@RequestParam(required = false) String sort,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
if (tag != null && !tag.trim().isEmpty()) {
return Result.success(momentService.listByTag(tag, page, size));
}
return Result.success(momentService.list(scope, sort, page, size));
}
/** 同城动态分页;不传 city 时由后端根据当前用户资料解析。 */
@GetMapping({"/city", "/same-city"})
public Result<PageResult<MomentDTO>> listCityMoments(
@RequestParam(required = false) String city,
@RequestParam(required = false) String province,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(momentService.listCity(city, province, page, size));
}
@GetMapping("/user")
public Result<PageResult<MomentDTO>> listByUser(
@RequestParam String userId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(momentService.listByUserId(userId, page, size));
}
@GetMapping("/my")
public Result<PageResult<MomentDTO>> listMyMoments(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(momentService.listMyMoments(page, size));
}
/**
* 青春号列表(仅视频类型的动态)
*/
@GetMapping("/youth")
public Result<PageResult<MomentDTO>> listYouthVideos(
@RequestParam(required = false) String userId,
@RequestParam(required = false, defaultValue = "new") String sortBy,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(momentService.listYouthVideos(userId, sortBy, page, size));
}
/**
* 我的青春号视频列表
*/
@GetMapping("/my-youth")
public Result<PageResult<MomentDTO>> listMyYouthVideos(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(momentService.listMyYouthVideos(page, size));
}
@GetMapping("/hot")
public Result<PageResult<MomentDTO>> hot(
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(momentService.listHot(category, page, size));
}
@GetMapping("/detail")
public Result<MomentDTO> getById(@RequestParam String id) {
return Result.success(momentService.getById(id));
}
@PostMapping
public Result<MomentDTO> create(
@Valid @RequestBody MomentRequest request) {
return Result.success(momentService.create(request));
}
@PutMapping
public Result<MomentDTO> update(
@RequestParam String id,
@Valid @RequestBody MomentRequest request) {
return Result.success(momentService.update(id, request));
}
@DeleteMapping
public Result<Void> delete(@RequestParam String id) {
momentService.delete(id);
return Result.success();
}
@PostMapping("/like")
public Result<InteractionLikeStateDTO> like(@RequestParam String id) {
return Result.success(likeCommentService.toggleLikeState(id));
}
/** 将点赞设置为指定状态;相同状态请求可安全重放。 */
@PutMapping("/like")
public Result<InteractionLikeStateDTO> setLike(@RequestParam String id,
@RequestBody(required = false) JsonNode body) {
return Result.success(likeCommentService.setLike(id, requiredBoolean(body, "liked", "点赞状态不能为空")));
}
@PostMapping("/favorite")
public Result<Map<String, Object>> favorite(@RequestParam String id) {
boolean favorited = momentService.toggleFavorite(id);
long favoriteCount = momentService.getFavoriteCount(id);
Map<String, Object> result = new HashMap<>();
result.put("favorited", favorited);
result.put("favoriteCount", favoriteCount);
return Result.success(result);
}
@GetMapping("/favorite-count")
public Result<Map<String, Object>> getFavoriteCount(@RequestParam String id) {
Map<String, Object> result = new HashMap<>();
result.put("favoriteCount", momentService.getFavoriteCount(id));
result.put("isFavorited", momentService.isFavorited(id));
return Result.success(result);
}
@GetMapping("/favorites")
public Result<PageResult<MomentDTO>> listMyFavorites(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(momentService.listMyFavorites(page, size));
}
/**
* 获取点赞数(所有人可查看)
*/
@GetMapping("/like-count")
public Result<Map<String, Object>> getLikeCount(@RequestParam String id) {
long likeCount = likeCommentService.getLikeCount(id);
boolean isLiked = likeCommentService.isLiked(id);
Map<String, Object> result = new HashMap<>();
result.put("likeCount", likeCount);
result.put("isLiked", isLiked);
return Result.success(result);
}
/**
* 获取点赞用户列表(仅发布者查看)
*/
@GetMapping("/likes")
public Result<PageResult<MomentLikeUserDTO>> getLikeUsers(
@RequestParam String id,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(likeCommentService.getLikeUsers(id, page, size));
}
/**
* 添加评论
*/
@PostMapping("/comments")
public Result<MomentCommentDTO> addComment(
@Valid @RequestBody CommentRequest request) {
return Result.success(likeCommentService.addComment(request.getMomentId(), request));
}
/**
* 获取评论列表
*/
@GetMapping("/comments")
public Result<PageResult<MomentCommentDTO>> getComments(
@RequestParam String momentId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(likeCommentService.getComments(momentId, page, size));
}
/**
* 删除评论
*/
@DeleteMapping("/comments")
public Result<Void> deleteComment(
@RequestParam String momentId,
@RequestParam String commentId) {
likeCommentService.deleteComment(momentId, commentId);
return Result.success();
}
/**
* 评论点赞/取消点赞
*/
@PostMapping("/comments/like")
public Result<InteractionLikeStateDTO> likeComment(
@RequestParam String commentId) {
return Result.success(likeCommentService.toggleCommentLikeState(commentId));
}
/** 将评论点赞设置为指定状态;相同状态请求可安全重放。 */
@PutMapping("/comments/like")
public Result<InteractionLikeStateDTO> setCommentLike(
@RequestParam String commentId, @RequestBody(required = false) JsonNode body) {
return Result.success(likeCommentService.setCommentLike(commentId,
requiredBoolean(body, "liked", "点赞状态不能为空")));
}
/**
* 更新视频播放进度
*/
@PostMapping("/play-progress")
public Result<Void> updatePlayProgress(
@RequestParam String id,
@RequestParam(required = false, defaultValue = "0") Integer playPosition) {
if (playPosition == null || playPosition < 0) {
playPosition = 0;
}
videoPlayService.updatePlayProgress(id, playPosition);
return Result.success();
}
/**
* 获取视频播放进度
*/
@GetMapping("/play-progress")
public Result<Integer> getPlayProgress(@RequestParam String id) {
return Result.success(videoPlayService.getPlayProgress(id));
}
/**
* 记录视频分享
*/
@PostMapping("/share")
public Result<Void> shareVideo(@RequestParam String id,
@RequestParam(required = false) String requestId) {
momentShareCountService.incrementShare(id, requestId);
return Result.success();
}
private boolean requiredBoolean(JsonNode body, String field, String missingMessage) {
if (body == null || !body.has(field) || body.get(field).isNull()) {
throw new BusinessException(missingMessage);
}
JsonNode value = body.get(field);
if (!value.isBoolean()) {
throw new BusinessException("请求参数格式错误");
}
return value.booleanValue();
}
}

128
campus-server/src/main/java/com/campus/controller/NotificationController.java

@ -0,0 +1,128 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.NotificationRequest;
import com.campus.dto.response.NotificationDTO;
import com.campus.dto.response.UnreadCountDTO;
import com.campus.dto.response.UnreadDetailDTO;
import com.campus.service.NotificationService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
import java.util.Map;
import com.campus.entity.NotificationPreference;
import com.campus.entity.NotificationTemplate;
@RestController
@RequestMapping("/api/campus/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
/** 查询通知列表 */
@GetMapping
public Result<PageResult<NotificationDTO>> list(
@RequestParam(required = false) String type,
@RequestParam(required = false) Boolean isRead,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
String userId = UserContext.getCurrentUserId();
return Result.success(notificationService.list(userId, type, isRead, page, size));
}
/** 未读通知数量 */
@GetMapping("/unread-count")
public Result<UnreadCountDTO> getUnreadCount() {
String userId = UserContext.getCurrentUserId();
long total = notificationService.getUnreadCount(userId);
return Result.success(new UnreadCountDTO(total));
}
/** 未读数明细(通知 + 聊天分开返回,避免前端用减法间接计算) */
@GetMapping("/unread-detail")
public Result<UnreadDetailDTO> getUnreadDetail() {
String userId = UserContext.getCurrentUserId();
return Result.success(notificationService.getUnreadDetail(userId));
}
/** 全部未读总数(通知 + 聊天) */
@GetMapping("/total-unread")
public Result<UnreadCountDTO> getTotalUnreadCount() {
String userId = UserContext.getCurrentUserId();
return Result.success(notificationService.getTotalUnreadCount(userId));
}
/** 创建通知 */
@PostMapping
public Result<NotificationDTO> create(@Valid @RequestBody NotificationRequest request) {
String userId = UserContext.getCurrentUserId();
String senderId = request.getSenderId() != null ? request.getSenderId() : userId;
return Result.success(notificationService.create(request, userId, senderId));
}
/** 标记单条通知已读 */
@PutMapping("/{id}/read")
public Result<Void> markAsRead(@PathVariable String id) {
String userId = UserContext.getCurrentUserId();
notificationService.markAsRead(id, userId);
return Result.success();
}
/** 标记全部已读 */
@PutMapping("/read-all")
public Result<Void> markAllAsRead() {
String userId = UserContext.getCurrentUserId();
notificationService.markAllAsRead(userId);
return Result.success();
}
/** 删除通知 */
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
notificationService.delete(id, UserContext.getCurrentUserId());
return Result.success();
}
@PutMapping("/read-batch")
public Result<Void> readBatch(@RequestBody List<String> ids) {
notificationService.markBatchAsRead(UserContext.getCurrentUserId(), ids);
return Result.success();
}
@DeleteMapping("/batch")
public Result<Void> deleteBatch(@RequestBody List<String> ids) {
notificationService.deleteBatch(UserContext.getCurrentUserId(), ids);
return Result.success();
}
@GetMapping("/preferences")
public Result<NotificationPreference> preference() {
return Result.success(notificationService.getPreference(UserContext.getCurrentUserId()));
}
@PutMapping("/preferences")
public Result<NotificationPreference> savePreference(@RequestBody NotificationPreference preference) {
return Result.success(notificationService.savePreference(UserContext.getCurrentUserId(), preference));
}
@PostMapping("/admin/broadcast")
public Result<Map<String, Object>> broadcast(@RequestBody Map<String, String> body) {
int sent = notificationService.broadcast(body.get("title"), body.get("content"), body.get("link"));
return Result.success(java.util.Collections.singletonMap("sent", sent));
}
@GetMapping("/admin/templates")
public Result<List<NotificationTemplate>> templates() {
return Result.success(notificationService.listTemplates());
}
@PostMapping("/admin/templates")
public Result<NotificationTemplate> saveTemplate(@RequestBody NotificationTemplate template) {
return Result.success(notificationService.saveTemplate(template));
}
}

37
campus-server/src/main/java/com/campus/controller/NotificationSseController.java

@ -0,0 +1,37 @@
package com.campus.controller;
import com.campus.service.SseService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* 通知 SSE 长连接端点
* <p>
* 前端建立连接后,服务端通过此通道实时推送新通知事件,替代原有的 30 秒轮询。
*/
@Slf4j
@RestController
@RequestMapping("/api/campus/notifications")
@RequiredArgsConstructor
public class NotificationSseController {
private final SseService sseService;
/**
* SSE 订阅端点
* <p>
* 前端调用此接口建立长连接,服务端通过 SSE 协议实时推送通知。
* 连接保持时间最长 30 分钟,之后前端应自动重连。
*/
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
String userId = UserContext.getCurrentUserId();
return sseService.subscribe(userId);
}
}

148
campus-server/src/main/java/com/campus/controller/OcrController.java

@ -0,0 +1,148 @@
package com.campus.controller;
import com.campus.common.BusinessException;
import com.campus.common.Result;
import com.campus.common.ServiceUnavailableException;
import com.campus.dto.response.ChsiReportDTO;
import com.campus.service.AliOcrService;
import com.campus.service.XuexinReportService;
import com.campus.util.OssUtil;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* OCR文字识别控制器
*
* 接口列表:
* POST /api/ocr/chsi-report - 识别学信网报告(上传单页 PDF)
* POST /api/ocr/raw - 通用OCR识别(返回原始文本,调试用)
*
* <p>识别通过({@code validChsiReport=true})时会同时把结果落成一条
* {@code xuexin_report} 临时材料,并在响应里带回 {@code reportId} 与 {@code dedup}。
* 前端用 {@code dedup.status} 就能在 Step1 提示「该学信网报告已被认证」,
* Step2 提交时必须带上 {@code reportId}。</p>
*/
@Slf4j
@RestController
@RequestMapping("/api/ocr")
@RequiredArgsConstructor
@CrossOrigin
public class OcrController {
private final AliOcrService aliOcrService;
private final XuexinReportService xuexinReportService;
private final OssUtil ossUtil;
/**
* 识别学信网报告(上传 PDF 方式)
*
* @param file 从学信网下载的单页 PDF
* @return 识别出的结构化信息(姓名、性别、学校、专业、层次等)+ reportId + dedup
*/
@PostMapping(value = "/chsi-report", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<ChsiReportDTO> recognizeChsiReport(
@RequestParam("file") MultipartFile file) {
log.info("学信网报告OCR识别请求: size={}KB", file.getSize() / 1024);
long start = System.currentTimeMillis();
ChsiReportDTO result = aliOcrService.recognizeChsiReport(file);
long cost = System.currentTimeMillis() - start;
log.info("学信网报告OCR识别完成: success={}, 耗时={}ms", result.isSuccess(), cost);
if (!result.isSuccess()) {
throw toBusinessException(result);
}
String reportPdfUrl;
try {
reportPdfUrl = ossUtil.uploadFile(file, "xuexin-reports");
} catch (Exception e) {
log.error("学信网报告PDF存证失败", e);
throw new ServiceUnavailableException("认证材料存储服务暂时不可用,请稍后重试");
}
try {
attachDedup(result, reportPdfUrl);
if (result.getReportId() == null) {
safeDelete(reportPdfUrl);
}
} catch (RuntimeException e) {
safeDelete(reportPdfUrl);
throw e;
}
return Result.success(result);
}
/**
* 通用OCR识别接口(返回原始文本行列表,调试用)
*
* 用于排查解析问题,查看OCR实际识别出了什么文字
*
* @param file 图片文件
* @return OCR识别出的所有文本行
*/
@PostMapping(value = "/raw", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<List<String>> recognizeRaw(
@RequestParam("file") MultipartFile file) {
log.info("通用OCR识别请求: fileName={}, size={}KB",
file.getOriginalFilename(),
file.getSize() / 1024);
try {
List<String> lines = aliOcrService.recognizeRaw(file);
return Result.success(lines);
} catch (Exception e) {
log.error("通用OCR识别失败", e);
throw new ServiceUnavailableException("OCR识别服务暂时不可用,请稍后重试");
}
}
/**
* 识别成功后落库并判定这份材料是否已被认证。
*
* <p>判重失败不能影响识别本身:OCR 结果照常返回,只是没有 {@code reportId},
* 前端也就无法进入 Step2 提交。</p>
*/
private void attachDedup(ChsiReportDTO result, String reportPdfUrl) {
String userId = UserContext.getCurrentUserId();
if (userId == null) {
throw new BusinessException("用户未登录");
}
try {
result.setDedup(xuexinReportService.register(userId, result, reportPdfUrl));
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("学信网材料落库失败: userId={}", userId, e);
throw new ServiceUnavailableException("认证材料保存服务暂时不可用,请稍后重试");
}
}
private void safeDelete(String fileUrl) {
try {
ossUtil.deleteFile(fileUrl);
} catch (Exception e) {
log.warn("清理未使用的学信网PDF失败", e);
}
}
/** 内容不合格 → 400;OCR 服务本身故障 → 503。 */
private static RuntimeException toBusinessException(ChsiReportDTO result) {
String msg = result.getErrorMsg();
if (msg == null || msg.trim().isEmpty()) {
msg = "上传的 PDF 不是有效的学信网验证报告";
}
if (msg.startsWith("识别失败")) {
log.warn("OCR服务调用失败: {}", msg);
return new ServiceUnavailableException("OCR识别服务暂时不可用,请稍后重试");
}
return new BusinessException(msg);
}
}

118
campus-server/src/main/java/com/campus/controller/OssController.java

@ -0,0 +1,118 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.config.OssProperties;
import com.campus.dto.response.StsTokenResponse;
import com.campus.service.OssStsService;
import com.campus.service.VodService;
import com.campus.util.OssUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* OSS 相关接口
* - STS 临时凭证获取
* - OSS 预签名上传 URL(用于图片等非视频文件)
* - VOD 转码/审核完成回调
*
* 注意:视频上传已迁移至 VOD 直传方案,请使用 /api/vod/upload
*/
@Slf4j
@RestController
@RequestMapping("/api/oss")
@RequiredArgsConstructor
public class OssController {
private final OssStsService ossStsService;
private final VodService vodService;
private final OssUtil ossUtil;
private final OssProperties ossProperties;
/**
* 获取 STS 临时上传凭证
* GET /api/oss/get-sts-token
*
* 返回临时 AK/SK/Token,前端用于直传 OSS
*/
@GetMapping("/get-sts-token")
public Result<StsTokenResponse> getStsToken() {
StsTokenResponse token = ossStsService.getStsToken();
return Result.success(token);
}
/**
* 生成 OSS 预签名上传 URL
* POST /api/oss/presigned-url
*
* 前端拿到 presigned URL 后直接用 HTTP PUT 上传,无需 OSS SDK
*/
@PostMapping("/presigned-url")
public Result<Map<String, Object>> getPresignedUrl(@RequestBody Map<String, String> body) {
String fileName = body.getOrDefault("fileName", "upload.mp4");
String contentType = body.getOrDefault("contentType", "video/mp4");
// 生成 OSS key
String key = ossUtil.generateFilePath("videos", fileName);
// 生成预签名 URL
String presignedUrl = ossUtil.generatePresignedUrl(key, contentType, 3600);
Map<String, Object> result = new HashMap<>();
result.put("presignedUrl", presignedUrl);
result.put("ossKey", key);
String domain = ossProperties.getDomain();
if (!domain.endsWith("/")) {
domain += "/";
}
result.put("cdnUrl", domain + key);
return Result.success(result);
}
/**
* OSS 上传完成回调
* POST /api/oss/upload-callback
*
* 注意:视频上传已迁移至 VOD 直传(/api/vod/upload),此回调仅用于图片等非视频文件
* 文件上传后由 OSS 自动触发,后端无需额外处理
*/
@PostMapping("/upload-callback")
public Result<Map<String, Object>> uploadCallback(
@RequestParam(required = false) String key,
@RequestParam(required = false) String name,
@RequestParam(required = false) String size,
@RequestParam(required = false) String md5,
@RequestParam(required = false) String bucket) {
log.info("OSS上传回调: key={}, name={}, size={}, bucket={}", key, name, size, bucket);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
return Result.success(result);
}
/**
* 阿里云 VOD 处理结果回调
* POST /api/oss/vod-callback
*
* VOD 转码/审核完成后回调此接口,更新数据库
*/
@PostMapping("/vod-callback")
public Result<Map<String, Object>> vodCallback(@RequestBody Map<String, Object> body) {
log.info("VOD回调: {}", body);
try {
vodService.handleVodCallback(body);
} catch (Exception e) {
log.error("VOD回调处理失败", e);
}
Map<String, Object> result = new HashMap<>();
result.put("success", true);
return Result.success(result);
}
}

113
campus-server/src/main/java/com/campus/controller/PointsController.java

@ -0,0 +1,113 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.AdjustPointsRequest;
import com.campus.dto.response.PointRuleDTO;
import com.campus.dto.response.PointTransactionDTO;
import com.campus.dto.response.PointsAccountDTO;
import com.campus.entity.UserPoints;
import com.campus.service.PointsService;
import com.campus.service.UserPointsService;
import com.campus.service.AuthorizationService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
/**
* 积分控制器
*/
@RestController
@RequestMapping("/api/campus/points")
@RequiredArgsConstructor
public class PointsController {
private final PointsService pointsService;
private final UserPointsService userPointsService;
private final AuthorizationService authorizationService;
/**
* 获取我的积分账户信息
*/
@GetMapping("/account")
public Result<PointsAccountDTO> getMyAccount() {
String userId = UserContext.getCurrentUserId();
return Result.success(pointsService.getAccountInfo(userId));
}
/**
* 获取指定用户的积分账户信息
*/
@GetMapping("/account-other")
public Result<PointsAccountDTO> getAccount(@RequestParam String userId) {
authorizationService.requireAdmin();
return Result.success(pointsService.getAccountInfo(userId));
}
/**
* 每日签到
*/
@PostMapping("/check-in")
public Result<PointTransactionDTO> checkIn() {
String userId = UserContext.getCurrentUserId();
return Result.success(toTransactionDTO(pointsService.dailyCheckIn(userId)));
}
/**
* 获取我的积分变动记录
*/
@GetMapping("/transactions")
public Result<PageResult<PointTransactionDTO>> getMyTransactions(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
String userId = UserContext.getCurrentUserId();
return Result.success(pointsService.getTransactions(userId, page, size));
}
/**
* 获取积分规则列表
*/
@GetMapping("/rules")
public Result<List<PointRuleDTO>> getRules() {
return Result.success(pointsService.getActiveRules());
}
/**
* 获取积分排行榜
*/
@GetMapping("/ranking")
public Result<List<PointsAccountDTO>> getRanking(
@RequestParam(defaultValue = "10") int top) {
return Result.success(pointsService.getPointsRanking(top));
}
/**
* 手动调整用户积分(正数增加,负数扣除)
*/
@PostMapping("/adjust")
public Result<UserPoints> adjustPoints(@Valid @RequestBody AdjustPointsRequest request) {
authorizationService.requireAdmin();
return Result.success(userPointsService.addPoints(request.getUserId(), request.getAmount()));
}
private PointTransactionDTO toTransactionDTO(com.campus.entity.PointTransaction t) {
if (t == null) {
return null;
}
PointTransactionDTO dto = new PointTransactionDTO();
dto.setId(t.getId());
dto.setRuleCode(t.getRuleCode());
dto.setRuleName(t.getRuleName());
dto.setType(t.getType());
dto.setPoints(t.getPoints());
dto.setBalanceAfter(t.getBalanceAfter());
dto.setRelatedType(t.getRelatedType());
dto.setRelatedId(t.getRelatedId());
dto.setCreatedAt(t.getCreatedAt());
dto.setRemark(t.getRemark());
return dto;
}
}

31
campus-server/src/main/java/com/campus/controller/PrivacySettingsController.java

@ -0,0 +1,31 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.PrivacySettingsRequest;
import com.campus.dto.response.PrivacySettingsDTO;
import com.campus.service.PrivacySettingsService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 设置页使用的隐私开关接口。 */
@RestController
@RequestMapping("/api/users/privacy")
@RequiredArgsConstructor
public class PrivacySettingsController {
private final PrivacySettingsService privacySettingsService;
@GetMapping
public Result<PrivacySettingsDTO> get() {
return Result.success(privacySettingsService.getCurrent());
}
@PutMapping
public Result<PrivacySettingsDTO> update(@RequestBody PrivacySettingsRequest request) {
return Result.success(privacySettingsService.update(request));
}
}

148
campus-server/src/main/java/com/campus/controller/ProfileController.java

@ -0,0 +1,148 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.XuexinSubmitRequest;
import com.campus.entity.User;
import com.campus.entity.UserPoints;
import com.campus.entity.XuexinReport;
import com.campus.service.AuthorizationService;
import com.campus.service.UserPointsService;
import com.campus.service.UserService;
import com.campus.service.ProfileService;
import com.campus.service.XuexinReportService;
import com.campus.util.OssUtil;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/profile")
@RequiredArgsConstructor
public class ProfileController {
private final UserService userService;
private final ProfileService profileService;
private final UserPointsService userPointsService;
private final OssUtil ossUtil;
private final AuthorizationService authorizationService;
private final XuexinReportService xuexinReportService;
@GetMapping
public Result<User> getMyProfile() {
String userId = UserContext.getCurrentUserId();
return Result.success(userService.getUserById(userId));
}
@GetMapping("/other")
public Result<User> getProfile(@RequestParam String userId) {
return Result.success(userService.getPublicUserById(userId));
}
@PutMapping
public Result<User> updateProfile(@RequestBody User updateData) {
String userId = UserContext.getCurrentUserId();
return Result.success(profileService.updateProfile(UserContext.getCurrentUserId(), userId, updateData));
}
@PutMapping("/other")
public Result<User> updateProfile(
@RequestParam String userId,
@RequestBody User updateData) {
authorizationService.requireAdmin();
return Result.success(profileService.updateProfile(UserContext.getCurrentUserId(), userId, updateData));
}
/**
* 学信网认证 — 提交接口。
*
* <p>前置条件:Step1 调 {@code POST /api/ocr/chsi-report} 已拿到 {@code reportId},
* 且其结果里 {@code dedup.status=AVAILABLE}。</p>
*
* <p>本接口<b>不再信任请求体里的教育字段</b>:姓名/学校/学历等一律以
* {@code xuexin_report} 表中的 OCR 记录为准,请求体只需 {@code reportId}
* 且客户端不能覆盖 PDF 中识别出的认证字段。
* 这样即使前端伪造 {@code validChsiReport},也拿不到认证。</p>
*
* <p>完整校验与写入逻辑见 {@link XuexinReportService#submit}:</p>
* <ul>
* <li>材料不存在 / 不属于当前用户 → 400「未找到认证材料,请重新上传报告」</li>
* <li>材料已过期或被替换 → 400「该认证材料已失效,请重新上传报告」</li>
* <li>已认证且换更低学历 → 400「…不支持更换为更低学历…」</li>
* <li>已认证未满冷静期 → 400「认证通过后 3 天内不可更换认证材料,请在 … 后重试」</li>
* <li>重复提交同一份材料 → 幂等成功,原样返回当前用户</li>
* </ul>
*/
@PostMapping("/xuexin/submit")
public Result<User> submitXuexin(@RequestBody XuexinSubmitRequest req) {
String userId = UserContext.getCurrentUserId();
return Result.success(xuexinReportService.submit(userId, req));
}
/** 上传并立即保存当前用户头像,不依赖编辑资料页的其他字段保存。 */
@PostMapping(value = "/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<User> uploadAvatar(@RequestParam("file") MultipartFile file) {
String userId = UserContext.getCurrentUserId();
User user = userService.getUserById(userId);
String avatarUrl = ossUtil.uploadAvatar(file);
user.setAvatar(avatarUrl);
userService.saveUser(user);
return Result.success(user);
}
/**
* 查询当前用户学信网认证状态和已确认的学籍信息。
*
* <p>新增 {@code certifiedAt}(认证时间)、{@code reportId}(当前生效材料),
* 以及新版报告上的分院、系所、日期、民族、学历类别、学习形式和在线验证码。</p>
*/
@GetMapping("/xuexin/status")
public Result<Map<String, Object>> xuexinStatus() {
String userId = UserContext.getCurrentUserId();
User user = userService.getUserById(userId);
XuexinReport activeReport = xuexinReportService.findActiveReport(userId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("certified", Boolean.TRUE.equals(user.getXuexinCertified()));
result.put("number", user.getNumber());
result.put("school", user.getUniversityName());
result.put("department", user.getCollegeName());
result.put("major", user.getMajorName());
result.put("level", user.getEducation());
result.put("grade", user.getGrade());
result.put("enrollmentStatus", user.getXuexinEnrollmentStatus());
result.put("certifiedAt", user.getXuexinCertifiedAt());
result.put("reportId", user.getXuexinReportId());
result.put("reportName", activeReport == null ? null : activeReport.getReportName());
result.put("reportUpdatedDate", activeReport == null ? null : activeReport.getReportUpdatedDate());
result.put("name", activeReport == null ? null : activeReport.getName());
result.put("gender", activeReport == null ? null : activeReport.getGender());
result.put("birthDate", activeReport == null ? null : activeReport.getBirthDate());
result.put("ethnicity", activeReport == null ? null : activeReport.getEthnicity());
result.put("studyDuration", activeReport == null ? null : activeReport.getStudyDuration());
result.put("educationCategory", activeReport == null ? null : activeReport.getEducationCategory());
result.put("studyForm", activeReport == null ? null : activeReport.getStudyForm());
result.put("branchCampus", activeReport == null ? null : activeReport.getBranchCampus());
result.put("enrollmentDate", activeReport == null ? null : activeReport.getEnrollmentDate());
result.put("departureDate", activeReport == null ? null : activeReport.getDepartureDate());
result.put("onlineVerificationCode", activeReport == null ? null : activeReport.getOnlineVerificationCode());
return Result.success(result);
}
@GetMapping("/points")
public Result<UserPoints> getMyPoints() {
String userId = UserContext.getCurrentUserId();
return Result.success(userPointsService.getUserPoints(userId));
}
@GetMapping("/points-other")
public Result<UserPoints> getUserPoints(@RequestParam String userId) {
authorizationService.requireAdmin();
return Result.success(userPointsService.getUserPoints(userId));
}
}

67
campus-server/src/main/java/com/campus/controller/PushController.java

@ -0,0 +1,67 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.entity.UserDeviceToken;
import com.campus.repository.UserDeviceTokenRepository;
import com.campus.service.PushService;
import com.campus.util.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
@Slf4j
@RestController
@RequestMapping("/api/campus/push")
@RequiredArgsConstructor
public class PushController {
private final PushService pushService;
private final UserDeviceTokenRepository deviceTokenRepository;
/**
* 注册/更新设备推送Token
*/
@PostMapping("/register")
public Result<Void> registerDevice(@RequestBody Map<String, String> body) {
String userId = UserContext.getCurrentUserId();
String deviceToken = body.get("deviceToken");
String platform = body.getOrDefault("platform", "ANDROID");
if (deviceToken == null || deviceToken.isEmpty()) {
return Result.error("deviceToken不能为空");
}
// 去重:同一用户+同一设备不重复注册
Optional<UserDeviceToken> existing = deviceTokenRepository.findByUserIdAndDeviceToken(userId, deviceToken);
if (!existing.isPresent()) {
UserDeviceToken token = new UserDeviceToken();
token.setUserId(userId);
token.setDeviceToken(deviceToken);
token.setPlatform(platform.toUpperCase());
deviceTokenRepository.save(token);
}
// 调用阿里云绑定接口
pushService.bindAccount(userId, deviceToken, platform);
log.info("[推送] 设备注册成功 -> userId={}, platform={}", userId, platform);
return Result.success();
}
/**
* 解绑设备推送Token(退出登录时调用)
*/
@PostMapping("/unregister")
public Result<Void> unregisterDevice(@RequestBody Map<String, String> body) {
String userId = UserContext.getCurrentUserId();
String deviceToken = body.get("deviceToken");
if (deviceToken != null && !deviceToken.isEmpty()) {
deviceTokenRepository.deleteByDeviceToken(deviceToken);
}
log.info("[推送] 设备解绑 -> userId={}", userId);
return Result.success();
}
}

35
campus-server/src/main/java/com/campus/controller/QrScanController.java

@ -0,0 +1,35 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.QrScanResultDTO;
import com.campus.service.QrScanService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/** 扫一扫:二维码解码和青核业务路由解析。 */
@RestController
@RequestMapping("/api/qr")
@RequiredArgsConstructor
public class QrScanController {
private final QrScanService qrScanService;
@PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<QrScanResultDTO> scan(@RequestPart("file") MultipartFile file) {
return Result.success(qrScanService.decodeAndResolve(file));
}
/** 客户端已经完成扫码时,直接提交二维码文本解析业务目标。 */
@GetMapping("/resolve")
public Result<QrScanResultDTO> resolve(@RequestParam String text) {
return Result.success(qrScanService.resolve(text));
}
}

75
campus-server/src/main/java/com/campus/controller/RadarController.java

@ -0,0 +1,75 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.RadarDTO;
import com.campus.service.RadarService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 校园雷达控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/campus/radar")
@RequiredArgsConstructor
public class RadarController {
private final RadarService radarService;
/**
* 更新用户位置
*/
@PostMapping("/location")
public Result<Void> updateLocation(@RequestBody Map<String, Double> body) {
Double latitude = body.get("latitude");
Double longitude = body.get("longitude");
if (latitude == null || longitude == null) {
return Result.error("纬度和经度不能为空");
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return Result.error("无效的经纬度范围");
}
try {
radarService.updateLocation(latitude, longitude);
return Result.success();
} catch (Exception e) {
log.error("更新位置失败", e);
return Result.error(e.getMessage());
}
}
/**
* 扫描附近用户和活动
*
* @param mode 模式:nearby-附近, school-校内
* @param radius 半径(米),默认500
*/
@GetMapping("/scan")
public Result<RadarDTO> scan(
@RequestParam(defaultValue = "nearby") String mode,
@RequestParam(defaultValue = "500") Integer radius) {
if (!"nearby".equals(mode) && !"school".equals(mode)) {
return Result.error("无效的模式,必须是 nearby 或 school");
}
if (radius != null && (radius < 50 || radius > 10000)) {
return Result.error("扫描范围必须在 50-10000 米之间");
}
try {
RadarDTO result = radarService.scan(mode, radius);
return Result.success(result);
} catch (Exception e) {
log.error("扫描失败", e);
return Result.error(e.getMessage());
}
}
}

54
campus-server/src/main/java/com/campus/controller/ReportController.java

@ -0,0 +1,54 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.common.PageResult;
import com.campus.dto.request.ReportRequest;
import com.campus.dto.response.ReportDTO;
import com.campus.service.ReportService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
/** 举报基础接口;审核处理接口在后续步骤增加。 */
@RestController
@RequestMapping("/api/reports")
@RequiredArgsConstructor
public class ReportController {
private final ReportService reportService;
@PostMapping
public Result<ReportDTO> create(@Valid @RequestBody ReportRequest request) {
return Result.success(reportService.create(request));
}
@GetMapping("/mine")
public Result<List<ReportDTO>> listMine() {
return Result.success(reportService.listMine());
}
@GetMapping("/pending")
public Result<PageResult<ReportDTO>> listPending(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(reportService.listPending(page, size));
}
@PutMapping("/{id}/review")
public Result<ReportDTO> review(
@PathVariable String id,
@RequestParam String status,
@RequestParam(defaultValue = "NONE") String action,
@RequestParam(required = false) String note) {
return Result.success(reportService.review(id, status, action, note));
}
}

81
campus-server/src/main/java/com/campus/controller/ReviewController.java

@ -0,0 +1,81 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.service.AnnouncementService;
import com.campus.service.ActivityService;
import com.campus.service.MomentService;
import com.campus.service.AuthorizationService;
import com.campus.service.ModerationService;
import com.campus.common.PageResult;
import com.campus.dto.request.BatchContentReviewRequest;
import com.campus.dto.response.ContentReviewDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import javax.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/api/campus/review")
@RequiredArgsConstructor
public class ReviewController {
private final ActivityService activityService;
private final AnnouncementService announcementService;
private final MomentService momentService;
private final AuthorizationService authorizationService;
private final ModerationService moderationService;
@GetMapping("/workbench")
public Result<PageResult<ContentReviewDTO>> workbench(
@RequestParam(defaultValue = "pending") String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(moderationService.workbench(status, page, size));
}
@GetMapping("/history")
public Result<PageResult<ContentReviewDTO>> history(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(moderationService.history(page, size));
}
@PutMapping("/batch")
public Result<List<ContentReviewDTO>> batchReview(
@Valid @RequestBody BatchContentReviewRequest request) {
return Result.success(moderationService.batchReview(request));
}
@PutMapping("/activities")
public Result<com.campus.dto.response.ActivityDTO> reviewActivity(
@RequestParam String id,
@RequestParam boolean approved,
@RequestParam(required = false) String reason) {
authorizationService.requireReviewerOrAdmin();
return Result.success(activityService.reviewActivity(id, approved, reason));
}
@PutMapping("/announcements")
public Result<com.campus.dto.response.AnnouncementDTO> reviewAnnouncement(
@RequestParam String id,
@RequestParam String status,
@RequestParam(required = false) String reason) {
authorizationService.requireReviewerOrAdmin();
return Result.success(announcementService.review(id, status, reason));
}
@PutMapping("/moments")
public Result<com.campus.dto.response.MomentDTO> reviewMoment(
@RequestParam String id,
@RequestParam boolean approved,
@RequestParam(required = false) String reason) {
authorizationService.requireReviewerOrAdmin();
return Result.success(momentService.reviewMoment(id, approved, reason));
}
}

49
campus-server/src/main/java/com/campus/controller/SensitiveWordController.java

@ -0,0 +1,49 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.entity.SensitiveWord;
import com.campus.service.SensitiveWordService;
import com.campus.service.AuthorizationService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/sensitive-words")
@RequiredArgsConstructor
public class SensitiveWordController {
private final SensitiveWordService sensitiveWordService;
private final AuthorizationService authorizationService;
@GetMapping
public Result<PageResult<SensitiveWord>> list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
authorizationService.requireAdmin();
int safePage = Math.max(page, 1);
int safeSize = Math.min(Math.max(size, 1), 100);
Page<SensitiveWord> result = sensitiveWordService.getAllWords(
org.springframework.data.domain.PageRequest.of(safePage - 1, safeSize));
return Result.success(PageResult.of(result.getContent(), result.getTotalElements(), safePage, safeSize));
}
@PostMapping
public Result<SensitiveWord> create(@RequestBody SensitiveWord word) {
authorizationService.requireAdmin();
return Result.success(sensitiveWordService.addWord(word));
}
@PutMapping
public Result<SensitiveWord> update(@RequestParam String id, @RequestBody SensitiveWord word) {
authorizationService.requireAdmin();
return Result.success(sensitiveWordService.updateWord(id, word));
}
@DeleteMapping
public Result<Void> delete(@RequestParam String id) {
authorizationService.requireAdmin();
sensitiveWordService.deleteWord(id);
return Result.success();
}
}

33
campus-server/src/main/java/com/campus/controller/ShareController.java

@ -0,0 +1,33 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.ShareToFriendsRequest;
import com.campus.dto.request.ShareToGroupRequest;
import com.campus.dto.response.ShareSendResultDTO;
import com.campus.service.ShareService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/** App 内分享:批量分享到好友,或分享到一个群聊。 */
@RestController
@RequestMapping("/api/campus/shares")
@RequiredArgsConstructor
public class ShareController {
private final ShareService shareService;
@PostMapping("/friends")
public Result<ShareSendResultDTO> sendToFriends(@Valid @RequestBody ShareToFriendsRequest request) {
return Result.success(shareService.sendToFriends(request));
}
@PostMapping("/group")
public Result<ShareSendResultDTO> sendToGroup(@Valid @RequestBody ShareToGroupRequest request) {
return Result.success(shareService.sendToGroup(request));
}
}

24
campus-server/src/main/java/com/campus/controller/SocialGraphController.java

@ -0,0 +1,24 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.SocialGraphDTO;
import com.campus.service.SocialGraphService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/social-graph")
@RequiredArgsConstructor
public class SocialGraphController {
private final SocialGraphService socialGraphService;
@GetMapping
public Result<SocialGraphDTO> current(
@RequestParam(required = false, defaultValue = "200") Integer maxNodes) {
return Result.success(socialGraphService.getCurrentGraph(maxNodes));
}
}

178
campus-server/src/main/java/com/campus/controller/TopicController.java

@ -0,0 +1,178 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.TopicReviewRequest;
import com.campus.dto.response.MomentDTO;
import com.campus.dto.response.MyTopicsDTO;
import com.campus.dto.response.TopicDTO;
import com.campus.dto.response.TopicFavoriteDTO;
import com.campus.dto.response.TopicFollowDTO;
import com.campus.service.TopicDiscussionService;
import com.campus.service.TopicService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 话题广场接口。
*
* <p>领域边界:本控制器下的数据接口只返回话题域数据(topic 系列表 + 话题讨论),
* 不返回任何从动态派生出来的统计。动态内容请调动态域 {@code GET /api/campus/moments},
* 话题讨论请调 {@code GET /api/topics/{topicName}/discussions}。</p>
*
* <p>已废弃的 {@code GET /api/topics/{topicName}/moments} 保留 30 天,行为改为转发到
* 话题讨论列表(与新接口一致),因为它就是"话题页数据入口",语义必须跟话题页一致。</p>
*/
@RestController
@RequestMapping("/api/topics")
@RequiredArgsConstructor
public class TopicController {
private final TopicService topicService;
/** 兼容期内的讨论列表转发,话题统计不使用它。 */
private final TopicDiscussionService topicDiscussionService;
@GetMapping
public Result<List<TopicDTO>> list(@RequestParam(required = false, defaultValue = "20") Integer limit) {
return Result.success(topicService.listTopics(limit));
}
@GetMapping("/categories")
public Result<List<String>> categories() {
return Result.success(topicService.categories());
}
@GetMapping("/catalog")
public Result<PageResult<TopicDTO>> catalog(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "hot") String sort,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(topicService.catalog(keyword, category, sort, page, size));
}
@GetMapping("/rankings")
public Result<List<TopicDTO>> rankings(@RequestParam(defaultValue = "hot") String type,
@RequestParam(defaultValue = "20") Integer limit) {
return Result.success(topicService.rankings(type, limit));
}
@GetMapping("/recommendations")
public Result<List<TopicDTO>> recommendations(@RequestParam(defaultValue = "10") Integer limit) {
return Result.success(topicService.recommendations(limit));
}
@GetMapping("/my")
public Result<MyTopicsDTO> myTopics() {
return Result.success(topicService.myTopics());
}
@GetMapping("/pending")
public Result<List<TopicDTO>> pendingTopics() {
return Result.success(topicService.pendingTopics());
}
@GetMapping("/{topicName}")
public Result<TopicDTO> detail(@PathVariable String topicName) {
return Result.success(topicService.getTopic(topicName));
}
@PostMapping("/{topicName}/visit")
public Result<Void> visit(@PathVariable String topicName) {
topicService.recordVisit(topicName);
return Result.success();
}
/**
* 已废弃:话题下的内容列表。
*
* <p>行为已改为转发话题讨论列表(按 {@code topic_id} 查,只出讨论),与
* {@code GET /api/topics/{topicName}/discussions} 完全一致;前端应改用新路由,
* 本路由计划 30 天后下线。</p>
*
* @deprecated 使用 {@code GET /api/topics/{topicName}/discussions}
*/
@Deprecated
@GetMapping("/{topicName}/moments")
public Result<PageResult<MomentDTO>> moments(
@PathVariable String topicName,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(topicDiscussionService.list(topicName, "latest", page, size));
}
@PostMapping("/{topicName}/follow")
public Result<TopicFollowDTO> toggleFollow(@PathVariable String topicName) {
return Result.success(topicService.toggleFollow(topicName));
}
/**
* 收藏 / 取消收藏话题(toggle)。
*
* <p>返回最新状态与收藏数,前端用它直接刷新按钮与统计栏,不必再拉一次详情。</p>
*/
@PostMapping("/{topicName}/favorite")
public Result<TopicFavoriteDTO> toggleFavorite(@PathVariable String topicName) {
return Result.success(topicService.toggleFavorite(topicName));
}
@GetMapping("/following/list")
public Result<List<TopicDTO>> following() {
return Result.success(topicService.listFollowing());
}
@PostMapping
public Result<TopicDTO> create(@RequestBody Map<String, Object> body) {
return Result.success(topicService.createTopic((String) body.get("name"),
(String) body.get("description"), (String) body.get("category"),
(String) body.get("coverUrl")));
}
@PutMapping("/{id}")
public Result<TopicDTO> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
return Result.success(topicService.updateTopic(id, (String) body.get("description"),
(String) body.get("category"), (String) body.get("coverUrl"),
(Boolean) body.get("recommended"), (Boolean) body.get("pinned")));
}
/**
* 删除话题:软删 + 改名释放名称(原名称之后可以重新创建)。
* 权限:作者本人或管理员。
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
topicService.deleteTopic(id);
return Result.success();
}
/**
* 审核话题:同时兼容 {@code status/reviewNote}(新)与 {@code approved/note}(旧)。
* 两者都缺 → 400「审核结果不能为空」;都给且冲突 → 400「审核结果不明确」。
*/
@PutMapping("/{id}/review")
public Result<TopicDTO> review(@PathVariable String id, @RequestBody TopicReviewRequest body) {
return Result.success(topicService.reviewTopic(id, body));
}
@PostMapping("/{id}/aliases")
public Result<TopicDTO> addAlias(@PathVariable String id, @RequestBody Map<String, Object> body) {
return Result.success(topicService.addAlias(id, (String) body.get("alias")));
}
@PostMapping("/{id}/merge")
public Result<TopicDTO> merge(@PathVariable String id, @RequestBody Map<String, Object> body) {
return Result.success(topicService.merge(id, (String) body.get("targetTopicId")));
}
}

97
campus-server/src/main/java/com/campus/controller/TopicDiscussionController.java

@ -0,0 +1,97 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.dto.request.TopicDiscussionRequest;
import com.campus.dto.response.MomentDTO;
import com.campus.service.TopicDiscussionService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
* 话题讨论接口(话题板块自己的内容入口)。
*
* <p>与动态域彻底分离:讨论只看 {@code topic_id},{@code moment.tags} 是动态的分类标签,
* 不参与任何话题归属判断。因此带话题标签的动态**不会**出现在这里,
* 这里发出的讨论也**不会**出现在动态流。</p>
*/
@RestController
@RequestMapping("/api/topics")
@RequiredArgsConstructor
public class TopicDiscussionController {
private final TopicDiscussionService topicDiscussionService;
/**
* 发布讨论(话题页底部「发布你的讨论…」)。
*
* <p>鉴权:请求头 {@code access_token}(落在 SecurityConfig 的 {@code anyRequest().authenticated()} 内)。</p>
*/
@PostMapping("/{topicName}/discussions")
public Result<MomentDTO> publish(@PathVariable String topicName,
@Valid @RequestBody TopicDiscussionRequest request) {
return Result.success(topicDiscussionService.publish(topicName, request));
}
/**
* 编辑讨论(作者或管理员)。整体替换正文与媒体,重新走同步审核。
*
* <p>为什么不能复用 {@code PUT /api/campus/moments}:那个接口要求动态分类
* ({@code category})非空,而讨论不写分类——详见
* {@code 方案分析与实现(md)文件/实现方案-话题讨论图片与视频.md} §2.2。</p>
*/
@PutMapping("/discussions/{id}")
public Result<MomentDTO> update(@PathVariable String id,
@RequestBody(required = false) TopicDiscussionRequest request) {
return Result.success(topicDiscussionService.update(id, request));
}
/**
* 删除讨论(作者或管理员,软删)。
*
* <p>不清理 OSS 图片对象、不调 VOD 删媒资(实现方案 §9.5)。</p>
*/
@DeleteMapping("/discussions/{id}")
public Result<Void> delete(@PathVariable String id) {
topicDiscussionService.delete(id);
return Result.success();
}
/**
* 话题讨论列表。
*
* @param sort {@code latest}(默认)或 {@code hot};其他值返回 400「排序方式无效」
*/
@GetMapping("/{topicName}/discussions")
public Result<PageResult<MomentDTO>> list(
@PathVariable String topicName,
@RequestParam(required = false, defaultValue = "latest") String sort,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(topicDiscussionService.list(topicName, sort, page, size));
}
/**
* 我的讨论:作者查看/管理自己发过的讨论(含未过审),可按话题筛选。
*
* <p>注意:{@code /api/topics/my/discussions} 是字面量路径,优先级高于
* {@code /{topicName}/discussions},所以名字叫 “my” 的话题会被这条路由遮住。</p>
*/
@GetMapping("/my/discussions")
public Result<PageResult<MomentDTO>> mine(
@RequestParam(required = false) String topicId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(topicDiscussionService.listMine(topicId, page, size));
}
}

72
campus-server/src/main/java/com/campus/controller/UniversityController.java

@ -0,0 +1,72 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.request.UniversityQueryRequest;
import com.campus.dto.response.CollegeDTO;
import com.campus.dto.response.MajorDTO;
import com.campus.dto.response.PageResult;
import com.campus.dto.response.UniversityDTO;
import com.campus.dto.response.UniversityDistanceDTO;
import com.campus.dto.response.UniversityMapStatsDTO;
import com.campus.service.UniversityService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
/**
* 高校信息控制器
* 提供大学、学院、专业三级级联查询接口
*/
@RestController
@RequestMapping("/api/universities")
@RequiredArgsConstructor
public class UniversityController {
private final UniversityService universityService;
@GetMapping
public Result<List<UniversityDTO>> getAllUniversities() {
return Result.success(universityService.getAllUniversities());
}
@GetMapping("/search")
public Result<PageResult<UniversityDTO>> searchUniversities(UniversityQueryRequest request) {
return Result.success(universityService.searchUniversities(request));
}
@GetMapping("/detail")
public Result<UniversityDTO> getUniversity(@RequestParam String id) {
return Result.success(universityService.getUniversityById(id));
}
@GetMapping("/colleges")
public Result<List<CollegeDTO>> getColleges(@RequestParam String id) {
return Result.success(universityService.getCollegesByUniversity(id));
}
@GetMapping("/majors")
public Result<List<MajorDTO>> getMajorsByUniversity(@RequestParam String id) {
return Result.success(universityService.getMajorsByUniversity(id));
}
/**
* 查询附近大学(按距离由近及远)
*
* @param latitude 中心点纬度
* @param longitude 中心点经度
*/
@GetMapping("/nearby")
public Result<List<UniversityDistanceDTO>> getNearbyUniversities(@RequestParam BigDecimal latitude,
@RequestParam BigDecimal longitude) {
return Result.success(universityService.getNearbyUniversities(latitude, longitude));
}
@GetMapping("/map/stats")
public Result<List<UniversityMapStatsDTO>> mapStats(
@RequestParam(required = false) String province,
@RequestParam(required = false) String city) {
return Result.success(universityService.getMapStats(province, city));
}
}

190
campus-server/src/main/java/com/campus/controller/UserController.java

@ -0,0 +1,190 @@
package com.campus.controller;
import com.campus.common.PageResult;
import com.campus.common.Result;
import com.campus.entity.User;
import com.campus.dto.request.ChangePasswordRequest;
import com.campus.dto.request.DeactivateAccountRequest;
import com.campus.service.UserService;
import com.campus.service.UserDataExportService;
import com.campus.service.UserSessionService;
import com.campus.dto.response.UserSessionDTO;
import com.campus.util.UserContext;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserDataExportService userDataExportService;
private final UserSessionService userSessionService;
/**
* 退出登录
* 需携带有效 access_token 请求头
*/
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
userService.logoutByToken(accessToken(request));
return Result.success();
}
@GetMapping("/sessions")
public Result<List<UserSessionDTO>> sessions(HttpServletRequest request) {
return Result.success(userSessionService.list(UserContext.getCurrentUserId(), accessToken(request)));
}
@DeleteMapping("/sessions/{sessionId}")
public Result<Void> revokeSession(@PathVariable String sessionId) {
String userId = UserContext.getCurrentUserId();
userSessionService.revoke(userId, sessionId, userId);
return Result.success();
}
@DeleteMapping("/sessions")
public Result<Void> revokeAllSessions() {
String userId = UserContext.getCurrentUserId();
userSessionService.revokeAll(userId, userId);
return Result.success();
}
/**
* 获取账号安全页所需的基础状态。
*/
@GetMapping("/account-security")
public Result<Map<String, Object>> accountSecurity() {
String userId = UserContext.getCurrentUserId();
User user = userService.getUserById(userId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("userId", user.getId());
result.put("phone", maskPhone(user.getPhone()));
result.put("xuexinCertified", Boolean.TRUE.equals(user.getXuexinCertified()));
result.put("accountStatus", user.getAccountStatus() == null ? "ACTIVE" : user.getAccountStatus());
result.put("deactivationRequestedAt", user.getDeactivationRequestedAt());
result.put("scheduledDeletionAt", user.getScheduledDeletionAt());
result.put("isAdmin", Boolean.TRUE.equals(user.getIsAdmin()));
result.put("isReviewer", Boolean.TRUE.equals(user.getIsReviewer()));
result.put("passwordSet", user.getPassword() != null && !user.getPassword().isEmpty());
return Result.success(result);
}
/**
* 修改当前用户密码,成功后旧 token 会失效。
*/
@PostMapping("/change-password")
public Result<Map<String, Object>> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
String userId = UserContext.getCurrentUserId();
userService.changePassword(userId, request);
Map<String, Object> result = new LinkedHashMap<>();
result.put("requireRelogin", true);
result.put("message", "密码修改成功,请重新登录");
return Result.success(result);
}
/**
* 发起账号注销。必须显式传 confirm=true,账号进入 7 天冷静期。
*/
@PostMapping("/deactivate")
public Result<Void> deactivate(
@RequestParam(required = false) Boolean confirm,
@RequestBody(required = false) DeactivateAccountRequest request) {
Boolean resolvedConfirm = confirm;
if (resolvedConfirm == null && request != null) {
resolvedConfirm = request.getConfirm();
}
if (!Boolean.TRUE.equals(resolvedConfirm)) {
return Result.error(400, "请确认注销账号后再提交");
}
userService.deactivateAccount(UserContext.getCurrentUserId());
return Result.success();
}
@GetMapping("/deactivation-preview")
public Result<Map<String, Object>> deactivationPreview() {
String uid = UserContext.getCurrentUserId();
if (uid == null) throw new com.campus.common.BusinessException("用户未登录");
return Result.success(userService.deactivationPreview(uid));
}
@PostMapping("/recover")
public Result<User> recover() {
return Result.success(userService.recoverAccount(UserContext.getCurrentUserId()));
}
@GetMapping("/data-export")
public ResponseEntity<byte[]> exportData() {
byte[] data = userDataExportService.export(UserContext.getCurrentUserId());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=campus-user-data.json")
.body(data);
}
private String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
private String accessToken(HttpServletRequest request) {
String token = request.getHeader("access_token");
return token == null ? null : token.replaceAll("\\s", "");
}
@GetMapping("/detail")
public Result<User> getUser(@RequestParam String id) {
return Result.success(userService.getPublicUserById(id));
}
@GetMapping
public Result<PageResult<User>> getUsers(
@RequestParam(required = false) String school,
@RequestParam(required = false) String excludeId,
@PageableDefault(size = 20) Pageable pageable) {
if (school != null) {
return Result.success(PageResult.from(
userService.getUsersBySchool(school, excludeId, pageable)));
}
return Result.success(PageResult.from(
userService.getUsersBySchool("", excludeId, pageable)));
}
/**
* 通过环信用户名反查用户信息(用于 IM 消息到达时按 easemob ID 定位会话)
*/
@GetMapping("/by-easemob/{easemobId}")
public Result<EasemobUserInfo> getByEasemob(@PathVariable String easemobId) {
User user = userService.getUserByEasemobId(easemobId);
if (user == null) {
return Result.error(404, "用户不存在");
}
EasemobUserInfo info = new EasemobUserInfo();
info.setUserId(user.getId());
info.setNickname(user.getNickname());
info.setAvatar(user.getAvatar());
return Result.success(info);
}
@Data
public static class EasemobUserInfo {
private String userId;
private String nickname;
private String avatar;
}
}

28
campus-server/src/main/java/com/campus/controller/UserProfileController.java

@ -0,0 +1,28 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.UserProfileDTO;
import com.campus.service.FriendService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 用户资料接口(陌生人资料页)
*
* GET /api/users/profile?userId=xxx
*/
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserProfileController {
private final FriendService friendService;
@GetMapping("/profile")
public Result<UserProfileDTO> getProfile(@RequestParam String userId) {
return Result.success(friendService.getUserProfile(userId));
}
}

214
campus-server/src/main/java/com/campus/controller/VodController.java

@ -0,0 +1,214 @@
package com.campus.controller;
import com.campus.common.Result;
import com.campus.dto.response.VodUploadCredential;
import com.campus.service.VodService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 阿里云 VOD 视频点播接口
*
* 上传流程:
* 1. POST /api/vod/upload → 前端发送视频文件 → 后端 CreateUploadVideo + 上传到 VOD Bucket
* 2. VOD 自动转码/审核 → 回调 POST /api/oss/vod-callback → 更新数据库
*
* 播放流程:
* 3. GET /api/vod/play-auth/{videoId} → 获取 PlayAuth → 前端初始化播放器
*/
@Slf4j
@RestController
@RequestMapping("/api/vod")
@RequiredArgsConstructor
public class VodController {
private final VodService vodService;
/**
* 上传视频到 VOD(一步完成:创建凭证 + 上传到 VOD Bucket)
*
* POST /api/vod/upload
* Content-Type: multipart/form-data
* 参数: file (视频文件)
*
* @return { videoId, requestId }
*/
@PostMapping("/upload")
public Result<Map<String, Object>> uploadVideo(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.error(400, "文件不能为空");
}
String fileName = file.getOriginalFilename();
if (fileName == null) {
fileName = "video.mp4";
}
try {
// Step 1: 创建上传凭证(CreateUploadVideo)
log.info("开始VOD上传: fileName={}, size={}MB", fileName,
String.format("%.1f", file.getSize() / 1024.0 / 1024.0));
VodUploadCredential credential = vodService.createUploadVideo(fileName);
// Step 2: 使用临时凭证上传到 VOD Bucket
String videoId = vodService.uploadToVodBucket(
file.getInputStream(),
file.getSize(),
credential,
file.getContentType()
);
Map<String, Object> result = new HashMap<>();
result.put("videoId", videoId);
result.put("requestId", credential.getRequestId());
log.info("VOD上传完成: videoId={}", videoId);
return Result.success(result);
} catch (IOException e) {
log.error("VOD上传失败: 文件读取错误", e);
return Result.error(500, "文件读取失败: " + e.getMessage());
} catch (Exception e) {
log.error("VOD上传失败: fileName={}", fileName, e);
return Result.error(500, "视频上传失败: " + e.getMessage());
}
}
/**
* 上传图片到 VOD(一步完成:创建凭证 + 上传到 VOD Bucket)
*
* POST /api/vod/upload-image
* Content-Type: multipart/form-data
* 参数: file (图片文件)
*
* @return { imageId, imageUrl, requestId }
*/
@PostMapping("/upload-image")
public Result<Map<String, Object>> uploadImage(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.error(400, "文件不能为空");
}
String fileName = file.getOriginalFilename();
if (fileName == null) {
fileName = "image.jpg";
}
try {
log.info("开始VOD图片上传: fileName={}, size={}KB", fileName,
String.format("%.1f", file.getSize() / 1024.0));
// Step 1: 创建上传凭证(CreateUploadImage)
VodUploadCredential credential = vodService.createUploadImage(fileName);
// Step 2: 使用临时凭证上传到 VOD Bucket
String imageId = vodService.uploadToVodBucket(
file.getInputStream(),
file.getSize(),
credential,
file.getContentType()
);
Map<String, Object> result = new HashMap<>();
result.put("imageId", imageId);
result.put("imageUrl", credential.getImageUrl());
result.put("requestId", credential.getRequestId());
log.info("VOD图片上传完成: imageId={}, imageUrl={}", imageId, credential.getImageUrl());
return Result.success(result);
} catch (IOException e) {
log.error("VOD图片上传失败: 文件读取错误", e);
return Result.error(500, "文件读取失败: " + e.getMessage());
} catch (Exception e) {
log.error("VOD图片上传失败: fileName={}", fileName, e);
return Result.error(500, "图片上传失败: " + e.getMessage());
}
}
/**
* 创建上传凭证(用于前端直传场景 — 高级用法,需要前端集成 OSS SDK)
*
* POST /api/vod/create-upload
* Body: { "fileName": "video.mp4" }
*
* 注意:React Native 环境下建议使用 POST /api/vod/upload,后端代理上传更稳定
*/
@PostMapping("/create-upload")
public Result<VodUploadCredential> createUpload(@RequestBody Map<String, String> body) {
String fileName = body.getOrDefault("fileName", "video.mp4");
try {
VodUploadCredential credential = vodService.createUploadVideo(fileName);
return Result.success(credential);
} catch (Exception e) {
log.error("创建上传凭证失败: fileName={}", fileName, e);
return Result.error(500, "创建上传凭证失败: " + e.getMessage());
}
}
/**
* 刷新上传凭证(凭证有效期 3000 秒)
*
* POST /api/vod/refresh-upload/{videoId}
*/
@PostMapping("/refresh-upload/{videoId}")
public Result<VodUploadCredential> refreshUpload(@PathVariable String videoId) {
try {
VodUploadCredential credential = vodService.refreshUploadVideo(videoId);
return Result.success(credential);
} catch (Exception e) {
log.error("刷新上传凭证失败: videoId={}", videoId, e);
return Result.error(500, "刷新上传凭证失败: " + e.getMessage());
}
}
/**
* 获取视频播放凭证(PlayAuth — 安全播放)
*
* GET /api/vod/play-auth/{videoId}
*
* 返回 PlayAuth,前端用它初始化阿里云播放器 SDK
* 同时返回视频元信息(封面、时长等)
*/
@GetMapping("/play-auth/{videoId}")
public Result<Map<String, Object>> getPlayAuth(@PathVariable String videoId) {
try {
String playAuth = vodService.getPlayAuth(videoId);
Map<String, Object> playInfo = vodService.getPlayInfo(videoId);
Map<String, Object> result = new HashMap<>();
result.put("videoId", videoId);
result.put("playAuth", playAuth);
result.put("coverUrl", playInfo.get("coverUrl"));
result.put("duration", playInfo.get("duration"));
return Result.success(result);
} catch (Exception e) {
log.error("获取播放凭证失败: videoId={}", videoId, e);
return Result.error(500, "获取播放凭证失败: " + e.getMessage());
}
}
/**
* 获取视频播放地址(简单场景,直接返回 m3u8 URL)
*
* GET /api/vod/play-info/{videoId}
*/
@GetMapping("/play-info/{videoId}")
public Result<Map<String, Object>> getPlayInfo(@PathVariable String videoId) {
try {
Map<String, Object> playInfo = vodService.getPlayInfo(videoId);
return Result.success(playInfo);
} catch (Exception e) {
log.error("获取播放信息失败: videoId={}", videoId, e);
return Result.error(500, "获取播放信息失败: " + e.getMessage());
}
}
}

10
campus-server/src/main/java/com/campus/dto/request/ActivityCheckInRequest.java

@ -0,0 +1,10 @@
package com.campus.dto.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class ActivityCheckInRequest {
@NotBlank(message = "checkInCode 不能为空")
private String checkInCode;
}

15
campus-server/src/main/java/com/campus/dto/request/ActivityLocationRequest.java

@ -0,0 +1,15 @@
package com.campus.dto.request;
import lombok.Data;
@Data
public class ActivityLocationRequest {
private String text;
private String cityCode;
private String campusHint;
private String resolveId;
private String candidateId;
private Double latitude;
private Double longitude;
private String coordinateSystem;
}

30
campus-server/src/main/java/com/campus/dto/request/ActivityQueryRequest.java

@ -0,0 +1,30 @@
package com.campus.dto.request;
import lombok.Data;
@Data
public class ActivityQueryRequest {
private String type;
private String status;
private String reviewStatus;
private String keyword;
private String creatorId;
/**
* 排序方式:
* HEAT / DYNAMIC:按动态热度排序(综合参与分+互动分+临近度+时间衰减)
* TIME:按创建时间倒序(默认)
* EVENT_TIME:按活动时间排序(最近的在前面)
* PARTICIPATION:按报名人数排序
*/
private String sortBy;
/**
* 周期筛选:
* TODAY:今天创建的活动
* WEEK:本周创建的活动
* MONTH:本月创建的活动
* ALL / null:不限制
*/
private String period;
}

27
campus-server/src/main/java/com/campus/dto/request/ActivityRegistrationRequest.java

@ -0,0 +1,27 @@
package com.campus.dto.request;
import lombok.Data;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ActivityRegistrationRequest {
@NotBlank(message = "票档不能为空")
private String ticketId;
@NotNull(message = "报名数量不能为空")
@Min(value = 1, message = "报名数量至少为1")
private Integer quantity;
@NotBlank(message = "报名人姓名不能为空")
private String attendeeName;
@NotBlank(message = "报名人手机号不能为空")
private String attendeePhone;
private String universityName;
private String emergencyContact;
private String emergencyPhone;
}

83
campus-server/src/main/java/com/campus/dto/request/ActivityRequest.java

@ -0,0 +1,83 @@
package com.campus.dto.request;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.Date;
import java.util.List;
@Data
public class ActivityRequest {
@NotBlank(message = "标题不能为空")
private String title;
@NotBlank(message = "活动描述不能为空")
private String description;
@NotBlank(message = "类型不能为空")
private String type;
private String location;
/** 文字地点解析后二次确认的短期凭据;提供时以服务端地点为准。 */
private String locationToken;
private Double latitude;
private Double longitude;
private String contactName;
private String contactPhone;
private String rules;
private String schedule;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date registrationStartAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date registrationDeadline;
/** 前端兼容字段;与 registrationStartAt 二选一。 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date registrationStartTime;
/** 前端兼容字段;与 registrationDeadline 二选一。 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date registrationEndTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date freeCancellationDeadline;
private String cancellationRule;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date eventTime;
/** 前端兼容字段;与 eventTime 二选一。 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date endTime;
@NotNull(message = "最大人数不能为空")
private Integer maxCount;
private List<String> tags;
@NotBlank(message = "公开范围不能为空")
private String scope;
private String coverImage;
public Date getEventTime() {
return eventTime != null ? eventTime : startTime;
}
public Date getRegistrationStartAt() {
return registrationStartAt != null ? registrationStartAt : registrationStartTime;
}
public Date getRegistrationDeadline() {
return registrationDeadline != null ? registrationDeadline : registrationEndTime;
}
}

15
campus-server/src/main/java/com/campus/dto/request/ActivityReviewRequest.java

@ -0,0 +1,15 @@
package com.campus.dto.request;
import lombok.Data;
/** 活动报名审核动作:APPROVE、REJECT 或 REMOVE。 */
@Data
public class ActivityReviewRequest {
/** 推荐传 action;兼容旧调用也支持 approved=true/false。 */
private String action;
private Boolean approved;
private String reason;
}

26
campus-server/src/main/java/com/campus/dto/request/ActivityTicketRequest.java

@ -0,0 +1,26 @@
package com.campus.dto.request;
import lombok.Data;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
import java.math.BigDecimal;
@Data
public class ActivityTicketRequest {
@NotBlank(message = "票档名称不能为空")
private String name;
private String description;
@NotNull(message = "票档价格不能为空")
@DecimalMin(value = "0.00", message = "票档价格不能为负数")
private BigDecimal price;
@Min(value = 1, message = "票档名额必须大于0")
private Integer capacity;
private Integer sortOrder;
}

24
campus-server/src/main/java/com/campus/dto/request/AddFriendRequest.java

@ -0,0 +1,24 @@
package com.campus.dto.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* 添加好友请求 DTO
* <p>
* 发起好友申请,需等待对方同意后方可建立好友关系。
* </p>
*/
@Data
public class AddFriendRequest {
/** 对方用户ID */
@NotBlank(message = "好友ID不能为空")
private String friendId;
/** 申请附言(可选) */
@Size(max = 200, message = "附言长度不能超过200个字符")
private String message;
}

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

Loading…
Cancel
Save