commit
5bb2bb81c7
610 changed files with 59100 additions and 0 deletions
@ -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/ |
||||
@ -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/` 为对外发布用的独立快照目录,已被主仓库忽略。主工作区的修改不会自动同步到该目录,发布时需单独核对源码、配置排除规则和文档。 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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"] |
||||
@ -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 |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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: |
||||
@ -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> |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.campus.common; |
||||
|
|
||||
|
/** 权限不足时返回 HTTP 403,避免把鉴权失败误报为业务参数错误或服务器错误。 */ |
||||
|
public class ForbiddenException extends RuntimeException { |
||||
|
|
||||
|
public ForbiddenException(String message) { |
||||
|
super(message); |
||||
|
} |
||||
|
} |
||||
@ -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("服务器内部错误"); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.campus.common; |
||||
|
|
||||
|
/** 同一用户短时间内重复操作同一目标;由全局异常处理映射为 HTTP 429。 */ |
||||
|
public class TooManyRequestsException extends RuntimeException { |
||||
|
|
||||
|
public TooManyRequestsException(String message) { |
||||
|
super(message); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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() |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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/"; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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;} |
||||
|
} |
||||
@ -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));} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); } |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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"))); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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…
Reference in new issue