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