commit 5bb2bb81c7c94bbae0bf9746885a99c435794ece Author: zzy_0123 <13131249279@163.com> Date: Mon Oct 26 10:34:55 2026 +0800 feat: 首次导入青核后端项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72afbae --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# ============================================================ +# 这个交付物含真实凭证(数据库密码、云存储密钥)和数据库快照。 +# 规则:默认只提交 Java 源码与构建配置,其余一律排除。 +# ============================================================ + +# ---- 敏感配置:绝不入库 ---- +application.yml +application-dev.yml +*.local.yml +!/campus-server/compose.local.yml +.env +.env.* +!.env.example + +# ---- 数据库快照与迁移脚本:不入库 ---- +*.sql +*.sql.gz +*.dump +青核数据库-*.zip + +# ---- 大体积二进制:不入库 ---- +*.jar +*.zip +*.tar +*.tar.gz +*.tar.xz +*.xz +*.7z + +# ---- 构建产物 ---- +target/ +build/ +out/ +*.class +*.log + +# ---- IDE ---- +.idea/ +*.iml +.vscode/ +*.iws +*.code-workspace + +# ---- 临时目录 ---- +.rewrite-backup/ +# Windows 沙箱文件权限诊断的备份与报告(diagnose-windows-sandbox-acl 产出) +.acl-recovery/ +.acl-reports/ +.dsh-acl-recovery/ + +# ---- 临时排查文件:不入库 ---- +topic-check.txt +campus-server/cp.txt +.dump-class.py +.scan-classes.py + +# ---- 对外发布用的干净快照(独立 git 仓库)---- +gitea-upload/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c582b3 --- /dev/null +++ b/README.md @@ -0,0 +1,220 @@ +# 青核后端(qinghe-backend) + +青核是一个校园社交与服务平台,提供用户、动态、话题讨论、组局活动、好友与群聊、通知、认证和内容审核等能力。本仓库是青核项目的后端与工程协作仓库,不包含 App / Web 前端源码。 + +首次独立部署请阅读 [本地部署说明](方案分析与实现(md)文件/部署说明-本地部署.md),使用脱敏模板与专用启动器。下文普通 IDEA/JAR 启动用于已有安全配置的开发环境。 + +## 当前交付基线 + +| 项目 | 当前状态 | +| --- | --- | +| 后端模块 | `campus-server`,包根 `com.campus` | +| 默认端口 | `8009` | +| 业务接口前缀 | `/api` | +| 构建产物 | `campus-server/target/campus-server-1.0.0.jar` | +| 数据库迁移 | `campus-server/src/main/resources/db/migration/`;当前 Flyway 关闭,脚本不会自动执行 | +| 接口契约 | 运行时 Swagger / OpenAPI;内部专题接口资料由维护者单独提供 | +| 自动化测试 | 以当前源码执行 `mvn test` 或 `mvn clean verify` 的结果为准 | +| 部署与验收边界 | 见本仓库的本地部署说明;内部工作记录和开发规范不随源码公开 | + +2026-10-21 部署材料验证:`mvn -Pportable clean verify` 通过,496 用例 / 0 失败 / 0 错误 / 0 跳过;启动器离线检查及 Compose 静态校验通过。尚未执行 Docker 实机启动、全新空库或第三方服务验收,详细边界见本地部署说明。 + +## 仓库结构 + +```text +qinghe-backend/ +├─ campus-server/ Spring Boot 后端模块 +│ ├─ pom.xml Maven 构建配置 +│ ├─ .env.example 环境变量占位示例 +│ ├─ compose.local.yml 独立本地基础设施及可选后端容器 +│ ├─ Start-Local.ps1 独立配置启动器 +│ └─ src/ +│ ├─ main/java/com/campus/ Controller、Service、实体、持久层与配置 +│ ├─ main/resources/ 应用配置与 db/migration/ 迁移脚本 +│ └─ test/java/com/campus/ 后端测试 +├─ 方案分析与实现(md)文件/ +│ └─ 部署说明-本地部署.md 随源码提供的部署与验收说明 +└─ README.md 项目入口与启动、联调说明 +``` + +目录位置沿用现有工作区。其他内部方案、工作记录、`AGENTS.md`、真实应用配置和 `gitea-upload/` 保留在维护者本地,不随本次源码发布。 + +## 技术栈 + +- Java 8 编译目标,本地可使用 JDK 17 / 21 构建运行。 +- Spring Boot **2.7.18**。 +- Spring MVC、Spring Security、Spring Data JPA / Hibernate,使用 `javax.*`。 +- MySQL、Redis、RabbitMQ;Flyway 依赖已引入,当前配置关闭自动迁移。 +- 环信 IM,阿里云 OSS、VOD、推送、OCR 和短信。 +- Maven、JUnit 5、Mockito。 +- SpringDoc OpenAPI UI 1.7.0。 + +## 环境准备 + +开发机需要安装 JDK 和 Maven,并可在 PowerShell 中执行: + +```powershell +java -version +mvn -version +``` + +后端启动需要数据库、Redis、RabbitMQ、JWT 等配置,以及对应业务使用的第三方服务配置。使用团队通过安全渠道提供的配置或在运行环境中注入对应 Spring 配置项。不要将真实密码、JWT secret、Token 或云服务密钥写入 README、脚本、日志或 Git。 + +仓库存在两份应用配置: + +- `campus-server/src/main/resources/application.yml`:模块内的应用配置,IDEA 启动时加载,构建时打入 JAR。 +- 根目录 `application.yml`:外部应用配置;按下文从仓库根目录运行 JAR 时默认加载,同名配置项优先于 JAR 内配置。 + +两份配置涉及共同配置项时必须同步维护。启动前确认工作目录和实际连接环境;配置文件被 Git 忽略,拉取源码后需单独准备。 + +另提供不含真实凭据的 [application.example.yml](campus-server/src/main/resources/application.example.yml) 和 [.env.example](campus-server/.env.example)。不要覆盖现有真实配置;新部署者按部署说明复制私有 `.env` 并填写自己的配置。万能验证码在模板中默认关闭,未接入云服务时相关功能不可用。 + +## 基础设施准备 + +独立开发环境使用现有模块目录下的 [compose.local.yml](campus-server/compose.local.yml),提供 MySQL、Redis、RabbitMQ,不调整其他文件位置。先准备私有 `.env` 并启动 Docker Desktop,再在 `campus-server` 目录执行: + +```powershell +docker compose --env-file .env -f compose.local.yml up -d mysql redis rabbitmq +docker compose --env-file .env -f compose.local.yml ps +``` + +等待三个服务 healthy 后启动后端。宿主机默认端口分别为 `13306/16379/15672`,RabbitMQ 管理页端口为 `15673`。数据保留、首次初始化及可选后端容器步骤见本地部署说明。当前尚未完成 Docker 实机与全新空库验收。 + +## 启动后端 + +### 构建并验证 + +以下命令从仓库根目录执行,进入模块后完成编译、测试、打包与 Maven 验证: + +```powershell +cd .\campus-server +mvn clean verify +``` + +需要快速打包且已经完成对应测试时,可在 `campus-server` 目录执行: + +```powershell +mvn clean package -DskipTests +``` + +该命令跳过测试,不能作为测试通过的依据。构建产物为 `campus-server/target/campus-server-1.0.0.jar`。 + +独立部署/对外分发使用 `mvn -Pportable clean verify`,产物为 `target/portable/campus-server-1.0.0.jar`。该 profile 排除真实应用配置;普通开发产物可能包含本机配置,不应直接分发。独立启动器 [Start-Local.ps1](campus-server/Start-Local.ps1) 会采用该 profile 并显式加载脱敏模板。 + +### IDEA 启动 + +1. 打开 `campus-server/pom.xml`,等待 Maven 依赖加载完成。 +2. 选择 JDK 17 或 21,运行配置的工作目录设为 `campus-server`。 +3. 运行 `com.campus.CampusApplication`,等待日志出现 `Started CampusApplication`。 + +### 使用最新 JAR 启动 + +完成构建后,在 `campus-server` 目录执行: + +```powershell +java -jar .\target\campus-server-1.0.0.jar +``` + +需要使用根目录外部配置时,另开终端并从仓库根目录执行以下命令,选择一种方式启动即可: + +```powershell +java -jar .\campus-server\target\campus-server-1.0.0.jar +``` + +默认监听 `8009`。遇到端口占用先确认是否已有后端实例;需要另开实例时可在启动命令末尾添加 `--server.port=8010`,并相应调整联调地址。当前工作区采用上述源码或 JAR 启动方式。 + +### 启动验证 + +在另一个 PowerShell 窗口执行: + +```powershell +Invoke-RestMethod 'http://127.0.0.1:8009/actuator/health' +``` + +预期返回 `{"status":"UP"}`。接口文档入口为 [Swagger UI](http://127.0.0.1:8009/swagger-ui.html)。进入联调前应处理导致启动失败或健康状态异常的问题;第三方业务能力仍需按对应功能单独验收。 + +## 测试与接口校验 + +以下命令均在 `campus-server` 目录执行: + +```powershell +# 快速回归 +mvn test + +# 发布前完整验证 +mvn clean verify + +# 只运行指定测试类 +mvn -Dtest=GroupAnnouncementServiceTest test +``` + +测试报告位于 `campus-server/target/surefire-reports/`。验证记录需注明执行时间、代码版本、使用环境及结果。 + +启动后检查 [Swagger UI](http://127.0.0.1:8009/swagger-ui.html) 与 [运行时 OpenAPI](http://127.0.0.1:8009/v3/api-docs),并按功能对接文档核对路径、参数、鉴权、响应字段、状态码和错误文案。Markdown 接口清单是文档快照,发现与运行时不一致时需核对源码并同步更新。 + +## 接口文档与前端联调 + +随仓库提供的部署说明及运行时接口入口: + +| 文档 | 内容 | +| --- | --- | +| [本地部署说明](方案分析与实现(md)文件/部署说明-本地部署.md) | 配置、基础设施、数据库初始化、升级与验收 | +| [Swagger UI](http://127.0.0.1:8009/swagger-ui.html) | 后端启动后查看接口和参数 | +| [运行时 OpenAPI](http://127.0.0.1:8009/v3/api-docs) | 后端启动后获取接口契约 | + +其他专题接口清单与前端对接资料由维护者单独提供;需要联调某个功能时,先取得对应版本的契约说明。 + +本地联调的 API Base URL 为: + +```text +http://127.0.0.1:8009/api +``` + +接口清单写出的完整路径已包含 `/api`;拼接时避免重复添加。手机联调使用手机可访问的电脑地址和端口。使用内网穿透时,将前端与 Apifox 的 Base URL 统一替换为实际隧道地址并保留 `/api` 前缀。 + +需要认证的接口使用请求头: + +```http +access_token: <登录返回的令牌> +``` + +同名 Query 参数作为兜底方式,日常联调优先使用 Header。测试 Token、验证码和临时公网地址不要写入提交文件或公开文档。 + +接口统一返回 `{code,message,data}`;成功但没有内容时使用 `code=200, data=null`。分页参数从第 1 页开始,默认每页 20 条,上限 100 条;时间格式为 `yyyy-MM-dd HH:mm:ss`(GMT+8)。具体权限、参数、错误文案和状态码以对应功能文档为准。 + +## 数据库迁移与上线 + +当前配置为 `spring.flyway.enabled=false`,`db/migration/*.sql` **不会随启动自动执行**。上线某项功能前,阅读对应前端对接文档的迁移要求,核对目标数据库已执行的脚本,再人工执行所需迁移。 + +执行数据清理或包含 DDL 的脚本前先备份。MySQL DDL 可能隐式提交,不能假设整份脚本可以回滚。当前 Hibernate 使用 `ddl-auto: update`,它不能替代迁移脚本中的数据清理、外键及约束校验。 + +全新独立库与已有数据库升级的步骤不同,不能把历史迁移全部重跑。具体建表、唯一约束和外键核验流程见 [本地部署说明](方案分析与实现(md)文件/部署说明-本地部署.md) §6。 + +## 开发约定 + +仓库维护者另有内部开发规范。每个新功能按以下顺序交付: + +1. 核对需求、现有实现、实体、权限及接口语义,确认影响范围。 +2. 明确设计与兼容性要求,必要时在现有方案目录记录实现方案。 +3. 实现 DTO、Controller、Service、Repository 和必要迁移,遵循统一响应、异常、事务及防重复提交规则。 +4. 补齐单元测试,覆盖精确中文文案、权限、边界及失败时不落库;按风险补充集成验证。 +5. 执行 `mvn test`,发布前执行 `mvn clean verify`,并完成相应运行时接口和依赖验证。 +6. 在 `方案分析与实现(md)文件/` 交付 `前端对接-<功能名>.md`,说明兼容性、前端改造点、上线顺序及人工迁移要求。 +7. 记录本次改动、验证结果与未完成事项;内部工作台账由维护者单独保管。 + +根目录的 Markdown 入口为 `AGENTS.md` 和 `README.md`;实现方案、分析及前端对接文档统一保留在 `方案分析与实现(md)文件/`。参考其他项目时按本仓库实际结构填写文档,不据此调整现有文件位置。 + +提交前复核差异,使用精确文件清单加入版本控制。迁移 SQL 经复核后单独纳入,共享文件发生冲突时先核对双方改动及基线,再合并验证。 + +## 数据与安全边界 + +- 不提交含真实凭据的应用配置、Token、密钥、数据库快照、JAR、压缩包、日志和 `target/` 等产物。 +- 迁移 SQL 是受控例外:经复核后按文件精确加入版本控制。 +- 日志、错误响应和调试输出不记录密码、JWT、验证码及不必要的个人信息。 +- 测试与联调使用明确授权的环境和账号,不删除真实业务数据,不为联调放宽权限校验。 + +## 相关项目边界 + +本仓库只维护青核后端和后端文档。App / Web 前端源码位于其他仓库,所需前端改造通过对接文档交付;本文不假定前端项目在开发机上的具体路径。 + +`gitea-upload/` 为对外发布用的独立快照目录,已被主仓库忽略。主工作区的修改不会自动同步到该目录,发布时需单独核对源码、配置排除规则和文档。 diff --git a/campus-server/.dockerignore b/campus-server/.dockerignore new file mode 100644 index 0000000..0a44842 --- /dev/null +++ b/campus-server/.dockerignore @@ -0,0 +1,9 @@ +** +!Dockerfile +!target/ +!target/portable/ +!target/portable/campus-server-1.0.0.jar +!src/ +!src/main/ +!src/main/resources/ +!src/main/resources/application.example.yml diff --git a/campus-server/.env.example b/campus-server/.env.example new file mode 100644 index 0000000..fa56f7b --- /dev/null +++ b/campus-server/.env.example @@ -0,0 +1,51 @@ +# 复制成同目录 .env 后修改。仅包含示例值,不包含任何现有环境凭据。 +# Compose 会读取 --env-file;宿主机 Java 不会自动读取 .env,请用 Start-Local.ps1。 +# 值为单行原文,不加引号,不写行尾注释,不引用其他变量。 +COMPOSE_PROJECT_NAME=qinghe-local +DB_HOST=127.0.0.1 +DB_PORT=13306 +DB_NAME=qinghe_local +DB_USERNAME=qinghe +DB_PASSWORD=replace-me-db-password +MYSQL_ROOT_PASSWORD=replace-me-root-password +REDIS_HOST=127.0.0.1 +REDIS_PORT=16379 +REDIS_PASSWORD=replace-me-redis-password +RABBITMQ_HOST=127.0.0.1 +RABBITMQ_PORT=15672 +RABBITMQ_MANAGEMENT_PORT=15673 +RABBITMQ_USERNAME=qinghe +RABBITMQ_PASSWORD=replace-me-rabbitmq-password +JWT_SECRET=replace-me-with-a-random-secret-of-at-least-32-bytes +SERVER_PORT=8009 +# 首次独立空库用 update 建表;旧库升级先按文档审核迁移,验收后可用 validate。 +DB_DDL_AUTO=update +# 以下均为可选云服务配置;留空仅供受限的本地基础验证,不能使用对应云功能。 +OSS_ENDPOINT=http://127.0.0.1:19000 +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_BUCKET_NAME=qinghe-local-placeholder +OSS_DOMAIN=http://127.0.0.1:19000 +SMS_ACCESS_KEY_ID= +SMS_ACCESS_KEY_SECRET= +SMS_SIGN_NAME= +SMS_TEMPLATE_CODE= +SMS_REGION_ID=cn-hangzhou +PUSH_ENABLED=false +PUSH_ACCESS_KEY_ID= +PUSH_ACCESS_KEY_SECRET= +PUSH_ANDROID_APP_KEY= +PUSH_IOS_APP_KEY= +OCR_ACCESS_KEY_ID= +OCR_ACCESS_KEY_SECRET= +VOD_ACCESS_KEY_ID= +VOD_ACCESS_KEY_SECRET= +VOD_TRANSCODE_TEMPLATE_GROUP_ID= +VOD_CALLBACK_URL= +EASEMOB_APP_KEY= +EASEMOB_CLIENT_ID= +EASEMOB_CLIENT_SECRET= +EASEMOB_ORG_NAME= +EASEMOB_APP_NAME= +EASEMOB_BASE_URL=https://a1.easemob.com +DISCUSSION_ALLOWED_MEDIA_HOSTS=localhost,127.0.0.1 diff --git a/campus-server/.gitignore b/campus-server/.gitignore new file mode 100644 index 0000000..03dc0a9 --- /dev/null +++ b/campus-server/.gitignore @@ -0,0 +1,8 @@ +target/ +*.log +.idea/ +*.iml +.vscode/ +# This private delivery contains actual credentials. Never add these files to Git. +src/main/resources/application.yml +src/main/resources/application-dev.yml diff --git a/campus-server/Dockerfile b/campus-server/Dockerfile new file mode 100644 index 0000000..a7433ef --- /dev/null +++ b/campus-server/Dockerfile @@ -0,0 +1,17 @@ +# 在 campus-server 目录先执行 mvn -Pportable clean verify。 +# 从软件源安装 FFmpeg,构建不再依赖开发机的离线压缩包。 +FROM eclipse-temurin:17-jre-jammy + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg fontconfig fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system campus \ + && useradd --system --gid campus --home-dir /app campus + +WORKDIR /app +COPY target/portable/campus-server-1.0.0.jar /app/app.jar +COPY src/main/resources/application.example.yml /app/config/application.example.yml + +USER campus +EXPOSE 8009 +ENTRYPOINT ["java", "-Duser.timezone=GMT+08", "-jar", "/app/app.jar", "--spring.config.location=file:/app/config/application.example.yml"] diff --git a/campus-server/Start-Local.ps1 b/campus-server/Start-Local.ps1 new file mode 100644 index 0000000..96d0315 --- /dev/null +++ b/campus-server/Start-Local.ps1 @@ -0,0 +1,76 @@ +[CmdletBinding()] +param( + [string]$EnvFile = '.env', + [switch]$SkipBuild, + [switch]$CheckOnly +) + +$ErrorActionPreference = 'Stop' +# 仅载入此脚本允许的配置名,不把 .env 当成可执行脚本。 +$examplePath = Join-Path $PSScriptRoot '.env.example' +$allowedNames = @{} +foreach ($line in Get-Content -LiteralPath $examplePath -Encoding UTF8) { + if ($line -match '^([A-Z][A-Z0-9_]*)=') { $allowedNames[$Matches[1]] = $true } +} +$envPath = if ([IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $PSScriptRoot $EnvFile } +if (-not (Test-Path -LiteralPath $envPath -PathType Leaf)) { + throw '缺少本地 .env;请从 .env.example 复制到 .env 并填写,不要覆盖现有文件。' +} +$settings = @{} +foreach ($line in Get-Content -LiteralPath $envPath -Encoding UTF8) { + if ([string]::IsNullOrWhiteSpace($line) -or $line.TrimStart().StartsWith('#')) { continue } + if ($line -notmatch '^([A-Z][A-Z0-9_]*)=(.*)$') { throw '环境文件格式错误:请使用 KEY=value,不使用 export 或引号。' } + $settingName = $Matches[1] + $settingValue = $Matches[2] + if (-not $allowedNames.ContainsKey($settingName)) { throw "环境文件包含未知配置名:$settingName" } + if ($settings.ContainsKey($settingName)) { throw "环境文件包含重复配置名:$settingName" } + if ($settingValue -match '^["'']' -or $settingValue -match '\s#' -or $settingValue.Contains('$')) { + throw "配置 $settingName 必须使用未加引号的单行原文,不能包含变量引用或行尾注释。" + } + $settings[$settingName] = $settingValue +} +foreach ($requiredName in @('DB_PASSWORD','MYSQL_ROOT_PASSWORD','REDIS_PASSWORD','RABBITMQ_PASSWORD','JWT_SECRET')) { + if (-not $settings.ContainsKey($requiredName) -or [string]::IsNullOrWhiteSpace($settings[$requiredName]) -or $settings[$requiredName].StartsWith('replace-me-')) { + throw "请为 $requiredName 设置自己的非示例值。" + } +} +if ([Text.Encoding]::UTF8.GetByteCount($settings['JWT_SECRET']) -lt 32) { throw 'JWT_SECRET 至少需要 32 字节。' } +if ($CheckOnly) { + Write-Host '本地配置格式与必填项检查通过(未连接任何服务、未构建或启动)。' + return +} + +$savedEnvironment = @{} +foreach ($settingName in $allowedNames.Keys) { + $savedEnvironment[$settingName] = [Environment]::GetEnvironmentVariable($settingName, 'Process') +} +Push-Location $PSScriptRoot +try { + # 清除继承的同名云凭据,只接受该 .env 中填写的值;空项回落到模板的占位值。 + foreach ($settingName in $allowedNames.Keys) { + $settingValue = if ($settings.ContainsKey($settingName) -and $settings[$settingName] -ne '') { $settings[$settingName] } else { $null } + if ($null -eq $settingValue) { + Remove-Item -LiteralPath "Env:$settingName" -ErrorAction SilentlyContinue + } else { + [Environment]::SetEnvironmentVariable($settingName, $settingValue, 'Process') + } + } + if (-not $SkipBuild) { + & mvn -Pportable clean verify + if ($LASTEXITCODE -ne 0) { throw '构建或测试失败,未启动后端。' } + } + $jarPath = Join-Path $PSScriptRoot 'target/portable/campus-server-1.0.0.jar' + if (-not (Test-Path -LiteralPath $jarPath -PathType Leaf)) { throw '缺少 portable JAR,请先执行 mvn -Pportable clean verify。' } + $configPath = (Join-Path $PSScriptRoot 'src/main/resources/application.example.yml').Replace('\', '/') + & java -Duser.timezone=GMT+08 -jar $jarPath "--spring.config.location=file:$configPath" + if ($LASTEXITCODE -ne 0) { throw '后端退出异常,请检查启动日志。' } +} finally { + foreach ($settingName in $allowedNames.Keys) { + if ($null -eq $savedEnvironment[$settingName]) { + Remove-Item -LiteralPath "Env:$settingName" -ErrorAction SilentlyContinue + } else { + [Environment]::SetEnvironmentVariable($settingName, $savedEnvironment[$settingName], 'Process') + } + } + Pop-Location +} diff --git a/campus-server/Test-LocalDeployment.ps1 b/campus-server/Test-LocalDeployment.ps1 new file mode 100644 index 0000000..6d7c462 --- /dev/null +++ b/campus-server/Test-LocalDeployment.ps1 @@ -0,0 +1,122 @@ +[CmdletBinding()] +param() + +# 仅做离线检查,不启动 Java、Maven 或 Docker,不连接数据库和云服务。 +$ErrorActionPreference = 'Stop' +$launcher = Join-Path $PSScriptRoot 'Start-Local.ps1' +$tokens = $null +$parseErrors = $null +[System.Management.Automation.Language.Parser]::ParseFile($launcher, [ref]$tokens, [ref]$parseErrors) | Out-Null +if ($parseErrors.Count) { throw 'Start-Local.ps1 语法检查失败。' } +$fixtureDirectory = Join-Path ([IO.Path]::GetTempPath()) ('qinghe-deployment-check-' + [Guid]::NewGuid().ToString('N')) +[void](New-Item -ItemType Directory -Path $fixtureDirectory) +$fixtureFile = Join-Path $fixtureDirectory '.env' +$utf8Bom = New-Object System.Text.UTF8Encoding($true) +$baseFixture = @' +DB_PASSWORD=test-db-value +MYSQL_ROOT_PASSWORD=test-root-value +REDIS_PASSWORD=test-redis-value +RABBITMQ_PASSWORD=test-rabbit-value +JWT_SECRET=test-only-secret-with-at-least-32-bytes-000 +'@ +$passedCases = 0 + +function Assert-ConfigurationCase { + param([string]$Name, [string]$Content, [bool]$ShouldPass, [string]$ExpectedMessage) + [IO.File]::WriteAllText($fixtureFile, $Content, $utf8Bom) + $didPass = $true + $failureMessage = '' + try { & $launcher -EnvFile $fixtureFile -CheckOnly } catch { + $didPass = $false + $failureMessage = $_.Exception.Message + } + if ($didPass -ne $ShouldPass) { throw "用例失败:$Name,结果不符合预期。" } + if (-not $ShouldPass -and -not $failureMessage.Contains($ExpectedMessage)) { + throw "用例失败:$Name,未得到预期的配置错误提示。" + } + Write-Host "PASS: $Name" +} + +try { + Assert-ConfigurationCase '合法配置' $baseFixture $true '' + $passedCases++ + Assert-ConfigurationCase '拒绝短 JWT secret' ($baseFixture.Replace('test-only-secret-with-at-least-32-bytes-000', 'short')) $false '至少需要 32 字节' + $passedCases++ + Assert-ConfigurationCase '拒绝未修改的示例密码' ($baseFixture.Replace('test-db-value', 'replace-me-db-password')) $false 'DB_PASSWORD' + $passedCases++ + Assert-ConfigurationCase '拒绝缺失密码' ($baseFixture.Replace('REDIS_PASSWORD=test-redis-value', 'REDIS_PASSWORD=')) $false 'REDIS_PASSWORD' + $passedCases++ + Assert-ConfigurationCase '拒绝重复变量' ($baseFixture + "`nDB_PASSWORD=second-value") $false '重复配置名' + $passedCases++ + Assert-ConfigurationCase '拒绝未知变量' ($baseFixture + "`nPATH=unknown") $false '未知配置名' + $passedCases++ + Assert-ConfigurationCase '拒绝脚本表达式' ($baseFixture + "`nOSS_DOMAIN=" + '$(Get-Process)') $false '不能包含变量引用' + $passedCases++ + Assert-ConfigurationCase '拒绝带引号的值' ($baseFixture + "`nOSS_DOMAIN=" + '"localhost"') $false '未加引号' + $passedCases++ + + $examplePath = Join-Path $PSScriptRoot '.env.example' + $exampleContent = [IO.File]::ReadAllText($examplePath) + Assert-ConfigurationCase '原始示例必须先填写' $exampleContent $false 'DB_PASSWORD' + $passedCases++ + + # 用局部 Java 替身检查启动参数及环境恢复,不启动 JVM 或访问任何服务。 + if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'target/portable/campus-server-1.0.0.jar')) { + $javaObservation = @{ Arguments = @(); DatabasePassword = $null } + function java { + $javaObservation.Arguments = @($args) + $javaObservation.DatabasePassword = [Environment]::GetEnvironmentVariable('DB_PASSWORD', 'Process') + $global:LASTEXITCODE = 0 + } + $previousPassword = [Environment]::GetEnvironmentVariable('DB_PASSWORD', 'Process') + try { + [Environment]::SetEnvironmentVariable('DB_PASSWORD', 'outer-test-sentinel', 'Process') + [IO.File]::WriteAllText($fixtureFile, $baseFixture, $utf8Bom) + & $launcher -EnvFile $fixtureFile -SkipBuild + if ($javaObservation.DatabasePassword -ne 'test-db-value') { throw '启动时未正确注入本地变量。' } + $locationArguments = @($javaObservation.Arguments | Where-Object { $_ -like '--spring.config.location=file:*application.example.yml' }) + if ($locationArguments.Count -ne 1) { throw '未显式指定独立模板。' } + if ([Environment]::GetEnvironmentVariable('DB_PASSWORD', 'Process') -ne 'outer-test-sentinel') { + throw '退出后没有恢复原进程环境变量。' + } + Write-Host 'PASS: 独立配置参数、环境注入及退出恢复(Java 替身,无外部连接)' + $passedCases++ + } finally { + if ($null -eq $previousPassword) { + Remove-Item -LiteralPath Env:DB_PASSWORD -ErrorAction SilentlyContinue + } else { + [Environment]::SetEnvironmentVariable('DB_PASSWORD', $previousPassword, 'Process') + } + Remove-Item -LiteralPath Function:\java + } + } + + $jarPath = Join-Path $PSScriptRoot 'target/portable/campus-server-1.0.0.jar' + if (Test-Path -LiteralPath $jarPath -PathType Leaf) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($jarPath) + try { + $privateEntries = @($archive.Entries | Where-Object { + $_.FullName -match '^BOOT-INF/classes/(.*/)?(application[^/]*\.(yml|yaml|properties)|\.env[^/]*)$' + }) + if ($privateEntries.Count) { throw '部署 JAR 混入应用配置或 .env 文件,禁止分发。' } + if (-not $archive.GetEntry('BOOT-INF/classes/com/campus/CampusApplication.class')) { + throw '部署 JAR 缺少应用主类。' + } + } finally { $archive.Dispose() } + Write-Host 'PASS: 部署 JAR 不含约定的私有应用配置,且存在主类' + $passedCases++ + } else { + Write-Host 'SKIP: 未找到 portable JAR,打包内容尚未检查。' + } + Write-Host "离线检查通过:$passedCases 项。未连接外部服务。" +} finally { + # 只清理本脚本刚创建、路径已核对的临时夹;不触及用户环境文件。 + $resolvedFixture = [IO.Path]::GetFullPath($fixtureDirectory) + $resolvedTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not $resolvedFixture.StartsWith($resolvedTemp, [StringComparison]::OrdinalIgnoreCase) -or + -not ([IO.Path]::GetFileName($resolvedFixture)).StartsWith('qinghe-deployment-check-')) { + throw '临时检查目录超出预期路径,未清理。' + } + Remove-Item -LiteralPath $resolvedFixture -Recurse -Force +} diff --git a/campus-server/compose.local.yml b/campus-server/compose.local.yml new file mode 100644 index 0000000..a874056 --- /dev/null +++ b/campus-server/compose.local.yml @@ -0,0 +1,91 @@ +# 在 campus-server 目录执行;仅默认启动基础设施,backend 需显式启用 app profile。 +services: + mysql: + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请先设置 MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME:-qinghe_local} + MYSQL_USER: ${DB_USERNAME:-qinghe} + MYSQL_PASSWORD: ${DB_PASSWORD:?请先设置 DB_PASSWORD} + TZ: Asia/Shanghai + command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"] + ports: + - "127.0.0.1:${DB_PORT:-13306}:3306" + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=\"$$MYSQL_PASSWORD\" mysql --protocol=TCP -h 127.0.0.1 -u\"$$MYSQL_USER\" \"$$MYSQL_DATABASE\" -e 'SELECT 1' >/dev/null"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 40s + redis: + image: redis:7.4-alpine + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?请先设置 REDIS_PASSWORD} + command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\""] + ports: + - "127.0.0.1:${REDIS_PORT:-16379}:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"] + interval: 10s + timeout: 5s + retries: 10 + rabbitmq: + image: rabbitmq:4.1-management + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-qinghe} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?请先设置 RABBITMQ_PASSWORD} + ports: + - "127.0.0.1:${RABBITMQ_PORT:-15672}:5672" + - "127.0.0.1:${RABBITMQ_MANAGEMENT_PORT:-15673}:15672" + volumes: + - rabbitmq-data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 10s + retries: 20 + start_period: 30s + backend: + profiles: ["app"] + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + environment: + DB_HOST: mysql + DB_PORT: "3306" + REDIS_HOST: redis + REDIS_PORT: "6379" + RABBITMQ_HOST: rabbitmq + RABBITMQ_PORT: "5672" + SERVER_PORT: "8009" + JWT_SECRET: ${JWT_SECRET:?请先设置 JWT_SECRET} + # env_file 中空值不会触发 Spring 的默认值,这里统一回落到明确的本地占位值。 + OSS_ENDPOINT: ${OSS_ENDPOINT:-http://127.0.0.1:19000} + OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-your-local-placeholder} + OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-your-local-placeholder} + OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-qinghe-local-placeholder} + OSS_DOMAIN: ${OSS_DOMAIN:-http://127.0.0.1:19000} + SMS_ACCESS_KEY_ID: ${SMS_ACCESS_KEY_ID:-your-local-placeholder} + SMS_ACCESS_KEY_SECRET: ${SMS_ACCESS_KEY_SECRET:-your-local-placeholder} + SMS_SIGN_NAME: ${SMS_SIGN_NAME:-local-placeholder} + SMS_TEMPLATE_CODE: ${SMS_TEMPLATE_CODE:-local-placeholder} + SMS_REGION_ID: ${SMS_REGION_ID:-cn-hangzhou} + ports: + - "127.0.0.1:${SERVER_PORT:-8009}:8009" + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy +volumes: + mysql-data: + redis-data: + rabbitmq-data: diff --git a/campus-server/pom.xml b/campus-server/pom.xml new file mode 100644 index 0000000..23ae4bd --- /dev/null +++ b/campus-server/pom.xml @@ -0,0 +1,264 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.campus + campus-server + 1.0.0 + campus-server + Campus Comprehensive Service Platform + + + 1.8 + 1.8 + 1.8 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + org.springframework.boot + spring-boot-starter-amqp + + + + + com.mysql + mysql-connector-j + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + 8.5.13 + + + + + org.projectlombok + lombok + true + + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + + + com.aliyun + dysmsapi20170525 + 2.0.0 + + + + + com.easemob.im + im-sdk-core + 0.8.56 + + + + io.projectreactor + reactor-core + 3.4.29 + + + + com.aliyun.oss + aliyun-sdk-oss + 3.17.4 + + + + com.aliyun + aliyun-java-sdk-sts + 3.1.2 + + + + com.aliyun + aliyun-java-sdk-vod + 2.16.5 + + + + com.aliyun + aliyun-java-sdk-push + 3.13.10 + + + com.aliyun + aliyun-java-sdk-core + 4.5.25 + + + + com.aliyun + ocr_api20210707 + 3.1.2 + + + com.aliyun + darabonba-stream + 0.0.1 + + + + + com.aliyun + tea-openapi + 0.3.6 + + + com.aliyun + tea + 1.3.1 + + + com.aliyun + tea-util + 0.2.23 + + + com.aliyun + openapiutil + 0.2.2 + + + com.google.code.gson + gson + + + + + com.google.zxing + core + 3.5.3 + + + com.google.zxing + javase + 3.5.3 + + + + + org.jsoup + jsoup + 1.17.2 + + + + org.apache.pdfbox + pdfbox + 2.0.33 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + + + portable + + ${project.basedir}/target/portable + + + src/main/resources + false + + **/application*.yml + **/application*.yaml + **/application*.properties + **/.env* + + + + + + + diff --git a/campus-server/src/main/java/com/campus/CampusApplication.java b/campus-server/src/main/java/com/campus/CampusApplication.java new file mode 100644 index 0000000..7205efe --- /dev/null +++ b/campus-server/src/main/java/com/campus/CampusApplication.java @@ -0,0 +1,16 @@ +package com.campus; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableAsync +@EnableScheduling +public class CampusApplication { + + public static void main(String[] args) { + SpringApplication.run(CampusApplication.class, args); + } +} diff --git a/campus-server/src/main/java/com/campus/common/BaseEntity.java b/campus-server/src/main/java/com/campus/common/BaseEntity.java new file mode 100644 index 0000000..396f8f0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/common/BaseEntity.java @@ -0,0 +1,43 @@ +package com.campus.common; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import org.hibernate.annotations.Where; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import javax.persistence.*; +import java.io.Serializable; +import java.util.Date; +import java.util.UUID; + +@Data +@MappedSuperclass +@Where(clause = "deleted = false") +public abstract class BaseEntity implements Serializable { + + @Id + @Column(name = "id", nullable = false, updatable = false, length = 36) + private String id; + + /** 逻辑删除标记:true-已删除,false-未删除 */ + @Column(name = "deleted", nullable = false) + private Boolean deleted = false; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; + + @PrePersist + public void prePersist() { + if (this.id == null) { + this.id = UUID.randomUUID().toString(); + } + } +} diff --git a/campus-server/src/main/java/com/campus/common/BusinessException.java b/campus-server/src/main/java/com/campus/common/BusinessException.java new file mode 100644 index 0000000..93da569 --- /dev/null +++ b/campus-server/src/main/java/com/campus/common/BusinessException.java @@ -0,0 +1,16 @@ +package com.campus.common; + +/** + * 业务异常,用于可预期的业务校验失败(如手机号已注册、密码错误等)。 + * 通过 GlobalExceptionHandler 返回 HTTP 400,而非 500。 + */ +public class BusinessException extends RuntimeException { + + public BusinessException(String message) { + super(message); + } + + public BusinessException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/campus-server/src/main/java/com/campus/common/ForbiddenException.java b/campus-server/src/main/java/com/campus/common/ForbiddenException.java new file mode 100644 index 0000000..79cef9e --- /dev/null +++ b/campus-server/src/main/java/com/campus/common/ForbiddenException.java @@ -0,0 +1,9 @@ +package com.campus.common; + +/** 权限不足时返回 HTTP 403,避免把鉴权失败误报为业务参数错误或服务器错误。 */ +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/campus-server/src/main/java/com/campus/common/GlobalExceptionHandler.java b/campus-server/src/main/java/com/campus/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..eb51cc5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/common/GlobalExceptionHandler.java @@ -0,0 +1,116 @@ +package com.campus.common; + +import lombok.extern.slf4j.Slf4j; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.MultipartException; + +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result 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 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 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 handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex) { + log.warn("File size exceeded: {}", ex.getMessage()); + return Result.error(400, "文件大小超过限制,请压缩后重新上传(最大100MB)"); + } + + @ExceptionHandler(MultipartException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleMultipartException(MultipartException ex) { + log.error("Multipart parsing failed: {}", ex.getMessage()); + return Result.error(400, "文件上传失败,请检查网络连接或文件格式后重试"); + } + + @ExceptionHandler(BusinessException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleBusinessException(BusinessException ex) { + log.warn("Business exception: {}", ex.getMessage()); + return Result.error(400, ex.getMessage()); + } + + @ExceptionHandler(ForbiddenException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public Result handleForbiddenException(ForbiddenException ex) { + log.warn("Forbidden operation: {}", ex.getMessage()); + return Result.error(403, ex.getMessage()); + } + + @ExceptionHandler(ServiceUnavailableException.class) + @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) + public Result 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 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 handleRuntimeException(RuntimeException ex) { + log.error("Runtime exception: ", ex); + return Result.error(500, "服务器内部错误"); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleException(Exception ex) { + log.error("Unexpected exception: ", ex); + return Result.error("服务器内部错误"); + } +} diff --git a/campus-server/src/main/java/com/campus/common/PageResult.java b/campus-server/src/main/java/com/campus/common/PageResult.java new file mode 100644 index 0000000..e9ab749 --- /dev/null +++ b/campus-server/src/main/java/com/campus/common/PageResult.java @@ -0,0 +1,37 @@ +package com.campus.common; + +import lombok.Data; +import org.springframework.data.domain.Page; + +import java.util.Collections; +import java.util.List; + +@Data +public class PageResult { + private List items; + private Long total; + private Integer page; + private Integer pageSize; + + public static PageResult from(Page page) { + PageResult result = new PageResult<>(); + result.setItems(page.getContent()); + result.setTotal(page.getTotalElements()); + result.setPage(page.getNumber() + 1); + result.setPageSize(page.getSize()); + return result; + } + + public static PageResult of(List items, long total, int page, int pageSize) { + PageResult result = new PageResult<>(); + result.setItems(items); + result.setTotal(total); + result.setPage(page); + result.setPageSize(pageSize); + return result; + } + + public static PageResult empty(int page, int pageSize) { + return of(Collections.emptyList(), 0, page, pageSize); + } +} diff --git a/campus-server/src/main/java/com/campus/common/Result.java b/campus-server/src/main/java/com/campus/common/Result.java new file mode 100644 index 0000000..0761806 --- /dev/null +++ b/campus-server/src/main/java/com/campus/common/Result.java @@ -0,0 +1,44 @@ +package com.campus.common; + +import lombok.Data; + +@Data +public class Result { + private Integer code; + private String message; + private T data; + + public static Result success(T data) { + Result result = new Result<>(); + result.setCode(200); + result.setMessage("success"); + result.setData(data); + return result; + } + + public static Result success() { + return success(null); + } + + public static Result error(String message) { + Result result = new Result<>(); + result.setCode(500); + result.setMessage(message); + return result; + } + + public static Result error(Integer code, String message) { + Result result = new Result<>(); + result.setCode(code); + result.setMessage(message); + return result; + } + + public static Result tokenExpired() { + return error(401, "token已失效,请重新登录"); + } + + public static Result unauthorized(String message) { + return error(401, message); + } +} diff --git a/campus-server/src/main/java/com/campus/common/ServiceUnavailableException.java b/campus-server/src/main/java/com/campus/common/ServiceUnavailableException.java new file mode 100644 index 0000000..efaf1ac --- /dev/null +++ b/campus-server/src/main/java/com/campus/common/ServiceUnavailableException.java @@ -0,0 +1,13 @@ +package com.campus.common; + +/** 外部基础服务暂时不可用,例如 Redis 会话存储连接失败。 */ +public class ServiceUnavailableException extends RuntimeException { + + public ServiceUnavailableException(String message) { + super(message); + } + + public ServiceUnavailableException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/campus-server/src/main/java/com/campus/common/TooManyRequestsException.java b/campus-server/src/main/java/com/campus/common/TooManyRequestsException.java new file mode 100644 index 0000000..cfab62e --- /dev/null +++ b/campus-server/src/main/java/com/campus/common/TooManyRequestsException.java @@ -0,0 +1,9 @@ +package com.campus.common; + +/** 同一用户短时间内重复操作同一目标;由全局异常处理映射为 HTTP 429。 */ +public class TooManyRequestsException extends RuntimeException { + + public TooManyRequestsException(String message) { + super(message); + } +} diff --git a/campus-server/src/main/java/com/campus/config/AchievementInitializer.java b/campus-server/src/main/java/com/campus/config/AchievementInitializer.java new file mode 100644 index 0000000..7f60f88 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/AchievementInitializer.java @@ -0,0 +1,20 @@ +package com.campus.config; +import com.campus.entity.AchievementDefinition; +import com.campus.repository.AchievementDefinitionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +@Component +@RequiredArgsConstructor +public class AchievementInitializer implements CommandLineRunner { + private final AchievementDefinitionRepository repository; + @Override @Transactional + public void run(String... args) { + String[][] rows={{"ACTIVITY_EXPERT","活动达人","参与并发起10场校园活动","10"},{"MOMENT_EXPERT","动态达人","发布20条审核通过的普通动态","20"},{"CHECKIN_EXPERT","签到达人","连续签到30天","30"},{"FRIEND_EXPERT","交友达人","添加100位好友","100"},{"GIFT_EXPERT","送礼达人","送出50份礼物","50"},{"CAMPUS_EXPERT","校园达人","完成学信网认证,填写昵称、头像、学校、专业和年级","1"},{"POPULAR_EXPERT","人气达人","动态及青春号累计获得1000个有效赞(不含自赞和话题讨论)","1000"},{"SHARE_EXPERT","分享达人","成功发送50条站内分享卡片","50"}}; + for(int i=0;i 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); + } +} diff --git a/campus-server/src/main/java/com/campus/config/AliyunPushConfig.java b/campus-server/src/main/java/com/campus/config/AliyunPushConfig.java new file mode 100644 index 0000000..f76df8a --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/AliyunPushConfig.java @@ -0,0 +1,23 @@ +package com.campus.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "aliyun.push") +public class AliyunPushConfig { + + private Boolean enabled = true; + + private String accessKeyId; + + private String accessKeySecret; + + private String regionId = "cn-hangzhou"; + + private String androidAppKey; + + private String iosAppKey; +} diff --git a/campus-server/src/main/java/com/campus/config/AsyncConfig.java b/campus-server/src/main/java/com/campus/config/AsyncConfig.java new file mode 100644 index 0000000..b4ae448 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/AsyncConfig.java @@ -0,0 +1,39 @@ +package com.campus.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 异步线程池配置 + * + * 核心策略: + * - 核心线程 4(应对正常流量) + * - 最大线程 8(高峰弹性伸缩) + * - 队列 100(缓冲突增任务) + * - CallerRunsPolicy(队列满时由调用线程执行,防止 OOM) + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + /** 通用异步任务线程池(推送通知、审核等) */ + @Bean("taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-task-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } + +} diff --git a/campus-server/src/main/java/com/campus/config/AuthDataIntegrityInitializer.java b/campus-server/src/main/java/com/campus/config/AuthDataIntegrityInitializer.java new file mode 100644 index 0000000..9b4b148 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/AuthDataIntegrityInitializer.java @@ -0,0 +1,43 @@ +package com.campus.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 登录账号数据完整性防线。 + * + * 历史数据库曾缺少 phone 唯一索引,导致并发注册产生两个有效账号,并让登录查询抛出 + * NonUniqueResultException。启动时先拒绝带重复手机号的脏库,再确保数据库存在唯一索引。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthDataIntegrityInitializer implements ApplicationRunner { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void run(ApplicationArguments args) { + Integer duplicateGroups = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM (SELECT phone FROM `user` GROUP BY phone HAVING COUNT(*) > 1) duplicates", + Integer.class); + if (duplicateGroups != null && duplicateGroups > 0) { + throw new IllegalStateException( + "用户表存在 " + duplicateGroups + " 组重复手机号,已拒绝启动;请先执行账号数据修复"); + } + + Integer uniquePhoneIndexes = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.statistics " + + "WHERE table_schema = DATABASE() AND table_name = 'user' " + + "AND column_name = 'phone' AND non_unique = 0", + Integer.class); + if (uniquePhoneIndexes == null || uniquePhoneIndexes == 0) { + jdbcTemplate.execute("ALTER TABLE `user` ADD CONSTRAINT `uk_user_phone` UNIQUE (`phone`)"); + log.info("已创建用户手机号唯一索引 uk_user_phone"); + } + } +} diff --git a/campus-server/src/main/java/com/campus/config/CommerceCatalogInitializer.java b/campus-server/src/main/java/com/campus/config/CommerceCatalogInitializer.java new file mode 100644 index 0000000..9140809 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/CommerceCatalogInitializer.java @@ -0,0 +1,97 @@ +package com.campus.config; + +import com.campus.entity.LotteryPrize; +import com.campus.entity.MallProduct; +import com.campus.entity.TaskDefinition; +import com.campus.repository.LotteryPrizeRepository; +import com.campus.repository.MallProductRepository; +import com.campus.repository.TaskDefinitionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** 只补齐缺失的默认配置,不覆盖管理员已经调整过的数据。 */ +@Configuration +@RequiredArgsConstructor +public class CommerceCatalogInitializer { + + private final MallProductRepository productRepository; + private final TaskDefinitionRepository taskRepository; + private final LotteryPrizeRepository prizeRepository; + + @Bean + public ApplicationRunner initializeCommerceCatalog() { + return args -> { + ensureProduct("demo-theme", "个性主题体验包", "展示用主题皮肤", 30, "THEME", 30, 1); + ensureProduct("demo-avatar-frame", "头像框体验包", "展示用头像框", 20, "AVATAR_FRAME", 30, 2); + ensureProduct("demo-boost", "匹配加速体验", "展示用匹配加速权益", 50, "MATCH_BOOST", 7, 3); + + ensureTask("complete-profile", "完善个人资料", "完善家乡、个人介绍和兴趣标签", 10, "ONCE", 1); + ensureTask("daily-check-in", "每日签到", "完成今日签到", 5, "DAILY", 2); + ensureTask("publish-moment", "发布一条动态", "分享校园生活", 10, "ONCE", 3); + ensureTask("real-name-auth", "实名认证", "完成学信网实名认证", 100, "ONCE", 4); + ensureTask("first-login", "首次登录", "首次登录获得积分", 10, "ONCE", 0); + + ensurePrize("NONE", "谢谢参与", 60, -1, 0, null, null, 1); + ensurePrize("BEAN_5", "青豆 5", 25, -1, 5, null, null, 2); + ensurePrize("AVATAR_FRAME", "头像框体验卡", 10, -1, 0, "AVATAR_FRAME", 7, 3); + ensurePrize("MATCH_BOOST", "匹配加速体验卡", 5, -1, 0, "MATCH_BOOST", 3, 4); + }; + } + + private void ensureProduct(String code, String name, String description, int price, + String entitlementType, Integer durationDays, int sort) { + if (productRepository.findByCode(code).isPresent()) return; + MallProduct product = new MallProduct(); + product.setId(code); + product.setCode(code); + product.setName(name); + product.setDescription(description); + product.setPricePoints(price); + product.setStock(-1); + product.setEntitlementType(entitlementType); + product.setDurationDays(durationDays); + product.setSort(sort); + productRepository.save(product); + } + + private void ensureTask(String code, String name, String description, int reward, + String cycle, int sort) { + java.util.Optional 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); + } +} diff --git a/campus-server/src/main/java/com/campus/config/DictInitializer.java b/campus-server/src/main/java/com/campus/config/DictInitializer.java new file mode 100644 index 0000000..5ab8b31 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/DictInitializer.java @@ -0,0 +1,228 @@ +package com.campus.config; + +import com.campus.entity.SysDict; +import com.campus.entity.SysDictItem; +import com.campus.repository.SysDictItemRepository; +import com.campus.repository.SysDictRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; + +/** + * 字典预置数据初始化器 + * 项目启动时若字典主表为空则写入预置数据 + */ +@Slf4j +@Component +@Order(1) +@RequiredArgsConstructor +public class DictInitializer implements CommandLineRunner { + + private final SysDictRepository sysDictRepository; + private final SysDictItemRepository sysDictItemRepository; + + @Override + @Transactional + public void run(String... args) { + log.info("开始初始化字典数据..."); + + // 公告类型 + initDictIfNotExists("announcement_type", "公告类型", "系统公告、讲座、演出、招聘会等的分类", + Arrays.asList( + item("all", "全部", 1, null, null), + item("lecture", "讲座", 2, null, null), + item("concert", "演出", 3, null, null), + item("jobfair", "招聘会", 4, null, null), + item("meeting", "会议", 5, null, null), + item("activity", "活动", 6, null, null), + item("notice", "通知", 7, null, null), + item("general", "通用", 99, null, null) + )); + + // 活动类型 + initDictIfNotExists("activity_type", "活动类型", "组局活动分类", + Arrays.asList( + item("sports", "运动", 1, null, "sports"), + item("study", "学习", 2, null, "study"), + item("party", "聚会", 3, null, "party"), + item("food", "美食", 4, null, "food"), + item("travel", "出行", 5, null, "travel"), + item("game", "游戏", 6, null, "game"), + item("volunteer", "志愿", 7, null, "volunteer"), + item("other", "其他", 99, null, null) + )); + + // 动态分类 + initDictIfNotExists("moment_category", "动态分类", "校园动态分类", + Arrays.asList( + item("lost_found", "失物招领", 1, null, null), + item("second_hand", "二手市场", 2, null, null), + item("club", "社团活动", 3, null, null), + item("help", "校园求助", 6, null, null), + item("study", "学习交流", 7, null, null), + item("rent", "租房合租", 8, null, null), + item("friends", "交友约伴", 9, null, null), + item("skill", "技能交换", 10, null, null) + )); + + // 动态发布范围 + initDictIfNotExists("moment_scope", "动态发布范围", "动态可见范围", + Arrays.asList( + item("personal", "个人动态", 1, null, null), + item("campus", "校园动态", 2, null, null) + )); + + // 学校类型 + initDictIfNotExists("university_type", "学校类型", "高等院校分类", + Arrays.asList( + item("comprehensive", "综合类", 1, null, null), + item("science", "理工类", 2, null, null), + item("liberal_arts", "文史类", 3, null, null), + item("finance", "财经类", 4, null, null), + item("language", "语言类", 5, null, null), + item("medical", "医药类", 6, null, null), + item("agriculture", "农林类", 7, null, null), + item("normal", "师范类", 8, null, null), + item("art", "艺术类", 9, null, null), + item("nationalities", "民族类", 10, null, null), + item("military", "军事类", 11, null, null), + item("sport", "体育类", 12, null, null), + item("police", "政法类", 13, null, null) + )); + + // 积分交易类型 + initDictIfNotExists("points_type", "积分类型", "积分交易类型", + Arrays.asList( + item("earn", "获得", 1, "#67C23A", null), + item("spend", "消费", 2, "#F56C6C", null), + item("freeze", "冻结", 3, "#E6A23C", null), + item("unfreeze", "解冻", 4, "#909399", null) + )); + + // 通知类型 + initDictIfNotExists("notification_type", "通知类型", "系统通知类型", + Arrays.asList( + item("follow", "关注通知", 1, null, null), + item("friend_request", "好友申请", 2, null, null), + item("friend_accepted", "好友通过", 3, null, null), + item("like", "点赞通知", 4, null, null), + item("comment", "评论通知", 5, null, null), + item("mention", "@提醒", 6, null, null), + item("system", "系统通知", 7, null, null), + item("activity_review", "活动审核", 8, null, null), + item("moment_review", "动态审核", 9, null, null), + item("post_review", "帖子审核", 10, null, null) + )); + + // 审核状态 + initDictIfNotExists("review_status", "审核状态", "内容审核状态", + Arrays.asList( + item("pending", "待审核", 1, "#E6A23C", null), + item("approved", "已通过", 2, "#67C23A", null), + item("rejected", "已拒绝", 3, "#F56C6C", null) + )); + + // 公告发布范围 + initDictIfNotExists("announcement_scope", "公告发布范围", "公告可见范围", + Arrays.asList( + item("all", "全部学校", 1, null, null), + item("specified", "指定学校", 2, null, null) + )); + + // 视频转码状态 + initDictIfNotExists("video_status", "视频转码状态", "视频转码任务状态", + Arrays.asList( + item("pending", "等待转码", 1, "#E6A23C", null), + item("processing", "转码中", 2, "#409EFF", null), + item("completed", "转码完成", 3, "#67C23A", null), + item("failed", "转码失败", 4, "#F56C6C", null) + )); + + // 青春号视频标签 + initDictIfNotExists("youth_video_tag", "青春号视频标签", "青春号短视频分类标签", + Arrays.asList( + item("music_dance", "音乐舞蹈", 1, "#FF6B9D", "music"), + item("humor", "幽默趣事", 2, "#FFB84D", "smile"), + item("games_sports", "游戏体育", 3, "#5B8FF9", "gamepad-2"), + item("challenge_growth", "挑战成长", 4, "#B37FEB", "sparkles"), + item("beauty_fashion", "美妆时尚", 5, "#FF85C0", "lipstick"), + item("food_travel", "美食旅游", 6, "#FF7A45", "coffee"), + item("art_handicraft", "艺术手工", 7, "#F759AB", "gift"), + item("animals", "动物伙伴", 8, "#52C41A", "paw-print"), + item("tech_digital", "科技数码", 9, "#13C2C2", "vr-headset"), + item("knowledge_speech", "知识演讲", 10, "#36CFC9", "book-open"), + item("people_views", "人物观点", 11, "#9254DE", "user"), + item("workplace_business", "职场商业", 12, "#2F54EB", "briefcase") + )); + + // 好友状态 + initDictIfNotExists("friendship_status", "好友状态", "好友关系状态", + Arrays.asList( + item("pending", "待确认", 1, "#E6A23C", null), + item("accepted", "已通过", 2, "#67C23A", null), + item("rejected", "已拒绝", 3, "#F56C6C", null), + item("blocked", "已拉黑", 4, "#909399", null) + )); + + // 兴趣爱好标签 - 强制更新确保数据最新 + initDictIfNotExists("interest_tags", "兴趣爱好", "用户注册时选择的兴趣爱好标签", + Arrays.asList( + item("reading", "阅读", 1, "#6366F1", "📚"), + item("music", "音乐", 2, "#EC4899", "🎵"), + item("movie", "电影", 3, "#8B5CF6", "🎬"), + item("gaming", "游戏", 4, "#10B981", "🎮"), + item("sports", "运动", 5, "#F59E0B", "⚽"), + item("travel", "旅行", 6, "#06B6D4", "✈️"), + item("photography", "摄影", 7, "#84CC16", "📷"), + item("food", "美食", 8, "#EF4444", "🍜"), + item("programming", "编程", 9, "#2563EB", "💻"), + item("art", "艺术", 10, "#D946EF", "🎨"), + item("dance", "舞蹈", 11, "#F97316", "💃"), + item("fitness", "健身", 12, "#0EA5E9", "🏋️"), + item("pets", "宠物", 13, "#14B8A6", "🐕"), + item("coffee", "咖啡", 14, "#78350F", "☕"), + item("singing", "唱歌", 15, "#DC2626", "🎤"), + item("writing", "写作", 16, "#64748B", "✍️") + )); + + log.info("字典数据初始化完成"); + } + + private void initDictIfNotExists(String code, String name, String description, List 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 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; + } +} diff --git a/campus-server/src/main/java/com/campus/config/EasemobConfig.java b/campus-server/src/main/java/com/campus/config/EasemobConfig.java new file mode 100644 index 0000000..b69b13a --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/EasemobConfig.java @@ -0,0 +1,58 @@ +package com.campus.config; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.easemob.im.server.EMProperties; +import com.easemob.im.server.EMService; + +@Data +@Slf4j +@Configuration +@ConfigurationProperties(prefix = "easemob") +public class EasemobConfig { + + private String appKey; + private String clientId; + private String clientSecret; + private String orgName; + private String appName; + private String baseUrl = "https://a1.easemob.com"; + + @Bean + public EMService emService() { + if (!isValidConfig()) { + log.warn("环信配置无效,将跳过EMService初始化。请在application.yml中正确配置环信参数(appKey格式应为:org_name#app_name)"); + return null; + } + + try { + EMProperties.Builder builder = EMProperties.builder() + .setAppkey(appKey) + .setClientId(clientId) + .setClientSecret(clientSecret); + // 关键:必须显式指定数据中心(baseUri),否则 SDK 默认连接国内1区 a1.easemob.com, + // 而 base-url 配置为国内2区时会导致发消息连错区、发送失败。 + if (baseUrl != null && !baseUrl.isEmpty()) { + builder.setBaseUri(baseUrl); + } + EMProperties properties = builder.build(); + + EMService service = new EMService(properties); + log.info("环信EMService初始化成功, baseUri={}", baseUrl); + return service; + } catch (Exception e) { + log.error("环信EMService初始化失败: {}", e.getMessage()); + return null; + } + } + + private boolean isValidConfig() { + return appKey != null && !appKey.isEmpty() && appKey.contains("#") + && clientId != null && !clientId.isEmpty() + && clientSecret != null && !clientSecret.isEmpty(); + } +} diff --git a/campus-server/src/main/java/com/campus/config/EasemobGroupInitializer.java b/campus-server/src/main/java/com/campus/config/EasemobGroupInitializer.java new file mode 100644 index 0000000..9c51e6a --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/EasemobGroupInitializer.java @@ -0,0 +1,101 @@ +package com.campus.config; + +import com.campus.entity.Group; +import com.campus.repository.GroupRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.service.EasemobService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + + +/** + * 环信群组补建初始化器 + * 为存量 chat_group 中 easemob_group_id 为空的记录补建环信群组 + */ +@Slf4j +@Component +@Order(10) +@RequiredArgsConstructor +public class EasemobGroupInitializer implements CommandLineRunner { + + private final GroupRepository groupRepository; + private final GroupMemberRepository groupMemberRepository; + private final EasemobService easemobService; + + @Override + public void run(String... args) { + log.info("开始检查是否需要补建环信群组..."); + + int pageSize = 50; + int page = 0; + int fixedCount = 0; + int failCount = 0; + + while (true) { + Page groupPage = groupRepository.findAll( + PageRequest.of(page, pageSize)); + + for (Group group : groupPage.getContent()) { + if (group.getEasemobGroupId() != null) { + continue; + } + + // 查找群成员列表 + java.util.List members = + groupMemberRepository.findByGroupId(group.getId()); + java.util.List 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 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); + } +} diff --git a/campus-server/src/main/java/com/campus/config/GiftInitializer.java b/campus-server/src/main/java/com/campus/config/GiftInitializer.java new file mode 100644 index 0000000..3a511f7 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/GiftInitializer.java @@ -0,0 +1,59 @@ +package com.campus.config; + +import com.campus.entity.Gift; +import com.campus.repository.GiftRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * 礼物初始化器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GiftInitializer implements CommandLineRunner { + + private final GiftRepository giftRepository; + + @Override + public void run(String... args) { + if (giftRepository.count() > 0) { + log.info("礼物已存在,跳过初始化"); + return; + } + + log.info("初始化礼物..."); + + List 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; + } +} diff --git a/campus-server/src/main/java/com/campus/config/HttpRequestLoggingFilter.java b/campus-server/src/main/java/com/campus/config/HttpRequestLoggingFilter.java new file mode 100644 index 0000000..029efec --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/HttpRequestLoggingFilter.java @@ -0,0 +1,52 @@ +package com.campus.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** Lightweight request log used by the internal test environment. */ +@Slf4j +@Component +@Order(Ordered.LOWEST_PRECEDENCE) +public class HttpRequestLoggingFilter extends OncePerRequestFilter { + + @Value("${app.observability.slow-request-ms:1000}") + private long slowRequestMs; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long start = System.currentTimeMillis(); + try { + filterChain.doFilter(request, response); + } catch (IOException | ServletException | RuntimeException ex) { + log.error("[HTTP_ERROR] {} {} status={} duration={}ms", + request.getMethod(), request.getRequestURI(), response.getStatus(), + System.currentTimeMillis() - start, ex); + throw ex; + } finally { + long elapsed = System.currentTimeMillis() - start; + log.info("[HTTP] {} {} status={} duration={}ms remote={}", + request.getMethod(), + request.getRequestURI(), + response.getStatus(), + elapsed, + request.getRemoteAddr()); + if (elapsed >= slowRequestMs) { + log.warn("[SLOW_HTTP] {} {} status={} duration={}ms threshold={}ms", + request.getMethod(), request.getRequestURI(), response.getStatus(), elapsed, slowRequestMs); + } + } + } +} diff --git a/campus-server/src/main/java/com/campus/config/JwtAuthenticationFilter.java b/campus-server/src/main/java/com/campus/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..f24e558 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/JwtAuthenticationFilter.java @@ -0,0 +1,156 @@ +package com.campus.config; + +import com.campus.common.Result; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import com.campus.service.UserRestrictionService; +import com.campus.service.RedisRetryExecutor; +import com.campus.service.UserSessionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtConfig jwtConfig; + private final UserRepository userRepository; + private final RedisTemplate 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 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 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; + } +} diff --git a/campus-server/src/main/java/com/campus/config/JwtConfig.java b/campus-server/src/main/java/com/campus/config/JwtConfig.java new file mode 100644 index 0000000..b6d4832 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/JwtConfig.java @@ -0,0 +1,75 @@ +package com.campus.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JwtConfig { + + @Value("${jwt.secret:campus-platform-secret-key-must-be-at-least-256-bits-long}") + private String secret; + + @Value("${jwt.expiration:86400000}") + private long expiration; + + private Key getSigningKey() { + byte[] keyBytes = secret.getBytes(); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generateToken(String phone, String userId) { + Map 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 extractClaim(String token, Function 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)); + } +} diff --git a/campus-server/src/main/java/com/campus/config/OcrProperties.java b/campus-server/src/main/java/com/campus/config/OcrProperties.java new file mode 100644 index 0000000..4ab7e41 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/OcrProperties.java @@ -0,0 +1,49 @@ +package com.campus.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 阿里云OCR配置属性 + */ +@Data +@Component +@ConfigurationProperties(prefix = "aliyun.ocr") +public class OcrProperties { + + /** + * 是否启用OCR + */ + private boolean enabled = true; + + /** + * AccessKey ID + */ + private String accessKeyId; + + /** + * AccessKey Secret + */ + private String accessKeySecret; + + /** + * 区域ID,默认华东1(杭州) + */ + private String regionId = "cn-hangzhou"; + + /** + * OCR服务Endpoint + */ + private String endpoint = "ocr-api.cn-hangzhou.aliyuncs.com"; + + /** + * 连接超时时间(毫秒) + */ + private int connectTimeout = 10000; + + /** + * 读取超时时间(毫秒) + */ + private int readTimeout = 30000; +} diff --git a/campus-server/src/main/java/com/campus/config/OssConfig.java b/campus-server/src/main/java/com/campus/config/OssConfig.java new file mode 100644 index 0000000..0f1fa8f --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/OssConfig.java @@ -0,0 +1,29 @@ +package com.campus.config; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@ConditionalOnProperty(prefix = "aliyun.oss", name = "enabled", havingValue = "true", matchIfMissing = true) +public class OssConfig { + + @Autowired + private OssProperties ossProperties; + + @Bean(destroyMethod = "shutdown") + public OSS ossClient() { + log.info("初始化阿里云OSS客户端,Endpoint: {}, Bucket: {}", ossProperties.getEndpoint(), ossProperties.getBucketName()); + return new OSSClientBuilder() + .build( + ossProperties.getEndpoint(), + ossProperties.getAccessKeyId(), + ossProperties.getAccessKeySecret() + ); + } +} diff --git a/campus-server/src/main/java/com/campus/config/OssProperties.java b/campus-server/src/main/java/com/campus/config/OssProperties.java new file mode 100644 index 0000000..07bdf3b --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/OssProperties.java @@ -0,0 +1,37 @@ +package com.campus.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Data +@Component +@ConfigurationProperties(prefix = "aliyun.oss") +public class OssProperties { + + /** 是否启用OSS */ + private Boolean enabled = true; + + /** 阿里云OSS的endpoint */ + private String endpoint; + + /** 阿里云AccessKeyId */ + private String accessKeyId; + + /** 阿里云AccessKeySecret */ + private String accessKeySecret; + + /** OSS存储空间名称 */ + private String bucketName; + + /** 访问域名(自定义域名或默认域名) */ + private String domain; + + /** 最大文件大小(MB) */ + private Long maxFileSize = 100L; + + /** 允许的文件类型 */ + private List allowedFileTypes; +} diff --git a/campus-server/src/main/java/com/campus/config/PointRuleInitializer.java b/campus-server/src/main/java/com/campus/config/PointRuleInitializer.java new file mode 100644 index 0000000..e36d661 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/PointRuleInitializer.java @@ -0,0 +1,109 @@ +package com.campus.config; + +import com.campus.entity.PointRule; +import com.campus.repository.PointRuleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * 积分规则初始化器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PointRuleInitializer implements CommandLineRunner { + + private final PointRuleRepository pointRuleRepository; + + @Override + public void run(String... args) { + log.info("初始化积分规则..."); + + List 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 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; + } + } +} diff --git a/campus-server/src/main/java/com/campus/config/RabbitMQConfig.java b/campus-server/src/main/java/com/campus/config/RabbitMQConfig.java new file mode 100644 index 0000000..01269b7 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/RabbitMQConfig.java @@ -0,0 +1,96 @@ +package com.campus.config; + +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * RabbitMQ 配置 + * 交换机: video_upload_exchange (Topic) + * 队列: video_transcode_queue + video_transcode_dlq (死信) + */ +@Configuration +public class RabbitMQConfig { + + // ===== 交换机 ===== + public static final String VIDEO_UPLOAD_EXCHANGE = "video_upload_exchange"; + + // ===== 队列 ===== + public static final String VIDEO_TRANSCODE_QUEUE = "video_transcode_queue"; + public static final String VIDEO_TRANSCODE_DLQ = "video_transcode_dlq"; + + // ===== 路由键 ===== + public static final String VIDEO_TRANSCODE_ROUTING_KEY = "video.transcode"; + + /** + * 主题交换机 + */ + @Bean + public TopicExchange videoUploadExchange() { + return ExchangeBuilder.topicExchange(VIDEO_UPLOAD_EXCHANGE) + .durable(true) + .build(); + } + + /** + * 转码队列 — 绑定了死信交换机 + * 消息消费失败后自动路由到 DLQ + */ + @Bean + public Queue videoTranscodeQueue() { + return QueueBuilder.durable(VIDEO_TRANSCODE_QUEUE) + .deadLetterExchange(VIDEO_UPLOAD_EXCHANGE) + .deadLetterRoutingKey(VIDEO_TRANSCODE_DLQ) + .build(); + } + + /** + * 死信队列 + */ + @Bean + public Queue videoTranscodeDlq() { + return QueueBuilder.durable(VIDEO_TRANSCODE_DLQ).build(); + } + + /** + * 绑定: 转码队列 → 交换机 (routing-key = video.transcode) + */ + @Bean + public Binding bindTranscodeQueue() { + return BindingBuilder.bind(videoTranscodeQueue()) + .to(videoUploadExchange()) + .with(VIDEO_TRANSCODE_ROUTING_KEY); + } + + /** + * 绑定: 死信队列 → 交换机 (routing-key = video_transcode_dlq) + */ + @Bean + public Binding bindDlq() { + return BindingBuilder.bind(videoTranscodeDlq()) + .to(videoUploadExchange()) + .with(VIDEO_TRANSCODE_DLQ); + } + + /** + * JSON 消息转换器 + */ + @Bean + public Jackson2JsonMessageConverter jackson2JsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + /** + * RabbitTemplate — 用于发送消息 + */ + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, + Jackson2JsonMessageConverter converter) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(converter); + return template; + } +} diff --git a/campus-server/src/main/java/com/campus/config/RedisConfig.java b/campus-server/src/main/java/com/campus/config/RedisConfig.java new file mode 100644 index 0000000..a3c4429 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/RedisConfig.java @@ -0,0 +1,115 @@ +package com.campus.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import org.springframework.cache.CacheManager; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.cache.annotation.CachingConfigurerSupport; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +@EnableCaching +public class RedisConfig extends CachingConfigurerSupport { + + /** Redis 短暂断线时绕过缓存执行数据库逻辑,避免公开读接口直接返回 500。 */ + @Override + public CacheErrorHandler errorHandler() { + return new CacheErrorHandler() { + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + warn("GET", cache, key, exception); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + warn("PUT", cache, key, exception); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + warn("EVICT", cache, key, exception); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + warn("CLEAR", cache, null, exception); + } + + private void warn(String operation, Cache cache, Object key, RuntimeException exception) { + log.warn("Redis 缓存操作失败,已降级绕过缓存: operation={}, cache={}, key={}, cause={}", + operation, cache == null ? "unknown" : cache.getName(), key, + exception.getMessage()); + } + }; + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + Jackson2JsonRedisSerializer 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 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 cacheConfigs = new HashMap<>(); + cacheConfigs.put("moments", momentsConfig); + + return RedisCacheManager.builder(factory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } +} diff --git a/campus-server/src/main/java/com/campus/config/RedisConnectionConfig.java b/campus-server/src/main/java/com/campus/config/RedisConnectionConfig.java new file mode 100644 index 0000000..9412c7c --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/RedisConnectionConfig.java @@ -0,0 +1,30 @@ +package com.campus.config; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import org.springframework.boot.autoconfigure.data.redis.LettuceClientConfigurationBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** 提升云 Redis 空闲断线后的自愈能力。 */ +@Configuration +public class RedisConnectionConfig { + @Bean + public LettuceClientConfigurationBuilderCustomizer lettuceConnectionCustomizer() { + return builder -> builder.clientOptions(ClientOptions.builder() + .autoReconnect(true) + .pingBeforeActivateConnection(true) + // Windows 默认 TCP keepalive 间隔通常以小时计,云端空闲连接可能早已被回收。 + // 主动探测能让 Lettuce 在下一次业务命令到来前发现断线并重连。 + .socketOptions(SocketOptions.builder().keepAlive( + SocketOptions.KeepAliveOptions.builder() + .enable() + .idle(Duration.ofSeconds(30)) + .interval(Duration.ofSeconds(10)) + .count(3) + .build()).build()) + .build()); + } +} diff --git a/campus-server/src/main/java/com/campus/config/SecurityConfig.java b/campus-server/src/main/java/com/campus/config/SecurityConfig.java new file mode 100644 index 0000000..bb17388 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/SecurityConfig.java @@ -0,0 +1,82 @@ +package com.campus.config; + +import com.campus.common.Result; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import javax.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final ObjectMapper objectMapper; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf().disable() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + .antMatchers("/api/auth/**").permitAll() + .antMatchers("/api/public/**").permitAll() + .antMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .antMatchers("/api/universities/search").permitAll() + .antMatchers("/api/dicts/**").permitAll() + .antMatchers("/api/oss/vod-callback").permitAll() // VOD 回调,由阿里云调用 + .antMatchers("/api/oss/upload-callback").permitAll() // OSS 回调,由阿里云调用 + .antMatchers("/api/admin/**").hasRole("ADMIN") + .antMatchers("/api/sensitive-words/**").hasRole("ADMIN") + .antMatchers(HttpMethod.POST, "/api/campus/points/adjust").hasRole("ADMIN") + .antMatchers(HttpMethod.GET, "/api/campus/points/account-other").hasRole("ADMIN") + .antMatchers(HttpMethod.PUT, "/api/profile/other").hasRole("ADMIN") + .antMatchers(HttpMethod.GET, "/api/profile/points-other").hasRole("ADMIN") + .antMatchers(HttpMethod.POST, "/api/campus/notifications").hasRole("ADMIN") + .antMatchers("/api/campus/review/**").hasAnyRole("ADMIN", "REVIEWER") + .antMatchers(HttpMethod.PUT, "/api/campus/activities/review").hasAnyRole("ADMIN", "REVIEWER") + .antMatchers(HttpMethod.PUT, "/api/campus/announcements/review").hasAnyRole("ADMIN", "REVIEWER") + .antMatchers(HttpMethod.GET, "/api/reports/pending").hasAnyRole("ADMIN", "REVIEWER") + .antMatchers(HttpMethod.PUT, "/api/reports/*/review").hasAnyRole("ADMIN", "REVIEWER") + .anyRequest().authenticated() + .and() + .exceptionHandling() + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(Result.unauthorized("请先登录"))); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(Result.error(403, "无权限访问"))); + }) + .and() + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .httpBasic().disable() + .formLogin().disable(); + return http.build(); + } +} diff --git a/campus-server/src/main/java/com/campus/config/SensitiveWordInitializer.java b/campus-server/src/main/java/com/campus/config/SensitiveWordInitializer.java new file mode 100644 index 0000000..9082891 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/SensitiveWordInitializer.java @@ -0,0 +1,81 @@ +package com.campus.config; + +import com.campus.entity.SensitiveWord; +import com.campus.repository.SensitiveWordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SensitiveWordInitializer implements CommandLineRunner { + + private final SensitiveWordRepository sensitiveWordRepository; + + @Override + public void run(String... args) throws Exception { + if (sensitiveWordRepository.count() > 0) { + log.info("敏感词数据已存在,跳过初始化"); + return; + } + + log.info("开始初始化敏感词数据..."); + + List 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; + } +} diff --git a/campus-server/src/main/java/com/campus/config/StsProperties.java b/campus-server/src/main/java/com/campus/config/StsProperties.java new file mode 100644 index 0000000..8d3abe3 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/StsProperties.java @@ -0,0 +1,23 @@ +package com.campus.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 阿里云 STS 临时凭证配置 + */ +@Data +@Component +@ConfigurationProperties(prefix = "oss.sts") +public class StsProperties { + + /** RAM角色ARN */ + private String roleArn; + + /** 凭证有效期(秒) */ + private Long durationSeconds = 3600L; + + /** 允许上传的OSS路径前缀 */ + private String uploadPrefix = "videos/"; +} diff --git a/campus-server/src/main/java/com/campus/config/VodProperties.java b/campus-server/src/main/java/com/campus/config/VodProperties.java new file mode 100644 index 0000000..d089f50 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/VodProperties.java @@ -0,0 +1,29 @@ +package com.campus.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "vod") +public class VodProperties { + + /** VOD API 地域 */ + private String regionId = "cn-shanghai"; + + /** 阿里云 AccessKeyId(VOD专用) */ + private String accessKeyId; + + /** 阿里云 AccessKeySecret(VOD专用) */ + private String accessKeySecret; + + /** 转码模板组ID */ + private String transcodeTemplateGroupId; + + /** VOD 处理完成回调地址 */ + private String callbackUrl; + + /** VOD 回调认证 */ + private String callbackAuth; +} diff --git a/campus-server/src/main/java/com/campus/config/WebConfig.java b/campus-server/src/main/java/com/campus/config/WebConfig.java new file mode 100644 index 0000000..f7299f4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.campus.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/AccountDeletionReviewController.java b/campus-server/src/main/java/com/campus/controller/AccountDeletionReviewController.java new file mode 100644 index 0000000..47e9d70 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/AccountDeletionReviewController.java @@ -0,0 +1,17 @@ +package com.campus.controller; +import com.campus.common.*; +import com.campus.service.AccountDeletionReviewService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import java.util.Map; +@RestController +@RequestMapping("/api/admin/account-deletion/resources") +@RequiredArgsConstructor +public class AccountDeletionReviewController { + private final AccountDeletionReviewService service; + @GetMapping("/receipts") public Result>> receipts(@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size){return Result.success(service.receipts(page,size));} + @GetMapping public Result>> list(@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size){return Result.success(service.list(page,size));} + @PostMapping("/{id}/resolve") public Result 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;} +} diff --git a/campus-server/src/main/java/com/campus/controller/AchievementController.java b/campus-server/src/main/java/com/campus/controller/AchievementController.java new file mode 100644 index 0000000..5cd8ee1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/AchievementController.java @@ -0,0 +1,16 @@ +package com.campus.controller; +import com.campus.common.Result; +import com.campus.dto.response.*; +import com.campus.service.AchievementService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/api/achievements") +@RequiredArgsConstructor +public class AchievementController { + private final AchievementService service; + @GetMapping("/me") + public Result overview(@RequestParam(defaultValue="ALL") String status,@RequestParam(required=false) String categoryCode) {return Result.success(service.overview(status,categoryCode));} + @GetMapping("/{code}") + public Result detail(@PathVariable String code) {return Result.success(service.detail(code));} +} diff --git a/campus-server/src/main/java/com/campus/controller/ActivityController.java b/campus-server/src/main/java/com/campus/controller/ActivityController.java new file mode 100644 index 0000000..02e0c5c --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ActivityController.java @@ -0,0 +1,269 @@ +package com.campus.controller; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.ActivityQueryRequest; +import com.campus.dto.request.ActivityRequest; +import com.campus.dto.request.ActivityReviewRequest; +import com.campus.dto.response.ActivityDTO; +import com.campus.dto.response.ActivityParticipantDTO; +import com.campus.service.ActivityService; +import com.campus.service.ClickGuardService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +@RestController +@RequestMapping("/api/campus/activities") +@RequiredArgsConstructor +@Log4j2 +public class ActivityController { + + private final ActivityService activityService; + private final ClickGuardService clickGuardService; + + @GetMapping + public Result> 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> 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 view(@RequestParam String id) { + activityService.incrementViewCount(id); + return Result.success(); + } + + @GetMapping("/detail") + public Result 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 join( + @RequestParam String id, + @RequestParam(required = false) String remark) { + throw new BusinessException("报名需发起人审核,请提交报名申请"); + } + + @DeleteMapping("/quit") + public Result 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> getParticipants(@RequestParam String id) { + return Result.success(activityService.getParticipants(id)); + } + + @GetMapping("/{activityId}/participants/manage") + public Result> getParticipantsForManagement( + @PathVariable String activityId) { + return Result.success(activityService.getParticipantsForManagement(activityId)); + } + + @PostMapping("/{activityId}/applications") + public Result 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 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 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> 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> 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 create(@Valid @RequestBody ActivityRequest request) { + String creatorId = UserContext.getCurrentUserId(); + return Result.success(activityService.create(request, creatorId)); + } + + @PutMapping + public Result update(@RequestParam String id, @Valid @RequestBody ActivityRequest request) { + return Result.success(activityService.update(id, request)); + } + + @DeleteMapping + public Result delete(@RequestParam String id) { + activityService.delete(id); + return Result.success(); + } + + @DeleteMapping("/mine") + public Result deleteMy(@RequestParam String id) { + String userId = UserContext.getCurrentUserId(); + activityService.deleteMyActivity(id, userId); + return Result.success(); + } + + @PostMapping("/cancel") + public Result cancel(@RequestParam String id) { + String userId = UserContext.getCurrentUserId(); + activityService.cancelMyActivity(id, userId); + return Result.success(); + } + + @PostMapping("/{id}/withdraw") + public Result withdraw(@PathVariable String id) { + return Result.success(activityService.withdraw(id)); + } + + @PostMapping("/{id}/resubmit") + public Result resubmit(@PathVariable String id) { + return Result.success(activityService.resubmit(id)); + } + + @GetMapping("/{id}/operation-logs") + public Result> operationLogs(@PathVariable String id) { + return Result.success(activityService.operationLogs(id)); + } + + @PutMapping("/review") + public Result review( + @RequestParam String id, + @RequestParam boolean approved, + @RequestParam(required = false) String reason) { + return Result.success(activityService.reviewActivity(id, approved, reason)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/ActivityLocationController.java b/campus-server/src/main/java/com/campus/controller/ActivityLocationController.java new file mode 100644 index 0000000..1d7baeb --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ActivityLocationController.java @@ -0,0 +1,19 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.ActivityLocationRequest; +import com.campus.dto.response.ActivityLocationDTO; +import com.campus.service.location.ActivityLocationService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/campus/activity-locations") +public class ActivityLocationController { + private final ActivityLocationService service; + @PostMapping("/resolve") + public Result resolve(@RequestBody ActivityLocationRequest request) { return Result.success(service.resolve(request)); } + @PostMapping("/confirm") + public Result confirm(@RequestBody ActivityLocationRequest request) { return Result.success(service.confirm(request)); } +} diff --git a/campus-server/src/main/java/com/campus/controller/ActivityRegistrationController.java b/campus-server/src/main/java/com/campus/controller/ActivityRegistrationController.java new file mode 100644 index 0000000..d2d7bf2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ActivityRegistrationController.java @@ -0,0 +1,136 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.ActivityRegistrationRequest; +import com.campus.dto.request.ActivityTicketRequest; +import com.campus.dto.request.ActivityReviewRequest; +import com.campus.dto.request.ActivityCheckInRequest; +import com.campus.dto.response.ActivityRegistrationDTO; +import com.campus.dto.response.ActivityPaymentOrderDTO; +import com.campus.dto.response.ActivityTicketDTO; +import com.campus.service.ActivityRegistrationService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; +import java.util.Collections; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +/** 活动票档和报名单接口;支付确认在后续支付步骤接入。 */ +@RestController +@RequestMapping("/api/campus/activities") +@RequiredArgsConstructor +public class ActivityRegistrationController { + + private final ActivityRegistrationService registrationService; + + @GetMapping("/{activityId}/tickets") + public Result> listTickets(@PathVariable String activityId) { + return Result.success(registrationService.listTickets(activityId)); + } + + @PostMapping("/{activityId}/tickets") + public Result createTicket( + @PathVariable String activityId, + @Valid @RequestBody ActivityTicketRequest request) { + return Result.success(registrationService.createTicket(activityId, request)); + } + + @PostMapping("/{activityId}/registrations") + public Result register( + @PathVariable String activityId, + @Valid @RequestBody ActivityRegistrationRequest request) { + return Result.success(registrationService.register(activityId, request)); + } + + @GetMapping("/{activityId}/registrations") + public Result> listForActivity(@PathVariable String activityId) { + return Result.success(registrationService.listForActivity(activityId)); + } + + @GetMapping("/registrations/mine") + public Result> listMine() { + return Result.success(registrationService.listMine()); + } + + @GetMapping("/registrations/{registrationId}") + public Result getMine(@PathVariable String registrationId) { + return Result.success(registrationService.getMine(registrationId)); + } + + @DeleteMapping("/registrations/{registrationId}") + public Result cancel(@PathVariable String registrationId) { + return Result.success(registrationService.cancel(registrationId)); + } + + @PutMapping("/registrations/{registrationId}/review") + public Result 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 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 createPaymentOrder(@PathVariable String registrationId) { + return Result.success(registrationService.createPaymentOrder(registrationId)); + } + + @GetMapping("/registrations/{registrationId}/payment-order") + public Result getPaymentOrder(@PathVariable String registrationId) { + return Result.success(registrationService.getPaymentOrder(registrationId)); + } + + @DeleteMapping("/registrations/{registrationId}/payment-order") + public Result cancelPaymentOrder(@PathVariable String registrationId) { + return Result.success(registrationService.cancelPaymentOrder(registrationId)); + } + + @PostMapping("/{activityId}/check-in-code") + public Result> generateCheckInCode(@PathVariable String activityId) { + return Result.success(Collections.singletonMap("checkInCode", + registrationService.generateCheckInCode(activityId))); + } + + @PostMapping("/registrations/{registrationId}/check-in") + public Result checkIn(@PathVariable String registrationId, + @Valid @RequestBody ActivityCheckInRequest request) { + return Result.success(registrationService.checkIn(registrationId, request.getCheckInCode())); + } + + @GetMapping("/{activityId}/registrations/export") + public ResponseEntity 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)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/AdminController.java b/campus-server/src/main/java/com/campus/controller/AdminController.java new file mode 100644 index 0000000..d290f94 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/AdminController.java @@ -0,0 +1,184 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.AdminAccountStatusRequest; +import com.campus.dto.request.AdminRoleUpdateRequest; +import com.campus.dto.request.AdminPasswordResetRequest; +import com.campus.dto.request.RevokeRestrictionRequest; +import com.campus.dto.request.UserRestrictionRequest; +import com.campus.dto.response.AdminUserDTO; +import com.campus.dto.response.AdminAuditLogDTO; +import com.campus.dto.response.UserRestrictionDTO; +import com.campus.service.AdminService; +import com.campus.service.AuditLogService; +import com.campus.service.UserRestrictionService; +import com.campus.service.ActivityService; +import com.campus.dto.response.ActivityDTO; +import com.campus.service.MomentCityBackfillService; +import com.campus.service.CommerceAdminService; +import com.campus.entity.MallProduct; +import com.campus.entity.TaskDefinition; +import com.campus.entity.LotteryPrize; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.List; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminService adminService; + private final AuditLogService auditLogService; + private final UserRestrictionService userRestrictionService; + private final ActivityService activityService; + private final MomentCityBackfillService momentCityBackfillService; + private final CommerceAdminService commerceAdminService; + + @GetMapping("/me") + public Result> me() { + AdminUserDTO user = adminService.getUser(UserContext.getCurrentUserId()); + Map 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> dashboard() { + return Result.success(adminService.dashboard()); + } + + @GetMapping("/users") + public Result> 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 user(@PathVariable String id) { + return Result.success(adminService.getUser(id)); + } + + @PutMapping("/users/{id}/roles") + public Result updateRoles(@PathVariable String id, + @Valid @RequestBody AdminRoleUpdateRequest request) { + return Result.success(adminService.updateRoles(id, request)); + } + + @PutMapping("/users/{id}/status") + public Result updateStatus(@PathVariable String id, + @Valid @RequestBody AdminAccountStatusRequest request) { + return Result.success(adminService.updateAccountStatus(id, request)); + } + + @PostMapping("/users/{id}/kickout") + public Result kickout(@PathVariable String id) { + adminService.kickout(id); + return Result.success(); + } + + @PostMapping("/users/{id}/reset-password") + public Result resetPassword(@PathVariable String id, + @Valid @RequestBody AdminPasswordResetRequest request) { + adminService.resetPassword(id, request); + return Result.success(); + } + + @GetMapping("/audit-logs") + public Result> 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> 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 createRestriction(@PathVariable String id, + @Valid @RequestBody UserRestrictionRequest request) { + return Result.success(userRestrictionService.create(id, request)); + } + + @PostMapping("/restrictions/{id}/revoke") + public Result 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 cancelActivity(@PathVariable String id, + @RequestParam(required = false) String reason) { + return Result.success(activityService.adminCancel(id, reason)); + } + + @PostMapping("/maintenance/moments/backfill-city") + public Result> backfillMomentCity() { + Map result = new LinkedHashMap<>(); + result.put("updated", momentCityBackfillService.backfill()); + return Result.success(result); + } + + @GetMapping("/commerce/products") + public Result> commerceProducts() { + return Result.success(commerceAdminService.products()); + } + + @PutMapping("/commerce/products/{code}") + public Result saveCommerceProduct(@PathVariable String code, + @RequestBody MallProduct product) { + return Result.success(commerceAdminService.saveProduct(code, product)); + } + + @GetMapping("/commerce/tasks") + public Result> commerceTasks() { + return Result.success(commerceAdminService.tasks()); + } + + @PutMapping("/commerce/tasks/{code}") + public Result saveCommerceTask(@PathVariable String code, + @RequestBody TaskDefinition task) { + return Result.success(commerceAdminService.saveTask(code, task)); + } + + @GetMapping("/commerce/lottery-prizes") + public Result> lotteryPrizes() { + return Result.success(commerceAdminService.prizes()); + } + + @PutMapping("/commerce/lottery-prizes/{code}") + public Result saveLotteryPrize(@PathVariable String code, + @RequestBody LotteryPrize prize) { + return Result.success(commerceAdminService.savePrize(code, prize)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/AnnouncementController.java b/campus-server/src/main/java/com/campus/controller/AnnouncementController.java new file mode 100644 index 0000000..c31d4e6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/AnnouncementController.java @@ -0,0 +1,349 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.AnnouncementRequest; +import com.campus.dto.request.AnnouncementUpdateRequest; +import com.campus.dto.response.AnnouncementDTO; +import com.campus.service.AnnouncementService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/campus/announcements") +@RequiredArgsConstructor +public class AnnouncementController { + + private final AnnouncementService announcementService; + + /** + * 分页查询公告列表 + * + * @param type 公告类型(可选,例:general/event/notice) + * @param status 审核状态(可选,例:pending/approved/rejected) + * @param pageable 分页参数 ?page=1&size=20&sort=createdAt,desc + * @return 分页公告数据 + * @api GET /api/campus/announcements + * @reqExample GET /api/campus/announcements?type=event&status=approved&page=1&size=20 + * @resExample { + * "code": 200, + * "message": "success", + * "data": { + * "list": [ + * { + * "id": "18292888123456789012", + * "type": "event", + * "title": "校园歌手大赛", + * "subtitle": "第十届校园歌手大赛报名开始", + * "description": "面向全校同学的歌唱比赛", + * "content": "活动详情正文...", + * "eventDate": "2026-07-01", + * "eventTime": "19:00-21:30", + * "location": "大学生活动中心", + * "organizer": "校学生会", + * "capacity": 200, + * "coverImage": "https://oss.example.com/cover/xxx.jpg", + * "images": ["https://oss.example.com/img/1.jpg"], + * "status": "approved", + * "reviewReason": null, + * "scopeType": "all", + * "universityIds": [], + * "viewCount": 1234, + * "registeredCount": 56, + * "creatorId": "17283940000000000001", + * "createdAt": "2026-06-20 10:00:00", + * "updatedAt": "2026-06-20 10:00:00" + * } + * ], + * "total": 1, + * "page": 1, + * "pageSize": 20 + * } + * } + */ + @GetMapping + public Result> 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> 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 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 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 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 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 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 review( + @RequestParam String id, + @RequestParam String status, + @RequestParam(required = false) String reason) { + return Result.success(announcementService.review(id, status, reason)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/AreaController.java b/campus-server/src/main/java/com/campus/controller/AreaController.java new file mode 100644 index 0000000..d38f91d --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/AreaController.java @@ -0,0 +1,78 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.AreaDTO; +import com.campus.service.AreaService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 地区信息控制器 + * 提供省市区查询接口 + */ +@RestController +@RequestMapping("/api/areas") +@RequiredArgsConstructor +public class AreaController { + + private final AreaService areaService; + + /** + * 获取所有省份 + */ + @GetMapping("/provinces") + public Result> getProvinces() { + return Result.success(areaService.getProvinces()); + } + + /** + * 根据父级ID获取子地区 + * @param parentId 父级地区ID,0或不传表示获取省份 + */ + @GetMapping("/children") + public Result> getChildren(@RequestParam Integer parentId) { + return Result.success(areaService.getChildrenByParentId(parentId)); + } + + /** + * 获取完整的省市区三级树形结构 + */ + @GetMapping("/tree") + public Result> getAreaTree() { + return Result.success(areaService.getAreaTree()); + } + + /** + * 根据ID获取地区详情 + */ + @GetMapping("/detail") + public Result getAreaById(@RequestParam Integer areaId) { + return Result.success(areaService.getAreaById(areaId)); + } + + /** + * 根据编码获取地区 + */ + @GetMapping("/code") + public Result getAreaByCode(@RequestParam String code) { + return Result.success(areaService.getAreaByCode(code)); + } + + /** + * 获取热门地区 + */ + @GetMapping("/hot") + public Result> getHotAreas() { + return Result.success(areaService.getHotAreas()); + } + + /** + * 搜索地区 + */ + @GetMapping("/search") + public Result> searchAreas(@RequestParam String keyword) { + return Result.success(areaService.searchAreas(keyword)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/AuthController.java b/campus-server/src/main/java/com/campus/controller/AuthController.java new file mode 100644 index 0000000..061bd98 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/AuthController.java @@ -0,0 +1,124 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.LoginRequest; +import com.campus.dto.request.LoginWithCaptchaRequest; +import com.campus.dto.request.RegisterRequest; +import com.campus.dto.request.ResetPasswordRequest; +import com.campus.dto.response.LoginResponse; +import com.campus.entity.User; +import com.campus.service.CaptchaService; +import com.campus.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.servlet.http.HttpServletRequest; +import java.util.regex.Pattern; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final UserService userService; + private final CaptchaService captchaService; + + private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); + + /** + * 手机号+密码登录 + */ + @PostMapping("/login") + public Result 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 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 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 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 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(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/ChatController.java b/campus-server/src/main/java/com/campus/controller/ChatController.java new file mode 100644 index 0000000..3d394d5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ChatController.java @@ -0,0 +1,184 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.ChatMessageRequest; +import com.campus.dto.request.ConversationSettingsRequest; +import com.campus.dto.request.SendGroupMessageRequest; +import com.campus.dto.response.ChatMessageDTO; +import com.campus.dto.response.EasemobConfigDTO; +import com.campus.entity.ChatConversation; +import com.campus.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +@RestController +@RequestMapping("/api/campus/chat") +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + + @GetMapping("/config") + public Result getConfig() { + return Result.success(chatService.getConfig()); + } + + @PostMapping("/register") + public Result register() { + return Result.success(chatService.registerCurrentUser()); + } + + // ===================== 消息发送 ===================== + + @PostMapping("/messages/private") + public Result sendPrivateMessage(@Valid @RequestBody ChatMessageRequest request) { + return Result.success(chatService.sendPrivateMessage(request)); + } + + @PostMapping("/messages/group/{groupId}") + public Result sendGroupMessage(@PathVariable String groupId, @Valid @RequestBody SendGroupMessageRequest request) { + return Result.success(chatService.sendGroupMessage(groupId, request.getContent())); + } + + // ===================== 聊天记录 ===================== + + @GetMapping("/history/private/{peerId}") + public Result> 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> 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 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 report(@PathVariable String messageId, + @RequestBody Map body) { + return Result.success(chatService.reportPrivateMessage(messageId, body.get("reason"))); + } + + @GetMapping("/reports/pending") + public Result> pendingReports( + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { + return Result.success(chatService.pendingChatReports(page, size)); + } + + @PutMapping("/reports/{id}/review") + public Result reviewReport(@PathVariable String id, + @RequestBody Map 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> 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> 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> syncAllMessages( + @RequestParam(defaultValue = "0") Long since) { + return Result.success(chatService.syncAllMessages(since)); + } + + /** + * 获取服务端最大 seqId + */ + @GetMapping("/sync/max-seq") + public Result getMaxSeqId() { + return Result.success(chatService.getMaxSeqId()); + } + + // ===================== 已读标记 ===================== + + /** + * 标记会话已读。conversationId:私聊传对方用户ID,群聊传群ID。 + */ + @PutMapping("/read/{conversationId}") + public Result markMessagesAsRead(@PathVariable String conversationId) { + chatService.markMessagesAsRead(conversationId); + return Result.success(); + } + + @PutMapping("/read/all") + public Result markAllAsRead() { + chatService.markAllAsRead(); + return Result.success(); + } + + // ===================== 会话管理 ===================== + + @GetMapping("/conversations") + public Result> getConversations() { + return Result.success(chatService.getConversations()); + } + + @PutMapping("/conversations/settings") + public Result updateConversationSettings( + @Valid @RequestBody ConversationSettingsRequest request) { + return Result.success(chatService.updateConversationSettings(request)); + } + + @DeleteMapping("/conversations/{peerId}") + public Result deleteConversation(@PathVariable String peerId) { + chatService.deleteConversation(peerId); + return Result.success(); + } + + // ===================== 消息删除 ===================== + + @DeleteMapping("/messages/{messageId}") + public Result deleteMessage(@PathVariable String messageId) { + chatService.deleteMessage(messageId); + return Result.success(); + } + + @DeleteMapping("/messages/conversation/{conversationId}") + public Result clearConversationMessages(@PathVariable String conversationId) { + chatService.clearConversationMessages(conversationId); + return Result.success(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/CollegeController.java b/campus-server/src/main/java/com/campus/controller/CollegeController.java new file mode 100644 index 0000000..181c0bc --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/CollegeController.java @@ -0,0 +1,32 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.CollegeDTO; +import com.campus.dto.response.MajorDTO; +import com.campus.service.UniversityService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 学院信息控制器 + * 提供学院及下属专业的独立查询接口 + */ +@RestController +@RequestMapping("/api/colleges") +@RequiredArgsConstructor +public class CollegeController { + + private final UniversityService universityService; + + @GetMapping + public Result> getAllColleges() { + return Result.success(universityService.getAllColleges()); + } + + @GetMapping("/majors") + public Result> getMajorsByCollege(@RequestParam String collegeId) { + return Result.success(universityService.getMajorsByCollege(collegeId)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/CommentController.java b/campus-server/src/main/java/com/campus/controller/CommentController.java new file mode 100644 index 0000000..f6fc276 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/CommentController.java @@ -0,0 +1,40 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.CommentRequest; +import com.campus.entity.Comment; +import com.campus.service.CommentService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @GetMapping + public Result> getCommentsByPost( + @RequestParam String postId, + @PageableDefault(size = 20) Pageable pageable) { + return Result.success(commentService.getCommentsByPostId(postId, pageable)); + } + + @PostMapping + public Result createComment(@Valid @RequestBody CommentRequest request) { + return Result.success(commentService.createComment(request)); + } + + @DeleteMapping + public Result deleteComment(@RequestParam String id) { + commentService.deleteComment(id); + return Result.success(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/ContentAppealController.java b/campus-server/src/main/java/com/campus/controller/ContentAppealController.java new file mode 100644 index 0000000..675f156 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ContentAppealController.java @@ -0,0 +1,42 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.ContentAppealRequest; +import com.campus.dto.request.ContentAppealReviewRequest; +import com.campus.dto.response.ContentAppealDTO; +import com.campus.service.ContentAppealService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/content-appeals") +@RequiredArgsConstructor +public class ContentAppealController { + private final ContentAppealService service; + + @PostMapping + public Result create(@Valid @RequestBody ContentAppealRequest request) { + return Result.success(service.create(request)); + } + + @GetMapping("/mine") + public Result> mine(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(service.mine(page, size)); + } + + @GetMapping("/pending") + public Result> pending(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(service.pending(page, size)); + } + + @PutMapping("/{id}/review") + public Result review(@PathVariable String id, + @Valid @RequestBody ContentAppealReviewRequest request) { + return Result.success(service.review(id, request)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/DictController.java b/campus-server/src/main/java/com/campus/controller/DictController.java new file mode 100644 index 0000000..5389053 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/DictController.java @@ -0,0 +1,93 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.SysDictItemRequest; +import com.campus.dto.request.SysDictRequest; +import com.campus.dto.response.DictItemDTO; +import com.campus.dto.response.SysDictDTO; +import com.campus.dto.response.SysDictItemDTO; +import com.campus.service.DictService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +/** + * 字典管理 Controller + * - 公开接口(/api/dicts/**)供前端动态获取字典项 + * - 管理接口(/api/admin/dicts/**)用于字典和字典项的 CRUD + * + * 路径约定:所有参数(id、code、dictId)走 query 或 body,不出现在 URL 路径中 + */ +@RestController +@RequiredArgsConstructor +public class DictController { + + private final DictService dictService; + + // ============== 公开读取接口 ============== + + /** + * 根据字典编码获取启用的字典项(前端下拉框使用) + * 例:GET /api/dicts/items?code=announcement_type + */ + @GetMapping("/api/dicts/items") + public Result> getDictItems(@RequestParam String code) { + return Result.success(dictService.getEnabledItems(code)); + } + + // ============== 管理接口:字典主表 ============== + + @GetMapping("/api/admin/dicts") + public Result> listDicts() { + return Result.success(dictService.listAllDicts()); + } + + @GetMapping("/api/admin/dicts/detail") + public Result getDict(@RequestParam String id) { + return Result.success(dictService.getDictById(id)); + } + + @PostMapping("/api/admin/dicts") + public Result createDict(@Valid @RequestBody SysDictRequest request) { + return Result.success(dictService.createDict(request)); + } + + @PutMapping("/api/admin/dicts") + public Result updateDict(@RequestParam String id, + @Valid @RequestBody SysDictRequest request) { + return Result.success(dictService.updateDict(id, request)); + } + + @DeleteMapping("/api/admin/dicts") + public Result deleteDict(@RequestParam String id) { + dictService.deleteDict(id); + return Result.success(); + } + + // ============== 管理接口:字典项 ============== + + @GetMapping("/api/admin/dicts/items") + public Result> listItems(@RequestParam String dictId) { + return Result.success(dictService.listItemsByDictId(dictId)); + } + + @PostMapping("/api/admin/dicts/items") + public Result createItem(@RequestParam String dictId, + @Valid @RequestBody SysDictItemRequest request) { + return Result.success(dictService.createItem(dictId, request)); + } + + @PutMapping("/api/admin/dicts/items") + public Result updateItem(@RequestParam String itemId, + @Valid @RequestBody SysDictItemRequest request) { + return Result.success(dictService.updateItem(itemId, request)); + } + + @DeleteMapping("/api/admin/dicts/items") + public Result deleteItem(@RequestParam String itemId) { + dictService.deleteItem(itemId); + return Result.success(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/DraftController.java b/campus-server/src/main/java/com/campus/controller/DraftController.java new file mode 100644 index 0000000..456dba6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/DraftController.java @@ -0,0 +1,38 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.service.ContentDraftService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/draft") +@RequiredArgsConstructor +public class DraftController { + private final ContentDraftService service; + + @PostMapping("/save") + public Result> saveDraft(@RequestBody Map draft) { + return Result.success(service.save(UserContext.getCurrentUserId(), draft)); + } + + @GetMapping({"", "/list"}) + public Result>> listDrafts() { + return Result.success(service.listLatest(UserContext.getCurrentUserId())); + } + + @GetMapping("/{draftId}/versions") + public Result>> versions(@PathVariable String draftId) { + return Result.success(service.versions(UserContext.getCurrentUserId(), draftId)); + } + + @DeleteMapping("/{draftId}") + public Result deleteDraft(@PathVariable String draftId) { + service.delete(UserContext.getCurrentUserId(), draftId); + return Result.success(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/FileUploadController.java b/campus-server/src/main/java/com/campus/controller/FileUploadController.java new file mode 100644 index 0000000..12682fe --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/FileUploadController.java @@ -0,0 +1,183 @@ +package com.campus.controller; + +import com.campus.dto.response.FileUploadResponse; +import com.campus.util.OssUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@RestController +@RequestMapping("/api/file") +@CrossOrigin +public class FileUploadController { + + @Autowired + private OssUtil ossUtil; + + /** + * 通用文件上传 + */ + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public FileUploadResponse uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam(value = "folder", defaultValue = "images") String folder) { + log.info("上传文件: {}, 文件夹: {}", file.getOriginalFilename(), folder); + + String url = ossUtil.uploadFile(file, folder); + return FileUploadResponse.builder() + .url(url) + .fileName(file.getOriginalFilename()) + .fileSize(file.getSize()) + .fileType(file.getContentType()) + .build(); + } + + /** + * 上传图片 + */ + @PostMapping(value = "/upload/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public FileUploadResponse uploadImage(@RequestParam("file") MultipartFile file) { + String url = ossUtil.uploadImage(file); + return FileUploadResponse.builder() + .url(url) + .fileName(file.getOriginalFilename()) + .fileSize(file.getSize()) + .fileType(file.getContentType()) + .build(); + } + + /** + * 上传视频 + */ + @PostMapping(value = "/upload/video", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public FileUploadResponse uploadVideo(@RequestParam("file") MultipartFile file) { + long startTime = System.currentTimeMillis(); + String url = ossUtil.uploadVideo(file); + long elapsed = System.currentTimeMillis() - startTime; + log.info("视频上传完成: {}, 大小: {}MB, 总耗时: {}ms", + file.getOriginalFilename(), + String.format("%.1f", file.getSize() / 1024.0 / 1024.0), + elapsed); + return FileUploadResponse.builder() + .url(url) + .fileName(file.getOriginalFilename()) + .fileSize(file.getSize()) + .fileType(file.getContentType()) + .build(); + } + + /** + * 上传文档 + */ + @PostMapping(value = "/upload/document", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public FileUploadResponse uploadDocument(@RequestParam("file") MultipartFile file) { + String url = ossUtil.uploadDocument(file); + return FileUploadResponse.builder() + .url(url) + .fileName(file.getOriginalFilename()) + .fileSize(file.getSize()) + .fileType(file.getContentType()) + .build(); + } + + /** + * 上传头像 + */ + @PostMapping(value = "/upload/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public FileUploadResponse uploadAvatar(@RequestParam("file") MultipartFile file) { + String url = ossUtil.uploadAvatar(file); + return FileUploadResponse.builder() + .url(url) + .fileName(file.getOriginalFilename()) + .fileSize(file.getSize()) + .fileType(file.getContentType()) + .build(); + } + + /** + * 批量上传文件 + */ + @PostMapping(value = "/upload/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Map uploadFiles( + @RequestParam("files") MultipartFile[] files, + @RequestParam(value = "folder", defaultValue = "images") String folder) { + log.info("批量上传文件数: {}, 文件夹: {}", files.length, folder); + + List 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 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 uploadImages(@RequestParam("files") MultipartFile[] files) { + List 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 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 deleteFile(@RequestParam("url") String url) { + log.info("删除文件: {}", url); + ossUtil.deleteFile(url); + + Map result = new HashMap<>(); + result.put("message", "文件删除成功"); + result.put("url", url); + return result; + } + + /** + * 批量删除文件 + */ + @DeleteMapping("/delete/batch") + public Map deleteFiles(@RequestBody List urls) { + log.info("批量删除文件数: {}", urls.size()); + ossUtil.deleteFiles(urls); + + Map result = new HashMap<>(); + result.put("message", "文件批量删除成功"); + result.put("count", urls.size()); + result.put("urls", urls); + return result; + } +} diff --git a/campus-server/src/main/java/com/campus/controller/FollowController.java b/campus-server/src/main/java/com/campus/controller/FollowController.java new file mode 100644 index 0000000..2dc8270 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/FollowController.java @@ -0,0 +1,44 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.response.FollowUserDTO; +import com.campus.service.FollowService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class FollowController { + + private final FollowService followService; + + @PostMapping("/follow") + public Result> follow(@RequestParam String userId) { + return Result.success(followService.toggleFollow(userId)); + } + + @GetMapping("/followers") + public Result> 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> 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> getFollowStatus(@RequestParam String userId) { + return Result.success(followService.getFollowStatus(userId)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/FriendshipController.java b/campus-server/src/main/java/com/campus/controller/FriendshipController.java new file mode 100644 index 0000000..235bb09 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/FriendshipController.java @@ -0,0 +1,149 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.AddFriendRequest; +import com.campus.dto.request.FriendFilterRequest; +import com.campus.dto.request.FriendSearchRequest; +import com.campus.dto.request.GreetRequest; +import com.campus.dto.response.FriendDTO; +import com.campus.dto.response.UserProfileDTO; +import com.campus.entity.Friendship; +import com.campus.service.FriendService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +/** + * 交友模块统一入口(保留旧 /api/friendships 路径) + */ +@RestController +@RequestMapping("/api/friendships") +@RequiredArgsConstructor +public class FriendshipController { + + private final FriendService friendService; + + // ===================== 关系管理 ===================== + + @GetMapping("/my") + public Result> getMyFriends() { + return Result.success(friendService.listMyFriendCards()); + } + + @GetMapping("/pending") + public Result>> getPendingRequests() { + return Result.success(friendService.getMyReceivedPendingWithUser()); + } + + @GetMapping("/pending/sent") + public Result>> getSentRequests() { + return Result.success(friendService.getMySentRequests()); + } + + @PostMapping("/add") + public Result addFriend(@Valid @RequestBody AddFriendRequest request) { + return Result.success(friendService.addFriend(request)); + } + + /** 打招呼:向推荐/陌生人发送默认问候语私聊消息 */ + @PostMapping("/greet") + public Result greet(@Valid @RequestBody GreetRequest request) { + friendService.greetUser(request); + return Result.success(); + } + + @PostMapping + public Result sendFriendRequest(@RequestParam String friendId) { + AddFriendRequest req = new AddFriendRequest(); + req.setFriendId(friendId); + return Result.success(friendService.addFriend(req)); + } + + @PutMapping("/accept") + public Result acceptRequest(@RequestParam String id) { + return Result.success(friendService.acceptFriendRequest(id)); + } + + @PutMapping("/acceptByUserId") + public Result acceptRequestByUserId(@RequestParam String userId) { + return Result.success(friendService.acceptFriendRequestByUserId(userId)); + } + + @DeleteMapping("/reject") + public Result rejectRequest(@RequestParam String id) { + friendService.rejectFriendRequest(id); + return Result.success(); + } + + /** 删除好友(双向,仅限已是好友) */ + @DeleteMapping("/delete") + public Result deleteFriend(@RequestParam String friendId) { + friendService.deleteFriend(friendId); + return Result.success(); + } + + /** 撤回自己发起的好友申请(PENDING 状态) */ + @DeleteMapping("/requests") + public Result cancelFriendRequest(@RequestParam String friendId) { + friendService.cancelFriendRequest(friendId); + return Result.success(); + } + + /** 拉黑好友(将 ACCEPTED 关系改为 BLOCKED) */ + @PostMapping("/block") + public Result blockFriend(@RequestParam String friendId) { + friendService.blockFriend(friendId); + return Result.success(); + } + + /** 获取当前用户的黑名单。 */ + @GetMapping("/blacklist") + public Result> getBlacklist() { + return Result.success(friendService.listBlacklist()); + } + + /** 移出黑名单;移出后不会自动恢复好友关系。 */ + @DeleteMapping("/blacklist") + public Result unblockFriend(@RequestParam String friendId) { + friendService.unblockFriend(friendId); + return Result.success(); + } + + // ===================== 推荐 / 搜索 / 筛选 ===================== + + /** + * 智能推荐(首次 / 换一批) + * + * @param limit 本批数量(默认 20) + * @param batchToken 上一批返回的 token;首次不传或传空 → 开启新批次 + */ + @GetMapping("/recommend") + public Result 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> search(FriendSearchRequest request) { + return Result.success(friendService.search(request)); + } + + @GetMapping("/filter") + public Result> filter(FriendFilterRequest request) { + return Result.success(friendService.filter(request)); + } + + // ===================== 资料 / 共同好友 ===================== + + @GetMapping("/mutual") + public Result> getMutualFriends(@RequestParam String userId) { + return Result.success(friendService.getMutualFriends(userId)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/GiftController.java b/campus-server/src/main/java/com/campus/controller/GiftController.java new file mode 100644 index 0000000..ba83e0e --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/GiftController.java @@ -0,0 +1,89 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.SendGiftRequest; +import com.campus.dto.response.GiftDTO; +import com.campus.dto.response.GiftRecordDTO; +import com.campus.service.GiftService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 礼物控制器 + */ +@RestController +@RequestMapping("/api/campus/gifts") +@RequiredArgsConstructor +public class GiftController { + + private final GiftService giftService; + + /** + * 获取礼物列表 + */ + @GetMapping + public Result> getGiftList() { + return Result.success(giftService.getGiftList()); + } + + /** + * 赠送积分礼物 + */ + @PostMapping("/send") + public Result sendGift(@Valid @RequestBody SendGiftRequest request) { + return Result.success(giftService.sendGift(request)); + } + + /** + * 获取我收到的礼物 + */ + @GetMapping("/received") + public Result> getReceivedGifts( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(giftService.getReceivedGifts(page, size)); + } + + /** + * 获取我送出的礼物 + */ + @GetMapping("/sent") + public Result> getSentGifts( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(giftService.getSentGifts(page, size)); + } + + /** + * 获取指定动态收到的礼物列表 + */ + @GetMapping("/moment") + public Result> getGiftsByMoment(@RequestParam String momentId) { + return Result.success(giftService.getGiftsByMoment(momentId)); + } + + /** + * 获取指定动态收到的礼物总积分 + */ + @GetMapping("/moment/points") + public Result> getMomentGiftPoints(@RequestParam String momentId) { + Integer points = giftService.getMomentGiftPoints(momentId); + Map result = new HashMap<>(); + result.put("points", points != null ? points : 0); + return Result.success(result); + } + + /** + * 获取用户礼物统计 + */ + @GetMapping("/stats") + public Result> getUserGiftStats(@RequestParam String userId) { + return Result.success(giftService.getUserGiftStats(userId)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/GroupController.java b/campus-server/src/main/java/com/campus/controller/GroupController.java new file mode 100644 index 0000000..ca0bb4c --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/GroupController.java @@ -0,0 +1,201 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.CreateGroupRequest; +import com.campus.dto.request.AddGroupMembersRequest; +import com.campus.dto.request.CreateGroupInvitationRequest; +import com.campus.dto.request.UpdateGroupAnnouncementRequest; +import com.campus.dto.request.UpdateGroupNicknameRequest; +import com.campus.dto.response.GroupAnnouncementDTO; +import com.campus.dto.response.GroupInvitationDTO; +import com.campus.dto.response.GroupInviteResult; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.service.GroupAnnouncementService; +import com.campus.service.GroupInvitationService; +import com.campus.service.GroupService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +@RestController +@RequestMapping("/api/groups") +@RequiredArgsConstructor +public class GroupController { + + private final GroupService groupService; + private final GroupAnnouncementService groupAnnouncementService; + private final GroupInvitationService groupInvitationService; + + // ===================== 创建群组 ===================== + + @PostMapping + public Result 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 joinGroup(@PathVariable String groupId) { + return Result.success(groupService.joinGroup(groupId)); + } + + @PostMapping("/{groupId}/leave") + public Result leaveGroup(@PathVariable String groupId) { + groupService.leaveGroup(groupId); + return Result.success(); + } + + // ===================== 群成员管理 ===================== + + @PostMapping("/{groupId}/approve") + public Result approveJoinRequest(@PathVariable String groupId, @RequestParam String userId) { + groupService.approveJoinRequest(groupId, userId); + return Result.success(); + } + + @PostMapping("/{groupId}/reject") + public Result rejectJoinRequest(@PathVariable String groupId, @RequestParam String userId) { + groupService.rejectJoinRequest(groupId, userId); + return Result.success(); + } + + @GetMapping("/{groupId}/requests") + public Result> getJoinRequests(@PathVariable String groupId) { + return Result.success(groupService.getJoinRequests(groupId)); + } + + /** + * 邀请用户入群(原「添加群成员」)。 + * + *

⚠️ 行为变更:已建群拉人不再直接入群,改为**发群邀请**,等对方点「允许」后才成为成员。 + * 建群时带人({@code POST /api/groups} 的 memberIds)不受影响,仍是直接入群。 + * 响应体由 {@code List} 改为 {@link GroupInviteResult}(invited / failed)。 + */ + @PostMapping("/{groupId}/members") + public Result addMembers(@PathVariable String groupId, + @Valid @RequestBody AddGroupMembersRequest request) { + return Result.success(groupInvitationService.invite(groupId, request.getUserIds(), null)); + } + + // ===================== 群邀请 ===================== + + /** 发起群邀请(与 /members 等价,语义更明确的新路径)。 */ + @PostMapping("/{groupId}/invitations") + public Result inviteMembers(@PathVariable String groupId, + @Valid @RequestBody CreateGroupInvitationRequest request) { + return Result.success(groupInvitationService.invite(groupId, request.getUserIds(), request.getMessage())); + } + + /** 群主/管理员查看本群已发出的邀请(谁还没点允许)。 */ + @GetMapping("/{groupId}/invitations") + public Result> 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 removeMember(@PathVariable String groupId, @PathVariable String userId) { + groupService.removeMember(groupId, userId); + return Result.success(); + } + + @PutMapping("/{groupId}/members/{userId}/mute") + public Result 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 updateMyGroupNickname(@PathVariable String groupId, + @RequestBody UpdateGroupNicknameRequest request) { + return Result.success(groupService.updateMyGroupNickname(groupId, request.getNickname())); + } + + // ===================== 群组查询 ===================== + + @GetMapping("/my") + public Result> getMyGroups() { + return Result.success(groupService.getMyGroups()); + } + + @GetMapping("/search") + public Result> 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 getGroupInfo(@PathVariable String groupId) { + return Result.success(groupService.getGroupInfo(groupId)); + } + + @GetMapping("/{groupId}/members") + public Result> getGroupMembers(@PathVariable String groupId) { + return Result.success(groupService.getGroupMembers(groupId)); + } + + // ===================== 群公告 ===================== + + @GetMapping("/{groupId}/announcement") + public Result getAnnouncement(@PathVariable String groupId) { + return Result.success(groupAnnouncementService.getAnnouncement(groupId)); + } + + @PutMapping("/{groupId}/announcement") + public Result updateAnnouncement( + @PathVariable String groupId, + @Valid @RequestBody UpdateGroupAnnouncementRequest request) { + return Result.success(groupAnnouncementService.updateAnnouncement(groupId, request)); + } + + // ===================== 群主管理 ===================== + + @PostMapping("/{groupId}/transfer") + public Result transferOwner(@PathVariable String groupId, @RequestParam String newOwnerId) { + groupService.transferOwner(groupId, newOwnerId); + return Result.success(); + } + + @PutMapping("/{groupId}/admins/{userId}") + public Result appointAdmin(@PathVariable String groupId, @PathVariable String userId) { + groupService.appointAdmin(groupId, userId); + return Result.success(); + } + + @DeleteMapping("/{groupId}/admins/{userId}") + public Result revokeAdmin(@PathVariable String groupId, @PathVariable String userId) { + groupService.revokeAdmin(groupId, userId); + return Result.success(); + } + + // ===================== 群组资料修改 ===================== + + @PutMapping("/{groupId}") + public Result 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 deleteGroup(@PathVariable String groupId) { + groupService.deleteGroup(groupId); + return Result.success(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/GroupInvitationController.java b/campus-server/src/main/java/com/campus/controller/GroupInvitationController.java new file mode 100644 index 0000000..5283b53 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/GroupInvitationController.java @@ -0,0 +1,61 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.response.GroupInvitationDTO; +import com.campus.entity.GroupMember; +import com.campus.service.GroupInvitationService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 群邀请为中心的接口(被邀请人视角)。 + * + *

路径刻意与 {@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> 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 accept(@PathVariable String invitationId) { + return Result.success(groupInvitationService.accept(invitationId)); + } + + // ===================== 邀请人:撤回 ===================== + + @DeleteMapping("/{invitationId}") + public Result cancel(@PathVariable String invitationId) { + groupInvitationService.cancel(invitationId); + return Result.success(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/HomeController.java b/campus-server/src/main/java/com/campus/controller/HomeController.java new file mode 100644 index 0000000..3ac00c0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/HomeController.java @@ -0,0 +1,23 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.HomeOverviewDTO; +import com.campus.service.HomeService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 首页首屏聚合接口。 */ +@RestController +@RequestMapping("/api/home") +@RequiredArgsConstructor +public class HomeController { + + private final HomeService homeService; + + @GetMapping("/overview") + public Result overview() { + return Result.success(homeService.overview()); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/MajorController.java b/campus-server/src/main/java/com/campus/controller/MajorController.java new file mode 100644 index 0000000..2d36c04 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/MajorController.java @@ -0,0 +1,26 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.MajorDTO; +import com.campus.service.UniversityService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 专业信息控制器 + * 提供专业独立查询接口 + */ +@RestController +@RequestMapping("/api/majors") +@RequiredArgsConstructor +public class MajorController { + + private final UniversityService universityService; + + @GetMapping + public Result> getAllMajors() { + return Result.success(universityService.getAllMajors()); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/MatchingController.java b/campus-server/src/main/java/com/campus/controller/MatchingController.java new file mode 100644 index 0000000..735ebbd --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/MatchingController.java @@ -0,0 +1,63 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.MatchActionRequest; +import com.campus.dto.request.MatchPreferenceRequest; +import com.campus.dto.response.FriendDTO; +import com.campus.dto.response.MatchActionDTO; +import com.campus.dto.response.MatchPairDTO; +import com.campus.dto.response.MatchPreferenceDTO; +import com.campus.service.MatchingService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +/** 心动匹配接口。 */ +@RestController +@RequestMapping("/api/matches") +@RequiredArgsConstructor +public class MatchingController { + + private final MatchingService matchingService; + + @GetMapping("/preferences") + public Result getPreference() { + return Result.success(matchingService.getPreference()); + } + + @PutMapping("/preferences") + public Result updatePreference(@RequestBody MatchPreferenceRequest request) { + return Result.success(matchingService.updatePreference(request)); + } + + @GetMapping("/candidates") + public Result> candidates(@RequestParam(required = false, defaultValue = "20") Integer limit) { + return Result.success(matchingService.candidates(limit)); + } + + @PostMapping("/actions") + public Result action(@Valid @RequestBody MatchActionRequest request) { + return Result.success(matchingService.act(request)); + } + + @GetMapping + public Result> listPairs() { + return Result.success(matchingService.listPairs()); + } + + @DeleteMapping("/{pairId}") + public Result unmatch(@PathVariable String pairId) { + matchingService.unmatch(pairId); + return Result.success(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/MockCommerceController.java b/campus-server/src/main/java/com/campus/controller/MockCommerceController.java new file mode 100644 index 0000000..35fb4ae --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/MockCommerceController.java @@ -0,0 +1,176 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.response.PointTransactionDTO; +import com.campus.entity.CommerceOrder; +import com.campus.entity.LotteryDraw; +import com.campus.entity.UserEntitlement; +import com.campus.repository.CommerceOrderRepository; +import com.campus.repository.LotteryDrawRepository; +import com.campus.repository.UserEntitlementRepository; +import com.campus.service.CommerceService; +import com.campus.service.PointsService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 会员、钱包、青豆商城、任务和转盘接口。 + * 现金支付相关能力仍保持关闭;青豆兑换、任务和抽奖均为真实持久化业务。 + */ +@RestController +@RequestMapping("/api/campus") +@RequiredArgsConstructor +public class MockCommerceController { + + private final PointsService pointsService; + private final CommerceService commerceService; + private final LotteryDrawRepository lotteryDrawRepository; + private final CommerceOrderRepository commerceOrderRepository; + private final UserEntitlementRepository entitlementRepository; + + @GetMapping({"/membership", "/mock/membership"}) + public Result> membership() { + Map 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> wallet() { + String userId = UserContext.getCurrentUserId(); + Map 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> bills(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult transactions = pointsService + .getTransactions(UserContext.getCurrentUserId(), page, size); + List> items = transactions.getItems().stream().map(transaction -> { + Map 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 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> products() { + Map 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>> orders() { + List> items = commerceOrderRepository + .findByUserIdOrderByCreatedAtDesc(UserContext.getCurrentUserId()).stream().map(this::orderMap) + .collect(Collectors.toList()); + return Result.success(items); + } + + @GetMapping("/mall/entitlements") + public Result> entitlements() { + return Result.success(entitlementRepository + .findByUserIdOrderByCreatedAtDesc(UserContext.getCurrentUserId())); + } + + @PostMapping({"/mall/purchase", "/mock/mall/purchase"}) + public Result> purchase( + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + @RequestBody(required = false) Map 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> tasks() { + Map 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> claimTask(@PathVariable String taskId) { + return Result.success(commerceService.claimTask(UserContext.getCurrentUserId(), taskId)); + } + + @GetMapping({"/lottery", "/mock/lottery"}) + public Result> lottery() { + return Result.success(commerceService.lotteryInfo(UserContext.getCurrentUserId())); + } + + @GetMapping({"/lottery/history", "/mock/lottery/history"}) + public Result> 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> drawLottery( + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) { + return Result.success(commerceService.draw(UserContext.getCurrentUserId(), idempotencyKey)); + } + + private Map orderMap(CommerceOrder order) { + Map 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; + } +} diff --git a/campus-server/src/main/java/com/campus/controller/MomentController.java b/campus-server/src/main/java/com/campus/controller/MomentController.java new file mode 100644 index 0000000..5da2019 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/MomentController.java @@ -0,0 +1,325 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.common.BusinessException; +import com.campus.dto.request.CommentRequest; +import com.campus.dto.request.MomentRequest; +import com.campus.dto.response.MomentCommentDTO; +import com.campus.dto.response.MomentDTO; +import com.campus.dto.response.MomentLikeUserDTO; +import com.campus.dto.response.InteractionLikeStateDTO; +import com.campus.service.MomentLikeCommentService; +import com.campus.service.MomentService; +import com.campus.service.MomentShareCountService; +import com.campus.service.VideoPlayService; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/campus/moments") +@RequiredArgsConstructor +public class MomentController { + + private final MomentService momentService; + private final MomentLikeCommentService likeCommentService; + private final VideoPlayService videoPlayService; + private final MomentShareCountService momentShareCountService; + + @PostMapping("/{id}/block") + public Result block(@PathVariable String id) { + momentService.blockMoment(id); + return Result.success(); + } + + @DeleteMapping("/{id}/block") + public Result unblock(@PathVariable String id) { + momentService.unblockMoment(id); + return Result.success(); + } + + /** + * 更新动态的视频URL(后台上传完成后调用) + */ + @PutMapping("/video-url") + public Result updateVideoUrl( + @RequestParam String id, + @RequestParam String videoUrl) { + momentService.updateVideoUrl(id, videoUrl); + return Result.success(); + } + + /** + * 更新动态的 VOD 视频ID(VOD 直传完成后的后台回调) + */ + @PutMapping("/vod-video-id") + public Result updateVodVideoId( + @RequestParam String id, + @RequestParam String vodVideoId) { + momentService.updateVodVideoId(id, vodVideoId); + return Result.success(); + } + + /** + * 图片后台上传完成后,更新动态的 mediaUrls + */ + @PutMapping("/media-urls") + public Result updateMediaUrls( + @RequestParam String id, + @RequestBody MomentRequest request) { + momentService.updateMediaUrls(id, request.getMediaUrls()); + return Result.success(); + } + + /** + * 动态列表。传 {@code tag} 时按单个标签精确筛选(话题详情页使用), + * 不传则按 {@code scope} 正常分页。 + */ + @GetMapping + public Result> 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> 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> 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> listMyMoments( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(momentService.listMyMoments(page, size)); + } + + /** + * 青春号列表(仅视频类型的动态) + */ + @GetMapping("/youth") + public Result> 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> listMyYouthVideos( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(momentService.listMyYouthVideos(page, size)); + } + + @GetMapping("/hot") + public Result> 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 getById(@RequestParam String id) { + return Result.success(momentService.getById(id)); + } + + @PostMapping + public Result create( + @Valid @RequestBody MomentRequest request) { + return Result.success(momentService.create(request)); + } + + @PutMapping + public Result update( + @RequestParam String id, + @Valid @RequestBody MomentRequest request) { + return Result.success(momentService.update(id, request)); + } + + @DeleteMapping + public Result delete(@RequestParam String id) { + momentService.delete(id); + return Result.success(); + } + + @PostMapping("/like") + public Result like(@RequestParam String id) { + return Result.success(likeCommentService.toggleLikeState(id)); + } + + /** 将点赞设置为指定状态;相同状态请求可安全重放。 */ + @PutMapping("/like") + public Result setLike(@RequestParam String id, + @RequestBody(required = false) JsonNode body) { + return Result.success(likeCommentService.setLike(id, requiredBoolean(body, "liked", "点赞状态不能为空"))); + } + + @PostMapping("/favorite") + public Result> favorite(@RequestParam String id) { + boolean favorited = momentService.toggleFavorite(id); + long favoriteCount = momentService.getFavoriteCount(id); + Map result = new HashMap<>(); + result.put("favorited", favorited); + result.put("favoriteCount", favoriteCount); + return Result.success(result); + } + + @GetMapping("/favorite-count") + public Result> getFavoriteCount(@RequestParam String id) { + Map result = new HashMap<>(); + result.put("favoriteCount", momentService.getFavoriteCount(id)); + result.put("isFavorited", momentService.isFavorited(id)); + return Result.success(result); + } + + @GetMapping("/favorites") + public Result> listMyFavorites( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(momentService.listMyFavorites(page, size)); + } + + /** + * 获取点赞数(所有人可查看) + */ + @GetMapping("/like-count") + public Result> getLikeCount(@RequestParam String id) { + long likeCount = likeCommentService.getLikeCount(id); + boolean isLiked = likeCommentService.isLiked(id); + Map result = new HashMap<>(); + result.put("likeCount", likeCount); + result.put("isLiked", isLiked); + return Result.success(result); + } + + /** + * 获取点赞用户列表(仅发布者查看) + */ + @GetMapping("/likes") + public Result> 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 addComment( + @Valid @RequestBody CommentRequest request) { + return Result.success(likeCommentService.addComment(request.getMomentId(), request)); + } + + /** + * 获取评论列表 + */ + @GetMapping("/comments") + public Result> 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 deleteComment( + @RequestParam String momentId, + @RequestParam String commentId) { + likeCommentService.deleteComment(momentId, commentId); + return Result.success(); + } + + /** + * 评论点赞/取消点赞 + */ + @PostMapping("/comments/like") + public Result likeComment( + @RequestParam String commentId) { + return Result.success(likeCommentService.toggleCommentLikeState(commentId)); + } + + /** 将评论点赞设置为指定状态;相同状态请求可安全重放。 */ + @PutMapping("/comments/like") + public Result setCommentLike( + @RequestParam String commentId, @RequestBody(required = false) JsonNode body) { + return Result.success(likeCommentService.setCommentLike(commentId, + requiredBoolean(body, "liked", "点赞状态不能为空"))); + } + + /** + * 更新视频播放进度 + */ + @PostMapping("/play-progress") + public Result 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 getPlayProgress(@RequestParam String id) { + return Result.success(videoPlayService.getPlayProgress(id)); + } + + /** + * 记录视频分享 + */ + @PostMapping("/share") + public Result 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(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/NotificationController.java b/campus-server/src/main/java/com/campus/controller/NotificationController.java new file mode 100644 index 0000000..2fd9464 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/NotificationController.java @@ -0,0 +1,128 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.NotificationRequest; +import com.campus.dto.response.NotificationDTO; +import com.campus.dto.response.UnreadCountDTO; +import com.campus.dto.response.UnreadDetailDTO; +import com.campus.service.NotificationService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; +import com.campus.entity.NotificationPreference; +import com.campus.entity.NotificationTemplate; + +@RestController +@RequestMapping("/api/campus/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + /** 查询通知列表 */ + @GetMapping + public Result> 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 getUnreadCount() { + String userId = UserContext.getCurrentUserId(); + long total = notificationService.getUnreadCount(userId); + return Result.success(new UnreadCountDTO(total)); + } + + /** 未读数明细(通知 + 聊天分开返回,避免前端用减法间接计算) */ + @GetMapping("/unread-detail") + public Result getUnreadDetail() { + String userId = UserContext.getCurrentUserId(); + return Result.success(notificationService.getUnreadDetail(userId)); + } + + /** 全部未读总数(通知 + 聊天) */ + @GetMapping("/total-unread") + public Result getTotalUnreadCount() { + String userId = UserContext.getCurrentUserId(); + return Result.success(notificationService.getTotalUnreadCount(userId)); + } + + /** 创建通知 */ + @PostMapping + public Result 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 markAsRead(@PathVariable String id) { + String userId = UserContext.getCurrentUserId(); + notificationService.markAsRead(id, userId); + return Result.success(); + } + + /** 标记全部已读 */ + @PutMapping("/read-all") + public Result markAllAsRead() { + String userId = UserContext.getCurrentUserId(); + notificationService.markAllAsRead(userId); + return Result.success(); + } + + /** 删除通知 */ + @DeleteMapping("/{id}") + public Result delete(@PathVariable String id) { + notificationService.delete(id, UserContext.getCurrentUserId()); + return Result.success(); + } + + @PutMapping("/read-batch") + public Result readBatch(@RequestBody List ids) { + notificationService.markBatchAsRead(UserContext.getCurrentUserId(), ids); + return Result.success(); + } + + @DeleteMapping("/batch") + public Result deleteBatch(@RequestBody List ids) { + notificationService.deleteBatch(UserContext.getCurrentUserId(), ids); + return Result.success(); + } + + @GetMapping("/preferences") + public Result preference() { + return Result.success(notificationService.getPreference(UserContext.getCurrentUserId())); + } + + @PutMapping("/preferences") + public Result savePreference(@RequestBody NotificationPreference preference) { + return Result.success(notificationService.savePreference(UserContext.getCurrentUserId(), preference)); + } + + @PostMapping("/admin/broadcast") + public Result> broadcast(@RequestBody Map 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> templates() { + return Result.success(notificationService.listTemplates()); + } + + @PostMapping("/admin/templates") + public Result saveTemplate(@RequestBody NotificationTemplate template) { + return Result.success(notificationService.saveTemplate(template)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/NotificationSseController.java b/campus-server/src/main/java/com/campus/controller/NotificationSseController.java new file mode 100644 index 0000000..937b30d --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/NotificationSseController.java @@ -0,0 +1,37 @@ +package com.campus.controller; + +import com.campus.service.SseService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * 通知 SSE 长连接端点 + *

+ * 前端建立连接后,服务端通过此通道实时推送新通知事件,替代原有的 30 秒轮询。 + */ +@Slf4j +@RestController +@RequestMapping("/api/campus/notifications") +@RequiredArgsConstructor +public class NotificationSseController { + + private final SseService sseService; + + /** + * SSE 订阅端点 + *

+ * 前端调用此接口建立长连接,服务端通过 SSE 协议实时推送通知。 + * 连接保持时间最长 30 分钟,之后前端应自动重连。 + */ + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream() { + String userId = UserContext.getCurrentUserId(); + return sseService.subscribe(userId); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/OcrController.java b/campus-server/src/main/java/com/campus/controller/OcrController.java new file mode 100644 index 0000000..217644a --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/OcrController.java @@ -0,0 +1,148 @@ +package com.campus.controller; + +import com.campus.common.BusinessException; +import com.campus.common.Result; +import com.campus.common.ServiceUnavailableException; +import com.campus.dto.response.ChsiReportDTO; +import com.campus.service.AliOcrService; +import com.campus.service.XuexinReportService; +import com.campus.util.OssUtil; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * OCR文字识别控制器 + * + * 接口列表: + * POST /api/ocr/chsi-report - 识别学信网报告(上传单页 PDF) + * POST /api/ocr/raw - 通用OCR识别(返回原始文本,调试用) + * + *

识别通过({@code validChsiReport=true})时会同时把结果落成一条 + * {@code xuexin_report} 临时材料,并在响应里带回 {@code reportId} 与 {@code dedup}。 + * 前端用 {@code dedup.status} 就能在 Step1 提示「该学信网报告已被认证」, + * Step2 提交时必须带上 {@code reportId}。

+ */ +@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 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> recognizeRaw( + @RequestParam("file") MultipartFile file) { + + log.info("通用OCR识别请求: fileName={}, size={}KB", + file.getOriginalFilename(), + file.getSize() / 1024); + + try { + List lines = aliOcrService.recognizeRaw(file); + return Result.success(lines); + } catch (Exception e) { + log.error("通用OCR识别失败", e); + throw new ServiceUnavailableException("OCR识别服务暂时不可用,请稍后重试"); + } + } + + /** + * 识别成功后落库并判定这份材料是否已被认证。 + * + *

判重失败不能影响识别本身:OCR 结果照常返回,只是没有 {@code reportId}, + * 前端也就无法进入 Step2 提交。

+ */ + 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); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/OssController.java b/campus-server/src/main/java/com/campus/controller/OssController.java new file mode 100644 index 0000000..2ded294 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/OssController.java @@ -0,0 +1,118 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.config.OssProperties; +import com.campus.dto.response.StsTokenResponse; +import com.campus.service.OssStsService; +import com.campus.service.VodService; +import com.campus.util.OssUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * OSS 相关接口 + * - STS 临时凭证获取 + * - OSS 预签名上传 URL(用于图片等非视频文件) + * - VOD 转码/审核完成回调 + * + * 注意:视频上传已迁移至 VOD 直传方案,请使用 /api/vod/upload + */ +@Slf4j +@RestController +@RequestMapping("/api/oss") +@RequiredArgsConstructor +public class OssController { + + private final OssStsService ossStsService; + private final VodService vodService; + private final OssUtil ossUtil; + private final OssProperties ossProperties; + + /** + * 获取 STS 临时上传凭证 + * GET /api/oss/get-sts-token + * + * 返回临时 AK/SK/Token,前端用于直传 OSS + */ + @GetMapping("/get-sts-token") + public Result 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> getPresignedUrl(@RequestBody Map 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 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> 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 result = new HashMap<>(); + result.put("success", true); + return Result.success(result); + } + + /** + * 阿里云 VOD 处理结果回调 + * POST /api/oss/vod-callback + * + * VOD 转码/审核完成后回调此接口,更新数据库 + */ + @PostMapping("/vod-callback") + public Result> vodCallback(@RequestBody Map body) { + log.info("VOD回调: {}", body); + + try { + vodService.handleVodCallback(body); + } catch (Exception e) { + log.error("VOD回调处理失败", e); + } + + Map result = new HashMap<>(); + result.put("success", true); + return Result.success(result); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/PointsController.java b/campus-server/src/main/java/com/campus/controller/PointsController.java new file mode 100644 index 0000000..37b1100 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/PointsController.java @@ -0,0 +1,113 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.AdjustPointsRequest; +import com.campus.dto.response.PointRuleDTO; +import com.campus.dto.response.PointTransactionDTO; +import com.campus.dto.response.PointsAccountDTO; +import com.campus.entity.UserPoints; +import com.campus.service.PointsService; +import com.campus.service.UserPointsService; +import com.campus.service.AuthorizationService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +/** + * 积分控制器 + */ +@RestController +@RequestMapping("/api/campus/points") +@RequiredArgsConstructor +public class PointsController { + + private final PointsService pointsService; + private final UserPointsService userPointsService; + private final AuthorizationService authorizationService; + + /** + * 获取我的积分账户信息 + */ + @GetMapping("/account") + public Result getMyAccount() { + String userId = UserContext.getCurrentUserId(); + return Result.success(pointsService.getAccountInfo(userId)); + } + + /** + * 获取指定用户的积分账户信息 + */ + @GetMapping("/account-other") + public Result getAccount(@RequestParam String userId) { + authorizationService.requireAdmin(); + return Result.success(pointsService.getAccountInfo(userId)); + } + + /** + * 每日签到 + */ + @PostMapping("/check-in") + public Result checkIn() { + String userId = UserContext.getCurrentUserId(); + return Result.success(toTransactionDTO(pointsService.dailyCheckIn(userId))); + } + + /** + * 获取我的积分变动记录 + */ + @GetMapping("/transactions") + public Result> 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> getRules() { + return Result.success(pointsService.getActiveRules()); + } + + /** + * 获取积分排行榜 + */ + @GetMapping("/ranking") + public Result> getRanking( + @RequestParam(defaultValue = "10") int top) { + return Result.success(pointsService.getPointsRanking(top)); + } + + /** + * 手动调整用户积分(正数增加,负数扣除) + */ + @PostMapping("/adjust") + public Result 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; + } +} diff --git a/campus-server/src/main/java/com/campus/controller/PrivacySettingsController.java b/campus-server/src/main/java/com/campus/controller/PrivacySettingsController.java new file mode 100644 index 0000000..baaae91 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/PrivacySettingsController.java @@ -0,0 +1,31 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.PrivacySettingsRequest; +import com.campus.dto.response.PrivacySettingsDTO; +import com.campus.service.PrivacySettingsService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 设置页使用的隐私开关接口。 */ +@RestController +@RequestMapping("/api/users/privacy") +@RequiredArgsConstructor +public class PrivacySettingsController { + + private final PrivacySettingsService privacySettingsService; + + @GetMapping + public Result get() { + return Result.success(privacySettingsService.getCurrent()); + } + + @PutMapping + public Result update(@RequestBody PrivacySettingsRequest request) { + return Result.success(privacySettingsService.update(request)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/ProfileController.java b/campus-server/src/main/java/com/campus/controller/ProfileController.java new file mode 100644 index 0000000..063b96e --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ProfileController.java @@ -0,0 +1,148 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.XuexinSubmitRequest; +import com.campus.entity.User; +import com.campus.entity.UserPoints; +import com.campus.entity.XuexinReport; +import com.campus.service.AuthorizationService; +import com.campus.service.UserPointsService; +import com.campus.service.UserService; +import com.campus.service.ProfileService; +import com.campus.service.XuexinReportService; +import com.campus.util.OssUtil; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/profile") +@RequiredArgsConstructor +public class ProfileController { + + private final UserService userService; + private final ProfileService profileService; + private final UserPointsService userPointsService; + private final OssUtil ossUtil; + private final AuthorizationService authorizationService; + private final XuexinReportService xuexinReportService; + + @GetMapping + public Result getMyProfile() { + String userId = UserContext.getCurrentUserId(); + return Result.success(userService.getUserById(userId)); + } + + @GetMapping("/other") + public Result getProfile(@RequestParam String userId) { + return Result.success(userService.getPublicUserById(userId)); + } + + @PutMapping + public Result updateProfile(@RequestBody User updateData) { + String userId = UserContext.getCurrentUserId(); + return Result.success(profileService.updateProfile(UserContext.getCurrentUserId(), userId, updateData)); + } + + @PutMapping("/other") + public Result updateProfile( + @RequestParam String userId, + @RequestBody User updateData) { + authorizationService.requireAdmin(); + return Result.success(profileService.updateProfile(UserContext.getCurrentUserId(), userId, updateData)); + } + + /** + * 学信网认证 — 提交接口。 + * + *

前置条件:Step1 调 {@code POST /api/ocr/chsi-report} 已拿到 {@code reportId}, + * 且其结果里 {@code dedup.status=AVAILABLE}。

+ * + *

本接口不再信任请求体里的教育字段:姓名/学校/学历等一律以 + * {@code xuexin_report} 表中的 OCR 记录为准,请求体只需 {@code reportId} + * 且客户端不能覆盖 PDF 中识别出的认证字段。 + * 这样即使前端伪造 {@code validChsiReport},也拿不到认证。

+ * + *

完整校验与写入逻辑见 {@link XuexinReportService#submit}:

+ *
    + *
  • 材料不存在 / 不属于当前用户 → 400「未找到认证材料,请重新上传报告」
  • + *
  • 材料已过期或被替换 → 400「该认证材料已失效,请重新上传报告」
  • + *
  • 已认证且换更低学历 → 400「…不支持更换为更低学历…」
  • + *
  • 已认证未满冷静期 → 400「认证通过后 3 天内不可更换认证材料,请在 … 后重试」
  • + *
  • 重复提交同一份材料 → 幂等成功,原样返回当前用户
  • + *
+ */ + @PostMapping("/xuexin/submit") + public Result 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 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); + } + + /** + * 查询当前用户学信网认证状态和已确认的学籍信息。 + * + *

新增 {@code certifiedAt}(认证时间)、{@code reportId}(当前生效材料), + * 以及新版报告上的分院、系所、日期、民族、学历类别、学习形式和在线验证码。

+ */ + @GetMapping("/xuexin/status") + public Result> xuexinStatus() { + String userId = UserContext.getCurrentUserId(); + User user = userService.getUserById(userId); + XuexinReport activeReport = xuexinReportService.findActiveReport(userId); + Map 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 getMyPoints() { + String userId = UserContext.getCurrentUserId(); + return Result.success(userPointsService.getUserPoints(userId)); + } + + @GetMapping("/points-other") + public Result getUserPoints(@RequestParam String userId) { + authorizationService.requireAdmin(); + return Result.success(userPointsService.getUserPoints(userId)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/PushController.java b/campus-server/src/main/java/com/campus/controller/PushController.java new file mode 100644 index 0000000..1dbd7aa --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/PushController.java @@ -0,0 +1,67 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.entity.UserDeviceToken; +import com.campus.repository.UserDeviceTokenRepository; +import com.campus.service.PushService; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Optional; + +@Slf4j +@RestController +@RequestMapping("/api/campus/push") +@RequiredArgsConstructor +public class PushController { + + private final PushService pushService; + private final UserDeviceTokenRepository deviceTokenRepository; + + /** + * 注册/更新设备推送Token + */ + @PostMapping("/register") + public Result registerDevice(@RequestBody Map 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 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 unregisterDevice(@RequestBody Map 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(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/QrScanController.java b/campus-server/src/main/java/com/campus/controller/QrScanController.java new file mode 100644 index 0000000..9a34266 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/QrScanController.java @@ -0,0 +1,35 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.QrScanResultDTO; +import com.campus.service.QrScanService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** 扫一扫:二维码解码和青核业务路由解析。 */ +@RestController +@RequestMapping("/api/qr") +@RequiredArgsConstructor +public class QrScanController { + + private final QrScanService qrScanService; + + @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Result scan(@RequestPart("file") MultipartFile file) { + return Result.success(qrScanService.decodeAndResolve(file)); + } + + /** 客户端已经完成扫码时,直接提交二维码文本解析业务目标。 */ + @GetMapping("/resolve") + public Result resolve(@RequestParam String text) { + return Result.success(qrScanService.resolve(text)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/RadarController.java b/campus-server/src/main/java/com/campus/controller/RadarController.java new file mode 100644 index 0000000..09f67af --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/RadarController.java @@ -0,0 +1,75 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.RadarDTO; +import com.campus.service.RadarService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 校园雷达控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/campus/radar") +@RequiredArgsConstructor +public class RadarController { + + private final RadarService radarService; + + /** + * 更新用户位置 + */ + @PostMapping("/location") + public Result updateLocation(@RequestBody Map 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 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()); + } + } +} diff --git a/campus-server/src/main/java/com/campus/controller/ReportController.java b/campus-server/src/main/java/com/campus/controller/ReportController.java new file mode 100644 index 0000000..1ec003d --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ReportController.java @@ -0,0 +1,54 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.common.PageResult; +import com.campus.dto.request.ReportRequest; +import com.campus.dto.response.ReportDTO; +import com.campus.service.ReportService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +/** 举报基础接口;审核处理接口在后续步骤增加。 */ +@RestController +@RequestMapping("/api/reports") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @PostMapping + public Result create(@Valid @RequestBody ReportRequest request) { + return Result.success(reportService.create(request)); + } + + @GetMapping("/mine") + public Result> listMine() { + return Result.success(reportService.listMine()); + } + + @GetMapping("/pending") + public Result> listPending( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(reportService.listPending(page, size)); + } + + @PutMapping("/{id}/review") + public Result 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)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/ReviewController.java b/campus-server/src/main/java/com/campus/controller/ReviewController.java new file mode 100644 index 0000000..a1742ee --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ReviewController.java @@ -0,0 +1,81 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.service.AnnouncementService; +import com.campus.service.ActivityService; +import com.campus.service.MomentService; +import com.campus.service.AuthorizationService; +import com.campus.service.ModerationService; +import com.campus.common.PageResult; +import com.campus.dto.request.BatchContentReviewRequest; +import com.campus.dto.response.ContentReviewDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import javax.validation.Valid; +import java.util.List; + +@RestController +@RequestMapping("/api/campus/review") +@RequiredArgsConstructor +public class ReviewController { + + private final ActivityService activityService; + private final AnnouncementService announcementService; + private final MomentService momentService; + private final AuthorizationService authorizationService; + private final ModerationService moderationService; + + @GetMapping("/workbench") + public Result> 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> history( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(moderationService.history(page, size)); + } + + @PutMapping("/batch") + public Result> batchReview( + @Valid @RequestBody BatchContentReviewRequest request) { + return Result.success(moderationService.batchReview(request)); + } + + @PutMapping("/activities") + public Result 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 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 reviewMoment( + @RequestParam String id, + @RequestParam boolean approved, + @RequestParam(required = false) String reason) { + authorizationService.requireReviewerOrAdmin(); + return Result.success(momentService.reviewMoment(id, approved, reason)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/SensitiveWordController.java b/campus-server/src/main/java/com/campus/controller/SensitiveWordController.java new file mode 100644 index 0000000..a834a47 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/SensitiveWordController.java @@ -0,0 +1,49 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.entity.SensitiveWord; +import com.campus.service.SensitiveWordService; +import com.campus.service.AuthorizationService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/sensitive-words") +@RequiredArgsConstructor +public class SensitiveWordController { + + private final SensitiveWordService sensitiveWordService; + private final AuthorizationService authorizationService; + + @GetMapping + public Result> 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 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 create(@RequestBody SensitiveWord word) { + authorizationService.requireAdmin(); + return Result.success(sensitiveWordService.addWord(word)); + } + + @PutMapping + public Result update(@RequestParam String id, @RequestBody SensitiveWord word) { + authorizationService.requireAdmin(); + return Result.success(sensitiveWordService.updateWord(id, word)); + } + + @DeleteMapping + public Result delete(@RequestParam String id) { + authorizationService.requireAdmin(); + sensitiveWordService.deleteWord(id); + return Result.success(); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/ShareController.java b/campus-server/src/main/java/com/campus/controller/ShareController.java new file mode 100644 index 0000000..040d1b6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/ShareController.java @@ -0,0 +1,33 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.ShareToFriendsRequest; +import com.campus.dto.request.ShareToGroupRequest; +import com.campus.dto.response.ShareSendResultDTO; +import com.campus.service.ShareService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +/** App 内分享:批量分享到好友,或分享到一个群聊。 */ +@RestController +@RequestMapping("/api/campus/shares") +@RequiredArgsConstructor +public class ShareController { + + private final ShareService shareService; + + @PostMapping("/friends") + public Result sendToFriends(@Valid @RequestBody ShareToFriendsRequest request) { + return Result.success(shareService.sendToFriends(request)); + } + + @PostMapping("/group") + public Result sendToGroup(@Valid @RequestBody ShareToGroupRequest request) { + return Result.success(shareService.sendToGroup(request)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/SocialGraphController.java b/campus-server/src/main/java/com/campus/controller/SocialGraphController.java new file mode 100644 index 0000000..856a828 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/SocialGraphController.java @@ -0,0 +1,24 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.SocialGraphDTO; +import com.campus.service.SocialGraphService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/social-graph") +@RequiredArgsConstructor +public class SocialGraphController { + + private final SocialGraphService socialGraphService; + + @GetMapping + public Result current( + @RequestParam(required = false, defaultValue = "200") Integer maxNodes) { + return Result.success(socialGraphService.getCurrentGraph(maxNodes)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/TopicController.java b/campus-server/src/main/java/com/campus/controller/TopicController.java new file mode 100644 index 0000000..7e0a576 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/TopicController.java @@ -0,0 +1,178 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.TopicReviewRequest; +import com.campus.dto.response.MomentDTO; +import com.campus.dto.response.MyTopicsDTO; +import com.campus.dto.response.TopicDTO; +import com.campus.dto.response.TopicFavoriteDTO; +import com.campus.dto.response.TopicFollowDTO; +import com.campus.service.TopicDiscussionService; +import com.campus.service.TopicService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 话题广场接口。 + * + *

领域边界:本控制器下的数据接口只返回话题域数据(topic 系列表 + 话题讨论), + * 不返回任何从动态派生出来的统计。动态内容请调动态域 {@code GET /api/campus/moments}, + * 话题讨论请调 {@code GET /api/topics/{topicName}/discussions}。

+ * + *

已废弃的 {@code GET /api/topics/{topicName}/moments} 保留 30 天,行为改为转发到 + * 话题讨论列表(与新接口一致),因为它就是"话题页数据入口",语义必须跟话题页一致。

+ */ +@RestController +@RequestMapping("/api/topics") +@RequiredArgsConstructor +public class TopicController { + + private final TopicService topicService; + /** 兼容期内的讨论列表转发,话题统计不使用它。 */ + private final TopicDiscussionService topicDiscussionService; + + @GetMapping + public Result> list(@RequestParam(required = false, defaultValue = "20") Integer limit) { + return Result.success(topicService.listTopics(limit)); + } + + @GetMapping("/categories") + public Result> categories() { + return Result.success(topicService.categories()); + } + + @GetMapping("/catalog") + public Result> 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> rankings(@RequestParam(defaultValue = "hot") String type, + @RequestParam(defaultValue = "20") Integer limit) { + return Result.success(topicService.rankings(type, limit)); + } + + @GetMapping("/recommendations") + public Result> recommendations(@RequestParam(defaultValue = "10") Integer limit) { + return Result.success(topicService.recommendations(limit)); + } + + @GetMapping("/my") + public Result myTopics() { + return Result.success(topicService.myTopics()); + } + + @GetMapping("/pending") + public Result> pendingTopics() { + return Result.success(topicService.pendingTopics()); + } + + @GetMapping("/{topicName}") + public Result detail(@PathVariable String topicName) { + return Result.success(topicService.getTopic(topicName)); + } + + @PostMapping("/{topicName}/visit") + public Result visit(@PathVariable String topicName) { + topicService.recordVisit(topicName); + return Result.success(); + } + + /** + * 已废弃:话题下的内容列表。 + * + *

行为已改为转发话题讨论列表(按 {@code topic_id} 查,只出讨论),与 + * {@code GET /api/topics/{topicName}/discussions} 完全一致;前端应改用新路由, + * 本路由计划 30 天后下线。

+ * + * @deprecated 使用 {@code GET /api/topics/{topicName}/discussions} + */ + @Deprecated + @GetMapping("/{topicName}/moments") + public Result> 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 toggleFollow(@PathVariable String topicName) { + return Result.success(topicService.toggleFollow(topicName)); + } + + /** + * 收藏 / 取消收藏话题(toggle)。 + * + *

返回最新状态与收藏数,前端用它直接刷新按钮与统计栏,不必再拉一次详情。

+ */ + @PostMapping("/{topicName}/favorite") + public Result toggleFavorite(@PathVariable String topicName) { + return Result.success(topicService.toggleFavorite(topicName)); + } + + @GetMapping("/following/list") + public Result> following() { + return Result.success(topicService.listFollowing()); + } + + @PostMapping + public Result create(@RequestBody Map 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 update(@PathVariable String id, @RequestBody Map 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 delete(@PathVariable String id) { + topicService.deleteTopic(id); + return Result.success(); + } + + /** + * 审核话题:同时兼容 {@code status/reviewNote}(新)与 {@code approved/note}(旧)。 + * 两者都缺 → 400「审核结果不能为空」;都给且冲突 → 400「审核结果不明确」。 + */ + @PutMapping("/{id}/review") + public Result review(@PathVariable String id, @RequestBody TopicReviewRequest body) { + return Result.success(topicService.reviewTopic(id, body)); + } + + @PostMapping("/{id}/aliases") + public Result addAlias(@PathVariable String id, @RequestBody Map body) { + return Result.success(topicService.addAlias(id, (String) body.get("alias"))); + } + + @PostMapping("/{id}/merge") + public Result merge(@PathVariable String id, @RequestBody Map body) { + return Result.success(topicService.merge(id, (String) body.get("targetTopicId"))); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/TopicDiscussionController.java b/campus-server/src/main/java/com/campus/controller/TopicDiscussionController.java new file mode 100644 index 0000000..4333647 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/TopicDiscussionController.java @@ -0,0 +1,97 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.dto.request.TopicDiscussionRequest; +import com.campus.dto.response.MomentDTO; +import com.campus.service.TopicDiscussionService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +/** + * 话题讨论接口(话题板块自己的内容入口)。 + * + *

与动态域彻底分离:讨论只看 {@code topic_id},{@code moment.tags} 是动态的分类标签, + * 不参与任何话题归属判断。因此带话题标签的动态**不会**出现在这里, + * 这里发出的讨论也**不会**出现在动态流。

+ */ +@RestController +@RequestMapping("/api/topics") +@RequiredArgsConstructor +public class TopicDiscussionController { + + private final TopicDiscussionService topicDiscussionService; + + /** + * 发布讨论(话题页底部「发布你的讨论…」)。 + * + *

鉴权:请求头 {@code access_token}(落在 SecurityConfig 的 {@code anyRequest().authenticated()} 内)。

+ */ + @PostMapping("/{topicName}/discussions") + public Result publish(@PathVariable String topicName, + @Valid @RequestBody TopicDiscussionRequest request) { + return Result.success(topicDiscussionService.publish(topicName, request)); + } + + /** + * 编辑讨论(作者或管理员)。整体替换正文与媒体,重新走同步审核。 + * + *

为什么不能复用 {@code PUT /api/campus/moments}:那个接口要求动态分类 + * ({@code category})非空,而讨论不写分类——详见 + * {@code 方案分析与实现(md)文件/实现方案-话题讨论图片与视频.md} §2.2。

+ */ + @PutMapping("/discussions/{id}") + public Result update(@PathVariable String id, + @RequestBody(required = false) TopicDiscussionRequest request) { + return Result.success(topicDiscussionService.update(id, request)); + } + + /** + * 删除讨论(作者或管理员,软删)。 + * + *

不清理 OSS 图片对象、不调 VOD 删媒资(实现方案 §9.5)。

+ */ + @DeleteMapping("/discussions/{id}") + public Result delete(@PathVariable String id) { + topicDiscussionService.delete(id); + return Result.success(); + } + + /** + * 话题讨论列表。 + * + * @param sort {@code latest}(默认)或 {@code hot};其他值返回 400「排序方式无效」 + */ + @GetMapping("/{topicName}/discussions") + public Result> 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)); + } + + /** + * 我的讨论:作者查看/管理自己发过的讨论(含未过审),可按话题筛选。 + * + *

注意:{@code /api/topics/my/discussions} 是字面量路径,优先级高于 + * {@code /{topicName}/discussions},所以名字叫 “my” 的话题会被这条路由遮住。

+ */ + @GetMapping("/my/discussions") + public Result> mine( + @RequestParam(required = false) String topicId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return Result.success(topicDiscussionService.listMine(topicId, page, size)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/UniversityController.java b/campus-server/src/main/java/com/campus/controller/UniversityController.java new file mode 100644 index 0000000..1eaf52e --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/UniversityController.java @@ -0,0 +1,72 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.request.UniversityQueryRequest; +import com.campus.dto.response.CollegeDTO; +import com.campus.dto.response.MajorDTO; +import com.campus.dto.response.PageResult; +import com.campus.dto.response.UniversityDTO; +import com.campus.dto.response.UniversityDistanceDTO; +import com.campus.dto.response.UniversityMapStatsDTO; +import com.campus.service.UniversityService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 高校信息控制器 + * 提供大学、学院、专业三级级联查询接口 + */ +@RestController +@RequestMapping("/api/universities") +@RequiredArgsConstructor +public class UniversityController { + + private final UniversityService universityService; + + @GetMapping + public Result> getAllUniversities() { + return Result.success(universityService.getAllUniversities()); + } + + @GetMapping("/search") + public Result> searchUniversities(UniversityQueryRequest request) { + return Result.success(universityService.searchUniversities(request)); + } + + @GetMapping("/detail") + public Result getUniversity(@RequestParam String id) { + return Result.success(universityService.getUniversityById(id)); + } + + @GetMapping("/colleges") + public Result> getColleges(@RequestParam String id) { + return Result.success(universityService.getCollegesByUniversity(id)); + } + + @GetMapping("/majors") + public Result> getMajorsByUniversity(@RequestParam String id) { + return Result.success(universityService.getMajorsByUniversity(id)); + } + + /** + * 查询附近大学(按距离由近及远) + * + * @param latitude 中心点纬度 + * @param longitude 中心点经度 + */ + @GetMapping("/nearby") + public Result> getNearbyUniversities(@RequestParam BigDecimal latitude, + @RequestParam BigDecimal longitude) { + return Result.success(universityService.getNearbyUniversities(latitude, longitude)); + } + + @GetMapping("/map/stats") + public Result> mapStats( + @RequestParam(required = false) String province, + @RequestParam(required = false) String city) { + return Result.success(universityService.getMapStats(province, city)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/UserController.java b/campus-server/src/main/java/com/campus/controller/UserController.java new file mode 100644 index 0000000..bc51802 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/UserController.java @@ -0,0 +1,190 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.entity.User; +import com.campus.dto.request.ChangePasswordRequest; +import com.campus.dto.request.DeactivateAccountRequest; +import com.campus.service.UserService; +import com.campus.service.UserDataExportService; +import com.campus.service.UserSessionService; +import com.campus.dto.response.UserSessionDTO; +import com.campus.util.UserContext; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import javax.servlet.http.HttpServletRequest; +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final UserDataExportService userDataExportService; + private final UserSessionService userSessionService; + + /** + * 退出登录 + * 需携带有效 access_token 请求头 + */ + @PostMapping("/logout") + public Result logout(HttpServletRequest request) { + userService.logoutByToken(accessToken(request)); + return Result.success(); + } + + @GetMapping("/sessions") + public Result> sessions(HttpServletRequest request) { + return Result.success(userSessionService.list(UserContext.getCurrentUserId(), accessToken(request))); + } + + @DeleteMapping("/sessions/{sessionId}") + public Result revokeSession(@PathVariable String sessionId) { + String userId = UserContext.getCurrentUserId(); + userSessionService.revoke(userId, sessionId, userId); + return Result.success(); + } + + @DeleteMapping("/sessions") + public Result revokeAllSessions() { + String userId = UserContext.getCurrentUserId(); + userSessionService.revokeAll(userId, userId); + return Result.success(); + } + + /** + * 获取账号安全页所需的基础状态。 + */ + @GetMapping("/account-security") + public Result> accountSecurity() { + String userId = UserContext.getCurrentUserId(); + User user = userService.getUserById(userId); + Map 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> changePassword(@Valid @RequestBody ChangePasswordRequest request) { + String userId = UserContext.getCurrentUserId(); + userService.changePassword(userId, request); + Map result = new LinkedHashMap<>(); + result.put("requireRelogin", true); + result.put("message", "密码修改成功,请重新登录"); + return Result.success(result); + } + + /** + * 发起账号注销。必须显式传 confirm=true,账号进入 7 天冷静期。 + */ + @PostMapping("/deactivate") + public Result 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> deactivationPreview() { + String uid = UserContext.getCurrentUserId(); + if (uid == null) throw new com.campus.common.BusinessException("用户未登录"); + return Result.success(userService.deactivationPreview(uid)); + } + + @PostMapping("/recover") + public Result recover() { + return Result.success(userService.recoverAccount(UserContext.getCurrentUserId())); + } + + @GetMapping("/data-export") + public ResponseEntity 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 getUser(@RequestParam String id) { + return Result.success(userService.getPublicUserById(id)); + } + + @GetMapping + public Result> 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 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; + } +} diff --git a/campus-server/src/main/java/com/campus/controller/UserProfileController.java b/campus-server/src/main/java/com/campus/controller/UserProfileController.java new file mode 100644 index 0000000..33995d3 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/UserProfileController.java @@ -0,0 +1,28 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.UserProfileDTO; +import com.campus.service.FriendService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 用户资料接口(陌生人资料页) + * + * GET /api/users/profile?userId=xxx + */ +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserProfileController { + + private final FriendService friendService; + + @GetMapping("/profile") + public Result getProfile(@RequestParam String userId) { + return Result.success(friendService.getUserProfile(userId)); + } +} diff --git a/campus-server/src/main/java/com/campus/controller/VodController.java b/campus-server/src/main/java/com/campus/controller/VodController.java new file mode 100644 index 0000000..8be3103 --- /dev/null +++ b/campus-server/src/main/java/com/campus/controller/VodController.java @@ -0,0 +1,214 @@ +package com.campus.controller; + +import com.campus.common.Result; +import com.campus.dto.response.VodUploadCredential; +import com.campus.service.VodService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * 阿里云 VOD 视频点播接口 + * + * 上传流程: + * 1. POST /api/vod/upload → 前端发送视频文件 → 后端 CreateUploadVideo + 上传到 VOD Bucket + * 2. VOD 自动转码/审核 → 回调 POST /api/oss/vod-callback → 更新数据库 + * + * 播放流程: + * 3. GET /api/vod/play-auth/{videoId} → 获取 PlayAuth → 前端初始化播放器 + */ +@Slf4j +@RestController +@RequestMapping("/api/vod") +@RequiredArgsConstructor +public class VodController { + + private final VodService vodService; + + /** + * 上传视频到 VOD(一步完成:创建凭证 + 上传到 VOD Bucket) + * + * POST /api/vod/upload + * Content-Type: multipart/form-data + * 参数: file (视频文件) + * + * @return { videoId, requestId } + */ + @PostMapping("/upload") + public Result> 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 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> 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 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 createUpload(@RequestBody Map 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 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> getPlayAuth(@PathVariable String videoId) { + try { + String playAuth = vodService.getPlayAuth(videoId); + Map playInfo = vodService.getPlayInfo(videoId); + + Map 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> getPlayInfo(@PathVariable String videoId) { + try { + Map playInfo = vodService.getPlayInfo(videoId); + return Result.success(playInfo); + } catch (Exception e) { + log.error("获取播放信息失败: videoId={}", videoId, e); + return Result.error(500, "获取播放信息失败: " + e.getMessage()); + } + } +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ActivityCheckInRequest.java b/campus-server/src/main/java/com/campus/dto/request/ActivityCheckInRequest.java new file mode 100644 index 0000000..4e5d27b --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ActivityCheckInRequest.java @@ -0,0 +1,10 @@ +package com.campus.dto.request; + +import lombok.Data; +import javax.validation.constraints.NotBlank; + +@Data +public class ActivityCheckInRequest { + @NotBlank(message = "checkInCode 不能为空") + private String checkInCode; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ActivityLocationRequest.java b/campus-server/src/main/java/com/campus/dto/request/ActivityLocationRequest.java new file mode 100644 index 0000000..fdb0d32 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ActivityLocationRequest.java @@ -0,0 +1,15 @@ +package com.campus.dto.request; + +import lombok.Data; + +@Data +public class ActivityLocationRequest { + private String text; + private String cityCode; + private String campusHint; + private String resolveId; + private String candidateId; + private Double latitude; + private Double longitude; + private String coordinateSystem; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ActivityQueryRequest.java b/campus-server/src/main/java/com/campus/dto/request/ActivityQueryRequest.java new file mode 100644 index 0000000..38afe04 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ActivityQueryRequest.java @@ -0,0 +1,30 @@ +package com.campus.dto.request; + +import lombok.Data; + +@Data +public class ActivityQueryRequest { + private String type; + private String status; + private String reviewStatus; + private String keyword; + private String creatorId; + + /** + * 排序方式: + * HEAT / DYNAMIC:按动态热度排序(综合参与分+互动分+临近度+时间衰减) + * TIME:按创建时间倒序(默认) + * EVENT_TIME:按活动时间排序(最近的在前面) + * PARTICIPATION:按报名人数排序 + */ + private String sortBy; + + /** + * 周期筛选: + * TODAY:今天创建的活动 + * WEEK:本周创建的活动 + * MONTH:本月创建的活动 + * ALL / null:不限制 + */ + private String period; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ActivityRegistrationRequest.java b/campus-server/src/main/java/com/campus/dto/request/ActivityRegistrationRequest.java new file mode 100644 index 0000000..b1dba69 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ActivityRegistrationRequest.java @@ -0,0 +1,27 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Data +public class ActivityRegistrationRequest { + @NotBlank(message = "票档不能为空") + private String ticketId; + + @NotNull(message = "报名数量不能为空") + @Min(value = 1, message = "报名数量至少为1") + private Integer quantity; + + @NotBlank(message = "报名人姓名不能为空") + private String attendeeName; + + @NotBlank(message = "报名人手机号不能为空") + private String attendeePhone; + + private String universityName; + private String emergencyContact; + private String emergencyPhone; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ActivityRequest.java b/campus-server/src/main/java/com/campus/dto/request/ActivityRequest.java new file mode 100644 index 0000000..f94aa9e --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ActivityRequest.java @@ -0,0 +1,83 @@ +package com.campus.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.util.Date; +import java.util.List; + +@Data +public class ActivityRequest { + + @NotBlank(message = "标题不能为空") + private String title; + + @NotBlank(message = "活动描述不能为空") + private String description; + + @NotBlank(message = "类型不能为空") + private String type; + + private String location; + /** 文字地点解析后二次确认的短期凭据;提供时以服务端地点为准。 */ + private String locationToken; + private Double latitude; + private Double longitude; + private String contactName; + private String contactPhone; + private String rules; + private String schedule; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date registrationStartAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date registrationDeadline; + + /** 前端兼容字段;与 registrationStartAt 二选一。 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date registrationStartTime; + + /** 前端兼容字段;与 registrationDeadline 二选一。 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date registrationEndTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date freeCancellationDeadline; + + private String cancellationRule; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date eventTime; + + /** 前端兼容字段;与 eventTime 二选一。 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date startTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date endTime; + + @NotNull(message = "最大人数不能为空") + private Integer maxCount; + + private List 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; + } +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ActivityReviewRequest.java b/campus-server/src/main/java/com/campus/dto/request/ActivityReviewRequest.java new file mode 100644 index 0000000..b032aeb --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ActivityReviewRequest.java @@ -0,0 +1,15 @@ +package com.campus.dto.request; + +import lombok.Data; + +/** 活动报名审核动作:APPROVE、REJECT 或 REMOVE。 */ +@Data +public class ActivityReviewRequest { + + /** 推荐传 action;兼容旧调用也支持 approved=true/false。 */ + private String action; + + private Boolean approved; + + private String reason; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ActivityTicketRequest.java b/campus-server/src/main/java/com/campus/dto/request/ActivityTicketRequest.java new file mode 100644 index 0000000..83abe11 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ActivityTicketRequest.java @@ -0,0 +1,26 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Min; +import java.math.BigDecimal; + +@Data +public class ActivityTicketRequest { + @NotBlank(message = "票档名称不能为空") + private String name; + + private String description; + + @NotNull(message = "票档价格不能为空") + @DecimalMin(value = "0.00", message = "票档价格不能为负数") + private BigDecimal price; + + @Min(value = 1, message = "票档名额必须大于0") + private Integer capacity; + + private Integer sortOrder; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/AddFriendRequest.java b/campus-server/src/main/java/com/campus/dto/request/AddFriendRequest.java new file mode 100644 index 0000000..eadc3a4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/AddFriendRequest.java @@ -0,0 +1,24 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +/** + * 添加好友请求 DTO + *

+ * 发起好友申请,需等待对方同意后方可建立好友关系。 + *

+ */ +@Data +public class AddFriendRequest { + + /** 对方用户ID */ + @NotBlank(message = "好友ID不能为空") + private String friendId; + + /** 申请附言(可选) */ + @Size(max = 200, message = "附言长度不能超过200个字符") + private String message; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/AddGroupMembersRequest.java b/campus-server/src/main/java/com/campus/dto/request/AddGroupMembersRequest.java new file mode 100644 index 0000000..d2610fb --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/AddGroupMembersRequest.java @@ -0,0 +1,12 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import java.util.List; + +@Data +public class AddGroupMembersRequest { + @NotEmpty(message = "请选择要添加的用户") + private List userIds; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/AdjustPointsRequest.java b/campus-server/src/main/java/com/campus/dto/request/AdjustPointsRequest.java new file mode 100644 index 0000000..ae0cde4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/AdjustPointsRequest.java @@ -0,0 +1,21 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +/** + * 调整用户积分请求(管理员接口,手动增减) + */ +@Data +public class AdjustPointsRequest { + + /** 目标用户ID */ + @NotBlank(message = "用户ID不能为空") + private String userId; + + /** 变动积分数量(正数增加,负数扣除) */ + @NotNull(message = "变动积分数量不能为空") + private Integer amount; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/AdminAccountStatusRequest.java b/campus-server/src/main/java/com/campus/dto/request/AdminAccountStatusRequest.java new file mode 100644 index 0000000..6d642b2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/AdminAccountStatusRequest.java @@ -0,0 +1,15 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +public class AdminAccountStatusRequest { + + @NotBlank(message = "accountStatus 不能为空") + private String accountStatus; + + /** 后台操作备注,可用于停用原因;不包含密码等敏感信息。 */ + private String reason; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/AdminPasswordResetRequest.java b/campus-server/src/main/java/com/campus/dto/request/AdminPasswordResetRequest.java new file mode 100644 index 0000000..cfe603b --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/AdminPasswordResetRequest.java @@ -0,0 +1,13 @@ +package com.campus.dto.request; + +import lombok.Data; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class AdminPasswordResetRequest { + @NotBlank(message = "newPassword 不能为空") + @Size(min = 8, max = 72, message = "密码长度必须为 8-72 位") + private String newPassword; + private String reason; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/AdminRoleUpdateRequest.java b/campus-server/src/main/java/com/campus/dto/request/AdminRoleUpdateRequest.java new file mode 100644 index 0000000..f63fb8d --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/AdminRoleUpdateRequest.java @@ -0,0 +1,15 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotNull; + +@Data +public class AdminRoleUpdateRequest { + + @NotNull(message = "isAdmin 不能为空") + private Boolean isAdmin; + + @NotNull(message = "isReviewer 不能为空") + private Boolean isReviewer; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/AnnouncementRequest.java b/campus-server/src/main/java/com/campus/dto/request/AnnouncementRequest.java new file mode 100644 index 0000000..5555d34 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/AnnouncementRequest.java @@ -0,0 +1,57 @@ +package com.campus.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.Date; +import java.util.List; + +@Data +public class AnnouncementRequest { + + @Size(max = 50, message = "公告类型长度不能超过50个字符") + private String type; + + @NotBlank(message = "公告标题不能为空") + @Size(max = 200, message = "公告标题长度不能超过200个字符") + private String title; + + @Size(max = 200, message = "副标题长度不能超过200个字符") + private String subtitle; + + @Size(max = 500, message = "描述长度不能超过500个字符") + private String description; + + private String content; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date eventDate; + + @Size(max = 50, message = "事件时间长度不能超过50个字符") + private String eventTime; + + @Size(max = 200, message = "地点长度不能超过200个字符") + private String location; + + @Size(max = 100, message = "组织者长度不能超过100个字符") + private String organizer; + + private Integer capacity; + + @Size(max = 500, message = "封面图片URL长度不能超过500个字符") + private String coverImage; + + private List images; + + /** 发布范围类型:all-全部学校,specified-指定学校 */ + @Size(max = 20, message = "发布范围类型长度不能超过20个字符") + private String scopeType = "all"; + + /** 指定学校ID列表 */ + private List universityIds; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date scheduledPublishAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/AnnouncementUpdateRequest.java b/campus-server/src/main/java/com/campus/dto/request/AnnouncementUpdateRequest.java new file mode 100644 index 0000000..c01089c --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/AnnouncementUpdateRequest.java @@ -0,0 +1,95 @@ +package com.campus.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import javax.validation.constraints.Size; +import java.util.Date; +import java.util.List; + +/** + * 公告更新请求体(部分更新,所有字段均为可选,非空才覆盖) + */ +@Data +public class AnnouncementUpdateRequest { + + /** + * 公告类型 + */ + @Size(max = 50, message = "公告类型长度不能超过50个字符") + private String type; + + /** + * 公告标题 + */ + @Size(max = 200, message = "公告标题长度不能超过200个字符") + private String title; + + /** + * 副标题 + */ + @Size(max = 200, message = "副标题长度不能超过200个字符") + private String subtitle; + + /** + * 描述 + */ + @Size(max = 500, message = "描述长度不能超过500个字符") + private String description; + + /** + * 正文内容 + */ + private String content; + + /** + * 活动日期 + */ + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date eventDate; + + /** + * 活动时间(时分) + */ + @Size(max = 50, message = "事件时间长度不能超过50个字符") + private String eventTime; + + /** + * 活动地点 + */ + @Size(max = 200, message = "地点长度不能超过200个字符") + private String location; + + /** + * 主办方 + */ + @Size(max = 100, message = "组织者长度不能超过100个字符") + private String organizer; + + /** + * 容量限制 + */ + private Integer capacity; + + /** + * 封面图片URL + */ + @Size(max = 500, message = "封面图片URL长度不能超过500个字符") + private String coverImage; + + /** + * 图片URL列表(传空数组 [] 表示清空图片) + */ + private List images; + + /** + * 发布范围类型:all-全部学校,specified-指定学校 + */ + @Size(max = 20, message = "发布范围类型长度不能超过20个字符") + private String scopeType; + + /** + * 指定学校ID列表(传空数组 [] 表示清空) + */ + private List universityIds; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/BatchContentReviewRequest.java b/campus-server/src/main/java/com/campus/dto/request/BatchContentReviewRequest.java new file mode 100644 index 0000000..e26e741 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/BatchContentReviewRequest.java @@ -0,0 +1,23 @@ +package com.campus.dto.request; + +import lombok.Data; +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +@Data +public class BatchContentReviewRequest { + @Valid + @NotEmpty(message = "items 不能为空") + private List items; + + @Data + public static class Item { + @javax.validation.constraints.NotBlank + private String contentType; + @javax.validation.constraints.NotBlank + private String contentId; + private boolean approved; + private String reason; + } +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ChangePasswordRequest.java b/campus-server/src/main/java/com/campus/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..03c4946 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ChangePasswordRequest.java @@ -0,0 +1,20 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +/** + * 已登录用户修改密码。 + */ +@Data +public class ChangePasswordRequest { + + @NotBlank(message = "旧密码不能为空") + private String oldPassword; + + @NotBlank(message = "新密码不能为空") + @Size(min = 6, max = 50, message = "密码长度必须在6-50个字符之间") + private String newPassword; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ChatMessageRequest.java b/campus-server/src/main/java/com/campus/dto/request/ChatMessageRequest.java new file mode 100644 index 0000000..e4be21f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ChatMessageRequest.java @@ -0,0 +1,21 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +public class ChatMessageRequest { + + @NotBlank(message = "对方用户ID不能为空") + private String peerId; + + @NotBlank(message = "消息内容不能为空") + private String content; + + private String messageType = "TEXT"; + + private String mediaUrl; + + private Integer duration; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/CommentRequest.java b/campus-server/src/main/java/com/campus/dto/request/CommentRequest.java new file mode 100644 index 0000000..f959ea5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/CommentRequest.java @@ -0,0 +1,24 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 评论请求DTO + */ +@Data +public class CommentRequest { + + /** 帖子ID(旧系统评论用) */ + private String postId; + + /** 动态ID(新系统评论用) */ + private String momentId; + + @NotBlank(message = "评论内容不能为空") + private String content; + + /** 回复的评论ID,NULL表示一级评论 */ + private String parentId; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ContentAppealRequest.java b/campus-server/src/main/java/com/campus/dto/request/ContentAppealRequest.java new file mode 100644 index 0000000..a3616b9 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ContentAppealRequest.java @@ -0,0 +1,14 @@ +package com.campus.dto.request; + +import lombok.Data; +import javax.validation.constraints.NotBlank; + +@Data +public class ContentAppealRequest { + @NotBlank(message = "contentType 不能为空") + private String contentType; + @NotBlank(message = "contentId 不能为空") + private String contentId; + @NotBlank(message = "reason 不能为空") + private String reason; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ContentAppealReviewRequest.java b/campus-server/src/main/java/com/campus/dto/request/ContentAppealReviewRequest.java new file mode 100644 index 0000000..14d936f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ContentAppealReviewRequest.java @@ -0,0 +1,11 @@ +package com.campus.dto.request; + +import lombok.Data; +import javax.validation.constraints.NotBlank; + +@Data +public class ContentAppealReviewRequest { + @NotBlank(message = "status 不能为空") + private String status; + private String note; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ConversationSettingsRequest.java b/campus-server/src/main/java/com/campus/dto/request/ConversationSettingsRequest.java new file mode 100644 index 0000000..ed68c98 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ConversationSettingsRequest.java @@ -0,0 +1,13 @@ +package com.campus.dto.request; + +import lombok.Data; + +/** 私聊/群聊会话设置。未传字段保持原值。 */ +@Data +public class ConversationSettingsRequest { + private String peerId; + /** PRIVATE 或 GROUP。 */ + private String conversationType = "PRIVATE"; + private Boolean pinned; + private Boolean muted; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/CreateGroupInvitationRequest.java b/campus-server/src/main/java/com/campus/dto/request/CreateGroupInvitationRequest.java new file mode 100644 index 0000000..938875c --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/CreateGroupInvitationRequest.java @@ -0,0 +1,20 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; +import java.util.List; + +/** + * 发起群聊邀请(把已建群的用户拉进群,需要对方「允许」后才真正入群)。 + */ +@Data +public class CreateGroupInvitationRequest { + + @NotEmpty(message = "请选择要邀请的用户") + private List userIds; + + @Size(max = 200, message = "邀请附言最多 200 字") + private String message; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/CreateGroupRequest.java b/campus-server/src/main/java/com/campus/dto/request/CreateGroupRequest.java new file mode 100644 index 0000000..ff5be6d --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/CreateGroupRequest.java @@ -0,0 +1,21 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import java.util.List; + +@Data +public class CreateGroupRequest { + + @NotBlank(message = "群名称不能为空") + private String name; + + private String description; + + private String avatar; + + private Integer maxMembers; + + private List memberIds; +} \ No newline at end of file diff --git a/campus-server/src/main/java/com/campus/dto/request/DeactivateAccountRequest.java b/campus-server/src/main/java/com/campus/dto/request/DeactivateAccountRequest.java new file mode 100644 index 0000000..288373f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/DeactivateAccountRequest.java @@ -0,0 +1,10 @@ +package com.campus.dto.request; + +import lombok.Data; + +/** 注销账号请求体,兼容前端把确认标记放在 JSON 中。 */ +@Data +public class DeactivateAccountRequest { + + private Boolean confirm; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/FriendFilterRequest.java b/campus-server/src/main/java/com/campus/dto/request/FriendFilterRequest.java new file mode 100644 index 0000000..c8f9ba2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/FriendFilterRequest.java @@ -0,0 +1,23 @@ +package com.campus.dto.request; + +import lombok.Data; + +import java.util.List; + +/** + * 多维筛选好友请求 + */ +@Data +public class FriendFilterRequest { + private String universityName; + private String gender; // M / F + private String majorName; + private String education; // 本科/硕士/博士 + private String grade; + private String hometown; + private String location; + /** 快捷筛选类型(同校/同龄/异性/共同兴趣/老乡),由后端基于当前登录用户信息处理 */ + private List quickFilters; + private Integer page = 1; + private Integer size = 20; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/FriendSearchRequest.java b/campus-server/src/main/java/com/campus/dto/request/FriendSearchRequest.java new file mode 100644 index 0000000..36d951c --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/FriendSearchRequest.java @@ -0,0 +1,13 @@ +package com.campus.dto.request; + +import lombok.Data; + +/** + * 搜索好友请求 + */ +@Data +public class FriendSearchRequest { + private String keyword; + private Integer page = 1; + private Integer size = 20; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/GreetRequest.java b/campus-server/src/main/java/com/campus/dto/request/GreetRequest.java new file mode 100644 index 0000000..efa429c --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/GreetRequest.java @@ -0,0 +1,21 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +/** + * 打招呼请求 DTO + */ +@Data +public class GreetRequest { + + /** 对方用户ID */ + @NotBlank(message = "对方用户ID不能为空") + private String friendId; + + /** 打招呼消息(可选),不传则使用默认问候语 */ + @Size(max = 200, message = "打招呼消息长度不能超过200个字符") + private String message; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/LoginRequest.java b/campus-server/src/main/java/com/campus/dto/request/LoginRequest.java new file mode 100644 index 0000000..b7c92f1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/LoginRequest.java @@ -0,0 +1,20 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class LoginRequest { + + @NotBlank(message = "手机号不能为空") + private String phone; + + @NotBlank(message = "密码不能为空") + private String password; + + private String deviceId; + private String deviceName; + private String platform; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/LoginWithCaptchaRequest.java b/campus-server/src/main/java/com/campus/dto/request/LoginWithCaptchaRequest.java new file mode 100644 index 0000000..899a5cb --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/LoginWithCaptchaRequest.java @@ -0,0 +1,19 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +public class LoginWithCaptchaRequest { + + @NotBlank(message = "手机号不能为空") + private String phone; + + @NotBlank(message = "验证码不能为空") + private String captcha; + + private String deviceId; + private String deviceName; + private String platform; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/MatchActionRequest.java b/campus-server/src/main/java/com/campus/dto/request/MatchActionRequest.java new file mode 100644 index 0000000..a15da15 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/MatchActionRequest.java @@ -0,0 +1,14 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +public class MatchActionRequest { + @NotBlank(message = "匹配对象不能为空") + private String targetUserId; + + @NotBlank(message = "匹配行为不能为空") + private String action; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/MatchPreferenceRequest.java b/campus-server/src/main/java/com/campus/dto/request/MatchPreferenceRequest.java new file mode 100644 index 0000000..84709f9 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/MatchPreferenceRequest.java @@ -0,0 +1,12 @@ +package com.campus.dto.request; + +import lombok.Data; + +/** 匹配偏好更新请求。未传字段保持原值。 */ +@Data +public class MatchPreferenceRequest { + private String preferredGender; + private Integer minAge; + private Integer maxAge; + private Boolean sameUniversityOnly; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/MomentRequest.java b/campus-server/src/main/java/com/campus/dto/request/MomentRequest.java new file mode 100644 index 0000000..e7ccf01 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/MomentRequest.java @@ -0,0 +1,49 @@ +package com.campus.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import java.util.Date; +import java.util.List; + +@Data +public class MomentRequest { + + @NotBlank(message = "动态内容不能为空") + private String content; + + private List mediaUrls; + + private List tags; + + /** 动态分类:失物招领/二手市场/社团活动/兼职招聘/全职招聘/校园求助/学习交流/租房合租/交友约伴/技能交换 */ + @NotBlank(message = "动态分类不能为空") + private String category; + + /** 动态发布范围:PERSONAL-个人动态(仅好友可见)/CAMPUS-校园动态(人人可见,类似抖音) */ + @NotBlank(message = "发布范围不能为空") + private String scope; + + /** 发布城市,用于同城动态筛选 */ + private String province; + private String city; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date expiresAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date scheduledPublishAt; + + /** 是否加入青春号(视频号):true-加入青春号,false-普通动态 */ + private Boolean isYouthVideo = false; + + /** 是否匿名发布:true-匿名,false-不匿名(动态与青春号视频均有效) */ + private Boolean anonymous; + + /** 视频原始URL */ + private String videoOriginalUrl; + + /** VOD 视频ID(阿里云点播媒资ID) */ + private String vodVideoId; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/NotificationRequest.java b/campus-server/src/main/java/com/campus/dto/request/NotificationRequest.java new file mode 100644 index 0000000..176e598 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/NotificationRequest.java @@ -0,0 +1,21 @@ +package com.campus.dto.request; + +import lombok.Data; + +@Data +public class NotificationRequest { + + private String type; + + private String title; + + private String content; + + private String avatar; + + private String link; + + private String relatedId; + + private String senderId; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/PrivacySettingsRequest.java b/campus-server/src/main/java/com/campus/dto/request/PrivacySettingsRequest.java new file mode 100644 index 0000000..c753335 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/PrivacySettingsRequest.java @@ -0,0 +1,13 @@ +package com.campus.dto.request; + +import lombok.Data; + +/** 隐私设置更新请求。未传字段保持原值。 */ +@Data +public class PrivacySettingsRequest { + private Boolean profileVisible; + private Boolean locationVisible; + private Boolean onlineStatusVisible; + private Boolean allowStrangerFriendRequest; + private Boolean allowStrangerMessage; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/RegisterRequest.java b/campus-server/src/main/java/com/campus/dto/request/RegisterRequest.java new file mode 100644 index 0000000..462928e --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/RegisterRequest.java @@ -0,0 +1,47 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class RegisterRequest { + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 50, message = "密码长度必须在6-50个字符之间") + private String password; + +// @NotBlank(message = "昵称不能为空") + @Size(max = 50, message = "昵称长度不能超过50个字符") + private String nickname; + + @NotBlank(message = "学校不能为空") + @Size(max = 100, message = "学校名称长度不能超过100个字符") + private String universityName; + +// @NotBlank(message = "专业不能为空") + @Size(max = 50, message = "专业名称长度不能超过50个字符") + private String majorName; + + private String universityId; + private String collegeId; + private String collegeName; + private String majorId; + private String secondMajorId; + private String secondMajorName; + +// @NotBlank(message = "年级不能为空") + @Size(max = 20, message = "年级长度不能超过20个字符") + private String grade; + + @NotBlank(message = "手机号不能为空") + private String phone; + + @NotBlank(message = "验证码不能为空") + private String captcha; + + private String deviceId; + private String deviceName; + private String platform; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ReportRequest.java b/campus-server/src/main/java/com/campus/dto/request/ReportRequest.java new file mode 100644 index 0000000..421e475 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ReportRequest.java @@ -0,0 +1,24 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +/** 提交举报请求。 */ +@Data +public class ReportRequest { + + @NotBlank(message = "举报对象类型不能为空") + private String targetType; + + @NotBlank(message = "举报对象不能为空") + private String targetId; + + @NotBlank(message = "举报原因不能为空") + @Size(max = 50, message = "举报原因长度不能超过50个字符") + private String reason; + + @Size(max = 500, message = "补充描述长度不能超过500个字符") + private String description; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ResetPasswordRequest.java b/campus-server/src/main/java/com/campus/dto/request/ResetPasswordRequest.java new file mode 100644 index 0000000..1c4e4f2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ResetPasswordRequest.java @@ -0,0 +1,23 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +/** + * 通过手机号验证码重置密码。 + */ +@Data +public class ResetPasswordRequest { + + @NotBlank(message = "手机号不能为空") + private String phone; + + @NotBlank(message = "验证码不能为空") + private String captcha; + + @NotBlank(message = "新密码不能为空") + @Size(min = 6, max = 50, message = "密码长度必须在6-50个字符之间") + private String newPassword; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/RevokeRestrictionRequest.java b/campus-server/src/main/java/com/campus/dto/request/RevokeRestrictionRequest.java new file mode 100644 index 0000000..9db8fb2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/RevokeRestrictionRequest.java @@ -0,0 +1,8 @@ +package com.campus.dto.request; + +import lombok.Data; + +@Data +public class RevokeRestrictionRequest { + private String reason; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/SendGiftRequest.java b/campus-server/src/main/java/com/campus/dto/request/SendGiftRequest.java new file mode 100644 index 0000000..3bf6141 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/SendGiftRequest.java @@ -0,0 +1,31 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; + +/** + * 送礼物请求DTO + */ +@Data +public class SendGiftRequest { + + /** 接收者ID */ + @NotNull(message = "接收者ID不能为空") + private String receiverId; + + /** 礼物ID */ + @NotNull(message = "礼物ID不能为空") + private String giftId; + + /** 礼物数量 */ + @Min(value = 1, message = "礼物数量至少为1") + private Integer count = 1; + + /** 动态ID(可选) */ + private String momentId; + + /** 留言 */ + private String message; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/SendGroupMessageRequest.java b/campus-server/src/main/java/com/campus/dto/request/SendGroupMessageRequest.java new file mode 100644 index 0000000..7141661 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/SendGroupMessageRequest.java @@ -0,0 +1,12 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +public class SendGroupMessageRequest { + + @NotBlank(message = "消息内容不能为空") + private String content; +} \ No newline at end of file diff --git a/campus-server/src/main/java/com/campus/dto/request/ShareToFriendsRequest.java b/campus-server/src/main/java/com/campus/dto/request/ShareToFriendsRequest.java new file mode 100644 index 0000000..eea113a --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ShareToFriendsRequest.java @@ -0,0 +1,29 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; +import javax.validation.constraints.Pattern; +import java.util.List; + +/** 把一条站内内容以卡片形式批量分享给好友。 */ +@Data +public class ShareToFriendsRequest { + + @NotBlank(message = "分享目标类型不能为空") + private String targetType; + + @NotBlank(message = "分享目标ID不能为空") + private String targetId; + + @NotEmpty(message = "请选择要分享的好友") + @Size(max = 20, message = "一次最多分享给20位好友") + private List receiverIds; + + /** 每次主动分享生成新的 UUID;重试同一请求时沿用原值。 */ + @Size(max = 64, message = "分享请求标识格式不正确") + @Pattern(regexp = "^[A-Za-z0-9._:-]{1,64}$", message = "分享请求标识格式不正确") + private String requestId; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/ShareToGroupRequest.java b/campus-server/src/main/java/com/campus/dto/request/ShareToGroupRequest.java new file mode 100644 index 0000000..9f6b4f0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/ShareToGroupRequest.java @@ -0,0 +1,26 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +/** 把一条站内内容以卡片形式分享到一个群聊。 */ +@Data +public class ShareToGroupRequest { + + @NotBlank(message = "分享目标类型不能为空") + private String targetType; + + @NotBlank(message = "分享目标ID不能为空") + private String targetId; + + @NotBlank(message = "请选择要分享的群聊") + private String groupId; + + /** 每次主动分享生成新的 UUID;重试同一请求时沿用原值。 */ + @Size(max = 64, message = "分享请求标识格式不正确") + @Pattern(regexp = "^[A-Za-z0-9._:-]{1,64}$", message = "分享请求标识格式不正确") + private String requestId; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/SysDictItemRequest.java b/campus-server/src/main/java/com/campus/dto/request/SysDictItemRequest.java new file mode 100644 index 0000000..8356b5a --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/SysDictItemRequest.java @@ -0,0 +1,33 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +/** + * 创建/更新字典项请求 + */ +@Data +public class SysDictItemRequest { + + @NotBlank(message = "项值不能为空") + @Size(max = 50) + private String value; + + @NotBlank(message = "显示文本不能为空") + @Size(max = 100) + private String label; + + private Integer sortOrder = 0; + + private Boolean status = true; + + @Size(max = 20) + private String color; + + @Size(max = 100) + private String icon; + + private String extra; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/SysDictRequest.java b/campus-server/src/main/java/com/campus/dto/request/SysDictRequest.java new file mode 100644 index 0000000..8a6e88e --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/SysDictRequest.java @@ -0,0 +1,26 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +/** + * 创建/更新字典主表请求 + */ +@Data +public class SysDictRequest { + + @NotBlank(message = "字典编码不能为空") + @Size(max = 50) + private String code; + + @NotBlank(message = "字典名称不能为空") + @Size(max = 100) + private String name; + + @Size(max = 500) + private String description; + + private Boolean status = true; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/TopicDiscussionRequest.java b/campus-server/src/main/java/com/campus/dto/request/TopicDiscussionRequest.java new file mode 100644 index 0000000..03dc069 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/TopicDiscussionRequest.java @@ -0,0 +1,42 @@ +package com.campus.dto.request; + +import lombok.Data; + +import java.util.List; + +/** + * 话题讨论发布请求。 + * + *

与 {@link MomentRequest} 刻意分开:讨论不写动态分类({@code category})与动态标签 + * ({@code tags}),话题归属由路径里的 {@code topicName} 决定,不占用动态的分类体系。

+ * + *

媒体形态(三选一,互斥):纯文本 / 图文(1-9 张图)/ 视频(1 条)。 + * 图片与视频同时非空会被服务端拒绝(400「图片和视频不能同时发布」)。 + * 图片与视频的合法性校验、以及"三者全空"的判定都在服务端 + * ({@code MomentService.createTopicDiscussion}),这里只承载字段。

+ */ +@Data +public class TopicDiscussionRequest { + + /** + * 讨论正文。 + * + *

不再是 {@code @NotBlank}:允许**纯视频无文案**(决策 2026-11-01)。 + * "正文 + 图片 + 视频全为空"由服务端拒绝,文案沿用「讨论内容不能为空」。

+ */ + private String content; + + /** 已上传到 OSS 的图片 URL 列表(1-9 张);**与 {@link #vodVideoId} 互斥**。 */ + private List mediaUrls; + + /** + * 已上传到阿里云 VOD 的视频 ID({@code POST /api/vod/upload} 的返回值); + * **与 {@link #mediaUrls} 互斥**。 + * + *

只接受 ID:播放地址、封面、时长一律由 VOD 转码回调回填,不接受前端传入。

+ */ + private String vodVideoId; + + /** 是否匿名发布,默认 false。 */ + private Boolean anonymous = false; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/TopicReviewRequest.java b/campus-server/src/main/java/com/campus/dto/request/TopicReviewRequest.java new file mode 100644 index 0000000..9287e24 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/TopicReviewRequest.java @@ -0,0 +1,22 @@ +package com.campus.dto.request; + +import lombok.Data; + +/** + * 话题审核入参。同时兼容两套字段: + *
    + *
  • 新(前端约定):{@code status=APPROVED|REJECTED} + {@code reviewNote}
  • + *
  • 旧(历史后台):{@code approved=true|false} + {@code note}
  • + *
+ * 两者都缺 → 400「审核结果不能为空」;都给且冲突 → 400「审核结果不明确」。 + */ +@Data +public class TopicReviewRequest { + /** APPROVED / REJECTED(大小写不敏感)。 */ + private String status; + /** 兼容旧字段,等价于 status=APPROVED/REJECTED。 */ + private Boolean approved; + private String reviewNote; + /** 兼容旧字段,等价于 reviewNote。 */ + private String note; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/UniversityQueryRequest.java b/campus-server/src/main/java/com/campus/dto/request/UniversityQueryRequest.java new file mode 100644 index 0000000..e4ab599 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/UniversityQueryRequest.java @@ -0,0 +1,43 @@ +package com.campus.dto.request; + +import lombok.Data; + +import java.util.Objects; + +@Data +public class UniversityQueryRequest { + private String province; + private String city; + private String level; + private String type; + private String keyword; + private Integer page = 1; + private Integer size = 20; + + public String toCacheKey() { + return String.join("_", + province == null ? "" : province, + city == null ? "" : city, + level == null ? "" : level, + type == null ? "" : type, + keyword == null ? "" : keyword + ); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UniversityQueryRequest that = (UniversityQueryRequest) o; + return Objects.equals(province, that.province) && + Objects.equals(city, that.city) && + Objects.equals(level, that.level) && + Objects.equals(type, that.type) && + Objects.equals(keyword, that.keyword); + } + + @Override + public int hashCode() { + return Objects.hash(province, city, level, type, keyword); + } +} diff --git a/campus-server/src/main/java/com/campus/dto/request/UpdateGroupAnnouncementRequest.java b/campus-server/src/main/java/com/campus/dto/request/UpdateGroupAnnouncementRequest.java new file mode 100644 index 0000000..3ecca7e --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/UpdateGroupAnnouncementRequest.java @@ -0,0 +1,14 @@ +package com.campus.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class UpdateGroupAnnouncementRequest { + + @NotBlank(message = "群公告不能为空") + @Size(max = 5000, message = "群公告不能超过5000个字符") + private String content; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/UpdateGroupNicknameRequest.java b/campus-server/src/main/java/com/campus/dto/request/UpdateGroupNicknameRequest.java new file mode 100644 index 0000000..d06c16f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/UpdateGroupNicknameRequest.java @@ -0,0 +1,8 @@ +package com.campus.dto.request; + +import lombok.Data; + +@Data +public class UpdateGroupNicknameRequest { + private String nickname; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/UserRestrictionRequest.java b/campus-server/src/main/java/com/campus/dto/request/UserRestrictionRequest.java new file mode 100644 index 0000000..aa2c134 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/UserRestrictionRequest.java @@ -0,0 +1,20 @@ +package com.campus.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import java.util.Date; + +@Data +public class UserRestrictionRequest { + @NotBlank(message = "restrictionType 不能为空") + private String restrictionType; + @NotBlank(message = "reason 不能为空") + private String reason; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date startsAt; + /** 为空表示永久限制。 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date expiresAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/request/VideoUploadRequest.java b/campus-server/src/main/java/com/campus/dto/request/VideoUploadRequest.java new file mode 100644 index 0000000..9e42cff --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/VideoUploadRequest.java @@ -0,0 +1,31 @@ +package com.campus.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VideoUploadRequest { + + @NotBlank(message = "视频标题不能为空") + private String title; + + private String description; + + private String tags; + + @NotBlank(message = "视频URL不能为空") + private String videoUrl; + + private String coverUrl; + + private String scope = "CAMPUS"; + + private Boolean isYouthVideo = false; +} \ No newline at end of file diff --git a/campus-server/src/main/java/com/campus/dto/request/XuexinSubmitRequest.java b/campus-server/src/main/java/com/campus/dto/request/XuexinSubmitRequest.java new file mode 100644 index 0000000..4470fa4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/request/XuexinSubmitRequest.java @@ -0,0 +1,23 @@ +package com.campus.dto.request; + +import lombok.Data; + +/** + * 学信网认证提交请求 DTO + * + * 前端在 Step 2 「确认信息」点【提交认证】时调用 /api/profile/xuexin/submit + * 只回传 PDF 识别阶段取得的 reportId。认证字段一律取服务端已落库的识别结果, + * 客户端不能覆盖。 + */ +@Data +public class XuexinSubmitRequest { + + /** + * 认证材料 ID:由 POST /api/ocr/chsi-report 返回的 reportId。 + * + *

必填。提交时后端一律以该 ID 对应的库中 PDF 识别记录为准, + * 请求体不再接收其他认证字段。缺失则返回 400「认证材料标识缺失,请重新上传报告」。

+ */ + private String reportId; + +} diff --git a/campus-server/src/main/java/com/campus/dto/response/AchievementDTO.java b/campus-server/src/main/java/com/campus/dto/response/AchievementDTO.java new file mode 100644 index 0000000..5fff612 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/AchievementDTO.java @@ -0,0 +1,14 @@ +package com.campus.dto.response; +import lombok.Data; +import com.fasterxml.jackson.annotation.JsonFormat; +import java.util.Date; +@Data +public class AchievementDTO { + private String code, name, description, categoryCode, categoryName, iconUrl; + private String colorIconUrl, grayIconUrl; + private boolean unlocked; + private long progress, target; + private int progressPercent; + @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone="GMT+8") + private Date unlockedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/AchievementOverviewDTO.java b/campus-server/src/main/java/com/campus/dto/response/AchievementOverviewDTO.java new file mode 100644 index 0000000..8897882 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/AchievementOverviewDTO.java @@ -0,0 +1,15 @@ +package com.campus.dto.response; +import lombok.Data; +import java.util.*; +@Data +public class AchievementOverviewDTO { + private String title, nextTitle; + private int unlockedCount, lockedCount, totalCount, completionPercent; + private List categories = new ArrayList<>(); + @Data + public static class Category { + private String code, name; + private int unlockedCount, totalCount; + private List achievements = new ArrayList<>(); + } +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ActivityDTO.java b/campus-server/src/main/java/com/campus/dto/response/ActivityDTO.java new file mode 100644 index 0000000..c36ed20 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ActivityDTO.java @@ -0,0 +1,82 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +@Data +public class ActivityDTO { + private String id; + private String title; + private String description; + private String type; + /** 与 type 相同的英文标准分类编码,兼容使用 activityType 的客户端。 */ + private String activityType; + /** 与 type 相同的英文标准分类编码,供按编码筛选。 */ + private String categoryCode; + /** 英文兼容分类编码;运动类对旧客户端返回 sport。 */ + private String category; + private String typeLabel; + private String categoryLabel; + private String location; + private Double latitude; + private Double longitude; + private Double distanceMeters; + 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 registrationStartTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date 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; + @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; + private Integer currentCount; + private Integer maxCount; + private String creatorId; + private String creatorName; + private String creatorAvatar; + private List tags; + private Integer heat; + private Integer viewCount; + private Double dynamicScore; + private String status; + private String reviewStatus; + private String reviewReason; + private String scope; + private String coverImage; + private String groupId; + private String groupEasemobId; + private Boolean isJoined; + /** + * 当前用户在本次活动中的报名状态:PENDING-待审核, CONFIRMED-已通过, REJECTED-已拒绝, + * REMOVED-已移出, CANCELLED-已取消;未报名为 null。 + */ + private String myParticipantStatus; + /** 当前用户的报名记录 ID,供「审核中」状态撤回申请使用。 */ + private String myParticipantId; + /** 被拒绝或被移出时的审核意见。 */ + private String myReviewReason; + /** 当前用户在该活动上累计被拒绝的次数(用于展示剩余额度)。 */ + private Integer myRejectCount; + /** 当前用户在该活动上累计撤回的次数(用于展示剩余额度)。 */ + private Integer myCancelCount; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ActivityLocationDTO.java b/campus-server/src/main/java/com/campus/dto/response/ActivityLocationDTO.java new file mode 100644 index 0000000..47f0480 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ActivityLocationDTO.java @@ -0,0 +1,26 @@ +package com.campus.dto.response; + +import lombok.Data; +import java.util.List; + +@Data +public class ActivityLocationDTO { + private String resolveId; + private String coordinateSystem = "GCJ02"; + private List candidates; + private String locationToken; + private String location; + private Double latitude; + private Double longitude; + private int expiresInSeconds; + + @Data + public static class Candidate { + private String candidateId; + private String name; + private String address; + private Double latitude; + private Double longitude; + private String precision; + } +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ActivityParticipantDTO.java b/campus-server/src/main/java/com/campus/dto/response/ActivityParticipantDTO.java new file mode 100644 index 0000000..5729988 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ActivityParticipantDTO.java @@ -0,0 +1,26 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class ActivityParticipantDTO { + private String id; + private String activityId; + private String userId; + private String userName; + private String userAvatar; + private String status; + private String reviewReason; + /** 报名申请次数(含被拒后重新申请),供活动创建者判断是否反复申请。 */ + private Integer applyCount; + /** 累计被拒绝次数(额度用尽后不能再申请)。 */ + private Integer rejectCount; + /** 累计撤回次数(额度用尽后不能再申请)。 */ + private Integer cancelCount; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date joinedAt; + private String remark; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ActivityPaymentOrderDTO.java b/campus-server/src/main/java/com/campus/dto/response/ActivityPaymentOrderDTO.java new file mode 100644 index 0000000..67446f5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ActivityPaymentOrderDTO.java @@ -0,0 +1,21 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +public class ActivityPaymentOrderDTO { + private String id; + private String registrationId; + private String userId; + private BigDecimal amount; + private String status; + private String provider; + private Boolean paymentRequired; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ActivityRegistrationDTO.java b/campus-server/src/main/java/com/campus/dto/response/ActivityRegistrationDTO.java new file mode 100644 index 0000000..a48b57b --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ActivityRegistrationDTO.java @@ -0,0 +1,32 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +public class ActivityRegistrationDTO { + private String id; + private String activityId; + private String ticketId; + private String userId; + private Integer quantity; + private BigDecimal totalAmount; + private String attendeeName; + private String attendeePhone; + private String universityName; + private String emergencyContact; + private String emergencyPhone; + private String status; + private String paymentStatus; + private String reviewStatus; + private String reviewReason; + private Boolean checkedIn; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date checkedInAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ActivityTicketDTO.java b/campus-server/src/main/java/com/campus/dto/response/ActivityTicketDTO.java new file mode 100644 index 0000000..a032b62 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ActivityTicketDTO.java @@ -0,0 +1,19 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class ActivityTicketDTO { + private String id; + private String activityId; + private String name; + private String description; + private BigDecimal price; + private Integer capacity; + private Integer soldCount; + private Integer remainingCount; + private Integer sortOrder; + private Boolean enabled; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/AdminAuditLogDTO.java b/campus-server/src/main/java/com/campus/dto/response/AdminAuditLogDTO.java new file mode 100644 index 0000000..ec66900 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/AdminAuditLogDTO.java @@ -0,0 +1,19 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.Date; + +@Data +public class AdminAuditLogDTO { + private String id; + private String operatorId; + private String operatorName; + private String action; + private String targetType; + private String targetId; + private String beforeSummary; + private String afterSummary; + private String remark; + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/AdminUserDTO.java b/campus-server/src/main/java/com/campus/dto/response/AdminUserDTO.java new file mode 100644 index 0000000..22766e9 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/AdminUserDTO.java @@ -0,0 +1,25 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class AdminUserDTO { + private String id; + private String number; + private String nickname; + private String phone; + private String avatar; + private String universityName; + private String majorName; + private Boolean xuexinCertified; + private Boolean isReviewer; + private Boolean isAdmin; + private String accountStatus; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/AnnouncementDTO.java b/campus-server/src/main/java/com/campus/dto/response/AnnouncementDTO.java new file mode 100644 index 0000000..ff6203b --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/AnnouncementDTO.java @@ -0,0 +1,40 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +@Data +public class AnnouncementDTO { + private String id; + private String type; + private String title; + private String subtitle; + private String description; + private String content; + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date eventDate; + private String eventTime; + private String location; + private String organizer; + private Integer capacity; + private String coverImage; + private List images; + private String status; + private String reviewReason; + /** 发布范围类型:all-全部学校,specified-指定学校 */ + private String scopeType; + /** 指定学校ID列表 */ + private List universityIds; + private Integer viewCount; + private Integer registeredCount; + private String creatorId; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date scheduledPublishAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/AreaDTO.java b/campus-server/src/main/java/com/campus/dto/response/AreaDTO.java new file mode 100644 index 0000000..ea46f5f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/AreaDTO.java @@ -0,0 +1,23 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.List; + +@Data +public class AreaDTO { + private Integer areaId; + private Integer areAreaId; + private String name; + private String shortName; + private String code; + private Integer hot; + private Integer isdel; + private Integer level; + private String lng; + private String lat; + private String letter; + private String pinyin; + private String firstChar; + private List children; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ChatMessageDTO.java b/campus-server/src/main/java/com/campus/dto/response/ChatMessageDTO.java new file mode 100644 index 0000000..6751f6e --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ChatMessageDTO.java @@ -0,0 +1,33 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class ChatMessageDTO { + /** 全局递增序列号,用于增量同步 */ + private Long seqId; + /** 业务UUID */ + private String id; + private String conversationId; + /** 私聊对端用户ID;群聊时等于群组ID。 */ + private String peerId; + /** PRIVATE / GROUP */ + private String conversationType; + private String senderId; + /** 群内显示昵称;仅群聊消息返回,优先使用群成员昵称。 */ + private String senderGroupNickname; + private String content; + private String messageType; + /** messageType=SHARE_CARD 时返回结构化卡片,其余消息为空。 */ + private ShareCardDTO shareCard; + private String mediaUrl; + private Integer duration; + private Boolean isRead; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date readAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ChsiReportDTO.java b/campus-server/src/main/java/com/campus/dto/response/ChsiReportDTO.java new file mode 100644 index 0000000..d9fadf0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ChsiReportDTO.java @@ -0,0 +1,126 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +import java.util.List; + +/** + * 学信网报告OCR识别结果DTO + */ +@Data +public class ChsiReportDTO { + + /** 报告名称,当前只接受“教育部学籍在线验证报告” */ + private String reportName; + + /** 报告更新日期,格式 yyyy-MM-dd */ + private String reportUpdatedDate; + + /** + * 姓名 + */ + private String name; + + /** + * 性别 + */ + private String gender; + + /** + * 出生日期(格式:yyyy-MM-dd) + */ + private String birthDate; + + /** 民族 */ + private String ethnicity; + + /** + * 学校/院校名称 + */ + private String school; + + /** + * 层次(本科、专科、研究生等) + */ + private String level; + + /** + * 分院/校区 + */ + private String branchCampus; + + /** + * 系所 + */ + private String department; + + /** + * 专业 + */ + private String major; + + /** + * 学制 + */ + private String studyDuration; + + /** 学历类别 */ + private String educationCategory; + + /** 学习形式 */ + private String studyForm; + + /** + * 入学日期(格式:yyyy-MM-dd) + */ + private String enrollmentDate; + + /** + * 离校日期(格式:yyyy-MM-dd) + */ + private String departureDate; + + /** + * 学籍状态(在籍、已毕业等) + */ + private String status; + + /** + * 在线验证码 + */ + private String onlineVerificationCode; + + /** + * PDF文本提取或OCR识别出的原始文本行,仅服务端落库存证,不返回客户端。 + */ + @JsonIgnore + private List rawLines; + + /** + * OCR识别是否成功 + */ + private boolean success; + + /** + * 是否判定为合法的学信网报告(标题/CHSI/学信网关键字 + 关键信息提取完整) + */ + private boolean validChsiReport; + + /** + * 识别失败时的错误信息 + */ + private String errorMsg; + + /** + * 落库后的认证材料 ID(xuexin_report.id),Step2 提交时回传。 + * 仅当 success=true 且 validChsiReport=true 时有值,否则为 null。 + */ + private String reportId; + + /** + * 判重结果:Step1 就能告诉用户「该报告已被认证」,不必等 Step2。 + * 恒不为 null。 + */ + private XuexinDedupDTO dedup; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/CollegeDTO.java b/campus-server/src/main/java/com/campus/dto/response/CollegeDTO.java new file mode 100644 index 0000000..8ebae72 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/CollegeDTO.java @@ -0,0 +1,20 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class CollegeDTO { + private String id; + private String universityId; + private String universityName; + private String name; + private String shortName; + private String description; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ContentAppealDTO.java b/campus-server/src/main/java/com/campus/dto/response/ContentAppealDTO.java new file mode 100644 index 0000000..e85dfe7 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ContentAppealDTO.java @@ -0,0 +1,18 @@ +package com.campus.dto.response; + +import lombok.Data; +import java.util.Date; + +@Data +public class ContentAppealDTO { + private String id; + private String userId; + private String contentType; + private String contentId; + private String reason; + private String status; + private String reviewedBy; + private String reviewNote; + private Date reviewedAt; + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ContentReviewDTO.java b/campus-server/src/main/java/com/campus/dto/response/ContentReviewDTO.java new file mode 100644 index 0000000..f8f2793 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ContentReviewDTO.java @@ -0,0 +1,18 @@ +package com.campus.dto.response; + +import lombok.Data; +import java.util.Date; + +@Data +public class ContentReviewDTO { + private String id; + private String contentType; + private String contentId; + private String status; + private String reason; + private Integer score; + private String reviewedBy; + private Date reviewedAt; + private String reviewType; + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/DictItemDTO.java b/campus-server/src/main/java/com/campus/dto/response/DictItemDTO.java new file mode 100644 index 0000000..73d3e88 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/DictItemDTO.java @@ -0,0 +1,20 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** + * 字典项 DTO(供前端下拉使用) + */ +@Data +public class DictItemDTO { + /** 项值 */ + private String value; + /** 显示文本 */ + private String label; + /** 颜色 */ + private String color; + /** 图标 */ + private String icon; + /** 扩展字段(JSON 字符串) */ + private String extra; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/EasemobConfigDTO.java b/campus-server/src/main/java/com/campus/dto/response/EasemobConfigDTO.java new file mode 100644 index 0000000..cdc0c81 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/EasemobConfigDTO.java @@ -0,0 +1,13 @@ +package com.campus.dto.response; + +import lombok.Data; + +@Data +public class EasemobConfigDTO { + + private String appKey; + + private String username; + + private String password; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/FileUploadResponse.java b/campus-server/src/main/java/com/campus/dto/response/FileUploadResponse.java new file mode 100644 index 0000000..55b8462 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/FileUploadResponse.java @@ -0,0 +1,27 @@ +package com.campus.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FileUploadResponse { + + /** 文件访问URL */ + private String url; + + /** 文件名 */ + private String fileName; + + /** 文件大小 */ + private Long fileSize; + + /** 文件类型 */ + private String fileType; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/FollowUserDTO.java b/campus-server/src/main/java/com/campus/dto/response/FollowUserDTO.java new file mode 100644 index 0000000..9119e74 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/FollowUserDTO.java @@ -0,0 +1,29 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class FollowUserDTO { + private String id; + private String number; + private String nickname; + private String avatar; + private String gender; + private Integer age; + private String universityName; + private String majorName; + private String grade; + private String bio; + private Integer followersCount; + private Integer followingCount; + private Boolean isFollowed; + private Boolean isMutual; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date followedAt; + + /** 学信网认证是否已通过 */ + private Boolean xuexinCertified; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/FriendDTO.java b/campus-server/src/main/java/com/campus/dto/response/FriendDTO.java new file mode 100644 index 0000000..7532adc --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/FriendDTO.java @@ -0,0 +1,45 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.Date; +import java.util.List; + +/** + * 好友卡片(推荐/搜索/筛选/列表通用) + */ +@Data +public class FriendDTO { + private String id; + private String number; + private String nickname; + private String avatar; + private String gender; + private Integer age; + private String universityName; + private String majorName; + private String grade; + private List interests; + private String bio; + private String hometown; + private String location; + private String education; + + /** 关系状态:NONE / SENT / RECEIVED / ACCEPTED / REJECTED */ + private String relationStatus; + + /** 匹配度(仅推荐场景)0~100 */ + private Integer matchScore; + + /** 匹配命中理由(仅推荐场景) */ + private List matchReasons; + + /** 学信网认证是否已通过 */ + private Boolean xuexinCertified; + + /** 当前用户是否已向该用户打过招呼 */ + private Boolean greeted; + + /** 心动匹配行为:LIKE / SUPER_LIKE / PASS。 */ + private String matchAction; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/GiftDTO.java b/campus-server/src/main/java/com/campus/dto/response/GiftDTO.java new file mode 100644 index 0000000..8ae6e9b --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/GiftDTO.java @@ -0,0 +1,20 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** + * 礼物DTO + */ +@Data +public class GiftDTO { + + private String id; + + private String name; + + private String iconUrl; + + private Integer points; + + private Integer value; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/GiftRecordDTO.java b/campus-server/src/main/java/com/campus/dto/response/GiftRecordDTO.java new file mode 100644 index 0000000..f7e3dc8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/GiftRecordDTO.java @@ -0,0 +1,44 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 礼物记录DTO + */ +@Data +public class GiftRecordDTO { + + private String id; + + private String senderId; + + private String senderNickname; + + private String senderAvatar; + + private String receiverId; + + private String receiverNickname; + + private String receiverAvatar; + + private String giftId; + + private String giftName; + + private String giftIcon; + + private Integer points; + + private Integer count; + + private String momentId; + + private String message; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/GroupAnnouncementDTO.java b/campus-server/src/main/java/com/campus/dto/response/GroupAnnouncementDTO.java new file mode 100644 index 0000000..c68236f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/GroupAnnouncementDTO.java @@ -0,0 +1,27 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class GroupAnnouncementDTO { + + private String groupId; + private String content; + private PublisherDTO publisher; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date publishedAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; + + @Data + public static class PublisherDTO { + private String id; + private String nickname; + private String avatar; + } +} diff --git a/campus-server/src/main/java/com/campus/dto/response/GroupInvitationDTO.java b/campus-server/src/main/java/com/campus/dto/response/GroupInvitationDTO.java new file mode 100644 index 0000000..378209b --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/GroupInvitationDTO.java @@ -0,0 +1,44 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 群聊邀请(前端用于渲染「群邀请」列表卡片与通知详情)。 + * + *

群信息与邀请人信息是冗余展开的:邀请列表页需要直接画出群头像/群名/邀请人头像, + * 不能让前端为每条邀请再发一次请求。 + */ +@Data +public class GroupInvitationDTO { + + private String id; + private String groupId; + private String groupName; + private String groupAvatar; + private Integer groupMemberCount; + + private String inviterId; + private String inviterNickname; + private String inviterAvatar; + + private String inviteeId; + private String inviteeNickname; + private String inviteeAvatar; + + /** PENDING / ACCEPTED / CANCELLED / EXPIRED(REJECTED 预留,当前不产生)。 */ + private String status; + + private String message; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date expireAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date respondedAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/GroupInviteResult.java b/campus-server/src/main/java/com/campus/dto/response/GroupInviteResult.java new file mode 100644 index 0000000..2107970 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/GroupInviteResult.java @@ -0,0 +1,44 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 批量发起群邀请的结果。 + * + *

既有成功又有失败:邀请是「逐人独立」的操作,一个用户已在群内不应该让其余人邀请失败, + * 因此这里返回部分成功语义,前端按人提示。 + */ +@Data +public class GroupInviteResult { + + /** 成功发出的邀请。 */ + private List invited = new ArrayList<>(); + + /** 未能发出邀请的用户及原始中文原因,前端直接展示 reason。 */ + private List failed = new ArrayList<>(); + + public void addInvited(GroupInvitationDTO dto) { + this.invited.add(dto); + } + + public void addFailure(String userId, String reason) { + this.failed.add(new Failure(userId, reason)); + } + + @Data + public static class Failure { + private String userId; + private String reason; + + public Failure() { + } + + public Failure(String userId, String reason) { + this.userId = userId; + this.reason = reason; + } + } +} diff --git a/campus-server/src/main/java/com/campus/dto/response/HomeOverviewDTO.java b/campus-server/src/main/java/com/campus/dto/response/HomeOverviewDTO.java new file mode 100644 index 0000000..d1df45e --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/HomeOverviewDTO.java @@ -0,0 +1,17 @@ +package com.campus.dto.response; + +import com.campus.common.PageResult; +import lombok.Data; + +import java.util.List; + +/** 首页首屏聚合数据。各列表仍保留独立接口作为分页和刷新入口。 */ +@Data +public class HomeOverviewDTO { + private UserProfileDTO profile; + private PointsAccountDTO points; + private UnreadDetailDTO unread; + private List recommendedFriends; + private PageResult activities; + private PageResult moments; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/InteractionLikeStateDTO.java b/campus-server/src/main/java/com/campus/dto/response/InteractionLikeStateDTO.java new file mode 100644 index 0000000..de39c54 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/InteractionLikeStateDTO.java @@ -0,0 +1,10 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** 点赞写操作返回的事务内状态快照。 */ +@Data +public class InteractionLikeStateDTO { + private Boolean liked; + private Long likeCount; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/LoginResponse.java b/campus-server/src/main/java/com/campus/dto/response/LoginResponse.java new file mode 100644 index 0000000..d570aca --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/LoginResponse.java @@ -0,0 +1,69 @@ +package com.campus.dto.response; + +import com.campus.entity.User; +import lombok.Data; + +import java.util.List; + +@Data +public class LoginResponse { + + private String token; + + private UserInfo user; + + @Data + public static class UserInfo { + private String id; + private String number; + private String nickname; + private String avatar; + private String gender; + private String universityName; + private String majorName; + private String grade; + private String phone; + private String education; + private String hometown; + private String location; + private String bio; + private List interests; + private Boolean isReviewer; + private Boolean isAdmin; + private String accountStatus; + /** 学信网认证是否已通过 */ + private Boolean xuexinCertified; + } + + public static UserInfo toUserInfo(User user) { + if (user == null) { + return null; + } + UserInfo info = new UserInfo(); + info.setId(user.getId()); + info.setNumber(user.getNumber()); + String nickname = user.getNickname(); + if (nickname == null || nickname.trim().isEmpty()) { + nickname = user.getPhone() != null && user.getPhone().length() >= 4 + ? "用户" + user.getPhone().substring(user.getPhone().length() - 4) + : "青核用户"; + } + info.setNickname(nickname); + info.setAvatar(user.getAvatar()); + info.setGender(user.getGender()); + info.setUniversityName(user.getUniversityName()); + info.setMajorName(user.getMajorName()); + info.setGrade(user.getGrade()); + info.setPhone(user.getPhone()); + info.setEducation(user.getEducation()); + info.setHometown(user.getHometown()); + info.setLocation(user.getLocation()); + info.setBio(user.getBio()); + info.setInterests(user.getInterests()); + info.setIsReviewer(user.getIsReviewer()); + info.setIsAdmin(user.getIsAdmin()); + info.setAccountStatus(user.getAccountStatus()); + info.setXuexinCertified(user.getXuexinCertified()); + return info; + } +} diff --git a/campus-server/src/main/java/com/campus/dto/response/MajorDTO.java b/campus-server/src/main/java/com/campus/dto/response/MajorDTO.java new file mode 100644 index 0000000..9b3d007 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/MajorDTO.java @@ -0,0 +1,24 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class MajorDTO { + private String id; + private String collegeId; + private String collegeName; + private String universityId; + private String universityName; + private String name; + private String shortName; + private String degreeType; + private String level; + private String description; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/MatchActionDTO.java b/campus-server/src/main/java/com/campus/dto/response/MatchActionDTO.java new file mode 100644 index 0000000..f034708 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/MatchActionDTO.java @@ -0,0 +1,12 @@ +package com.campus.dto.response; + +import lombok.Data; + +@Data +public class MatchActionDTO { + private String actionId; + private String targetUserId; + private String action; + private Boolean matched; + private String pairId; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/MatchPairDTO.java b/campus-server/src/main/java/com/campus/dto/response/MatchPairDTO.java new file mode 100644 index 0000000..d983833 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/MatchPairDTO.java @@ -0,0 +1,19 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class MatchPairDTO { + private String pairId; + private String userId; + private String nickname; + private String avatar; + private String universityName; + private String majorName; + private String status; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date matchedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/MatchPreferenceDTO.java b/campus-server/src/main/java/com/campus/dto/response/MatchPreferenceDTO.java new file mode 100644 index 0000000..9ab0270 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/MatchPreferenceDTO.java @@ -0,0 +1,12 @@ +package com.campus.dto.response; + +import lombok.Data; + +@Data +public class MatchPreferenceDTO { + private String userId; + private String preferredGender; + private Integer minAge; + private Integer maxAge; + private Boolean sameUniversityOnly; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/MomentCommentDTO.java b/campus-server/src/main/java/com/campus/dto/response/MomentCommentDTO.java new file mode 100644 index 0000000..cfffaff --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/MomentCommentDTO.java @@ -0,0 +1,31 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +/** + * 评论响应DTO + */ +@Data +public class MomentCommentDTO { + private String id; + private String momentId; + private String userId; + /** 当前登录用户是否为作者,服务端计算。 */ + private Boolean ownedByCurrentUser; + private Boolean isAnonymousAuthor; + private String userName; + private String userAvatar; + private String parentId; // 回复的评论ID,NULL表示一级评论 + private String parentNickname; // 回复的用户昵称 + private String content; + private Integer likeCount; + private Boolean isLiked; // 当前用户是否点赞 + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + private List replies; // 回复列表(只显示前几条) + private Integer replyCount; // 回复数量 +} diff --git a/campus-server/src/main/java/com/campus/dto/response/MomentDTO.java b/campus-server/src/main/java/com/campus/dto/response/MomentDTO.java new file mode 100644 index 0000000..7b62d58 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/MomentDTO.java @@ -0,0 +1,109 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +@Data +public class MomentDTO { + private String id; + /** 发布者用户ID */ + private String userId; + /** 当前登录用户是否为作者,服务端计算。 */ + private Boolean ownedByCurrentUser; + + /** + * 内容来源:{@code FEED}-动态(只出现在动态流)/ {@code TOPIC}-话题讨论(只出现在所属话题页)。 + * 前端用它区分「我的收藏」里的内容类型。 + */ + private String contentSource; + + /** 话题讨论所属话题ID;普通动态为 null。 */ + private String topicId; + + /** 话题讨论所属话题名称(后端批量补齐);普通动态为 null。注意不要用 tags 反推。 */ + private String topicName; + private String content; + private List mediaUrls; + private List tags; + private String category; + private String scope; + private String province; + private String city; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date expiresAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date scheduledPublishAt; + private Integer viewCount; + private Integer likeCount; + private Long favoriteCount; + private Integer commentCount; + private String reviewStatus; + private String reviewReason; + private Boolean isYouthVideo; + private Boolean anonymous; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; + + /** 视频原始URL */ + private String videoOriginalUrl; + + /** 视频封面URL */ + private String videoCoverUrl; + + /** VOD 视频ID */ + private String vodVideoId; + + /** CDN播放地址(m3u8) */ + private String playUrl; + + /** AI审核状态 */ + private String auditStatus; + + /** 视频时长(秒) */ + private Integer videoDuration; + + /** 视频文件大小(字节) */ + private Long videoFileSize; + + /** 视频宽度 */ + private Integer videoWidth; + + /** 视频高度 */ + private Integer videoHeight; + + /** + * 视频就绪状态(**派生字段,不落库**): + * {@code READY}-可播放 / {@code PROCESSING}-转码中 / {@code FAILED}-超时未就绪 / + * {@code REJECTED}-VOD 审核判违规 / {@code null}-不是视频内容。 + * + *

前端据此决定渲染播放器、占位还是提示失败。注意 {@code REJECTED} 只表示 + * "VOD 说违规",后端**不会**自动下架(见实现方案 §15),是否隐藏由前端决定。

+ */ + private String videoStatus; + + /** 分享次数 */ + private Integer shareCount; + + /** 发布者昵称 */ + private String userName; + + /** 发布者头像 */ + private String userAvatar; + + /** 当前用户是否已点赞 */ + private Boolean isLiked; + + /** 当前用户是否已收藏 */ + private Boolean isFavorited; + + /** 当前用户是否已关注发布者 */ + private Boolean isFollowed; + + /** 视频播放进度 */ + private Integer playPosition; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/MomentLikeUserDTO.java b/campus-server/src/main/java/com/campus/dto/response/MomentLikeUserDTO.java new file mode 100644 index 0000000..0a3cda4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/MomentLikeUserDTO.java @@ -0,0 +1,19 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 点赞用户信息(用于发布者查看) + */ +@Data +public class MomentLikeUserDTO { + private String id; + private String userId; + private String userName; + private String userAvatar; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date likedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/MyTopicsDTO.java b/campus-server/src/main/java/com/campus/dto/response/MyTopicsDTO.java new file mode 100644 index 0000000..e734b2a --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/MyTopicsDTO.java @@ -0,0 +1,19 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.List; + +/** 「我的话题」聚合结果。只包含话题域数据,不再包含由动态标签反推的"参与"。 */ +@Data +public class MyTopicsDTO { + private long followingCount; + private long createdCount; + /** 收藏的可见话题数(只统计广场可见话题,与 followingCount 口径一致)。 */ + private long favoriteCount; + private List recentVisits; + private List following; + /** 我收藏的可见话题,按收藏时间倒序,最多 20 条。 */ + private List favorites; + private List created; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/NotificationDTO.java b/campus-server/src/main/java/com/campus/dto/response/NotificationDTO.java new file mode 100644 index 0000000..e550a1f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/NotificationDTO.java @@ -0,0 +1,22 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +@Data +public class NotificationDTO { + private String id; + private String userId; + private String type; + private String title; + private String content; + private Boolean isRead; + private String avatar; + private String link; + private String relatedId; + private String senderId; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/PageResult.java b/campus-server/src/main/java/com/campus/dto/response/PageResult.java new file mode 100644 index 0000000..61fb691 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/PageResult.java @@ -0,0 +1,23 @@ +package com.campus.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PageResult { + private List items; + private long total; + private int page; + private int size; + private int totalPages; + + public static PageResult of(List items, long total, int page, int size) { + int totalPages = (int) Math.ceil((double) total / size); + return new PageResult<>(items, total, page, size, totalPages); + } +} diff --git a/campus-server/src/main/java/com/campus/dto/response/PointRuleDTO.java b/campus-server/src/main/java/com/campus/dto/response/PointRuleDTO.java new file mode 100644 index 0000000..0fecf9d --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/PointRuleDTO.java @@ -0,0 +1,26 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** + * 积分规则DTO + */ +@Data +public class PointRuleDTO { + + private String code; + + private String name; + + private String description; + + private String type; + + private Integer points; + + private Integer dailyLimit; + + private Integer totalLimit; + + private Boolean enabled; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/PointTransactionDTO.java b/campus-server/src/main/java/com/campus/dto/response/PointTransactionDTO.java new file mode 100644 index 0000000..709499f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/PointTransactionDTO.java @@ -0,0 +1,43 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 积分变动记录DTO + */ +@Data +public class PointTransactionDTO { + + private String id; + + /** 规则编码 */ + private String ruleCode; + + /** 规则名称 */ + private String ruleName; + + /** 变动类型 */ + private String type; + + /** 变动积分数量 */ + private Integer points; + + /** 变动后积分余额 */ + private Integer balanceAfter; + + /** 关联类型 */ + private String relatedType; + + /** 关联ID */ + private String relatedId; + + /** 变动时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + + /** 备注 */ + private String remark; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/PointsAccountDTO.java b/campus-server/src/main/java/com/campus/dto/response/PointsAccountDTO.java new file mode 100644 index 0000000..f687974 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/PointsAccountDTO.java @@ -0,0 +1,46 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** + * 积分账户响应DTO + */ +@Data +public class PointsAccountDTO { + + /** 用户ID */ + private String userId; + + /** 当前积分 */ + private Integer points; + + /** 累计获得积分 */ + private Integer totalEarned; + + /** 累计消耗积分 */ + private Integer totalSpent; + + /** 用户等级 */ + private Integer level; + + /** 经验值 */ + private Integer experience; + + /** 距离下一级还需要多少经验 */ + private Integer experienceToNextLevel; + + /** 累计签到天数 */ + private Integer totalCheckInDays; + + /** 连续签到天数 */ + private Integer consecutiveCheckInDays; + + /** 今日是否已签到 */ + private Boolean checkedInToday; + + /** 今日获得积分 */ + private Integer todayPoints; + + /** 积分排行榜排名 */ + private Integer rank; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/PrivacySettingsDTO.java b/campus-server/src/main/java/com/campus/dto/response/PrivacySettingsDTO.java new file mode 100644 index 0000000..11d8f1f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/PrivacySettingsDTO.java @@ -0,0 +1,13 @@ +package com.campus.dto.response; + +import lombok.Data; + +@Data +public class PrivacySettingsDTO { + private String userId; + private Boolean profileVisible; + private Boolean locationVisible; + private Boolean onlineStatusVisible; + private Boolean allowStrangerFriendRequest; + private Boolean allowStrangerMessage; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/QrScanResultDTO.java b/campus-server/src/main/java/com/campus/dto/response/QrScanResultDTO.java new file mode 100644 index 0000000..69d0b66 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/QrScanResultDTO.java @@ -0,0 +1,15 @@ +package com.campus.dto.response; + +import lombok.Data; + +@Data +public class QrScanResultDTO { + /** USER/ACTIVITY/UNKNOWN。 */ + private String type; + private String targetId; + private String originalText; + /** 前端确认后可跳转的业务路由。 */ + private String route; + private UserProfileDTO user; + private ActivityDTO activity; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/RadarDTO.java b/campus-server/src/main/java/com/campus/dto/response/RadarDTO.java new file mode 100644 index 0000000..39a9fa6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/RadarDTO.java @@ -0,0 +1,70 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.List; + +/** + * 雷达扫描结果DTO + */ +@Data +public class RadarDTO { + + /** 附近用户列表 */ + private List nearbyUsers; + + /** 附近活动列表 */ + private List nearbyActivities; + + /** + * 雷达用户DTO + */ + @Data + public static class RadarUserDTO { + private String id; + private String nickname; + private String avatar; + private String school; + private String grade; + private String major; + /** 距离(米) */ + private Integer distance; + /** 方向角度(0-360,0=北,90=东,180=南,270=西) */ + private Integer direction; + /** 最后活跃时间描述 */ + private String lastActive; + /** 兴趣爱好 */ + private List interests; + /** 是否在线 */ + private Boolean isOnline; + /** 是否已是好友 */ + private Boolean isFriend; + /** 是否已打过招呼 */ + private Boolean greeted; + } + + /** + * 雷达活动DTO + */ + @Data + public static class RadarActivityDTO { + private Double latitude; + private Double longitude; + private String coordinateSystem; + private String id; + private String title; + private String type; + /** 距离(米) */ + private Integer distance; + /** 方向角度 */ + private Integer direction; + /** 当前参与人数 */ + private Integer currentCount; + /** 最大参与人数 */ + private Integer maxCount; + /** 活动时间描述 */ + private String time; + /** 活动地点 */ + private String location; + } +} diff --git a/campus-server/src/main/java/com/campus/dto/response/RecommendBatchDTO.java b/campus-server/src/main/java/com/campus/dto/response/RecommendBatchDTO.java new file mode 100644 index 0000000..3007832 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/RecommendBatchDTO.java @@ -0,0 +1,24 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.List; + +/** + * 好友推荐批次结果(含换一批所需的状态信息) + */ +@Data +public class RecommendBatchDTO { + + /** 本批次的推荐列表 */ + private List list; + + /** 批次令牌(客户端原样回传即可,服务端通过 userId 索引 Redis 状态) */ + private String batchToken; + + /** 是否还有更多候选可换(false 表示池已耗尽,再换会触发重置) */ + private boolean hasMore; + + /** 候选池总大小 */ + private int total; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ReportDTO.java b/campus-server/src/main/java/com/campus/dto/response/ReportDTO.java new file mode 100644 index 0000000..12ccc29 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ReportDTO.java @@ -0,0 +1,27 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** 举报记录响应。 */ +@Data +public class ReportDTO { + private String id; + private String reporterId; + private String targetType; + private String targetId; + private String reason; + private String description; + private String status; + private String action; + private String reviewedBy; + private String reviewNote; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date reviewedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ShareCardDTO.java b/campus-server/src/main/java/com/campus/dto/response/ShareCardDTO.java new file mode 100644 index 0000000..b2ad881 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ShareCardDTO.java @@ -0,0 +1,20 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** 站内分享卡片。卡片字段只由服务端按业务 ID 生成,客户端不得自行构造。 */ +@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) +@Data +public class ShareCardDTO { + + /** 支持 MOMENT、TOPIC、ACTIVITY、ANNOUNCEMENT、BADGE、USER。 */ + private String targetType; + + private String targetId; + + private String title; + + private String summary; + + private String coverUrl; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ShareDestinationResultDTO.java b/campus-server/src/main/java/com/campus/dto/response/ShareDestinationResultDTO.java new file mode 100644 index 0000000..7e38b38 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ShareDestinationResultDTO.java @@ -0,0 +1,21 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** 单个好友或群聊的分享投递结果。 */ +@Data +public class ShareDestinationResultDTO { + + /** PRIVATE / GROUP */ + private String destinationType; + + private String destinationId; + + /** SENT / FAILED */ + private String status; + + private String messageId; + + /** 失败时给前端直接展示;成功时为空。 */ + private String message; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/ShareSendResultDTO.java b/campus-server/src/main/java/com/campus/dto/response/ShareSendResultDTO.java new file mode 100644 index 0000000..cfb7bf8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/ShareSendResultDTO.java @@ -0,0 +1,22 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.List; + +/** 批量分享结果。群聊分享也是只有一个目标的批次。 */ +@Data +public class ShareSendResultDTO { + + private ShareCardDTO card; + + private Integer requestedCount; + + private Integer sentCount; + + private Integer failedCount; + + private Boolean partialSuccess; + + private List results; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/SocialGraphDTO.java b/campus-server/src/main/java/com/campus/dto/response/SocialGraphDTO.java new file mode 100644 index 0000000..2a2570d --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/SocialGraphDTO.java @@ -0,0 +1,18 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.List; + +@Data +public class SocialGraphDTO { + private List nodes; + private List edges; + private Integer userCount; + private Integer groupCount; + private Integer activityCount; + private Integer friendCount; + private Integer followingCount; + private Integer followerCount; + private Integer matchedCount; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/SocialGraphEdgeDTO.java b/campus-server/src/main/java/com/campus/dto/response/SocialGraphEdgeDTO.java new file mode 100644 index 0000000..7ed4a5d --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/SocialGraphEdgeDTO.java @@ -0,0 +1,11 @@ +package com.campus.dto.response; + +import lombok.Data; + +@Data +public class SocialGraphEdgeDTO { + private String sourceId; + private String targetId; + /** FRIEND/FOLLOW/GROUP_MEMBER/PARTICIPATE/MATCHED. */ + private String relation; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/SocialGraphNodeDTO.java b/campus-server/src/main/java/com/campus/dto/response/SocialGraphNodeDTO.java new file mode 100644 index 0000000..eec490b --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/SocialGraphNodeDTO.java @@ -0,0 +1,14 @@ +package com.campus.dto.response; + +import lombok.Data; + +@Data +public class SocialGraphNodeDTO { + private String id; + /** USER/GROUP/ACTIVITY。 */ + private String type; + private String name; + private String avatar; + private String relation; + private String universityName; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/StsTokenResponse.java b/campus-server/src/main/java/com/campus/dto/response/StsTokenResponse.java new file mode 100644 index 0000000..1c3e214 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/StsTokenResponse.java @@ -0,0 +1,34 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** + * STS 临时凭证响应 + */ +@Data +public class StsTokenResponse { + + /** 临时 AccessKeyId */ + private String accessKeyId; + + /** 临时 AccessKeySecret */ + private String accessKeySecret; + + /** 安全 Token */ + private String securityToken; + + /** 凭证过期时间 */ + private String expiration; + + /** OSS Bucket 名称 */ + private String bucketName; + + /** OSS Endpoint (HTTPS) */ + private String endpoint; + + /** 上传路径前缀 */ + private String uploadPrefix; + + /** OSS 自定义域名(CDN) */ + private String domain; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/SysDictDTO.java b/campus-server/src/main/java/com/campus/dto/response/SysDictDTO.java new file mode 100644 index 0000000..07fa6e0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/SysDictDTO.java @@ -0,0 +1,19 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.Date; + +/** + * 字典主表 DTO(管理后台用) + */ +@Data +public class SysDictDTO { + private String id; + private String code; + private String name; + private String description; + private Boolean status; + private Date createdAt; + private Date updatedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/SysDictItemDTO.java b/campus-server/src/main/java/com/campus/dto/response/SysDictItemDTO.java new file mode 100644 index 0000000..fea6644 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/SysDictItemDTO.java @@ -0,0 +1,23 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.Date; + +/** + * 字典项明细 DTO(管理后台用) + */ +@Data +public class SysDictItemDTO { + private String id; + private String dictId; + private String value; + private String label; + private Integer sortOrder; + private Boolean status; + private String color; + private String icon; + private String extra; + private Date createdAt; + private Date updatedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/TopicDTO.java b/campus-server/src/main/java/com/campus/dto/response/TopicDTO.java new file mode 100644 index 0000000..bd06867 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/TopicDTO.java @@ -0,0 +1,44 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** + * 话题广场的话题数据。 + *

字段只来自话题域(topic / topic_alias / topic_follow / topic_visit / topic_hot_snapshot), + * 不再包含任何从动态(moment.tags)派生出来的统计;动态内容请调动态域接口 + * {@code GET /api/campus/moments?tag=}。

+ */ +@Data +public class TopicDTO { + private String id; + private String name; + private String description; + private String category; + private String coverUrl; + private String status; + private String reviewNote; + private Boolean recommended; + private Boolean pinned; + private java.util.List topicTags = new java.util.ArrayList<>(); + private java.util.List aliases; + /** 关注数(topic_follow 计数)。 */ + private Long followerCount; + /** 访问过该话题的人数(topic_visit 每用户一行,天然去重)。 */ + private Long visitCount; + /** 收藏过该话题的人数(topic_favorite 每用户一行,天然去重)。 */ + private Long favoriteCount; + /** + * 话题热度:关注、访问与近 7 天增长加权,不含动态互动。 + * + *

仅用于服务端排序与榜单({@code /catalog?sort=hot|rising}、{@code /rankings}); + * 统计栏已改为「关注 / 访问 / 收藏」,前端不再展示本字段,但接口暂时保留,避免破坏榜单与旧版前端。

+ */ + private Long heatScore; + private Long heatDelta; + private Integer heatChangePercent; + private Boolean followed; + /** 当前登录用户是否已收藏该话题。 */ + private Boolean favorited; + private java.util.Date lastVisitedAt; + private java.util.Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/TopicFavoriteDTO.java b/campus-server/src/main/java/com/campus/dto/response/TopicFavoriteDTO.java new file mode 100644 index 0000000..a7eb546 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/TopicFavoriteDTO.java @@ -0,0 +1,21 @@ +package com.campus.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** 收藏/取消收藏话题的返回体,供前端直接刷新按钮与收藏数。 */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TopicFavoriteDTO { + + /** 解析后的主话题名(传别名时返回正式名)。 */ + private String topicName; + + /** 本次操作后的状态:true-已收藏,false-已取消。 */ + private Boolean favorited; + + /** 该话题最新的收藏人数。 */ + private Long favoriteCount; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/TopicFollowDTO.java b/campus-server/src/main/java/com/campus/dto/response/TopicFollowDTO.java new file mode 100644 index 0000000..75ada06 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/TopicFollowDTO.java @@ -0,0 +1,9 @@ +package com.campus.dto.response; + +import lombok.Data; + +@Data +public class TopicFollowDTO { + private String topicName; + private Boolean followed; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UniversityDTO.java b/campus-server/src/main/java/com/campus/dto/response/UniversityDTO.java new file mode 100644 index 0000000..e32dac0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UniversityDTO.java @@ -0,0 +1,28 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +public class UniversityDTO { + private String id; + private String name; + private String schoolCode; + private String shortName; + private String logoUrl; + private String province; + private String city; + private String level; + private String type; + private Integer foundedYear; + private String description; + private BigDecimal latitude; + private BigDecimal longitude; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UniversityDistanceDTO.java b/campus-server/src/main/java/com/campus/dto/response/UniversityDistanceDTO.java new file mode 100644 index 0000000..6e41fc2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UniversityDistanceDTO.java @@ -0,0 +1,24 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 附近大学DTO(含距离信息) + */ +@Data +public class UniversityDistanceDTO { + private String id; + private String name; + private String shortName; + private String logoUrl; + private String province; + private String city; + private String level; + private String type; + private BigDecimal latitude; + private BigDecimal longitude; + /** 距离(km) */ + private Double distanceKm; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UniversityMapStatsDTO.java b/campus-server/src/main/java/com/campus/dto/response/UniversityMapStatsDTO.java new file mode 100644 index 0000000..af5814e --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UniversityMapStatsDTO.java @@ -0,0 +1,19 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.math.BigDecimal; + +/** 认证校友地图使用的高校聚合数据。 */ +@Data +public class UniversityMapStatsDTO { + private String id; + private String name; + private String shortName; + private String province; + private String city; + private BigDecimal latitude; + private BigDecimal longitude; + private Long userCount; + private Long certifiedUserCount; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UnreadCountDTO.java b/campus-server/src/main/java/com/campus/dto/response/UnreadCountDTO.java new file mode 100644 index 0000000..91821bb --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UnreadCountDTO.java @@ -0,0 +1,12 @@ +package com.campus.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UnreadCountDTO { + private Long total; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UnreadDetailDTO.java b/campus-server/src/main/java/com/campus/dto/response/UnreadDetailDTO.java new file mode 100644 index 0000000..cd4c271 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UnreadDetailDTO.java @@ -0,0 +1,17 @@ +package com.campus.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 未读数明细(通知 + 聊天分开返回,避免前端用减法间接计算导致不一致) + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UnreadDetailDTO { + private Long notificationUnread; + private Long chatUnread; + private Long total; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UserDTO.java b/campus-server/src/main/java/com/campus/dto/response/UserDTO.java new file mode 100644 index 0000000..6d7557c --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UserDTO.java @@ -0,0 +1,34 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +@Data +public class UserDTO { + private String id; + private String number; + private String nickname; + private String avatar; + private String gender; + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date birthday; + private Integer age; + private String hometown; + private String location; + private String education; + private String universityName; + private String majorName; + private String grade; + private String bio; + private List interests; + private Boolean isReviewer; + private Boolean isAdmin; + private String accountStatus; + /** 学信网认证是否已通过 */ + private Boolean xuexinCertified; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UserProfileDTO.java b/campus-server/src/main/java/com/campus/dto/response/UserProfileDTO.java new file mode 100644 index 0000000..0e028f5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UserProfileDTO.java @@ -0,0 +1,70 @@ +package com.campus.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +/** + * 用户详情(陌生人资料页) + */ +@Data +public class UserProfileDTO { + private String id; + private String number; + private String nickname; + private String avatar; + private String gender; + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date birthday; + private Integer age; + private String universityId; + private String universityName; + private String collegeId; + private String collegeName; + private String majorId; + private String majorName; + private String secondMajorId; + private String secondMajorName; + private String grade; + private String education; + private String educationType; + private String highSchool; + private String hometown; + private String hometownProvince; + private String hometownCity; + private String hometownDistrict; + private String location; + private String bio; + private List interests; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + + /** 关系状态:NONE/SENT/RECEIVED/ACCEPTED */ + private String relationStatus; + + /** 粉丝数 */ + private Integer followersCount; + + /** 关注数 */ + private Integer followingCount; + + /** 当前用户是否已关注该用户 */ + private Boolean isFollowed; + + /** 是否互相关注 */ + private Boolean isMutualFollow; + + /** 共同好友数 */ + private Integer mutualFriendCount; + + /** 共同好友预览(前若干位) */ + private List mutualFriends; + + /** 学信网认证是否已通过 */ + private Boolean xuexinCertified; + + /** 用户是否在线(有活跃SSE连接) */ + private Boolean isOnline; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UserRestrictionDTO.java b/campus-server/src/main/java/com/campus/dto/response/UserRestrictionDTO.java new file mode 100644 index 0000000..f74312f --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UserRestrictionDTO.java @@ -0,0 +1,21 @@ +package com.campus.dto.response; + +import lombok.Data; +import java.util.Date; + +@Data +public class UserRestrictionDTO { + private String id; + private String userId; + private String restrictionType; + private String reason; + private String status; + private Date startsAt; + private Date expiresAt; + private Boolean permanent; + private Boolean effective; + private String createdBy; + private String revokedBy; + private Date revokedAt; + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/UserSessionDTO.java b/campus-server/src/main/java/com/campus/dto/response/UserSessionDTO.java new file mode 100644 index 0000000..253c6ca --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/UserSessionDTO.java @@ -0,0 +1,19 @@ +package com.campus.dto.response; + +import lombok.Data; +import java.util.Date; + +@Data +public class UserSessionDTO { + private String id; + private String deviceId; + private String deviceName; + private String platform; + private String ipAddress; + private String userAgent; + private String status; + private Boolean current; + private Date lastSeenAt; + private Date expiresAt; + private Date createdAt; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/VodUploadCredential.java b/campus-server/src/main/java/com/campus/dto/response/VodUploadCredential.java new file mode 100644 index 0000000..ee19179 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/VodUploadCredential.java @@ -0,0 +1,28 @@ +package com.campus.dto.response; + +import lombok.Data; + +/** + * VOD 上传凭证响应 — CreateUploadVideo 返回 + * + * uploadAddress: Base64 编码的 JSON,解码后包含 Bucket、Endpoint、FileName + * uploadAuth: Base64 编码的 JSON,解码后包含临时 AccessKeyId、AccessKeySecret、SecurityToken + */ +@Data +public class VodUploadCredential { + + /** VOD 视频ID(媒资唯一标识) */ + private String videoId; + + /** 阿里云请求ID */ + private String requestId; + + /** Base64 编码的上传地址(含 Bucket、Endpoint、FileName) */ + private String uploadAddress; + + /** Base64 编码的上传凭证(含临时 AK/SK/Token) */ + private String uploadAuth; + + /** 图片上传专属:VOD 图片访问 URL(CreateUploadImage 直接返回) */ + private String imageUrl; +} diff --git a/campus-server/src/main/java/com/campus/dto/response/XuexinDedupDTO.java b/campus-server/src/main/java/com/campus/dto/response/XuexinDedupDTO.java new file mode 100644 index 0000000..eb327a4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/dto/response/XuexinDedupDTO.java @@ -0,0 +1,35 @@ +package com.campus.dto.response; + +import lombok.Data; + +import java.util.Date; + +/** + * 学信网材料判重结果:随 OCR 响应返回给前端,Step1 就能提示「已被认证」。 + */ +@Data +public class XuexinDedupDTO { + + /** 该报告可用,可进入 Step2 提交 */ + public static final String AVAILABLE = "AVAILABLE"; + /** 本人已用这份材料认证过 */ + public static final String ALREADY_CONFIRMED_BY_SELF = "ALREADY_CONFIRMED_BY_SELF"; + /** 这份材料已被其他账号认证 */ + public static final String ALREADY_CONFIRMED_BY_OTHER = "ALREADY_CONFIRMED_BY_OTHER"; + /** 这份材料正在被其他账号认证(PENDING 未过期) */ + public static final String IN_PROGRESS_BY_OTHER = "IN_PROGRESS_BY_OTHER"; + + public static final String MSG_AVAILABLE = "该报告可认证"; + public static final String MSG_BY_SELF = "您已完成学信网认证,无需重复提交"; + public static final String MSG_BY_OTHER = "该学信网报告已被认证"; + public static final String MSG_IN_PROGRESS = "该认证材料正在被其他账号使用,请稍后再试"; + + /** AVAILABLE / ALREADY_CONFIRMED_BY_SELF / ALREADY_CONFIRMED_BY_OTHER / IN_PROGRESS_BY_OTHER */ + private String status; + + /** 前端直接展示,无需文案映射 */ + private String message; + + /** 占用该材料的那条认证的通过时间;无则 null */ + private Date certifiedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/AchievementDefinition.java b/campus-server/src/main/java/com/campus/entity/AchievementDefinition.java new file mode 100644 index 0000000..a0c3997 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/AchievementDefinition.java @@ -0,0 +1,24 @@ +package com.campus.entity; +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Where; +import javax.persistence.*; +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name="achievement_definition", uniqueConstraints=@UniqueConstraint(name="uk_achievement_code", columnNames="code")) +@Where(clause="deleted = false") +public class AchievementDefinition extends BaseEntity { + @Column(nullable=false, length=40) private String code; + @Column(nullable=false, length=50) private String name; + @Column(nullable=false, length=200) private String description; + @Column(name="category_code", nullable=false, length=40) private String categoryCode = "CORE"; + @Column(name="category_name", nullable=false, length=50) private String categoryName = "核心功能徽章"; + @Column(name="icon_url", length=500) private String iconUrl; + @Column(name="color_icon_url", length=500) private String colorIconUrl; + @Column(name="gray_icon_url", length=500) private String grayIconUrl; + @Column(name="target_value", nullable=false) private Long targetValue; + @Column(nullable=false) private Boolean enabled = true; + @Column(nullable=false) private Integer sort = 0; +} diff --git a/campus-server/src/main/java/com/campus/entity/Activity.java b/campus-server/src/main/java/com/campus/entity/Activity.java new file mode 100644 index 0000000..303a579 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Activity.java @@ -0,0 +1,142 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "activity") +@Where(clause = "deleted = false") +public class Activity extends BaseEntity { + + /** 活动标题 */ + @Column(nullable = false, length = 200) + private String title; + + /** 活动描述 */ + @Column(columnDefinition = "TEXT") + private String description; + + /** 活动类型 */ + @Column(nullable = false, length = 50) + private String type; + + /** 活动地点 */ + @Column(length = 200) + private String location; + + @Column + private Double latitude; + + @Column + private Double longitude; + + @Column(name = "contact_name", length = 50) + private String contactName; + + @Column(name = "contact_phone", length = 30) + private String contactPhone; + + @Column(columnDefinition = "TEXT") + private String rules; + + @Column(columnDefinition = "TEXT") + private String schedule; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "registration_start_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date registrationStartAt; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "registration_deadline") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date registrationDeadline; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "free_cancellation_deadline") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date freeCancellationDeadline; + + @Column(name = "cancellation_rule", length = 500) + private String cancellationRule; + + @Column(name = "check_in_code_hash", length = 64) + private String checkInCodeHash; + + /** 活动时间 */ + @Column + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date eventTime; + + /** 活动结束时间;历史数据允许为空。 */ + @Column(name = "end_time") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date endTime; + + /** 当前报名人数 */ + @Column + private Integer currentCount = 0; + + /** 最大报名人数 */ + @Column(nullable = false) + private Integer maxCount; + + /** 创建者用户ID */ + @Column(nullable = false, length = 36) + private String creatorId; + + /** 活动标签(JSON字符串) */ + @Column(length = 500) + private String tags; + + /** 活动热度(基础热度,用于快速排序) */ + @Column(nullable = false) + private Integer heat = 0; + + /** 浏览量 */ + @Column(nullable = false) + private Integer viewCount = 0; + + /** 活动状态:ACTIVE/INACTIVE/ENDED */ + @Column(nullable = false, length = 20) + private String status = "ACTIVE"; + + /** 审核状态:pending/approved/rejected */ + @Column(length = 20) + private String reviewStatus = "pending"; + + /** 审核原因 */ + @Column(length = 500) + private String reviewReason; + + /** 活动可见范围(JSON字符串) */ + @Column(columnDefinition = "TEXT") + private String scope; + + /** 活动封面图片URL */ + @Column(length = 500) + private String coverImage; + + /** 关联的群组ID(活动创建时自动创建群组) */ + @Column(length = 36) + private String groupId; + + /** + * 获取活动展示名称:取标题 + */ + public String getDisplayTitle() { + if (title != null && !title.trim().isEmpty()) { + return title.trim(); + } + return "未命名活动"; + } +} diff --git a/campus-server/src/main/java/com/campus/entity/ActivityOperationLog.java b/campus-server/src/main/java/com/campus/entity/ActivityOperationLog.java new file mode 100644 index 0000000..e8e3fbe --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ActivityOperationLog.java @@ -0,0 +1,29 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "activity_operation_log", indexes = { + @Index(name = "idx_activity_log_activity", columnList = "activity_id,created_at") +}) +@Where(clause = "deleted = false") +public class ActivityOperationLog extends BaseEntity { + @Column(name = "activity_id", nullable = false, length = 36) + private String activityId; + @Column(name = "operator_id", nullable = false, length = 36) + private String operatorId; + @Column(nullable = false, length = 50) + private String operation; + @Column(name = "target_id", length = 36) + private String targetId; + @Column(length = 1000) + private String detail; +} diff --git a/campus-server/src/main/java/com/campus/entity/ActivityParticipant.java b/campus-server/src/main/java/com/campus/entity/ActivityParticipant.java new file mode 100644 index 0000000..bcd5747 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ActivityParticipant.java @@ -0,0 +1,66 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 活动参与者表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "activity_participant", + uniqueConstraints = { + @UniqueConstraint(name = "uk_ap_activity_user", columnNames = {"activity_id", "user_id"}) + }, + indexes = { + @Index(name = "idx_ap_activity_id", columnList = "activity_id"), + @Index(name = "idx_ap_user_id", columnList = "user_id") + }) +@Where(clause = "deleted = false") +public class ActivityParticipant extends BaseEntity { + + /** 活动ID */ + @Column(name = "activity_id", nullable = false, length = 36) + private String activityId; + + /** 用户ID */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 报名状态:PENDING-待审核, CONFIRMED-已通过, REJECTED-已拒绝, REMOVED-已移出, CANCELLED-主动退出 */ + @Column(nullable = false, length = 20) + private String status = "CONFIRMED"; // CONFIRMED-已确认, CANCELLED-已取消 + + /** 报名时间 */ + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "joined_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date joinedAt; + + /** + * 报名申请次数(含被拒、撤回后重新申请),只用于展示「第 N 次申请」; + * 是否还能申请由 rejectCount / cancelCount 两个独立额度决定,本字段不再参与拦截。 + */ + @Column(name = "apply_count", nullable = false) + private Integer applyCount = 1; + + /** 累计被活动创建者拒绝的次数;只有「待审核 → 被拒绝」+1,对已通过者执行移出不计。 */ + @Column(name = "reject_count", nullable = false) + private Integer rejectCount = 0; + + /** 累计主动撤回报名申请的次数;只有「待审核 → 撤回」+1,退出已通过的活动不计。 */ + @Column(name = "cancel_count", nullable = false) + private Integer cancelCount = 0; + + /** 备注/留言 */ + @Column(length = 500) + private String remark; +} diff --git a/campus-server/src/main/java/com/campus/entity/ActivityPaymentOrder.java b/campus-server/src/main/java/com/campus/entity/ActivityPaymentOrder.java new file mode 100644 index 0000000..e9d4057 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ActivityPaymentOrder.java @@ -0,0 +1,42 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.math.BigDecimal; + +/** 活动报名支付订单意图;真实支付由后续支付适配器推进。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "activity_payment_order", indexes = { + @Index(name = "uk_payment_registration", columnList = "registration_id", unique = true), + @Index(name = "idx_payment_user", columnList = "user_id") +}) +@Where(clause = "deleted = false") +public class ActivityPaymentOrder extends BaseEntity { + + @Column(name = "registration_id", nullable = false, length = 36) + private String registrationId; + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal amount = BigDecimal.ZERO; + + /** PENDING/PAID/CANCELLED/REFUNDED */ + @Column(nullable = false, length = 20) + private String status = "PENDING"; + + /** 当前未接入真实支付渠道。 */ + @Column(nullable = false, length = 30) + private String provider = "UNINTEGRATED"; +} diff --git a/campus-server/src/main/java/com/campus/entity/ActivityRegistration.java b/campus-server/src/main/java/com/campus/entity/ActivityRegistration.java new file mode 100644 index 0000000..975fed4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ActivityRegistration.java @@ -0,0 +1,81 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.math.BigDecimal; +import java.util.Date; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; + +/** 活动报名单,支付状态由后续支付模块推进。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "activity_registration", indexes = { + @Index(name = "idx_registration_activity", columnList = "activity_id"), + @Index(name = "idx_registration_user", columnList = "user_id"), + @Index(name = "idx_registration_status", columnList = "status") +}) +@Where(clause = "deleted = false") +public class ActivityRegistration extends BaseEntity { + + @Column(name = "activity_id", nullable = false, length = 36) + private String activityId; + + @Column(name = "ticket_id", nullable = false, length = 36) + private String ticketId; + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(nullable = false) + private Integer quantity = 1; + + @Column(name = "total_amount", nullable = false, precision = 10, scale = 2) + private BigDecimal totalAmount = BigDecimal.ZERO; + + @Column(name = "attendee_name", nullable = false, length = 50) + private String attendeeName; + + @Column(name = "attendee_phone", nullable = false, length = 20) + private String attendeePhone; + + @Column(name = "university_name", length = 100) + private String universityName; + + @Column(name = "emergency_contact", length = 50) + private String emergencyContact; + + @Column(name = "emergency_phone", length = 20) + private String emergencyPhone; + + /** PENDING_PAYMENT/CONFIRMED/CANCELLED */ + @Column(nullable = false, length = 20) + private String status = "PENDING_PAYMENT"; + + /** UNPAID/PAID/REFUNDED */ + @Column(name = "payment_status", nullable = false, length = 20) + private String paymentStatus = "UNPAID"; + + /** PENDING/APPROVED/REJECTED/REMOVED;旧数据为空时按已确认处理。 */ + @Column(name = "review_status", length = 20) + private String reviewStatus; + + @Column(name = "review_reason", length = 500) + private String reviewReason; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "checked_in_at") + private Date checkedInAt; + + @Column(name = "checked_in_by", length = 36) + private String checkedInBy; +} diff --git a/campus-server/src/main/java/com/campus/entity/ActivityTicket.java b/campus-server/src/main/java/com/campus/entity/ActivityTicket.java new file mode 100644 index 0000000..8a36149 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ActivityTicket.java @@ -0,0 +1,50 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.math.BigDecimal; + +/** 活动票档。支付能力接入前,票档只负责价格和名额计算。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "activity_ticket", indexes = { + @Index(name = "idx_ticket_activity", columnList = "activity_id"), + @Index(name = "idx_ticket_enabled", columnList = "activity_id,enabled") +}) +@Where(clause = "deleted = false") +public class ActivityTicket extends BaseEntity { + + @Column(name = "activity_id", nullable = false, length = 36) + private String activityId; + + @Column(nullable = false, length = 100) + private String name; + + @Column(length = 300) + private String description; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal price = BigDecimal.ZERO; + + /** null 表示不限制票档名额 */ + @Column + private Integer capacity; + + @Column(name = "sold_count", nullable = false) + private Integer soldCount = 0; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 0; + + @Column(nullable = false) + private Boolean enabled = true; +} diff --git a/campus-server/src/main/java/com/campus/entity/AdminAuditLog.java b/campus-server/src/main/java/com/campus/entity/AdminAuditLog.java new file mode 100644 index 0000000..5b39d3d --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/AdminAuditLog.java @@ -0,0 +1,50 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** 管理端关键操作审计日志。日志只记录摘要,不记录密码、令牌等敏感值。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "admin_audit_log", indexes = { + @Index(name = "idx_audit_operator", columnList = "operator_id"), + @Index(name = "idx_audit_action", columnList = "action"), + @Index(name = "idx_audit_target", columnList = "target_type,target_id"), + @Index(name = "idx_audit_created", columnList = "created_at") +}) +@Where(clause = "deleted = false") +public class AdminAuditLog extends BaseEntity { + + @Column(name = "operator_id", nullable = false, length = 36) + private String operatorId; + + @Column(name = "operator_name", length = 100) + private String operatorName; + + @Column(nullable = false, length = 80) + private String action; + + @Column(name = "target_type", nullable = false, length = 50) + private String targetType; + + @Column(name = "target_id", length = 100) + private String targetId; + + @Column(name = "before_summary", length = 1000) + private String beforeSummary; + + @Column(name = "after_summary", length = 1000) + private String afterSummary; + + @Column(length = 500) + private String remark; +} diff --git a/campus-server/src/main/java/com/campus/entity/Announcement.java b/campus-server/src/main/java/com/campus/entity/Announcement.java new file mode 100644 index 0000000..bf71d5e --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Announcement.java @@ -0,0 +1,101 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "announcement") +@Where(clause = "deleted = false") +public class Announcement extends BaseEntity { + + /** 公告类型 */ + @Column(nullable = false, length = 50) + private String type; + + /** 公告标题 */ + @Column(nullable = false, length = 200) + private String title; + + /** 副标题 */ + @Column(length = 200) + private String subtitle; + + /** 公告简述 */ + @Column(length = 500) + private String description; + + /** 公告正文内容 */ + @Column(columnDefinition = "TEXT") + private String content; + + /** 活动日期 */ + @Column + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date eventDate; + + /** 活动时间(时分) */ + @Column(length = 50) + private String eventTime; + + /** 活动地点 */ + @Column(length = 200) + private String location; + + /** 主办方 */ + @Column(length = 100) + private String organizer; + + /** 容量限制 */ + @Column + private Integer capacity; + + /** 封面图片URL */ + @Column(length = 500) + private String coverImage; + + /** 图片URL列表(JSON字符串) */ + @Column(columnDefinition = "TEXT") + private String images; + + /** 审核状态:pending/approved/rejected */ + @Column(nullable = false, length = 20) + private String status = "pending"; + + /** 审核原因 */ + @Column(length = 500) + private String reviewReason; + + /** 发布范围类型:all-全部学校,specified-指定学校 */ + @Column(name = "scope_type", length = 20) + private String scopeType = "all"; + + /** 指定学校ID列表(JSON数组字符串) */ + @Column(name = "university_ids", columnDefinition = "TEXT") + private String universityIds; + + /** 浏览次数 */ + @Column(nullable = false) + private Integer viewCount = 0; + + /** 已报名人数 */ + @Column(nullable = false) + private Integer registeredCount = 0; + + /** 创建者用户ID */ + @Column(nullable = false, length = 36) + private String creatorId; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "scheduled_publish_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date scheduledPublishAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/Area.java b/campus-server/src/main/java/com/campus/entity/Area.java new file mode 100644 index 0000000..dfa8807 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Area.java @@ -0,0 +1,64 @@ +package com.campus.entity; + +import lombok.Data; + +import javax.persistence.*; + +/** + * 地区实体 + */ +@Data +@Entity +@Table(name = "area") +public class Area { + + /** 地区ID */ + @Id + @Column(name = "area_id", nullable = false) + private Integer areaId; + + /** 父级地区ID */ + @Column(name = "are_area_id") + private Integer areAreaId; + + /** 名称 */ + @Column(length = 50) + private String name; + + /** 简称 */ + @Column(name = "short_name", length = 20) + private String shortName; + + /** 编码 */ + @Column(length = 6) + private String code; + + /** 1.热门 */ + private Integer hot; + + /** 是否删除1.删除,0不删除 */ + private Integer isdel; + + /** 级别 */ + private Integer level; + + /** 经度 */ + @Column(length = 50) + private String lng; + + /** 纬度 */ + @Column(length = 50) + private String lat; + + /** 拼音简称 */ + @Column(length = 10) + private String letter; + + /** 拼音 */ + @Column(length = 40) + private String pinyin; + + /** 第一个字母 */ + @Column(name = "firstchar", length = 4) + private String firstChar; +} diff --git a/campus-server/src/main/java/com/campus/entity/ChatConversation.java b/campus-server/src/main/java/com/campus/entity/ChatConversation.java new file mode 100644 index 0000000..2511213 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ChatConversation.java @@ -0,0 +1,79 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "chat_conversation") +@Where(clause = "deleted = false") +public class ChatConversation extends BaseEntity { + + /** 用户ID */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 对方用户ID(群聊时为群ID;私聊时为 conversationId,格式 userId1_userId2) */ + @Column(name = "peer_id", nullable = false, length = 100) + private String peerId; + + /** 会话类型:PRIVATE/GROUP/ACTIVITY */ + @Column(name = "conversation_type", nullable = false, length = 20) + private String conversationType; // PRIVATE, GROUP, ACTIVITY + + /** 群聊名称 */ + @Column(name = "group_name", length = 100) + private String groupName; + + /** 最后一条消息内容 */ + @Column(name = "last_message", length = 500) + private String lastMessage; + + /** 最后一条消息类型:TEXT/IMAGE/VIDEO/VOICE */ + @Column(name = "last_message_type", length = 20) + private String lastMessageType; + + /** 最后一条语音/视频消息时长(秒) */ + @Column(name = "last_message_duration") + private Integer lastMessageDuration; + + /** 最后一条消息时间 */ + @Column(name = "last_message_time") + private java.util.Date lastMessageTime; + + /** 未读消息数 */ + @Column(name = "unread_count", nullable = false) + private Integer unreadCount = 0; + + /** 是否置顶 */ + @Column(name = "is_pinned", nullable = false) + private Boolean isPinned = false; + + /** 是否免打扰 */ + @Column(name = "is_muted", nullable = false) + private Boolean isMuted = false; + + /** + * 该用户在此会话中已清空到的消息水位线: + * seq_id <= 该值的消息对该用户不可见(只影响本人,不影响其他参与者)。 + * 0 = 从未清空。清空聊天记录只抬高这个值,不删除共享的 chat_message。 + */ + @Column(name = "cleared_seq_id", nullable = false) + private Long clearedSeqId = 0L; + + /** 对方昵称(仅API返回,不持久化) */ + @javax.persistence.Transient + private String peerName; + + /** 对方头像(仅API返回,不持久化) */ + @javax.persistence.Transient + private String peerAvatar; +} diff --git a/campus-server/src/main/java/com/campus/entity/ChatMessage.java b/campus-server/src/main/java/com/campus/entity/ChatMessage.java new file mode 100644 index 0000000..6f51be4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ChatMessage.java @@ -0,0 +1,87 @@ +package com.campus.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.annotations.Where; + +import javax.persistence.*; +import java.util.Date; +import java.util.UUID; + +@Data +@Entity +@Table(name = "chat_message", indexes = { + @Index(name = "idx_chat_conv_deleted_seq", columnList = "conversation_id,deleted,seq_id"), + @Index(name = "idx_chat_conv_deleted_time_seq", columnList = "conversation_id,deleted,created_at,seq_id"), + @Index(name = "idx_chat_message_id", columnList = "message_id") +}) +@Where(clause = "deleted = false") +public class ChatMessage { + + /** 全局递增序列号(主键),MySQL AUTO_INCREMENT,用于增量同步 */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "seq_id") + private Long seqId; + + // ===== BaseEntity 字段(不再继承) ===== + /** 业务UUID */ + @Column(name = "message_id", nullable = false, length = 36) + private String messageId; + + @Column(nullable = false) + private Boolean deleted = false; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createdAt; + + @UpdateTimestamp + @Column + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updatedAt; + + @PrePersist + public void prePersist() { + if (messageId == null) { + messageId = UUID.randomUUID().toString(); + } + } + // ======================================== + + /** 所属会话ID 格式: userId1_userId2 */ + @Column(name = "conversation_id", nullable = false, length = 100) + private String conversationId; + + /** 发送者用户ID */ + @Column(nullable = false, length = 36) + private String senderId; + + /** 消息内容 */ + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + /** 消息类型:TEXT/IMAGE/VOICE/VIDEO/GIFT/GROUP_ANNOUNCEMENT/SHARE_CARD */ + @Column(nullable = false, length = 20) + private String messageType = "TEXT"; + + /** 媒体文件URL */ + @Column(length = 500) + private String mediaUrl; + + /** 语音/视频时长(秒) */ + @Column + private Integer duration; + + /** 是否已读 */ + @Column(nullable = false) + private Boolean isRead = false; + + /** 已读时间 */ + @Column + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date readAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/ChatReport.java b/campus-server/src/main/java/com/campus/entity/ChatReport.java new file mode 100644 index 0000000..b1f4c06 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ChatReport.java @@ -0,0 +1,25 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "chat_report", indexes = @Index(name = "idx_chat_report_status", columnList = "status")) +@Where(clause = "deleted = false") +public class ChatReport extends BaseEntity { + @Column(name = "reporter_id", nullable = false, length = 36) private String reporterId; + @Column(name = "message_id", nullable = false, length = 64) private String messageId; + @Column(name = "reported_user_id", nullable = false, length = 36) private String reportedUserId; + @Column(nullable = false, length = 500) private String reason; + @Column(nullable = false, length = 20) private String status = "PENDING"; + @Column(name = "reviewed_by", length = 36) private String reviewedBy; + @Column(name = "review_note", length = 500) private String reviewNote; + @Temporal(TemporalType.TIMESTAMP) @Column(name = "reviewed_at") private Date reviewedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/College.java b/campus-server/src/main/java/com/campus/entity/College.java new file mode 100644 index 0000000..855ef0b --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/College.java @@ -0,0 +1,38 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 学院实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "college") +@Where(clause = "deleted = false") +public class College extends BaseEntity { + + /** 所属大学ID */ + @Column(name = "university_id", nullable = false, length = 36) + private String universityId; + + /** 学院名称 */ + @Column(nullable = false, length = 100) + private String name; + + /** 学院简称 */ + @Column(name = "short_name", length = 50) + private String shortName; + + /** 学院简介 */ + @Column(columnDefinition = "TEXT") + private String description; +} diff --git a/campus-server/src/main/java/com/campus/entity/Comment.java b/campus-server/src/main/java/com/campus/entity/Comment.java new file mode 100644 index 0000000..4b57bc5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Comment.java @@ -0,0 +1,31 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "comment") +@Where(clause = "deleted = false") +public class Comment extends BaseEntity { + + /** 所属帖子ID */ + @Column(nullable = false, length = 36) + private String postId; + + /** 评论作者用户ID */ + @Column(nullable = false, length = 36) + private String authorId; + + /** 评论内容 */ + @Column(nullable = false, columnDefinition = "TEXT") + private String content; +} diff --git a/campus-server/src/main/java/com/campus/entity/CommerceOrder.java b/campus-server/src/main/java/com/campus/entity/CommerceOrder.java new file mode 100644 index 0000000..b862103 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/CommerceOrder.java @@ -0,0 +1,38 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** 青豆商城兑换订单。支付方式为积分时,扣款和订单在同一事务完成。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "commerce_order") +@Where(clause = "deleted = false") +public class CommerceOrder extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "product_id", nullable = false, length = 50) + private String productId; + + @Column(name = "product_name", nullable = false, length = 100) + private String productName; + + @Column(name = "price_points", nullable = false) + private Integer pricePoints; + + @Column(nullable = false, length = 20) + private String status = "PAID"; + + @Column(nullable = false, length = 20) + private String paymentMethod = "POINTS"; +} diff --git a/campus-server/src/main/java/com/campus/entity/ContentAppeal.java b/campus-server/src/main/java/com/campus/entity/ContentAppeal.java new file mode 100644 index 0000000..6a99b27 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ContentAppeal.java @@ -0,0 +1,41 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "content_appeal", indexes = { + @Index(name = "idx_appeal_user", columnList = "user_id"), + @Index(name = "idx_appeal_content", columnList = "content_type,content_id"), + @Index(name = "idx_appeal_status", columnList = "status") +}) +@Where(clause = "deleted = false") +public class ContentAppeal extends BaseEntity { + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + @Column(name = "content_type", nullable = false, length = 30) + private String contentType; + @Column(name = "content_id", nullable = false, length = 36) + private String contentId; + @Column(nullable = false, length = 1000) + private String reason; + @Column(nullable = false, length = 20) + private String status; + @Column(name = "reviewed_by", length = 36) + private String reviewedBy; + @Column(name = "review_note", length = 1000) + private String reviewNote; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "reviewed_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date reviewedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/ContentDraftVersion.java b/campus-server/src/main/java/com/campus/entity/ContentDraftVersion.java new file mode 100644 index 0000000..4edcaa6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ContentDraftVersion.java @@ -0,0 +1,26 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.persistence.*; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "content_draft_version", indexes = @Index(name = "idx_draft_user_key", columnList = "user_id,draft_key,version_no")) +@Where(clause = "deleted = false") +public class ContentDraftVersion extends BaseEntity { + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + @Column(name = "draft_key", nullable = false, length = 36) + private String draftKey; + @Column(name = "content_type", nullable = false, length = 30) + private String contentType; + @Column(name = "version_no", nullable = false) + private Integer version; + @Column(columnDefinition = "TEXT", nullable = false) + private String payload; +} diff --git a/campus-server/src/main/java/com/campus/entity/ContentReview.java b/campus-server/src/main/java/com/campus/entity/ContentReview.java new file mode 100644 index 0000000..3e82262 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ContentReview.java @@ -0,0 +1,65 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 内容审核记录表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "content_review", + indexes = { + @Index(name = "idx_cr_content_type", columnList = "content_type"), + @Index(name = "idx_cr_content_id", columnList = "content_id"), + @Index(name = "idx_cr_status", columnList = "status"), + @Index(name = "idx_cr_created_at", columnList = "created_at") + }) +@Where(clause = "deleted = false") +public class ContentReview extends BaseEntity { + + /** 内容类型 */ + @Column(name = "content_type", nullable = false, length = 50) + private String contentType; // ANNOUNCEMENT-公告, MOMENT-动态, ACTIVITY-活动 + + /** 内容ID */ + @Column(name = "content_id", nullable = false, length = 36) + private String contentId; + + /** 审核状态 */ + @Column(nullable = false, length = 20) + private String status; // pending-待审核, approved-通过, rejected-拒绝 + + /** 审核原因 */ + @Column(length = 500) + private String reason; + + /** 审核得分 */ + private Integer score; + + /** 审核详情(JSON格式) */ + @Column(columnDefinition = "TEXT") + private String resultDetails; + + /** 审核人 */ + @Column(name = "reviewed_by", length = 36) + private String reviewedBy; + + /** 审核时间 */ + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "reviewed_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date reviewedAt; + + /** 审核方式 */ + @Column(name = "review_type", length = 20) + private String reviewType; // AUTO-自动审核, MANUAL-人工审核 +} diff --git a/campus-server/src/main/java/com/campus/entity/Follow.java b/campus-server/src/main/java/com/campus/entity/Follow.java new file mode 100644 index 0000000..2d0fd81 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Follow.java @@ -0,0 +1,25 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_follow", uniqueConstraints = @javax.persistence.UniqueConstraint(name = "uk_follow_user_target", columnNames = {"follower_id", "following_id"})) +@Where(clause = "deleted = false") +public class Follow extends BaseEntity { + + @Column(name = "follower_id", nullable = false, length = 36) + private String followerId; + + @Column(name = "following_id", nullable = false, length = 36) + private String followingId; +} diff --git a/campus-server/src/main/java/com/campus/entity/Friendship.java b/campus-server/src/main/java/com/campus/entity/Friendship.java new file mode 100644 index 0000000..986fb2b --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Friendship.java @@ -0,0 +1,31 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "friendship", uniqueConstraints = @javax.persistence.UniqueConstraint(name = "idx_friendship_unique", columnNames = {"user_id", "friend_id"})) +@Where(clause = "deleted = false") +public class Friendship extends BaseEntity { + + /** 发起好友申请的用户ID */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 被添加为好友的用户ID */ + @Column(name = "friend_id", nullable = false, length = 36) + private String friendId; + + /** 好友状态:PENDING/ACCEPTED/REJECTED/BLOCKED */ + @Column(nullable = false, length = 20) + private String status = "PENDING"; +} diff --git a/campus-server/src/main/java/com/campus/entity/Gift.java b/campus-server/src/main/java/com/campus/entity/Gift.java new file mode 100644 index 0000000..5122914 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Gift.java @@ -0,0 +1,45 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 礼物表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "gift") +@Where(clause = "deleted = false") +public class Gift extends BaseEntity { + + /** 礼物名称 */ + @Column(nullable = false, length = 50) + private String name; + + /** 礼物图标URL */ + @Column(name = "icon_url", length = 500) + private String iconUrl; + + /** 所需积分 */ + @Column(nullable = false) + private Integer points; + + /** 礼物价值(用于计算收益比例) */ + @Column(nullable = false) + private Integer value; + + /** 排序 */ + @Column(nullable = false) + private Integer sort = 0; + + /** 是否启用 */ + @Column(nullable = false) + private Boolean enabled = true; +} diff --git a/campus-server/src/main/java/com/campus/entity/GiftRecord.java b/campus-server/src/main/java/com/campus/entity/GiftRecord.java new file mode 100644 index 0000000..87d9a34 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/GiftRecord.java @@ -0,0 +1,62 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 礼物赠送记录表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "gift_record", + indexes = { + @Index(name = "idx_gr_sender", columnList = "sender_id"), + @Index(name = "idx_gr_receiver", columnList = "receiver_id"), + @Index(name = "idx_gr_moment", columnList = "moment_id") + }) +@Where(clause = "deleted = false") +public class GiftRecord extends BaseEntity { + + /** 发送者ID */ + @Column(name = "sender_id", nullable = false, length = 36) + private String senderId; + + /** 接收者ID */ + @Column(name = "receiver_id", nullable = false, length = 36) + private String receiverId; + + /** 礼物ID */ + @Column(name = "gift_id", nullable = false, length = 36) + private String giftId; + + /** 礼物名称 */ + @Column(name = "gift_name", nullable = false, length = 50) + private String giftName; + + /** 礼物图标 */ + @Column(name = "gift_icon", length = 500) + private String giftIcon; + + /** 消耗积分 */ + @Column(name = "points", nullable = false) + private Integer points; + + /** 赠送数量 */ + @Column(nullable = false) + private Integer count = 1; + + /** 动态ID(可选,送给动态的礼物) */ + @Column(name = "moment_id", length = 64) + private String momentId; + + /** 留言 */ + @Column(length = 200) + private String message; +} diff --git a/campus-server/src/main/java/com/campus/entity/Greeting.java b/campus-server/src/main/java/com/campus/entity/Greeting.java new file mode 100644 index 0000000..2708eca --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Greeting.java @@ -0,0 +1,38 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 打招呼记录表:记录用户向陌生人发送的问候,用于持久化「已打招呼」状态。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "greeting", + uniqueConstraints = { + @UniqueConstraint(name = "uk_greeting_user_target", columnNames = {"user_id", "target_user_id"}) + }) +@Where(clause = "deleted = false") +public class Greeting extends BaseEntity { + + /** 打招呼发起者用户ID */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 被打招呼的用户ID */ + @Column(name = "target_user_id", nullable = false, length = 36) + private String targetUserId; + + /** 问候内容 */ + @Column(length = 200) + private String message; +} diff --git a/campus-server/src/main/java/com/campus/entity/Group.java b/campus-server/src/main/java/com/campus/entity/Group.java new file mode 100644 index 0000000..488ba04 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Group.java @@ -0,0 +1,72 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "chat_group") +@Where(clause = "deleted = false") +public class Group extends BaseEntity { + + @Column(nullable = false, length = 100) + private String name; + + @Column(length = 500) + private String description; + + @Column(length = 500) + private String avatar; + + @Column(name = "owner_id", nullable = false, length = 36) + private String ownerId; + + @Column(name = "max_members", nullable = false) + private Integer maxMembers = 200; + + @Column(name = "member_count", nullable = false) + private Integer memberCount = 0; + + @Column(name = "is_public", nullable = false) + private Boolean isPublic = false; + + @Column(name = "is_approval_required", nullable = false) + private Boolean isApprovalRequired = true; + + @Column(name = "easemob_group_id", length = 100) + private String easemobGroupId; + + @Column(name = "last_message", length = 500) + private String lastMessage; + + @Column(name = "last_message_time") + private java.util.Date lastMessageTime; + + /** + * 群来源:{@link #SOURCE_NORMAL} 用户自建群;{@link #SOURCE_ACTIVITY} 组局活动群。 + * 活动群不允许自助加入,只有活动创建者与已报名(CONFIRMED)的用户能进群。 + * 数据库列名用 group_source,避开 SQL 关键字 source 的不确定性。 + */ + @Column(name = "group_source", nullable = false, length = 20) + private String source = SOURCE_NORMAL; + + /** 活动群所属的活动 ID(source=ACTIVITY 时非空),用于校验报名资格。 */ + @Column(name = "activity_id", length = 36) + private String activityId; + + public static final String SOURCE_NORMAL = "NORMAL"; + public static final String SOURCE_ACTIVITY = "ACTIVITY"; + + /** 是否为组局活动群。 */ + public boolean isActivityGroup() { + return SOURCE_ACTIVITY.equals(this.source); + } +} \ No newline at end of file diff --git a/campus-server/src/main/java/com/campus/entity/GroupAnnouncement.java b/campus-server/src/main/java/com/campus/entity/GroupAnnouncement.java new file mode 100644 index 0000000..98bf865 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/GroupAnnouncement.java @@ -0,0 +1,41 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "group_announcement", uniqueConstraints = { + @UniqueConstraint(name = "uk_group_announcement_group_id", columnNames = "group_id") +}) +@Where(clause = "deleted = false") +public class GroupAnnouncement extends BaseEntity { + + @Column(name = "group_id", nullable = false, length = 36) + private String groupId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "publisher_id", nullable = false, length = 36) + private String publisherId; + + @Column(name = "publisher_nickname", length = 50) + private String publisherNickname; + + @Column(name = "publisher_avatar", length = 500) + private String publisherAvatar; + + @Column(name = "published_at", nullable = false) + private Date publishedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/GroupInvitation.java b/campus-server/src/main/java/com/campus/entity/GroupInvitation.java new file mode 100644 index 0000000..44383d9 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/GroupInvitation.java @@ -0,0 +1,113 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 群聊邀请:已在群内的群主/管理员把用户「拉进群」时产生的邀请记录。 + * + *

与「建群时带人」的区别:建群({@code GroupService.createGroup} 的 memberIds)是直接写入 + * {@link GroupMember}(role=MEMBER),不需要对方同意;而本表记录的是已建群后的邀请, + * 必须等被邀请人调用「允许」接口才真正写入 {@link GroupMember}。 + * + *

与 {@code GroupMember.role='PENDING'} 的区别:那个 PENDING 表示「用户主动申请入群、等群主审批」, + * 由 {@code GroupService.getJoinRequests} 消费;两者语义完全相反,不能混用一张表 + * (混用会让群主在「入群申请」里看到自己发出的邀请,且软删行会占死 uk_gm_group_user 唯一键)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "group_invitation", + uniqueConstraints = { + // 同一群 + 同一被邀请人最多一条待处理邀请;终态记录 pendingKey 为 NULL,不参与唯一性。 + // 注意 columnNames 必须写列名,不能写属性名(否则 Hibernate 启动期报 database column not found)。 + @UniqueConstraint(name = "uk_gi_pending", + columnNames = {"group_id", "invitee_id", "pending_key"}) + }, + indexes = { + // columnList 同样必须是列名 + @Index(name = "idx_gi_invitee_status", columnList = "invitee_id, status"), + @Index(name = "idx_gi_group_status", columnList = "group_id, status") + }) +@Where(clause = "deleted = false") +public class GroupInvitation extends BaseEntity { + + /** 等待被邀请人操作(唯一可流转的状态)。 */ + public static final String STATUS_PENDING = "PENDING"; + /** 被邀请人点了「允许」,成员关系已写入 GroupMember。 */ + public static final String STATUS_ACCEPTED = "ACCEPTED"; + /** 邀请人撤回。 */ + public static final String STATUS_CANCELLED = "CANCELLED"; + /** 超过 expireAt 未处理(懒判定,不依赖定时任务)。 */ + public static final String STATUS_EXPIRED = "EXPIRED"; + /** + * 被邀请人明确拒绝。当前产品决策下不产生该状态:被邀请人侧只有「允许」按钮, + * 不点即停在 PENDING 直到过期。此常量与状态位仅作预留,将来加「拒绝」接口时无需改表。 + */ + public static final String STATUS_REJECTED = "REJECTED"; + + /** pendingKey 在 PENDING 状态下的固定值。 */ + public static final String PENDING_KEY = "PENDING"; + + @Column(name = "group_id", nullable = false, length = 36) + private String groupId; + + /** 发起邀请的群主/管理员用户 ID。 */ + @Column(name = "inviter_id", nullable = false, length = 36) + private String inviterId; + + /** 被邀请用户 ID。 */ + @Column(name = "invitee_id", nullable = false, length = 36) + private String inviteeId; + + @Column(name = "status", nullable = false, length = 20) + private String status = STATUS_PENDING; + + /** + * 唯一性辅助列:PENDING 时为 {@link #PENDING_KEY},进入终态后置 null。 + * 由业务代码显式维护,不使用数据库生成列(原因见 V17 迁移脚本注释)。 + */ + @Column(name = "pending_key", length = 20) + private String pendingKey = PENDING_KEY; + + /** 邀请附言,可为空。 */ + @Column(name = "message", length = 200) + private String message; + + /** 过期时间,默认发出后 7 天。 */ + @Column(name = "expire_at", nullable = false) + private java.util.Date expireAt; + + /** 进入终态的时间。 */ + @Column(name = "responded_at") + private java.util.Date respondedAt; + + /** 是否仍待处理。 */ + public boolean isPending() { + return STATUS_PENDING.equals(this.status); + } + + /** 是否已过期(只比较时间,不修改状态;状态由 Service 的懒判定统一刷新)。 */ + public boolean isOverdue(java.util.Date now) { + return isPending() && expireAt != null && expireAt.before(now); + } + + /** + * 进入终态:同步把 pendingKey 置空,释放唯一键占位, + * 使该用户可以再次被同一群邀请。 + */ + public void settleTo(String finalStatus, java.util.Date now) { + this.status = finalStatus; + this.pendingKey = null; + this.respondedAt = now; + } +} diff --git a/campus-server/src/main/java/com/campus/entity/GroupMember.java b/campus-server/src/main/java/com/campus/entity/GroupMember.java new file mode 100644 index 0000000..64bce84 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/GroupMember.java @@ -0,0 +1,46 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.Transient; +import javax.persistence.UniqueConstraint; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "group_member", + uniqueConstraints = { + @UniqueConstraint(name = "uk_gm_group_user", columnNames = {"group_id", "user_id"}) + }) +@Where(clause = "deleted = false") +public class GroupMember extends BaseEntity { + + @Column(name = "group_id", nullable = false, length = 36) + private String groupId; + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "nickname", length = 50) + private String nickname; + + @Column(name = "role", nullable = false, length = 20) + private String role = "MEMBER"; + + @Column(name = "is_muted", nullable = false) + private Boolean isMuted = false; + + @Column(name = "join_time") + private java.util.Date joinTime; + + /** 用户头像(非持久化,查询时从 User 表填充) */ + @Transient + private String avatar; +} \ No newline at end of file diff --git a/campus-server/src/main/java/com/campus/entity/IdempotencyRecord.java b/campus-server/src/main/java/com/campus/entity/IdempotencyRecord.java new file mode 100644 index 0000000..62512d0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/IdempotencyRecord.java @@ -0,0 +1,36 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Lob; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 高风险写接口的幂等结果缓存。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "idempotency_record", uniqueConstraints = { + @UniqueConstraint(name = "uk_idempotency_user_operation_key", + columnNames = {"user_id", "operation", "idempotency_key"}) +}) +@Where(clause = "deleted = false") +public class IdempotencyRecord extends BaseEntity { + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + @Column(nullable = false, length = 80) + private String operation; + @Column(name = "idempotency_key", nullable = false, length = 100) + private String idempotencyKey; + @Column(name = "request_fingerprint", nullable = false, length = 200) + private String requestFingerprint; + @Lob + @Column(name = "response_json", nullable = false) + private String responseJson; +} diff --git a/campus-server/src/main/java/com/campus/entity/LotteryDraw.java b/campus-server/src/main/java/com/campus/entity/LotteryDraw.java new file mode 100644 index 0000000..9c372ad --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/LotteryDraw.java @@ -0,0 +1,41 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 抽奖记录,按业务时区日期限制每日次数。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "lottery_draw", uniqueConstraints = { + @UniqueConstraint(name = "uk_lottery_user_date_number", columnNames = {"user_id", "draw_date", "draw_number"}) +}) +@Where(clause = "deleted = false") +public class LotteryDraw extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "draw_date", nullable = false, length = 10) + private String drawDate; + + @Column(name = "draw_number", nullable = false) + private Integer drawNumber; + + @Column(name = "prize_name", nullable = false, length = 100) + private String prizeName; + + @Column(name = "prize_code", nullable = false, length = 50) + private String prizeCode; + + @Column(name = "reward_points", nullable = false) + private Integer rewardPoints = 0; +} diff --git a/campus-server/src/main/java/com/campus/entity/LotteryPrize.java b/campus-server/src/main/java/com/campus/entity/LotteryPrize.java new file mode 100644 index 0000000..0f21662 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/LotteryPrize.java @@ -0,0 +1,50 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 幸运转盘奖项。stock=-1 表示不限量。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "lottery_prize", uniqueConstraints = { + @UniqueConstraint(name = "uk_lottery_prize_code", columnNames = "code") +}) +@Where(clause = "deleted = false") +public class LotteryPrize extends BaseEntity { + + @Column(nullable = false, length = 50) + private String code; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false) + private Integer weight = 1; + + @Column(nullable = false) + private Integer stock = -1; + + @Column(name = "reward_points", nullable = false) + private Integer rewardPoints = 0; + + @Column(name = "entitlement_type", length = 50) + private String entitlementType; + + @Column(name = "duration_days") + private Integer durationDays; + + @Column(nullable = false) + private Boolean enabled = true; + + @Column(nullable = false) + private Integer sort = 0; +} diff --git a/campus-server/src/main/java/com/campus/entity/Major.java b/campus-server/src/main/java/com/campus/entity/Major.java new file mode 100644 index 0000000..001b4d6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Major.java @@ -0,0 +1,46 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 专业实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "major") +@Where(clause = "deleted = false") +public class Major extends BaseEntity { + + /** 所属学院ID */ + @Column(name = "college_id", nullable = false, length = 36) + private String collegeId; + + /** 专业名称 */ + @Column(nullable = false, length = 100) + private String name; + + /** 专业简称 */ + @Column(name = "short_name", length = 50) + private String shortName; + + /** 学位类型:学士/硕士/博士 */ + @Column(name = "degree_type", length = 20) + private String degreeType; + + /** 专业等级:国家级/省级/校级 */ + @Column(length = 20) + private String level; + + /** 专业简介 */ + @Column(columnDefinition = "TEXT") + private String description; +} diff --git a/campus-server/src/main/java/com/campus/entity/MallProduct.java b/campus-server/src/main/java/com/campus/entity/MallProduct.java new file mode 100644 index 0000000..a75ae60 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MallProduct.java @@ -0,0 +1,50 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 青豆商城商品。stock=-1 表示不限量。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "mall_product", uniqueConstraints = { + @UniqueConstraint(name = "uk_mall_product_code", columnNames = "code") +}) +@Where(clause = "deleted = false") +public class MallProduct extends BaseEntity { + + @Column(nullable = false, length = 50) + private String code; + + @Column(nullable = false, length = 100) + private String name; + + @Column(length = 500) + private String description; + + @Column(name = "price_points", nullable = false) + private Integer pricePoints; + + @Column(nullable = false) + private Integer stock = -1; + + @Column(nullable = false) + private Boolean enabled = true; + + @Column(name = "entitlement_type", length = 50) + private String entitlementType; + + @Column(name = "duration_days") + private Integer durationDays; + + @Column(nullable = false) + private Integer sort = 0; +} diff --git a/campus-server/src/main/java/com/campus/entity/MatchAction.java b/campus-server/src/main/java/com/campus/entity/MatchAction.java new file mode 100644 index 0000000..c16d5a5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MatchAction.java @@ -0,0 +1,33 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 用户对候选人的匹配行为。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "match_action", uniqueConstraints = { + @UniqueConstraint(name = "uk_match_action_user_target", columnNames = {"user_id", "target_user_id"}) +}) +@Where(clause = "deleted = false") +public class MatchAction extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "target_user_id", nullable = false, length = 36) + private String targetUserId; + + /** LIKE/SUPER_LIKE/PASS。 */ + @Column(nullable = false, length = 20) + private String action; +} diff --git a/campus-server/src/main/java/com/campus/entity/MatchPair.java b/campus-server/src/main/java/com/campus/entity/MatchPair.java new file mode 100644 index 0000000..4be7874 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MatchPair.java @@ -0,0 +1,33 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 互相喜欢后生成的配对关系。用户 ID 按字典序保存,避免重复配对。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "match_pair", uniqueConstraints = { + @UniqueConstraint(name = "uk_match_pair_users", columnNames = {"user_low_id", "user_high_id"}) +}) +@Where(clause = "deleted = false") +public class MatchPair extends BaseEntity { + + @Column(name = "user_low_id", nullable = false, length = 36) + private String userLowId; + + @Column(name = "user_high_id", nullable = false, length = 36) + private String userHighId; + + /** ACTIVE/UNMATCHED。当前先支持 ACTIVE,保留状态字段供取消配对使用。 */ + @Column(nullable = false, length = 20) + private String status = "ACTIVE"; +} diff --git a/campus-server/src/main/java/com/campus/entity/MatchPreference.java b/campus-server/src/main/java/com/campus/entity/MatchPreference.java new file mode 100644 index 0000000..acd8818 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MatchPreference.java @@ -0,0 +1,40 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 心动匹配偏好。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "match_preference", uniqueConstraints = { + @UniqueConstraint(name = "uk_match_preference_user_id", columnNames = "user_id") +}) +@Where(clause = "deleted = false") +public class MatchPreference extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** M/F/ANY。 */ + @Column(length = 10) + private String preferredGender = "ANY"; + + @Column + private Integer minAge = 18; + + @Column + private Integer maxAge = 100; + + /** 是否只看同校用户。 */ + @Column(nullable = false) + private Boolean sameUniversityOnly = false; +} diff --git a/campus-server/src/main/java/com/campus/entity/Moment.java b/campus-server/src/main/java/com/campus/entity/Moment.java new file mode 100644 index 0000000..63e6c76 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Moment.java @@ -0,0 +1,175 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@org.hibernate.annotations.DynamicUpdate +@Table(name = "moment", indexes = { + @Index(name = "idx_moment_user_created", columnList = "userId, created_at"), + @Index(name = "idx_moment_scope_created", columnList = "scope, created_at"), + @Index(name = "idx_moment_youth_likes", columnList = "isYouthVideo, reviewStatus, likeCount"), + @Index(name = "idx_moment_youth_views", columnList = "isYouthVideo, reviewStatus, viewCount"), + @Index(name = "idx_moment_youth_created", columnList = "isYouthVideo, reviewStatus, created_at"), + @Index(name = "idx_moment_created", columnList = "created_at"), + @Index(name = "idx_moment_city_review_created", columnList = "city, reviewStatus, created_at"), + @Index(name = "idx_moment_review_scope_created", columnList = "reviewStatus, scope, created_at"), + @Index(name = "idx_moment_review_created", columnList = "reviewStatus, created_at"), + // 注意:@Index.columnList 用的是**列名**(本实体这两个字段显式写了 @Column(name=...), + // 所以这里必须写 content_source / topic_id;写属性名会启动失败: + // "database column 'contentSource' not found") + @Index(name = "idx_moment_source_created", columnList = "content_source, created_at"), + @Index(name = "idx_moment_topic_created", columnList = "topic_id, created_at"), +}) +@Where(clause = "deleted = false and user_id not in (select visibility_user.id from user visibility_user where visibility_user.account_status = 'DEACTIVATING')") +public class Moment extends BaseEntity { + + /** 内容来源:普通动态(只进动态流)。 */ + public static final String SOURCE_FEED = "FEED"; + /** 内容来源:话题讨论(只进所属话题页)。 */ + public static final String SOURCE_TOPIC = "TOPIC"; + + /** + * 内容来源,决定这条内容只能出现在哪个列表: + * {@link #SOURCE_FEED} 动态流;{@link #SOURCE_TOPIC} 话题讨论。 + * + *

老数据由迁移脚本 V14 归一为 {@code FEED},所以这里给默认值兜底。

+ */ + @Column(name = "content_source", nullable = false, length = 20, + columnDefinition = "varchar(20) not null default 'FEED'") + private String contentSource = SOURCE_FEED; + + /** + * 话题讨论所属话题 ID({@code content_source=TOPIC} 时必填,普通动态恒为 {@code null})。 + * + *

归属只认这个字段,**不再用 {@code tags} 反查话题**:{@code tags} 是动态的分类标签。

+ */ + @Column(name = "topic_id", length = 36) + private String topicId; + + /** 发布者用户ID */ + @Column(nullable = false, length = 36) + private String userId; + + /** 动态文本内容 */ + @Column(columnDefinition = "TEXT") + private String content; + + /** 媒体文件URL列表(JSON字符串) */ + @Column(columnDefinition = "TEXT") + private String mediaUrls; + + /** 标签列表(JSON字符串) */ + @Column(length = 500) + private String tags; + + /** 动态分类:失物招领/二手市场/社团活动/兼职招聘/全职招聘/校园求助/学习交流/租房合租/交友约伴/技能交换 */ + @Column(length = 50) + private String category; + + /** 发布省份 */ + @Column(length = 50) + private String province; + + /** 发布城市,用于同城动态筛选 */ + @Column(length = 50) + private String city; + + /** 动态发布范围:PERSONAL-个人动态(仅好友可见)/CAMPUS-校园动态(人人可见,类似抖音) */ + @Column(nullable = false, length = 20) + private String scope; + + /** 过期时间 */ + @Column + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date expiresAt; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "scheduled_publish_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date scheduledPublishAt; + + /** 浏览次数 */ + @Column(nullable = false) + private Integer viewCount = 0; + + /** 点赞数 */ + @Column(nullable = false) + private Integer likeCount = 0; + + /** 评论数 */ + @Column(nullable = false) + private Integer commentCount = 0; + + /** 逻辑删除标志:true-已删除,false-未删除 */ + @Column(nullable = false, length = 20) + private String reviewStatus = "pending"; + + /** 审核原因 */ + @Column(length = 500) + private String reviewReason; + + /** 是否加入青春号(视频号):true-加入青春号,false-普通动态 */ + @Column(nullable = false) + private Boolean isYouthVideo = false; + + /** 是否匿名发布:true-匿名,false-不匿名 */ + @Column(nullable = false) + private Boolean anonymous = false; + + /** 视频原始URL */ + @Column(name = "video_original_url") + private String videoOriginalUrl; + + /** 视频封面URL */ + @Column(name = "video_cover_url") + private String videoCoverUrl; + + /** VOD 视频ID(阿里云点播媒资ID) */ + @Column(name = "vod_video_id", length = 64) + private String vodVideoId; + + /** 视频CDN播放地址(m3u8自适应码率) */ + @Column(name = "play_url", length = 500) + private String playUrl; + + /** AI审核状态:APPROVED-通过 REJECTED-违规 PENDING_REVIEW-人工复审 */ + @Column(name = "audit_status", length = 20) + private String auditStatus; + + /** 视频时长(秒) */ + @Column(name = "video_duration") + private Integer videoDuration; + + /** 视频文件大小(字节) */ + @Column(name = "video_file_size") + private Long videoFileSize; + + /** 视频宽度 */ + @Column(name = "video_width") + private Integer videoWidth; + + /** 视频高度 */ + @Column(name = "video_height") + private Integer videoHeight; + + /** 分享次数 */ + @Column(name = "share_count", nullable = false) + private Integer shareCount = 0; + + public boolean hasVideoContent() { + return Boolean.TRUE.equals(isYouthVideo) + || (vodVideoId != null && !vodVideoId.isEmpty()) + || (playUrl != null && !playUrl.isEmpty()) + || (videoOriginalUrl != null && !videoOriginalUrl.isEmpty()); + } +} diff --git a/campus-server/src/main/java/com/campus/entity/MomentComment.java b/campus-server/src/main/java/com/campus/entity/MomentComment.java new file mode 100644 index 0000000..58ecd7f --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MomentComment.java @@ -0,0 +1,55 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 动态评论表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@org.hibernate.annotations.DynamicUpdate +@Table(name = "moment_comment", + indexes = { + @Index(name = "idx_comment_moment_id", columnList = "moment_id"), + @Index(name = "idx_comment_user_id", columnList = "user_id"), + @Index(name = "idx_comment_parent_id", columnList = "parent_id") + }) +@Where(clause = "deleted = false") +public class MomentComment extends BaseEntity { + + /** 动态ID */ + @Column(name = "moment_id", nullable = false, length = 36) + private String momentId; + + /** 评论用户ID */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 评论内容 */ + @Column(nullable = false, length = 1000) + private String content; + + /** 父评论ID(用于回复) */ + @Column(name = "parent_id", length = 36) + private String parentId; + + /** 回复数量 */ + @Column(nullable = false) + private Integer replyCount = 0; + + /** 点赞数 */ + @Column(nullable = false) + private Integer likeCount = 0; + + /** 状态 */ + @Column(nullable = false, length = 20) + private String status = "ACTIVE"; // ACTIVE-正常, DELETED-已删除 +} diff --git a/campus-server/src/main/java/com/campus/entity/MomentCommentLike.java b/campus-server/src/main/java/com/campus/entity/MomentCommentLike.java new file mode 100644 index 0000000..4f32ef0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MomentCommentLike.java @@ -0,0 +1,33 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 评论点赞表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "moment_comment_like", + indexes = { + @Index(name = "idx_clike_comment_id", columnList = "comment_id"), + @Index(name = "idx_clike_user_id", columnList = "user_id") + }, uniqueConstraints = @UniqueConstraint(name = "uk_clike_comment_user", columnNames = {"comment_id", "user_id"})) +@Where(clause = "deleted = false") +public class MomentCommentLike extends BaseEntity { + + /** 评论ID */ + @Column(name = "comment_id", nullable = false, length = 36) + private String commentId; + + /** 点赞用户ID */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; +} diff --git a/campus-server/src/main/java/com/campus/entity/MomentFavorite.java b/campus-server/src/main/java/com/campus/entity/MomentFavorite.java new file mode 100644 index 0000000..29bc790 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MomentFavorite.java @@ -0,0 +1,33 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 动态收藏记录。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "moment_favorite", indexes = { + @Index(name = "idx_favorite_user_created", columnList = "user_id, created_at"), + @Index(name = "idx_favorite_moment_id", columnList = "moment_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_favorite_moment_user", columnNames = {"moment_id", "user_id"}) +}) +@Where(clause = "deleted = false") +public class MomentFavorite extends BaseEntity { + + @Column(name = "moment_id", nullable = false, length = 36) + private String momentId; + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; +} diff --git a/campus-server/src/main/java/com/campus/entity/MomentLike.java b/campus-server/src/main/java/com/campus/entity/MomentLike.java new file mode 100644 index 0000000..1d28560 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MomentLike.java @@ -0,0 +1,36 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 动态点赞表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "moment_like", + indexes = { + @Index(name = "idx_like_moment_id", columnList = "moment_id"), + @Index(name = "idx_like_user_id", columnList = "user_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_like_moment_user", columnNames = {"moment_id", "user_id"}) + }) +@Where(clause = "deleted = false") +public class MomentLike extends BaseEntity { + + /** 动态ID */ + @Column(name = "moment_id", nullable = false, length = 36) + private String momentId; + + /** 点赞用户ID */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; +} diff --git a/campus-server/src/main/java/com/campus/entity/MomentViewRecord.java b/campus-server/src/main/java/com/campus/entity/MomentViewRecord.java new file mode 100644 index 0000000..79a1e6b --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/MomentViewRecord.java @@ -0,0 +1,31 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "moment_view_record", uniqueConstraints = { + @UniqueConstraint(name = "uk_moment_view_user", columnNames = {"moment_id", "user_id"}) +}) +@Where(clause = "deleted = false") +public class MomentViewRecord extends BaseEntity { + @Column(name = "moment_id", nullable = false, length = 36) + private String momentId; + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "last_view_time", nullable = false) + private Date lastViewTime; +} diff --git a/campus-server/src/main/java/com/campus/entity/Notification.java b/campus-server/src/main/java/com/campus/entity/Notification.java new file mode 100644 index 0000000..c582922 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Notification.java @@ -0,0 +1,56 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "notification", indexes = { + @Index(name = "idx_notification_user_deleted_read", columnList = "user_id,deleted,is_read"), + @Index(name = "idx_notification_user_deleted_type", columnList = "user_id,deleted,type") +}) +@Where(clause = "deleted = false") +public class Notification extends BaseEntity { + + /** 接收通知的用户ID */ + @Column(name = "user_id", nullable = false, length = 64) + private String userId; + + /** 通知类型:match/message/application/review */ + @Column(nullable = false, length = 50) + private String type; + + /** 通知标题 */ + @Column(nullable = false, length = 200) + private String title; + + /** 通知内容 */ + @Column(columnDefinition = "TEXT") + private String content; + + /** 是否已读 */ + @Column(name = "is_read", nullable = false) + private Boolean isRead = false; + + /** 通知关联头像URL */ + @Column(length = 500) + private String avatar; + + /** 跳转链接 */ + @Column(length = 500) + private String link; + + /** 关联实体ID */ + @Column(length = 64) + private String relatedId; + + /** 发送者用户ID */ + @Column(length = 64) + private String senderId; +} diff --git a/campus-server/src/main/java/com/campus/entity/NotificationPreference.java b/campus-server/src/main/java/com/campus/entity/NotificationPreference.java new file mode 100644 index 0000000..ad51b17 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/NotificationPreference.java @@ -0,0 +1,25 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.persistence.*; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "notification_preference", uniqueConstraints = @UniqueConstraint(name = "uk_notification_pref_user", columnNames = "user_id")) +@Where(clause = "deleted = false") +public class NotificationPreference extends BaseEntity { + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + @Column(nullable = false) private Boolean pushEnabled = true; + @Column(nullable = false) private Boolean systemEnabled = true; + @Column(nullable = false) private Boolean socialEnabled = true; + @Column(nullable = false) private Boolean activityEnabled = true; + @Column(nullable = false) private Boolean chatEnabled = true; + @Column(name = "quiet_start", length = 5) private String quietStart; + @Column(name = "quiet_end", length = 5) private String quietEnd; +} diff --git a/campus-server/src/main/java/com/campus/entity/NotificationTemplate.java b/campus-server/src/main/java/com/campus/entity/NotificationTemplate.java new file mode 100644 index 0000000..e423876 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/NotificationTemplate.java @@ -0,0 +1,21 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.persistence.*; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "notification_template", uniqueConstraints = @UniqueConstraint(name = "uk_notification_template_code", columnNames = "code")) +@Where(clause = "deleted = false") +public class NotificationTemplate extends BaseEntity { + @Column(nullable = false, length = 50) private String code; + @Column(nullable = false, length = 100) private String name; + @Column(nullable = false, length = 200) private String title; + @Column(nullable = false, columnDefinition = "TEXT") private String content; + @Column(nullable = false) private Boolean enabled = true; +} diff --git a/campus-server/src/main/java/com/campus/entity/PointRule.java b/campus-server/src/main/java/com/campus/entity/PointRule.java new file mode 100644 index 0000000..843cfa8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/PointRule.java @@ -0,0 +1,65 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.math.BigDecimal; + +/** + * 积分规则配置表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "point_rule") +@Where(clause = "deleted = false") +public class PointRule extends BaseEntity { + + /** 规则编码 */ + @Column(nullable = false, unique = true, length = 50) + private String code; + + /** 规则名称 */ + @Column(nullable = false, length = 100) + private String name; + + /** 规则描述 */ + @Column(length = 500) + private String description; + + /** 积分类型 */ + @Column(nullable = false, length = 20) + private String type; // EARN-获得, SPEND-消耗 + + /** 积分数量 */ + @Column(nullable = false) + private Integer points; + + /** 每日上限(0表示不限制) */ + @Column(nullable = false) + private Integer dailyLimit = 0; + + /** 永久上限(0表示不限制) */ + @Column(nullable = false) + private Integer totalLimit = 0; + + /** 是否启用 */ + @Column(nullable = false) + private Boolean enabled = true; + + /** 排序 */ + @Column(nullable = false) + private Integer sort = 0; + + /** 关联类型(如 MOMENT-动态, COMMENT-评论) */ + @Column(length = 50) + private String relatedType; + + /** 备注 */ + @Column(length = 200) + private String remark; +} diff --git a/campus-server/src/main/java/com/campus/entity/PointTransaction.java b/campus-server/src/main/java/com/campus/entity/PointTransaction.java new file mode 100644 index 0000000..728249f --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/PointTransaction.java @@ -0,0 +1,71 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 积分变动记录表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "point_transaction", + indexes = { + @Index(name = "idx_pt_user_id", columnList = "user_id"), + @Index(name = "idx_pt_rule_code", columnList = "rule_code"), + @Index(name = "idx_pt_created_at", columnList = "created_at"), + @Index(name = "idx_pt_related", columnList = "related_type, related_id") + }) +@Where(clause = "deleted = false") +public class PointTransaction extends BaseEntity { + + /** 用户ID */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 规则编码 */ + @Column(name = "rule_code", nullable = false, length = 50) + private String ruleCode; + + /** 规则名称 */ + @Column(name = "rule_name", nullable = false, length = 100) + private String ruleName; + + /** 变动类型 */ + @Column(nullable = false, length = 20) + private String type; // EARN-获得, SPEND-消耗 + + /** 变动积分数量 */ + @Column(nullable = false) + private Integer points; + + /** 变动前积分余额 */ + @Column(nullable = false) + private Integer balanceBefore; + + /** 变动后积分余额 */ + @Column(nullable = false) + private Integer balanceAfter; + + /** 关联类型(如 MOMENT-动态, COMMENT-评论, ACTIVITY-活动, GIFT-礼物) */ + @Column(name = "related_type", length = 50) + private String relatedType; + + /** 关联ID */ + @Column(name = "related_id", length = 36) + private String relatedId; + + /** 关联用户ID(如被点赞的用户、被评论的发布者) */ + @Column(name = "related_user_id", length = 36) + private String relatedUserId; + + /** 备注 */ + @Column(length = 200) + private String remark; +} diff --git a/campus-server/src/main/java/com/campus/entity/Report.java b/campus-server/src/main/java/com/campus/entity/Report.java new file mode 100644 index 0000000..c33d08a --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Report.java @@ -0,0 +1,66 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.util.Date; + +/** 用户举报记录。审核字段先保留,审核流程在后续步骤实现。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "content_report", indexes = { + @Index(name = "idx_reporter_id", columnList = "reporter_id"), + @Index(name = "idx_report_target", columnList = "target_type,target_id"), + @Index(name = "idx_report_status", columnList = "status") +}) +@Where(clause = "deleted = false") +public class Report extends BaseEntity { + + /** 举报人用户ID */ + @Column(name = "reporter_id", nullable = false, length = 36) + private String reporterId; + + /** 被举报对象类型:MOMENT/ACTIVITY/ANNOUNCEMENT/USER */ + @Column(name = "target_type", nullable = false, length = 30) + private String targetType; + + /** 被举报对象ID */ + @Column(name = "target_id", nullable = false, length = 36) + private String targetId; + + /** 举报原因编码或前端展示值 */ + @Column(nullable = false, length = 50) + private String reason; + + /** 补充描述 */ + @Column(length = 500) + private String description; + + /** 处理状态:PENDING/ACCEPTED/REJECTED */ + @Column(nullable = false, length = 20) + private String status = "PENDING"; + + /** 处理动作:NONE/HIDE_CONTENT */ + @Column(nullable = false, length = 30) + private String action = "NONE"; + + /** 审核员用户ID */ + @Column(name = "reviewed_by", length = 36) + private String reviewedBy; + + /** 审核备注/处理结果 */ + @Column(name = "review_note", length = 500) + private String reviewNote; + + /** 审核时间 */ + @Column(name = "reviewed_at") + private Date reviewedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/RewardIssuanceLog.java b/campus-server/src/main/java/com/campus/entity/RewardIssuanceLog.java new file mode 100644 index 0000000..7ba7c09 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/RewardIssuanceLog.java @@ -0,0 +1,44 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 奖励发放账本,用唯一来源约束防止重复发奖。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "reward_issuance_log", uniqueConstraints = { + @UniqueConstraint(name = "uk_reward_source", columnNames = {"source_type", "source_id", "reward_type"}) +}) +@Where(clause = "deleted = false") +public class RewardIssuanceLog extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "source_type", nullable = false, length = 20) + private String sourceType; + + @Column(name = "source_id", nullable = false, length = 36) + private String sourceId; + + @Column(name = "reward_type", nullable = false, length = 30) + private String rewardType; + + @Column(name = "reward_value", nullable = false, length = 100) + private String rewardValue; + + @Column(nullable = false, length = 20) + private String status = "SUCCESS"; + + @Column(length = 36) + private String operator; +} diff --git a/campus-server/src/main/java/com/campus/entity/SensitiveWord.java b/campus-server/src/main/java/com/campus/entity/SensitiveWord.java new file mode 100644 index 0000000..66d2fc4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/SensitiveWord.java @@ -0,0 +1,45 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 敏感词表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "sensitive_word", + indexes = { + @Index(name = "idx_sw_word", columnList = "word"), + @Index(name = "idx_sw_level", columnList = "level") + }) +@Where(clause = "deleted = false") +public class SensitiveWord extends BaseEntity { + + /** 敏感词 */ + @Column(nullable = false, unique = true, length = 100) + private String word; + + /** 敏感级别 */ + @Column(nullable = false, length = 20) + private String level; // HIGH-高危, MEDIUM-中危, LOW-低危 + + /** 类别 */ + @Column(name = "type", length = 50) + private String type; // POLITICS-政治, PORN-色情, VIOLENCE-暴力, AD-广告, GAMBLING-赌博, DRUGS-毒品, FRAUD-诈骗, CURSE-脏话 + + /** 状态 */ + @Column(nullable = false) + private Boolean enabled = true; + + /** 创建人 */ + @Column(name = "created_by", length = 36) + private String createdBy; +} diff --git a/campus-server/src/main/java/com/campus/entity/ShareRequestRecord.java b/campus-server/src/main/java/com/campus/entity/ShareRequestRecord.java new file mode 100644 index 0000000..3029236 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/ShareRequestRecord.java @@ -0,0 +1,50 @@ +package com.campus.entity; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Where; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 分享幂等记录;处理中状态不会自动重放远端消息,以免外部结果未知时重复投递。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "share_request", indexes = { + @Index(name = "idx_share_request_updated_at", columnList = "updated_at") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_share_request_sender_type_key", + columnNames = {"sender_id", "request_type", "request_id"}) +}) +@Where(clause = "deleted = false") +public class ShareRequestRecord extends BaseEntity { + + @Column(name = "sender_id", nullable = false, length = 36) + private String senderId; + + @Column(name = "request_type", nullable = false, length = 32) + private String requestType; + + @Column(name = "request_id", nullable = false, length = 64) + private String requestId; + + @Column(name = "request_hash", nullable = false, length = 64) + private String requestHash; + + @Column(nullable = false, length = 24) + private String status; + + @Column(name = "response_json", columnDefinition = "MEDIUMTEXT") + private String responseJson; + + @Column(name = "error_type", length = 24) + private String errorType; + + @Column(name = "error_message", length = 500) + private String errorMessage; +} diff --git a/campus-server/src/main/java/com/campus/entity/SysDict.java b/campus-server/src/main/java/com/campus/entity/SysDict.java new file mode 100644 index 0000000..627dcd2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/SysDict.java @@ -0,0 +1,42 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 字典主表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "sys_dict", + uniqueConstraints = { + @UniqueConstraint(name = "uk_dict_code", columnNames = {"code", "deleted"}) + }) +@Where(clause = "deleted = false") +public class SysDict extends BaseEntity { + + /** 字典编码(业务用,如 announcement_type) */ + @Column(nullable = false, length = 50) + private String code; + + /** 字典名称(中文,如 "公告类型") */ + @Column(nullable = false, length = 100) + private String name; + + /** 描述 */ + @Column(length = 500) + private String description; + + /** 状态:1-启用 0-禁用 */ + @Column(nullable = false) + private Boolean status = true; +} diff --git a/campus-server/src/main/java/com/campus/entity/SysDictItem.java b/campus-server/src/main/java/com/campus/entity/SysDictItem.java new file mode 100644 index 0000000..abece17 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/SysDictItem.java @@ -0,0 +1,59 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * 字典项明细表 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "sys_dict_item", + indexes = { + @Index(name = "idx_dict_value", columnList = "dict_id,value,deleted"), + @Index(name = "idx_dict_sort", columnList = "dict_id,sort_order") + }) +@Where(clause = "deleted = false") +public class SysDictItem extends BaseEntity { + + /** 字典主表ID */ + @Column(name = "dict_id", nullable = false, length = 36) + private String dictId; + + /** 项值(业务存储用,如 "lecture") */ + @Column(nullable = false, length = 50) + private String value; + + /** 显示文本(如 "讲座") */ + @Column(nullable = false, length = 100) + private String label; + + /** 排序 */ + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 0; + + /** 状态:1-启用 0-禁用 */ + @Column(nullable = false) + private Boolean status = true; + + /** 标签颜色(前端展示用,可选) */ + @Column(length = 20) + private String color; + + /** 图标(可选) */ + @Column(length = 100) + private String icon; + + /** JSON 扩展字段(按业务自定义) */ + @Column(columnDefinition = "TEXT") + private String extra; +} diff --git a/campus-server/src/main/java/com/campus/entity/TaskClaim.java b/campus-server/src/main/java/com/campus/entity/TaskClaim.java new file mode 100644 index 0000000..038d47f --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/TaskClaim.java @@ -0,0 +1,32 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 用户任务领取记录。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "task_claim", uniqueConstraints = { + @UniqueConstraint(name = "uk_task_claim_user_task", columnNames = {"user_id", "task_id"}) +}) +@Where(clause = "deleted = false") +public class TaskClaim extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "task_id", nullable = false, length = 80) + private String taskId; + + @Column(name = "reward_points", nullable = false) + private Integer rewardPoints; +} diff --git a/campus-server/src/main/java/com/campus/entity/TaskDefinition.java b/campus-server/src/main/java/com/campus/entity/TaskDefinition.java new file mode 100644 index 0000000..38c00cf --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/TaskDefinition.java @@ -0,0 +1,48 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 可由管理员配置的积分任务定义。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "task_definition", uniqueConstraints = { + @UniqueConstraint(name = "uk_task_definition_code", columnNames = "code") +}) +@Where(clause = "deleted = false") +public class TaskDefinition extends BaseEntity { + + @Column(nullable = false, length = 50) + private String code; + + @Column(nullable = false, length = 100) + private String name; + + @Column(length = 500) + private String description; + + @Column(nullable = false) + private Integer target = 1; + + @Column(name = "reward_points", nullable = false) + private Integer rewardPoints = 0; + + /** ONCE 或 DAILY。 */ + @Column(nullable = false, length = 20) + private String cycle = "ONCE"; + + @Column(nullable = false) + private Boolean enabled = true; + + @Column(nullable = false) + private Integer sort = 0; +} diff --git a/campus-server/src/main/java/com/campus/entity/Topic.java b/campus-server/src/main/java/com/campus/entity/Topic.java new file mode 100644 index 0000000..a2b8a61 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/Topic.java @@ -0,0 +1,43 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "topic", uniqueConstraints = @UniqueConstraint(name = "uk_topic_name", columnNames = "name")) +@Where(clause = "deleted = false") +public class Topic extends BaseEntity { + @Column(nullable = false, length = 100) + private String name; + @Column(length = 500) + private String description; + @Column(length = 50) + private String category; + @Column(name = "cover_url", length = 500) + private String coverUrl; + @Column(nullable = false, length = 20) + private String status = "PENDING"; + @Column(nullable = false) + private Boolean recommended = false; + @Column(nullable = false) + private Boolean pinned = false; + @Column(name = "created_by", length = 36) + private String createdBy; + @Column(name = "merged_into_id", length = 36) + private String mergedIntoId; + @Column(name = "reviewed_by", length = 36) + private String reviewedBy; + @Column(name = "review_note", length = 500) + private String reviewNote; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "reviewed_at") + private Date reviewedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/TopicAlias.java b/campus-server/src/main/java/com/campus/entity/TopicAlias.java new file mode 100644 index 0000000..4cd98dc --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/TopicAlias.java @@ -0,0 +1,20 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.persistence.*; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "topic_alias", uniqueConstraints = @UniqueConstraint(name = "uk_topic_alias", columnNames = "alias")) +@Where(clause = "deleted = false") +public class TopicAlias extends BaseEntity { + @Column(name = "topic_id", nullable = false, length = 36) + private String topicId; + @Column(nullable = false, length = 100) + private String alias; +} diff --git a/campus-server/src/main/java/com/campus/entity/TopicFavorite.java b/campus-server/src/main/java/com/campus/entity/TopicFavorite.java new file mode 100644 index 0000000..a22bee6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/TopicFavorite.java @@ -0,0 +1,43 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 用户收藏的话题。关联的是 topic.name(正式话题),与动态收藏(moment_favorite)无关。 + * + *

与 {@link TopicFollow} 同构:同一用户同一话题只有一行,因此 + * {@code COUNT(*)} 天然表示"收藏人数"。收藏是"个人书签",与关注(订阅更新)独立计数。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "topic_favorite", indexes = { + @Index(name = "idx_topic_favorite_stable", columnList = "topic_id,user_id"), + @Index(name = "idx_topic_favorite_topic", columnList = "topic_name"), + @Index(name = "idx_topic_favorite_user_time", columnList = "user_id,created_at") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_topic_favorite_user_topic", columnNames = {"user_id", "topic_name"}) +}) +@Where(clause = "deleted = false") +public class TopicFavorite extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 稳定关联ID;扩展阶段允许历史行为空,名称暂保留用于旧版读取。 */ + @Column(name = "topic_id", length = 36) + private String topicId; + + @Column(name = "topic_name", nullable = false, length = 100) + private String topicName; +} diff --git a/campus-server/src/main/java/com/campus/entity/TopicFollow.java b/campus-server/src/main/java/com/campus/entity/TopicFollow.java new file mode 100644 index 0000000..cf626fe --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/TopicFollow.java @@ -0,0 +1,38 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 用户关注的话题。关联的是 topic.name(正式话题),与动态标签无关。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "topic_follow", indexes = { + @Index(name = "idx_topic_follow_stable", columnList = "topic_id,user_id"), + @Index(name = "idx_topic_follow_topic", columnList = "topic_name"), + @Index(name = "idx_topic_follow_user", columnList = "user_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_topic_follow_user_topic", columnNames = {"user_id", "topic_name"}) +}) +@Where(clause = "deleted = false") +public class TopicFollow extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 稳定关联ID;扩展阶段允许历史行为空,名称暂保留用于旧版读取。 */ + @Column(name = "topic_id", length = 36) + private String topicId; + + @Column(name = "topic_name", nullable = false, length = 100) + private String topicName; +} diff --git a/campus-server/src/main/java/com/campus/entity/TopicHotSnapshot.java b/campus-server/src/main/java/com/campus/entity/TopicHotSnapshot.java new file mode 100644 index 0000000..ff0b60b --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/TopicHotSnapshot.java @@ -0,0 +1,49 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.persistence.*; +import java.util.Date; + +/** + * 话题热度快照(每小时一条,供 rising 榜单算增量)。 + *

话题热度只由话题域指标构成(关注 / 访问 / 近 7 天增长),不再读取动态。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "topic_hot_snapshot", indexes = {@Index(name = "idx_topic_hot_snapshot_stable", columnList = "topic_id,captured_at"), @Index(name = "idx_topic_snapshot_time", columnList = "captured_at")}) +@Where(clause = "deleted = false") +public class TopicHotSnapshot extends BaseEntity { + /** 稳定关联ID;扩展阶段允许历史行为空,名称暂保留用于旧版读取。 */ + @Column(name = "topic_id", length = 36) + private String topicId; + + @Column(name = "topic_name", nullable = false, length = 100) + private String topicName; + + /** + * 已废弃:历史列按"动态数"写入,解耦后不再读取也不再有业务含义,固定写 0。 + * 保留列是为了避免在有数据时执行 DROP COLUMN 的 DDL 风险(Flyway 关闭,迁移需人工执行)。 + */ + @Deprecated + @Column(name = "moment_count", nullable = false) + private Long momentCount; + + @Column(name = "follower_count", nullable = false) + private Long followerCount; + + /** 访问人数(话题域指标)。 */ + @Column(name = "visit_count", nullable = false) + private Long visitCount = 0L; + + @Column(name = "heat_score", nullable = false) + private Long heatScore; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "captured_at", nullable = false) + private Date capturedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/TopicVisit.java b/campus-server/src/main/java/com/campus/entity/TopicVisit.java new file mode 100644 index 0000000..e423b29 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/TopicVisit.java @@ -0,0 +1,38 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import javax.persistence.UniqueConstraint; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "topic_visit", indexes = {@Index(name = "idx_topic_visit_stable", columnList = "topic_id,user_id"), @Index(name = "idx_topic_visit_user_time", columnList = "user_id,last_visited_at")}, + uniqueConstraints = @UniqueConstraint(name = "uk_topic_visit_user_topic", columnNames = {"user_id", "topic_name"})) +@Where(clause = "deleted = false") +public class TopicVisit extends BaseEntity { + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 稳定关联ID;扩展阶段允许历史行为空,名称暂保留用于旧版读取。 */ + @Column(name = "topic_id", length = 36) + private String topicId; + + @Column(name = "topic_name", nullable = false, length = 100) + private String topicName; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "last_visited_at", nullable = false) + private Date lastVisitedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/University.java b/campus-server/src/main/java/com/campus/entity/University.java new file mode 100644 index 0000000..d3375a5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/University.java @@ -0,0 +1,75 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * 大学实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "university", indexes = { + @Index(name = "idx_province_city", columnList = "province, city"), + @Index(name = "idx_level_type", columnList = "level, type"), + @Index(name = "idx_name", columnList = "name") +}) +@Where(clause = "deleted = false") +public class University extends BaseEntity { + + /** 大学名称 */ + @Column(nullable = false, unique = true, length = 100) + private String name; + + /** 院校代码(教育部学校(机构)标识码,10位数字,全国唯一、始终不变,用于生成学号前缀) */ + @Column(name = "school_code", length = 20) + private String schoolCode; + + /** 大学简称 */ + @Column(name = "short_name", length = 50) + private String shortName; + + /** 校徽Logo URL */ + @Column(name = "logo_url", length = 255) + private String logoUrl; + + /** 所在省份 */ + @Column(nullable = false, length = 50) + private String province; + + /** 所在城市 */ + @Column(nullable = false, length = 50) + private String city; + + /** 学校层次:985/211/双一流/普通 */ + @Column(length = 20) + private String level; + + /** 学校类型:综合/理工/师范/财经/医药/语言/政法/体育/艺术/农林 */ + @Column(length = 20) + private String type; + + /** 建校年份 */ + @Column(name = "founded_year") + private Integer foundedYear; + + /** 纬度 */ + @Column(precision = 10, scale = 7) + private java.math.BigDecimal latitude; + + /** 经度 */ + @Column(precision = 10, scale = 7) + private java.math.BigDecimal longitude; + + /** 学校简介 */ + @Column(columnDefinition = "TEXT") + private String description; +} diff --git a/campus-server/src/main/java/com/campus/entity/User.java b/campus-server/src/main/java/com/campus/entity/User.java new file mode 100644 index 0000000..2e269ad --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/User.java @@ -0,0 +1,189 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import javax.persistence.*; +import java.util.Date; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(exclude = "interests") +@Entity +@org.hibernate.annotations.DynamicUpdate +@Table(name = "user") +@Where(clause = "deleted = false") +public class User extends BaseEntity { + + /** 用户昵称 */ + @Column(nullable = false, length = 50) + private String nickname; + + /** 用户头像URL */ + @Column(length = 255) + private String avatar; + + /** 性别:M 男 / F 女 / N 保密 */ + @Column(length = 10) + private String gender; + + /** 出生日期 */ + @Column + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date birthday; + + /** 老家 */ + @Column(length = 100) + private String hometown; + + /** 家乡-省 */ + @Column(length = 50) + private String hometownProvince; + + /** 家乡-市 */ + @Column(length = 50) + private String hometownCity; + + /** 家乡-县/区 */ + @Column(length = 50) + private String hometownDistrict; + + /** 所在地 */ + @Column(length = 100) + private String location; + + /** 学历:本科/硕士/博士 */ + @Column(length = 20) + private String education; + + /** 教育形式:全日制/非全日制 */ + @Column(length = 20) + private String educationType; + + /** 所属学校ID(关联大学表) */ + @Column(length = 36) + private String universityId; + + /** 所属学校 */ + @Column(nullable = false, length = 100) + private String universityName; + + /** 学院ID(关联学院表) */ + @Column(length = 36) + private String collegeId; + + /** 学院 */ + @Column(length = 50) + private String collegeName; + + /** 专业ID(关联专业表) */ + @Column(length = 36) + private String majorId; + + /** 专业 */ + @Column(nullable = false, length = 50) + private String majorName; + + /** 第二专业ID(关联专业表) */ + @Column(length = 36) + private String secondMajorId; + + /** 第二专业 */ + @Column(length = 50) + private String secondMajorName; + + /** 年级 */ + @Column(nullable = false, length = 20) + private String grade; + + /** 高中学校 */ + @Column(length = 100) + private String highSchool; + + /** 个人简介 */ + @Column(length = 500) + private String bio; + + /** 兴趣爱好列表 */ + @ElementCollection + @CollectionTable(name = "user_interests", joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "interest") + private List interests; + + /** 是否为审核员 */ + @Column(nullable = false) + private Boolean isReviewer = false; + + /** 是否为系统管理员。管理员拥有账号、字典、敏感词和人工积分等管理权限。 */ + @Column(nullable = false) + private Boolean isAdmin = false; + + /** 账号状态:ACTIVE-正常,DISABLED-已被管理员停用。 */ + @Column(nullable = false, length = 20) + private String accountStatus = "ACTIVE"; + + /** 用户发起注销的时间;注销进入 7 天冷静期。 */ + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "deactivation_requested_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date deactivationRequestedAt; + + /** 冷静期结束后执行数据清理的时间。 */ + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "scheduled_deletion_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date scheduledDeletionAt; + + /** 粉丝数(多少人关注了我) */ + @Column(nullable = false) + private Integer followersCount = 0; + + /** 关注数(我关注了多少人) */ + @Column(nullable = false) + private Integer followingCount = 0; + + /** 手机号(用于登录) */ + @Column(unique = true, nullable = false, length = 20) + private String phone; + + /** 学生识别号码(格式:10位院校代码 + 7位序号,如:41110100010000001) */ + @Column(name = "number", unique = true, length = 30) + private String number; + + /** 登录密码,禁止序列化返回客户端。 */ + @JsonIgnore + @Column(nullable = false) + private String password; + + /** 学信网认证是否已通过:提交学信网报告后,只要识别通过(validChsiReport=true)就立刻 true,不再走人工审核 */ + @Column(nullable = false) + private Boolean xuexinCertified = false; + + /** 学信网认证:学籍状态(在籍/不在籍(毕业)/休学…) */ + @Column(length = 30) + private String xuexinEnrollmentStatus; + + /** 学信网认证通过时间:更换认证材料的冷静期以此为准(NULL 视为已过冷静期) */ + @Column + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date xuexinCertifiedAt; + + /** 当前生效的认证材料(xuexin_report.id),用于从用户反查原始 OCR 记录 */ + @Column(length = 36) + private String xuexinReportId; + + /** 当前位置纬度 */ + @Column + private Double latitude; + + /** 当前位置经度 */ + @Column + private Double longitude; +} diff --git a/campus-server/src/main/java/com/campus/entity/UserAchievement.java b/campus-server/src/main/java/com/campus/entity/UserAchievement.java new file mode 100644 index 0000000..8455507 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserAchievement.java @@ -0,0 +1,19 @@ +package com.campus.entity; +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Where; +import javax.persistence.*; +import java.util.Date; +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name="user_achievement", uniqueConstraints=@UniqueConstraint(name="uk_user_achievement", columnNames={"user_id","achievement_code"})) +@Where(clause="deleted = false") +public class UserAchievement extends BaseEntity { + @Column(name="user_id", nullable=false, length=36) private String userId; + @Column(name="achievement_code", nullable=false, length=40) private String achievementCode; + @Column(name="progress_value", nullable=false) private Long progressValue; + @Temporal(TemporalType.TIMESTAMP) + @Column(name="unlocked_at", nullable=false) private Date unlockedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/UserBlockedMoment.java b/campus-server/src/main/java/com/campus/entity/UserBlockedMoment.java new file mode 100644 index 0000000..39c3f7c --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserBlockedMoment.java @@ -0,0 +1,21 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import javax.persistence.*; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_blocked_moment", uniqueConstraints = @UniqueConstraint( + name = "uk_user_blocked_moment", columnNames = {"user_id", "moment_id"})) +@Where(clause = "deleted = false") +public class UserBlockedMoment extends BaseEntity { + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + @Column(name = "moment_id", nullable = false, length = 36) + private String momentId; +} diff --git a/campus-server/src/main/java/com/campus/entity/UserDeviceToken.java b/campus-server/src/main/java/com/campus/entity/UserDeviceToken.java new file mode 100644 index 0000000..2f1d949 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserDeviceToken.java @@ -0,0 +1,34 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; + +/** + * 用户推送设备Token记录 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_device_token", uniqueConstraints = { + @UniqueConstraint(columnNames = {"userId", "deviceToken"}) +}) +@Where(clause = "deleted = false") +public class UserDeviceToken extends BaseEntity { + + /** 用户ID */ + @Column(nullable = false, length = 64) + private String userId; + + /** 设备Token(Android/iOS push deviceId) */ + @Column(nullable = false, length = 500) + private String deviceToken; + + /** 平台:ANDROID / IOS */ + @Column(nullable = false, length = 20) + private String platform; +} diff --git a/campus-server/src/main/java/com/campus/entity/UserEntitlement.java b/campus-server/src/main/java/com/campus/entity/UserEntitlement.java new file mode 100644 index 0000000..72c33bd --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserEntitlement.java @@ -0,0 +1,39 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import java.util.Date; + +/** 商城兑换或抽奖发放给用户的非现金权益。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_entitlement") +@Where(clause = "deleted = false") +public class UserEntitlement extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(nullable = false, length = 50) + private String type; + + @Column(name = "source_type", nullable = false, length = 20) + private String sourceType; + + @Column(name = "source_id", nullable = false, length = 36) + private String sourceId; + + @Column(name = "expires_at") + private Date expiresAt; + + @Column(nullable = false, length = 20) + private String status = "ACTIVE"; +} diff --git a/campus-server/src/main/java/com/campus/entity/UserPoints.java b/campus-server/src/main/java/com/campus/entity/UserPoints.java new file mode 100644 index 0000000..fad8261 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserPoints.java @@ -0,0 +1,68 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +/** + * 积分账户表(用户积分主表) + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_points") +@Where(clause = "deleted = false") +public class UserPoints extends BaseEntity { + + /** 用户ID */ + @Column(name = "user_id", nullable = false, unique = true, length = 36) + private String userId; + + /** 当前可用积分 */ + @Column(nullable = false) + private Integer points = 0; + + /** 累计获得积分 */ + @Column(nullable = false) + private Integer totalEarned = 0; + + /** 累计消耗积分 */ + @Column(nullable = false) + private Integer totalSpent = 0; + + /** 累计签到天数 */ + @Column(nullable = false) + private Integer totalCheckInDays = 0; + + /** 连续签到天数 */ + @Column(nullable = false) + private Integer consecutiveCheckInDays = 0; + + /** 最后签到时间 */ + @Temporal(TemporalType.DATE) + @Column(name = "last_check_in_date") + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date lastCheckInDate; + + /** 用户等级 */ + @Column(nullable = false) + private Integer level = 1; + + /** 经验值 */ + @Column(nullable = false) + private Integer experience = 0; + + /** + * 获取用户等级 + * 规则:每100经验值升一级 + */ + public void updateLevel() { + this.level = (this.experience / 100) + 1; + } +} diff --git a/campus-server/src/main/java/com/campus/entity/UserPrivacySetting.java b/campus-server/src/main/java/com/campus/entity/UserPrivacySetting.java new file mode 100644 index 0000000..54672d6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserPrivacySetting.java @@ -0,0 +1,46 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** 用户隐私设置。没有历史记录的用户按服务层默认值处理。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_privacy_setting", uniqueConstraints = { + @UniqueConstraint(name = "uk_user_privacy_setting_user_id", columnNames = "user_id") +}) +@Where(clause = "deleted = false") +public class UserPrivacySetting extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** 是否允许陌生人查看资料。 */ + @Column(nullable = false) + private Boolean profileVisible = true; + + /** 是否在校园雷达中展示位置。 */ + @Column(nullable = false) + private Boolean locationVisible = true; + + /** 是否展示在线状态。 */ + @Column(nullable = false) + private Boolean onlineStatusVisible = true; + + /** 是否允许陌生人发起好友申请。 */ + @Column(nullable = false) + private Boolean allowStrangerFriendRequest = true; + + /** 是否允许陌生人发起消息。 */ + @Column(nullable = false) + private Boolean allowStrangerMessage = true; +} diff --git a/campus-server/src/main/java/com/campus/entity/UserRestriction.java b/campus-server/src/main/java/com/campus/entity/UserRestriction.java new file mode 100644 index 0000000..694d66c --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserRestriction.java @@ -0,0 +1,48 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_restriction", indexes = { + @Index(name = "idx_restriction_user", columnList = "user_id"), + @Index(name = "idx_restriction_status", columnList = "status"), + @Index(name = "idx_restriction_expiry", columnList = "expires_at") +}) +@Where(clause = "deleted = false") +public class UserRestriction extends BaseEntity { + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + @Column(name = "restriction_type", nullable = false, length = 20) + private String restrictionType; + @Column(nullable = false, length = 500) + private String reason; + @Column(nullable = false, length = 20) + private String status; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "starts_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date startsAt; + /** null 表示永久限制。 */ + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "expires_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date expiresAt; + @Column(name = "created_by", nullable = false, length = 36) + private String createdBy; + @Column(name = "revoked_by", length = 36) + private String revokedBy; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "revoked_at") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date revokedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/UserSession.java b/campus-server/src/main/java/com/campus/entity/UserSession.java new file mode 100644 index 0000000..ebe1f1d --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserSession.java @@ -0,0 +1,48 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_session", indexes = { + @Index(name = "idx_session_user_status", columnList = "user_id,status"), + @Index(name = "idx_session_token_hash", columnList = "token_hash", unique = true) +}) +@Where(clause = "deleted = false") +public class UserSession extends BaseEntity { + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + @Column(name = "device_id", length = 100) + private String deviceId; + @Column(name = "device_name", length = 100) + private String deviceName; + @Column(length = 30) + private String platform; + @Column(name = "ip_address", length = 64) + private String ipAddress; + @Column(name = "user_agent", length = 500) + private String userAgent; + @Column(nullable = false, length = 20) + private String status = "ACTIVE"; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "last_seen_at", nullable = false) + private Date lastSeenAt; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "expires_at", nullable = false) + private Date expiresAt; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "revoked_at") + private Date revokedAt; + @Column(name = "revoked_by", length = 36) + private String revokedBy; +} diff --git a/campus-server/src/main/java/com/campus/entity/UserTaskProgress.java b/campus-server/src/main/java/com/campus/entity/UserTaskProgress.java new file mode 100644 index 0000000..6309c33 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/UserTaskProgress.java @@ -0,0 +1,45 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.util.Date; + +/** 用户在某个任务周期内的进度与领取状态。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "user_task_progress", uniqueConstraints = { + @UniqueConstraint(name = "uk_user_task_progress", columnNames = {"user_id", "task_code", "cycle_key"}) +}) +@Where(clause = "deleted = false") +public class UserTaskProgress extends BaseEntity { + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "task_code", nullable = false, length = 50) + private String taskCode; + + @Column(name = "cycle_key", nullable = false, length = 20) + private String cycleKey; + + @Column(nullable = false) + private Integer progress = 0; + + @Column(nullable = false) + private Boolean completed = false; + + @Column(nullable = false) + private Boolean claimed = false; + + @Column(name = "claimed_at") + private Date claimedAt; +} diff --git a/campus-server/src/main/java/com/campus/entity/VideoLike.java b/campus-server/src/main/java/com/campus/entity/VideoLike.java new file mode 100644 index 0000000..c05558a --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/VideoLike.java @@ -0,0 +1,28 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "video_like", uniqueConstraints = { + @UniqueConstraint(columnNames = {"video_id", "user_id"}) +}) +@Where(clause = "deleted = false") +public class VideoLike extends BaseEntity { + + @Column(name = "video_id", nullable = false, length = 36) + private String videoId; + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "is_liked", nullable = false) + private Boolean isLiked = true; +} diff --git a/campus-server/src/main/java/com/campus/entity/VideoPlayRecord.java b/campus-server/src/main/java/com/campus/entity/VideoPlayRecord.java new file mode 100644 index 0000000..f70f273 --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/VideoPlayRecord.java @@ -0,0 +1,37 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.*; +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "video_play_record", uniqueConstraints = { + @UniqueConstraint(columnNames = {"video_id", "user_id"}) +}) +@Where(clause = "deleted = false") +public class VideoPlayRecord extends BaseEntity { + + @Column(name = "video_id", nullable = false, length = 36) + private String videoId; + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "play_position", nullable = false) + private Integer playPosition = 0; + + @Column(name = "last_play_time") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date lastPlayTime; + + @Column(name = "play_count", nullable = false) + private Integer playCount = 1; +} diff --git a/campus-server/src/main/java/com/campus/entity/XuexinReport.java b/campus-server/src/main/java/com/campus/entity/XuexinReport.java new file mode 100644 index 0000000..e27ff4b --- /dev/null +++ b/campus-server/src/main/java/com/campus/entity/XuexinReport.java @@ -0,0 +1,151 @@ +package com.campus.entity; + +import org.hibernate.annotations.Where; + +import com.campus.common.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.util.Date; + +/** + * 学信网认证材料(OCR 落库 + 判重 + 存证)。 + * + *

状态机:{@code PENDING}(OCR 成功后 30 分钟临时态)→ {@code CONFIRMED}(认证生效) + * → {@code SUPERSEDED}(被新材料替换,仅留档)。过期 PENDING 由 + * {@code XuexinReportCleanupScheduler} 物理删除。

+ * + *

本表永不软删:{@link BaseEntity#getDeleted()} 恒为 false。原因是 + * {@code uk_xuexin_report_dedup_key} 会被软删行继续占用,导致同一份材料「查不到却插不进去」, + * 表现为 500(参见 AGENTS §7.21)。

+ * + *

一人一条生效认证由 {@code uk_xuexin_report_active_user} 保证: + * CONFIRMED 行把 {@code activeMarker} 设为 {@code userId},其余状态置 NULL; + * MySQL 唯一索引允许多个 NULL,所以 SUPERSEDED 行不占键。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "xuexin_report", indexes = { + @Index(name = "idx_xuexin_report_user_time", columnList = "user_id,created_at"), + @Index(name = "idx_xuexin_report_status_expire", columnList = "status,expire_at") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_xuexin_report_dedup_key", columnNames = "dedup_key"), + @UniqueConstraint(name = "uk_xuexin_report_active_user", columnNames = "active_marker") +}) +@Where(clause = "deleted = false") +public class XuexinReport extends BaseEntity { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_CONFIRMED = "CONFIRMED"; + public static final String STATUS_SUPERSEDED = "SUPERSEDED"; + + /** 提交账号 */ + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + /** PENDING / CONFIRMED / SUPERSEDED */ + @Column(name = "status", nullable = false, length = 20) + private String status; + + /** CONFIRMED 时 = userId,其余为 null(配合唯一键实现「一人一条生效认证」) */ + @Column(name = "active_marker", length = 36) + private String activeMarker; + + /** FP:sha256(稳定学籍字段)。刻意不含在线验证码——验证码有有效期可重新生成,纳入会削弱判重 */ + @Column(name = "dedup_key", nullable = false, length = 160) + private String dedupKey; + + /** 稳定学籍字段的 sha256,仅排查用,不参与唯一约束 */ + @Column(name = "data_fingerprint", nullable = false, length = 64) + private String dataFingerprint; + + /** 学历等级:博士 70 / 硕士 60 / 本科 50 / 高职 40 / 专科 30 / 未知 0 */ + @Column(name = "level_rank", nullable = false) + private Integer levelRank = 0; + + /** 报告标题,当前固定为“教育部学籍在线验证报告” */ + @Column(name = "report_name", length = 100) + private String reportName; + + /** 报告更新日期 yyyy-MM-dd */ + @Column(name = "report_updated_date", length = 10) + private String reportUpdatedDate; + + /** 学信网在线验证码 */ + @Column(name = "online_verification_code", length = 64) + private String onlineVerificationCode; + + /** 报告原始 PDF 的 OSS 地址,存证用 */ + @Column(name = "report_pdf_url", length = 500) + private String reportPdfUrl; + + @Column(name = "name", length = 50) + private String name; + + @Column(name = "gender", length = 8) + private String gender; + + /** yyyy-MM-dd */ + @Column(name = "birth_date", length = 10) + private String birthDate; + + @Column(name = "school", length = 200) + private String school; + + @Column(name = "level", length = 50) + private String level; + + @Column(name = "department", length = 100) + private String department; + + /** 分院/校区,与系所分开保存 */ + @Column(name = "branch_campus", length = 100) + private String branchCampus; + + @Column(name = "major", length = 100) + private String major; + + @Column(name = "study_duration", length = 20) + private String studyDuration; + + @Column(name = "enrollment_date", length = 20) + private String enrollmentDate; + + @Column(name = "departure_date", length = 20) + private String departureDate; + + @Column(name = "enrollment_status", length = 30) + private String enrollmentStatus; + + @Column(name = "study_form", length = 30) + private String studyForm; + + @Column(name = "education_category", length = 30) + private String educationCategory; + + @Column(name = "ethnicity", length = 20) + private String ethnicity; + + /** PDF 文本提取或 OCR 原始文本(\n 分隔),审计/复现用 */ + @Column(name = "raw_text", columnDefinition = "mediumtext") + private String rawText; + + @Column(name = "valid_chsi_report", nullable = false) + private Boolean validChsiReport = Boolean.TRUE; + + /** 仅 PENDING 有值 */ + @Column(name = "expire_at") + private Date expireAt; + + @Column(name = "confirmed_at") + private Date confirmedAt; + + @Column(name = "superseded_at") + private Date supersededAt; +} diff --git a/campus-server/src/main/java/com/campus/repository/AchievementDefinitionRepository.java b/campus-server/src/main/java/com/campus/repository/AchievementDefinitionRepository.java new file mode 100644 index 0000000..e2c710f --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/AchievementDefinitionRepository.java @@ -0,0 +1,11 @@ +package com.campus.repository; +import com.campus.entity.AchievementDefinition; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.util.*; +public interface AchievementDefinitionRepository extends JpaRepository { + Optional findByCode(String code); + List findByEnabledTrueOrderBySortAscCodeAsc(); + @Query(value="select * from achievement_definition where code = ?1", nativeQuery=true) + Optional findIncludingDeleted(String code); +} diff --git a/campus-server/src/main/java/com/campus/repository/ActivityOperationLogRepository.java b/campus-server/src/main/java/com/campus/repository/ActivityOperationLogRepository.java new file mode 100644 index 0000000..e6d39d7 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ActivityOperationLogRepository.java @@ -0,0 +1,11 @@ +package com.campus.repository; + +import com.campus.entity.ActivityOperationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface ActivityOperationLogRepository extends JpaRepository { + List findByActivityIdOrderByCreatedAtDesc(String activityId); +} diff --git a/campus-server/src/main/java/com/campus/repository/ActivityParticipantRepository.java b/campus-server/src/main/java/com/campus/repository/ActivityParticipantRepository.java new file mode 100644 index 0000000..aa909b0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ActivityParticipantRepository.java @@ -0,0 +1,47 @@ +package com.campus.repository; + +import com.campus.entity.Activity; +import com.campus.entity.ActivityParticipant; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ActivityParticipantRepository extends JpaRepository { + + List findByActivityId(String activityId); + + List findByActivityIdAndStatus(String activityId, String status); + + List findByActivityIdOrderByJoinedAtDesc(String activityId); + + Page findByActivityId(String activityId, Pageable pageable); + + List findByUserId(String userId); + + Page findByUserId(String userId, Pageable pageable); + + /** 返回全部匹配行;历史脏数据下可能存在多行,由调用方逐行处理。 */ + List findByActivityIdAndUserIdAndStatus(String activityId, String userId, String status); + + /** 历史上可能存在重复报名行,这里必须只取一行,避免非唯一结果异常导致 500。 */ + Optional findFirstByActivityIdAndUserIdAndStatusOrderByJoinedAtDesc( + String activityId, String userId, String status); + + Optional findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(String activityId, String userId); + + boolean existsByActivityIdAndUserIdAndStatus(String activityId, String userId, String status); + + long countByActivityId(String activityId); + + @Query("SELECT a FROM Activity a WHERE a.deleted = false AND a.id IN (" + + "SELECT p.activityId FROM ActivityParticipant p WHERE p.deleted = false AND p.userId = :userId AND p.status = 'CONFIRMED') " + + "ORDER BY a.createdAt DESC") + Page findJoinedActivitiesByUserId(@Param("userId") String userId, Pageable pageable); +} diff --git a/campus-server/src/main/java/com/campus/repository/ActivityPaymentOrderRepository.java b/campus-server/src/main/java/com/campus/repository/ActivityPaymentOrderRepository.java new file mode 100644 index 0000000..d12ad84 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ActivityPaymentOrderRepository.java @@ -0,0 +1,13 @@ +package com.campus.repository; + +import com.campus.entity.ActivityPaymentOrder; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ActivityPaymentOrderRepository extends JpaRepository { + Optional findByRegistrationId(String registrationId); + Optional findByRegistrationIdAndUserId(String registrationId, String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/ActivityRegistrationRepository.java b/campus-server/src/main/java/com/campus/repository/ActivityRegistrationRepository.java new file mode 100644 index 0000000..35319a9 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ActivityRegistrationRepository.java @@ -0,0 +1,15 @@ +package com.campus.repository; + +import com.campus.entity.ActivityRegistration; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ActivityRegistrationRepository extends JpaRepository { + List findByUserIdOrderByCreatedAtDesc(String userId); + List findByActivityIdOrderByCreatedAtDesc(String activityId); + List findByActivityIdAndStatusIn(String activityId, List statuses); + boolean existsByActivityIdAndUserIdAndStatusIn(String activityId, String userId, List statuses); +} diff --git a/campus-server/src/main/java/com/campus/repository/ActivityRepository.java b/campus-server/src/main/java/com/campus/repository/ActivityRepository.java new file mode 100644 index 0000000..e31b528 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ActivityRepository.java @@ -0,0 +1,255 @@ +package com.campus.repository; + +import com.campus.entity.Activity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import javax.persistence.LockModeType; + +import java.util.Date; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ActivityRepository extends JpaRepository { + + @Query(value = "SELECT * FROM activity WHERE id = :id LIMIT 1", nativeQuery = true) + Optional findIncludingDeleted(@Param("id") String id); + + @Modifying + @Query(value = "UPDATE activity SET deleted = false, review_status = 'approved', review_reason = NULL, " + + "status = CASE WHEN status = 'INACTIVE' THEN 'ACTIVE' ELSE status END WHERE id = :id", nativeQuery = true) + int restoreApproved(@Param("id") String id); + + long countByReviewStatus(String reviewStatus); + + /** + * 按关联群反查活动,用于「群来源标记缺失」时的兜底判定(只会让判定更严格)。 + * 注意:受 {@code @Where(deleted = false)} 约束,软删活动查不到; + * 需要覆盖软删场景时用 {@link #findIncludingDeleted(String)}。 + */ + Optional findFirstByGroupId(String groupId); + + String ACTIVITY_TYPE_FILTER = "(:type IS NULL OR :type = '' OR LOWER(a.type) = LOWER(:type) OR " + + "(LOWER(:type) = 'sports' AND (LOWER(a.type) IN ('sport', 'sports') OR a.type IN ('运动', '运动健身', '体育', '体育运动'))) OR " + + "(LOWER(:type) = 'study' AND (LOWER(a.type) = 'study' OR a.type IN ('学习', '学习交流'))) OR " + + "(LOWER(:type) = 'party' AND (LOWER(a.type) IN ('party', 'offline', 'gathering') OR a.type IN ('聚会', '线下聚会'))) OR " + + "(LOWER(:type) = 'food' AND (LOWER(a.type) = 'food' OR a.type IN ('美食', '餐饮', '餐饮美食'))) OR " + + "(LOWER(:type) = 'travel' AND (LOWER(a.type) IN ('travel', 'trip') OR a.type IN ('出行', '旅行'))) OR " + + "(LOWER(:type) = 'game' AND (LOWER(a.type) IN ('game', 'ent', 'entertainment') OR a.type IN ('游戏', '娱乐', '休闲娱乐'))) OR " + + "(LOWER(:type) = 'volunteer' AND (LOWER(a.type) IN ('volunteer', 'charity') OR a.type IN ('志愿', '志愿服务', '公益', '公益志愿'))) OR " + + "(LOWER(:type) = 'other' AND (LOWER(a.type) IN ('other', 'others') OR a.type = '其他')))"; + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select a from Activity a where a.id = :id") + java.util.Optional findByIdForUpdate(@Param("id") String id); + + Page findByType(String type, Pageable pageable); + + @Query("SELECT a FROM Activity a WHERE a.deleted = false AND a.status <> 'CANCELLED' AND a.creatorId = :creatorId") + Page findByCreatorId(@Param("creatorId") String creatorId, Pageable pageable); + + @Query("SELECT a FROM Activity a WHERE a.deleted = false AND a.status <> 'CANCELLED' AND a.status = :status") + Page findByStatus(@Param("status") String status, Pageable pageable); + + List findByStatusAndEndTimeBefore(String status, Date endTime); + + List findByStatusAndEndTimeIsNullAndEventTimeBefore(String status, Date eventTime); + + /** 已结束但群聊尚未成功解散的活动,供定时任务继续重试。 */ + List findByStatusAndGroupIdIsNotNull(String status); + + Page findByReviewStatus(String reviewStatus, Pageable pageable); + + Page findByStatusAndType(String status, String type, Pageable pageable); + + @Query("SELECT a FROM Activity a WHERE a.deleted = false AND a.status <> 'CANCELLED' AND " + + ACTIVITY_TYPE_FILTER + " AND " + + "(:status IS NULL OR :status = '' OR a.status = :status) AND " + + "(:reviewStatus IS NULL OR :reviewStatus = '' OR a.reviewStatus = :reviewStatus) AND " + + "(:creatorId IS NULL OR :creatorId = '' OR a.creatorId = :creatorId) AND " + + "(:keyword IS NULL OR :keyword = '' OR " + + "a.title LIKE CONCAT('%', :keyword, '%') OR " + + "a.description LIKE CONCAT('%', :keyword, '%') OR " + + "a.location LIKE CONCAT('%', :keyword, '%')) " + + "ORDER BY a.heat DESC, a.createdAt DESC") + Page findByConditions( + @Param("type") String type, + @Param("status") String status, + @Param("reviewStatus") String reviewStatus, + @Param("creatorId") String creatorId, + @Param("keyword") String keyword, + Pageable pageable); + + @Query("SELECT a FROM Activity a WHERE a.deleted = false AND a.status <> 'CANCELLED' AND " + + ACTIVITY_TYPE_FILTER + " AND " + + "(:status IS NULL OR :status = '' OR a.status = :status) AND " + + "(:reviewStatus IS NULL OR :reviewStatus = '' OR a.reviewStatus = :reviewStatus) AND " + + "(:creatorId IS NULL OR :creatorId = '' OR a.creatorId = :creatorId) AND " + + "(:keyword IS NULL OR :keyword = '' OR " + + "a.title LIKE CONCAT('%', :keyword, '%') OR " + + "a.description LIKE CONCAT('%', :keyword, '%') OR " + + "a.location LIKE CONCAT('%', :keyword, '%')) AND " + + "(:periodStart IS NULL OR a.createdAt >= :periodStart) " + + "ORDER BY a.createdAt DESC") + Page findByConditionsWithPeriod( + @Param("type") String type, + @Param("status") String status, + @Param("reviewStatus") String reviewStatus, + @Param("creatorId") String creatorId, + @Param("keyword") String keyword, + @Param("periodStart") Date periodStart, + Pageable pageable); + + @Query(value = "SELECT a.* FROM activity a WHERE a.deleted = false AND a.status <> 'CANCELLED' AND " + + ACTIVITY_TYPE_FILTER + " AND " + + "(:status IS NULL OR :status = '' OR a.status = :status) AND " + + "(:reviewStatus IS NULL OR :reviewStatus = '' OR a.review_status = :reviewStatus) AND " + + "(:creatorId IS NULL OR :creatorId = '' OR a.creator_id = :creatorId) AND " + + "(:keyword IS NULL OR :keyword = '' OR " + + "a.title LIKE CONCAT('%', :keyword, '%') OR " + + "a.description LIKE CONCAT('%', :keyword, '%') OR " + + "a.location LIKE CONCAT('%', :keyword, '%')) AND " + + "(:periodStart IS NULL OR a.created_at >= :periodStart) AND " + + "(:userSchool IS NULL OR :userSchool = '' OR " + + "a.scope IS NULL OR a.scope = '' OR a.scope = 'ALL' OR " + + "a.scope = :userSchool OR " + + "(JSON_VALID(a.scope) AND JSON_CONTAINS(a.scope, JSON_QUOTE(:userSchool)))) " + + "ORDER BY a.created_at DESC", + countQuery = "SELECT COUNT(*) FROM activity a WHERE a.deleted = false AND a.status <> 'CANCELLED' AND " + + ACTIVITY_TYPE_FILTER + " AND " + + "(:status IS NULL OR :status = '' OR a.status = :status) AND " + + "(:reviewStatus IS NULL OR :reviewStatus = '' OR a.review_status = :reviewStatus) AND " + + "(:creatorId IS NULL OR :creatorId = '' OR a.creator_id = :creatorId) AND " + + "(:keyword IS NULL OR :keyword = '' OR " + + "a.title LIKE CONCAT('%', :keyword, '%') OR " + + "a.description LIKE CONCAT('%', :keyword, '%') OR " + + "a.location LIKE CONCAT('%', :keyword, '%')) AND " + + "(:periodStart IS NULL OR a.created_at >= :periodStart) AND " + + "(:userSchool IS NULL OR :userSchool = '' OR " + + "a.scope IS NULL OR a.scope = '' OR a.scope = 'ALL' OR " + + "a.scope = :userSchool OR " + + "(JSON_VALID(a.scope) AND JSON_CONTAINS(a.scope, JSON_QUOTE(:userSchool))))", + nativeQuery = true) + Page findByConditionsWithPeriodAndScope( + @Param("type") String type, + @Param("status") String status, + @Param("reviewStatus") String reviewStatus, + @Param("creatorId") String creatorId, + @Param("keyword") String keyword, + @Param("periodStart") Date periodStart, + @Param("userSchool") String userSchool, + Pageable pageable); + + @Query(value = "SELECT a.* FROM activity a WHERE a.deleted = false AND a.status <> 'CANCELLED' AND " + + ACTIVITY_TYPE_FILTER + " AND " + + "(:userSchool IS NULL OR :userSchool = '' OR " + + "a.scope IS NULL OR a.scope = '' OR a.scope = 'ALL' OR " + + "a.scope = :userSchool OR " + + "(JSON_VALID(a.scope) AND JSON_CONTAINS(a.scope, JSON_QUOTE(:userSchool)))) " + + "ORDER BY a.heat DESC, a.created_at DESC", + countQuery = "SELECT COUNT(*) FROM activity a WHERE a.deleted = false AND a.status <> 'CANCELLED' AND " + + ACTIVITY_TYPE_FILTER + " AND " + + "(:userSchool IS NULL OR :userSchool = '' OR " + + "a.scope IS NULL OR a.scope = '' OR a.scope = 'ALL' OR " + + "a.scope = :userSchool OR " + + "(JSON_VALID(a.scope) AND JSON_CONTAINS(a.scope, JSON_QUOTE(:userSchool))))", + nativeQuery = true) + Page findByTypeAndScope( + @Param("type") String type, + @Param("userSchool") String userSchool, + Pageable pageable); + + @Query(value = "SELECT a.* FROM activity a WHERE a.deleted = false AND a.status = :status AND a.status <> 'CANCELLED' AND " + + "(:userSchool IS NULL OR :userSchool = '' OR " + + "a.scope IS NULL OR a.scope = '' OR a.scope = 'ALL' OR " + + "a.scope = :userSchool OR " + + "(JSON_VALID(a.scope) AND JSON_CONTAINS(a.scope, JSON_QUOTE(:userSchool)))) " + + "ORDER BY a.heat DESC, a.created_at DESC", + countQuery = "SELECT COUNT(*) FROM activity a WHERE a.deleted = false AND a.status = :status AND a.status <> 'CANCELLED' AND " + + "(:userSchool IS NULL OR :userSchool = '' OR " + + "a.scope IS NULL OR a.scope = '' OR a.scope = 'ALL' OR " + + "a.scope = :userSchool OR " + + "(JSON_VALID(a.scope) AND JSON_CONTAINS(a.scope, JSON_QUOTE(:userSchool))))", + nativeQuery = true) + Page findByStatusAndScope( + @Param("status") String status, + @Param("userSchool") String userSchool, + Pageable pageable); + + /** + * 查找附近用户创建的活动 + */ + @Query(value = "SELECT a.* FROM activity a " + + "INNER JOIN user u ON a.creator_id = u.id " + + "WHERE a.deleted = false AND a.status = 'ACTIVE' AND a.status <> 'CANCELLED' " + + "AND u.deleted = false AND u.latitude IS NOT NULL AND u.longitude IS NOT NULL " + + "AND u.id != :excludeId " + + "AND (6371000 * 2 * ASIN(SQRT(POWER(SIN((:lat - u.latitude) * PI() / 180 / 2), 2) + " + + "COS(:lat * PI() / 180) * COS(u.latitude * PI() / 180) * " + + "POWER(SIN((:lng - u.longitude) * PI() / 180 / 2), 2)))) < :radius " + + "ORDER BY a.created_at DESC LIMIT 50", nativeQuery = true) + List findNearbyActivities(@Param("excludeId") String excludeId, + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radius") int radius); + + /** + * 查找同校用户创建的附近活动 + */ + @Query(value = "SELECT a.* FROM activity a " + + "INNER JOIN user u ON a.creator_id = u.id " + + "WHERE a.deleted = false AND a.status = 'ACTIVE' AND a.status <> 'CANCELLED' " + + "AND u.deleted = false AND u.latitude IS NOT NULL AND u.longitude IS NOT NULL " + + "AND u.id != :excludeId AND u.university_name = :school " + + "AND (6371000 * 2 * ASIN(SQRT(POWER(SIN((:lat - u.latitude) * PI() / 180 / 2), 2) + " + + "COS(:lat * PI() / 180) * COS(u.latitude * PI() / 180) * " + + "POWER(SIN((:lng - u.longitude) * PI() / 180 / 2), 2)))) < :radius " + + "ORDER BY a.created_at DESC LIMIT 50", nativeQuery = true) + List findNearbyActivitiesBySchool(@Param("school") String school, + @Param("excludeId") String excludeId, + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radius") int radius); + + /** + * 查找附近用户创建的活动 + */ + @Query(value = "SELECT a.* FROM activity a " + + "INNER JOIN user u ON a.creator_id = u.id " + + "WHERE a.deleted = false AND a.status = 'ACTIVE' AND a.status <> 'CANCELLED' " + + "AND u.deleted = false AND a.latitude IS NOT NULL AND a.longitude IS NOT NULL " + + "AND u.id != :excludeId " + + "AND (6371000 * 2 * ASIN(SQRT(POWER(SIN((:lat - a.latitude) * PI() / 180 / 2), 2) + " + + "COS(:lat * PI() / 180) * COS(a.latitude * PI() / 180) * " + + "POWER(SIN((:lng - a.longitude) * PI() / 180 / 2), 2)))) < :radius " + + "ORDER BY a.created_at DESC LIMIT 50", nativeQuery = true) + List findNearbyActivitiesAtVenue(@Param("excludeId") String excludeId, + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radius") int radius); + + /** + * 查找同校用户创建的附近活动 + */ + @Query(value = "SELECT a.* FROM activity a " + + "INNER JOIN user u ON a.creator_id = u.id " + + "WHERE a.deleted = false AND a.status = 'ACTIVE' AND a.status <> 'CANCELLED' " + + "AND u.deleted = false AND a.latitude IS NOT NULL AND a.longitude IS NOT NULL " + + "AND u.id != :excludeId AND u.university_name = :school " + + "AND (6371000 * 2 * ASIN(SQRT(POWER(SIN((:lat - a.latitude) * PI() / 180 / 2), 2) + " + + "COS(:lat * PI() / 180) * COS(a.latitude * PI() / 180) * " + + "POWER(SIN((:lng - a.longitude) * PI() / 180 / 2), 2)))) < :radius " + + "ORDER BY a.created_at DESC LIMIT 50", nativeQuery = true) + List findNearbyActivitiesAtVenueBySchool(@Param("school") String school, + @Param("excludeId") String excludeId, + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radius") int radius); +} diff --git a/campus-server/src/main/java/com/campus/repository/ActivityTicketRepository.java b/campus-server/src/main/java/com/campus/repository/ActivityTicketRepository.java new file mode 100644 index 0000000..87139f6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ActivityTicketRepository.java @@ -0,0 +1,22 @@ +package com.campus.repository; + +import com.campus.entity.ActivityTicket; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import javax.persistence.LockModeType; + +import java.util.List; + +@Repository +public interface ActivityTicketRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from ActivityTicket t where t.id = :id") + java.util.Optional findByIdForUpdate(@Param("id") String id); + + List findByActivityIdAndEnabledTrueOrderBySortOrderAsc(String activityId); + List findByActivityIdOrderBySortOrderAsc(String activityId); +} diff --git a/campus-server/src/main/java/com/campus/repository/AdminAuditLogRepository.java b/campus-server/src/main/java/com/campus/repository/AdminAuditLogRepository.java new file mode 100644 index 0000000..1442965 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/AdminAuditLogRepository.java @@ -0,0 +1,24 @@ +package com.campus.repository; + +import com.campus.entity.AdminAuditLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface AdminAuditLogRepository extends JpaRepository { + + @Query("select l from AdminAuditLog l where " + + "(:operatorId is null or l.operatorId = :operatorId) and " + + "(:action is null or l.action = :action) and " + + "(:targetType is null or l.targetType = :targetType) and " + + "(:targetId is null or l.targetId = :targetId)") + Page search(@Param("operatorId") String operatorId, + @Param("action") String action, + @Param("targetType") String targetType, + @Param("targetId") String targetId, + Pageable pageable); +} diff --git a/campus-server/src/main/java/com/campus/repository/AnnouncementRepository.java b/campus-server/src/main/java/com/campus/repository/AnnouncementRepository.java new file mode 100644 index 0000000..0162c81 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/AnnouncementRepository.java @@ -0,0 +1,47 @@ +package com.campus.repository; + +import com.campus.entity.Announcement; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.util.Optional; +import java.util.Date; +import java.util.List; + +@Repository +public interface AnnouncementRepository extends JpaRepository { + + @Query(value = "SELECT * FROM announcement WHERE id = :id LIMIT 1", nativeQuery = true) + Optional findIncludingDeleted(@Param("id") String id); + + @Modifying + @Query(value = "UPDATE announcement SET deleted = false, status = 'approved', review_reason = NULL WHERE id = :id", nativeQuery = true) + int restoreApproved(@Param("id") String id); + + long countByStatus(String status); + + Page findByType(String type, Pageable pageable); + + Page findByStatus(String status, Pageable pageable); + + Page findByTypeAndStatus(String type, String status, Pageable pageable); + + Page findByCreatorId(String creatorId, Pageable pageable); + + List findByStatusAndScheduledPublishAtBefore(String status, Date deadline); + + @Query("SELECT a FROM Announcement a WHERE a.deleted = false AND (a.scopeType = :scopeType OR a.universityIds LIKE %:universityId%)") + Page findByScopeTypeOrUniversityIdContaining(@Param("scopeType") String scopeType, + @Param("universityId") String universityId, + Pageable pageable); + + @Query("SELECT a FROM Announcement a WHERE a.deleted = false AND a.type = :type AND (a.scopeType = :scopeType OR a.universityIds LIKE %:universityId%)") + Page findByTypeAndScopeTypeOrUniversityIdContaining(@Param("type") String type, + @Param("scopeType") String scopeType, + @Param("universityId") String universityId, + Pageable pageable); +} diff --git a/campus-server/src/main/java/com/campus/repository/AreaRepository.java b/campus-server/src/main/java/com/campus/repository/AreaRepository.java new file mode 100644 index 0000000..5d5009e --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/AreaRepository.java @@ -0,0 +1,41 @@ +package com.campus.repository; + +import com.campus.entity.Area; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AreaRepository extends JpaRepository { + + /** + * 根据父级地区ID查询子地区 + */ + List findByAreAreaIdAndIsdelOrderByHotDesc(Integer areAreaId, Integer isdel); + + /** + * 根据级别查询地区 + */ + List findByLevelAndIsdelOrderByHotDesc(Integer level, Integer isdel); + + /** + * 查询热门地区 + */ + List findByHotAndIsdelOrderByHotDesc(Integer hot, Integer isdel); + + /** + * 根据名称模糊查询 + */ + List findByNameContainingAndIsdel(String name, Integer isdel); + + /** + * 根据编码查询 + */ + Area findByCodeAndIsdel(String code, Integer isdel); + + /** + * 查询所有未删除的地区 + */ + List findByIsdel(Integer isdel); +} diff --git a/campus-server/src/main/java/com/campus/repository/ChatConversationRepository.java b/campus-server/src/main/java/com/campus/repository/ChatConversationRepository.java new file mode 100644 index 0000000..b0edb46 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ChatConversationRepository.java @@ -0,0 +1,31 @@ +package com.campus.repository; + +import com.campus.entity.ChatConversation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ChatConversationRepository extends JpaRepository { + + Optional findByUserIdAndPeerId(String userId, String peerId); + + List findByUserIdOrderByLastMessageTimeDesc(String userId); + + List findByUserId(String userId); + + void deleteByUserIdAndPeerId(String userId, String peerId); + + /** 解散群聊时移除所有成员的会话入口。 */ + void deleteByPeerId(String peerId); + + long countByUserIdAndUnreadCountGreaterThan(String userId, Integer count); + + /** 汇总用户所有会话的未读消息总数 */ + @Query("SELECT COALESCE(SUM(c.unreadCount), 0) FROM ChatConversation c WHERE c.userId = :userId") + long sumUnreadCountByUserId(@Param("userId") String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/ChatMessageRepository.java b/campus-server/src/main/java/com/campus/repository/ChatMessageRepository.java new file mode 100644 index 0000000..7b702ce --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ChatMessageRepository.java @@ -0,0 +1,81 @@ +package com.campus.repository; + +import com.campus.entity.ChatMessage; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ChatMessageRepository extends JpaRepository { + + /** 按业务UUID查询 */ + Optional findByMessageId(String messageId); + + long countByConversationIdAndIsReadFalse(String conversationId); + + List findByConversationIdAndIsReadFalse(String conversationId); + + /** + * 把自己在某个会话中未读的消息标记为已读(用于「全部标为已读」, + * 与 chat_conversation.unread_count = 0 保持同步)。 + */ + @Modifying + @Query("UPDATE ChatMessage m SET m.isRead = true WHERE m.conversationId = :conversationId " + + "AND m.senderId <> :userId AND m.isRead = false") + int markAsReadByConversationIdAndUserId(@Param("conversationId") String conversationId, + @Param("userId") String userId); + + // ==================== 历史记录(按用户清空水位线过滤) ==================== + // + // 「清空聊天记录」不再删除 chat_message(那是双方/全群共享的一份记录), + // 而是抬高 chat_conversation.cleared_seq_id。因此所有读取路径都必须带上 + // seqId > clearedSeqId,否则用户清空后仍能搜到 / 导出 / 被增量同步拉回旧消息。 + + /** 取某会话当前最大 seq_id(清空时用来定格水位线) */ + @Query("SELECT COALESCE(MAX(m.seqId), 0) FROM ChatMessage m WHERE m.conversationId = :conversationId") + Long findMaxSeqIdByConversationId(@Param("conversationId") String conversationId); + + /** 历史分页:只返回水位线之后的消息 */ + Page findByConversationIdAndSeqIdGreaterThan( + String conversationId, Long seqId, Pageable pageable); + + /** 导出(倒序):只导水位线之后的消息 */ + List findByConversationIdAndSeqIdGreaterThanOrderByCreatedAtDesc( + String conversationId, Long seqId, Pageable pageable); + + /** + * 关键词搜索:只搜水位线之后的消息。 + * createdAt 精度有限、可能并列,补 seqId 作次级排序键保证分页顺序稳定。 + */ + @Query("select m from ChatMessage m where m.conversationId = :conversationId " + + "and m.seqId > :clearedSeqId " + + "and lower(m.content) like lower(concat('%', :keyword, '%')) " + + "order by m.createdAt desc, m.seqId desc") + Page search(@Param("conversationId") String conversationId, + @Param("keyword") String keyword, + @Param("clearedSeqId") Long clearedSeqId, + Pageable pageable); + + // ==================== 增量同步 ==================== + + /** 获取全局最大 seq_id */ + @Query("SELECT COALESCE(MAX(m.seqId), 0) FROM ChatMessage m") + Long findMaxSeqId(); + + /** + * 按会话增量拉取:只返回 seq_id > sinceSeqId 的消息。 + * 调用方需保证 sinceSeqId 已与「该用户的清空水位线」取过最大值。 + */ + List findByConversationIdAndSeqIdGreaterThanOrderBySeqIdAsc( + String conversationId, Long sinceSeqId); + + /** 全局增量拉取:返回所有 seq_id > sinceSeqId 的消息(各会话水位线由调用方在内存中过滤) */ + List findBySeqIdGreaterThanOrderBySeqIdAsc(Long sinceSeqId); +} diff --git a/campus-server/src/main/java/com/campus/repository/ChatReportRepository.java b/campus-server/src/main/java/com/campus/repository/ChatReportRepository.java new file mode 100644 index 0000000..78e9c8c --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ChatReportRepository.java @@ -0,0 +1,9 @@ +package com.campus.repository; +import com.campus.entity.ChatReport; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +public interface ChatReportRepository extends JpaRepository { + Page findByStatusOrderByCreatedAtAsc(String status, Pageable pageable); + boolean existsByReporterIdAndMessageId(String reporterId, String messageId); +} diff --git a/campus-server/src/main/java/com/campus/repository/CollegeRepository.java b/campus-server/src/main/java/com/campus/repository/CollegeRepository.java new file mode 100644 index 0000000..dcbd585 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/CollegeRepository.java @@ -0,0 +1,15 @@ +package com.campus.repository; + +import com.campus.entity.College; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CollegeRepository extends JpaRepository { + + List findByUniversityId(String universityId); + + List findByNameContaining(String name); +} diff --git a/campus-server/src/main/java/com/campus/repository/CommentRepository.java b/campus-server/src/main/java/com/campus/repository/CommentRepository.java new file mode 100644 index 0000000..9f60492 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/CommentRepository.java @@ -0,0 +1,15 @@ +package com.campus.repository; + +import com.campus.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + + Page findByPostId(String postId, Pageable pageable); + + Long countByPostId(String postId); + + Page findByAuthorId(String authorId, Pageable pageable); +} diff --git a/campus-server/src/main/java/com/campus/repository/CommerceOrderRepository.java b/campus-server/src/main/java/com/campus/repository/CommerceOrderRepository.java new file mode 100644 index 0000000..a0bfce3 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/CommerceOrderRepository.java @@ -0,0 +1,10 @@ +package com.campus.repository; + +import com.campus.entity.CommerceOrder; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CommerceOrderRepository extends JpaRepository { + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/ContentAppealRepository.java b/campus-server/src/main/java/com/campus/repository/ContentAppealRepository.java new file mode 100644 index 0000000..cac2a46 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ContentAppealRepository.java @@ -0,0 +1,15 @@ +package com.campus.repository; + +import com.campus.entity.ContentAppeal; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ContentAppealRepository extends JpaRepository { + boolean existsByUserIdAndContentTypeAndContentIdAndStatus( + String userId, String contentType, String contentId, String status); + Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); + Page findByStatusOrderByCreatedAtAsc(String status, Pageable pageable); +} diff --git a/campus-server/src/main/java/com/campus/repository/ContentDraftVersionRepository.java b/campus-server/src/main/java/com/campus/repository/ContentDraftVersionRepository.java new file mode 100644 index 0000000..522863f --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ContentDraftVersionRepository.java @@ -0,0 +1,17 @@ +package com.campus.repository; + +import com.campus.entity.ContentDraftVersion; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface ContentDraftVersionRepository extends JpaRepository { + List findByUserIdOrderByUpdatedAtDesc(String userId); + List findByUserIdAndDraftKeyOrderByVersionDesc(String userId, String draftKey); + @Query("select max(d.version) from ContentDraftVersion d where d.userId=:userId and d.draftKey=:draftKey") + Integer maxVersion(@Param("userId") String userId, @Param("draftKey") String draftKey); + void deleteByUserIdAndDraftKey(String userId, String draftKey); +} diff --git a/campus-server/src/main/java/com/campus/repository/ContentReviewRepository.java b/campus-server/src/main/java/com/campus/repository/ContentReviewRepository.java new file mode 100644 index 0000000..ec52c24 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ContentReviewRepository.java @@ -0,0 +1,21 @@ +package com.campus.repository; + +import com.campus.entity.ContentReview; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Repository +public interface ContentReviewRepository extends JpaRepository { + + List findByContentTypeAndContentId(String contentType, String contentId); + + List findByStatus(String status); + + Page findByStatusOrderByCreatedAtAsc(String status, Pageable pageable); + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/campus-server/src/main/java/com/campus/repository/FollowRepository.java b/campus-server/src/main/java/com/campus/repository/FollowRepository.java new file mode 100644 index 0000000..25972bd --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/FollowRepository.java @@ -0,0 +1,42 @@ +package com.campus.repository; + +import com.campus.entity.Follow; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface FollowRepository extends JpaRepository { + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("SELECT e FROM Follow e WHERE e.followerId = :first AND e.followingId = :second AND e.deleted = false") + Optional findForInteraction(@org.springframework.data.repository.query.Param("first") String first, @org.springframework.data.repository.query.Param("second") String second); + + Optional findByFollowerIdAndFollowingId(String followerId, String followingId); + + boolean existsByFollowerIdAndFollowingId(String followerId, String followingId); + + Page findByFollowerId(String followerId, Pageable pageable); + + Page findByFollowingId(String followingId, Pageable pageable); + + List findByFollowerId(String followerId); + + List findAllByFollowingId(String followingId); + + long countByFollowingId(String followingId); + + long countByFollowerId(String followerId); + + @Modifying + @Query("DELETE FROM Follow f WHERE f.followerId = ?1 AND f.followingId = ?2") + int deleteByFollowerIdAndFollowingId(String followerId, String followingId); + @Modifying + @Query(value = "INSERT INTO user_follow (id, follower_id, following_id, created_at, updated_at, deleted) VALUES (:id, :follower, :following, NOW(6), NOW(6), 0) ON DUPLICATE KEY UPDATE deleted = 0, updated_at = NOW(6)", nativeQuery = true) + int upsertActive(@org.springframework.data.repository.query.Param("id") String id, + @org.springframework.data.repository.query.Param("follower") String follower, + @org.springframework.data.repository.query.Param("following") String following); +} diff --git a/campus-server/src/main/java/com/campus/repository/FriendshipRepository.java b/campus-server/src/main/java/com/campus/repository/FriendshipRepository.java new file mode 100644 index 0000000..d9e611c --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/FriendshipRepository.java @@ -0,0 +1,31 @@ +package com.campus.repository; + +import com.campus.entity.Friendship; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface FriendshipRepository extends JpaRepository { + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("SELECT e FROM Friendship e WHERE e.userId = :first AND e.friendId = :second AND e.deleted = false") + Optional findForInteraction(@org.springframework.data.repository.query.Param("first") String first, @org.springframework.data.repository.query.Param("second") String second); + @org.springframework.data.jpa.repository.Query("SELECT f.userId, f.friendId FROM Friendship f WHERE f.id = :id AND f.deleted = false") + List findParties(@org.springframework.data.repository.query.Param("id") String id); + + + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("SELECT f FROM Friendship f WHERE f.id = :id AND f.deleted = false") + Optional findByIdForInteraction(@org.springframework.data.repository.query.Param("id") String id); + + List findByUserIdAndStatus(String userId, String status); + + List findByFriendIdAndStatus(String friendId, String status); + + Optional findByUserIdAndFriendId(String userId, String friendId); + + boolean existsByUserIdAndFriendId(String userId, String friendId); + @org.springframework.data.jpa.repository.Query(value = "SELECT * FROM friendship WHERE user_id = :first AND friend_id = :second LIMIT 1 FOR UPDATE", nativeQuery = true) + java.util.Optional findIncludingDeleted(@org.springframework.data.repository.query.Param("first") String first, + @org.springframework.data.repository.query.Param("second") String second); +} diff --git a/campus-server/src/main/java/com/campus/repository/GiftRecordRepository.java b/campus-server/src/main/java/com/campus/repository/GiftRecordRepository.java new file mode 100644 index 0000000..c9a193e --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/GiftRecordRepository.java @@ -0,0 +1,53 @@ +package com.campus.repository; + +import com.campus.entity.GiftRecord; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GiftRecordRepository extends JpaRepository { + + /** + * 查询用户收到的礼物记录 + */ + Page findByReceiverIdOrderByCreatedAtDesc(String receiverId, Pageable pageable); + + /** + * 查询用户送出的礼物记录 + */ + Page findBySenderIdOrderByCreatedAtDesc(String senderId, Pageable pageable); + + /** + * 查询动态收到的礼物记录 + */ + List findByMomentIdOrderByCreatedAtDesc(String momentId); + + /** + * 统计用户收到的礼物总积分 + */ + @Query("SELECT COALESCE(SUM(g.points * g.count), 0) FROM GiftRecord g WHERE g.deleted = false AND g.receiverId = :userId") + Integer sumPointsReceived(@Param("userId") String userId); + + /** + * 统计用户送出的礼物总积分 + */ + @Query("SELECT COALESCE(SUM(g.points * g.count), 0) FROM GiftRecord g WHERE g.deleted = false AND g.senderId = :userId") + Integer sumPointsSent(@Param("userId") String userId); + + /** + * 统计动态收到的礼物总积分 + */ + @Query("SELECT COALESCE(SUM(g.points * g.count), 0) FROM GiftRecord g WHERE g.deleted = false AND g.momentId = :momentId") + Integer sumPointsByMoment(@Param("momentId") String momentId); + + /** + * 统计用户收到某礼物的数量 + */ + int countByReceiverIdAndGiftId(String receiverId, String giftId); +} diff --git a/campus-server/src/main/java/com/campus/repository/GiftRepository.java b/campus-server/src/main/java/com/campus/repository/GiftRepository.java new file mode 100644 index 0000000..de841d9 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/GiftRepository.java @@ -0,0 +1,13 @@ +package com.campus.repository; + +import com.campus.entity.Gift; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GiftRepository extends JpaRepository { + + List findByEnabledTrueOrderBySortAsc(); +} diff --git a/campus-server/src/main/java/com/campus/repository/GreetingRepository.java b/campus-server/src/main/java/com/campus/repository/GreetingRepository.java new file mode 100644 index 0000000..656722c --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/GreetingRepository.java @@ -0,0 +1,18 @@ +package com.campus.repository; + +import com.campus.entity.Greeting; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface GreetingRepository extends JpaRepository { + + boolean existsByUserIdAndTargetUserId(String userId, String targetUserId); + + /** 批量查询当前用户对多个目标用户是否已打招呼 */ + List findByUserIdAndTargetUserIdIn(String userId, Collection targetUserIds); + @org.springframework.data.jpa.repository.Query(value = "SELECT * FROM greeting WHERE user_id = :first AND target_user_id = :second LIMIT 1 FOR UPDATE", nativeQuery = true) + java.util.Optional findIncludingDeleted(@org.springframework.data.repository.query.Param("first") String first, + @org.springframework.data.repository.query.Param("second") String second); +} diff --git a/campus-server/src/main/java/com/campus/repository/GroupAnnouncementRepository.java b/campus-server/src/main/java/com/campus/repository/GroupAnnouncementRepository.java new file mode 100644 index 0000000..2d3b32c --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/GroupAnnouncementRepository.java @@ -0,0 +1,15 @@ +package com.campus.repository; + +import com.campus.entity.GroupAnnouncement; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface GroupAnnouncementRepository extends JpaRepository { + + Optional findByGroupId(String groupId); + + void deleteByGroupId(String groupId); +} diff --git a/campus-server/src/main/java/com/campus/repository/GroupInvitationRepository.java b/campus-server/src/main/java/com/campus/repository/GroupInvitationRepository.java new file mode 100644 index 0000000..bc5ec6e --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/GroupInvitationRepository.java @@ -0,0 +1,68 @@ +package com.campus.repository; + +import com.campus.entity.GroupInvitation; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Date; +import java.util.Optional; + +@Repository +public interface GroupInvitationRepository extends JpaRepository { + + /** + * 查「同一群 + 同一人」当前那条待处理邀请。 + * 用 pendingKey 而不是 status 查询,与唯一键 uk_gi_pending 的口径严格一致。 + */ + Optional findByGroupIdAndInviteeIdAndPendingKey( + String groupId, String inviteeId, String pendingKey); + + Page findByInviteeIdAndStatusOrderByCreatedAtDesc( + String inviteeId, String status, Pageable pageable); + + Page findByInviteeIdOrderByCreatedAtDesc(String inviteeId, Pageable pageable); + + Page findByGroupIdAndStatusOrderByCreatedAtDesc( + String groupId, String status, Pageable pageable); + + /** + * 懒过期:把某用户所有已过期的待处理邀请刷成 EXPIRED,并释放 pendingKey。 + * bulk update 不会被 @Where 自动过滤,因此显式带上 deleted = false。 + */ + @Modifying + @Query("UPDATE GroupInvitation gi SET gi.status = 'EXPIRED', gi.pendingKey = NULL, gi.respondedAt = :now " + + "WHERE gi.inviteeId = :inviteeId AND gi.status = 'PENDING' " + + "AND gi.expireAt < :now AND gi.deleted = false") + int expireOverdueByInvitee(@Param("inviteeId") String inviteeId, @Param("now") Date now); + + /** 群主/管理员查看本群邀请前的懒过期刷新。 */ + @Modifying + @Query("UPDATE GroupInvitation gi SET gi.status = 'EXPIRED', gi.pendingKey = NULL, gi.respondedAt = :now " + + "WHERE gi.groupId = :groupId AND gi.status = 'PENDING' " + + "AND gi.expireAt < :now AND gi.deleted = false") + int expireOverdueByGroup(@Param("groupId") String groupId, @Param("now") Date now); + + /** + * 某用户被移出群(或被踢)后,作废他/她对该群的待处理邀请,避免"刚被踢又被邀请"的错位状态。 + */ + @Modifying + @Query("UPDATE GroupInvitation gi SET gi.status = 'CANCELLED', gi.pendingKey = NULL, gi.respondedAt = :now " + + "WHERE gi.groupId = :groupId AND gi.inviteeId = :inviteeId AND gi.status = 'PENDING' " + + "AND gi.deleted = false") + int cancelPendingFor(@Param("groupId") String groupId, + @Param("inviteeId") String inviteeId, + @Param("now") Date now); + + /** + * 解散群时物理清理该群全部邀请(含软删行)。 + * 派生 delete 方法对 @Where 不生效,这里显式写 bulk delete 以保证清理彻底。 + */ + @Modifying + @Query("DELETE FROM GroupInvitation gi WHERE gi.groupId = :groupId") + int deleteAllByGroupId(@Param("groupId") String groupId); +} diff --git a/campus-server/src/main/java/com/campus/repository/GroupMemberRepository.java b/campus-server/src/main/java/com/campus/repository/GroupMemberRepository.java new file mode 100644 index 0000000..d0d919c --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/GroupMemberRepository.java @@ -0,0 +1,48 @@ +package com.campus.repository; + +import com.campus.entity.GroupMember; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Collection; + +@Repository +public interface GroupMemberRepository extends JpaRepository { + + /** + * 历史上可能存在同一(群,用户)的重复成员行,这里只取一行, + * 避免非唯一结果异常把群接口打成 500。 + */ + Optional findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(String groupId, String userId); + + List findByGroupId(String groupId); + + List findByGroupIdIn(Collection groupIds); + + List findByGroupIdOrderByJoinTimeAsc(String groupId); + + List findByGroupIdAndRoleOrderByJoinTimeAsc(String groupId, String role); + + List findByUserId(String userId); + + long countByGroupId(String groupId); + + boolean existsByGroupIdAndUserId(String groupId, String userId); + + @Modifying + @Query("UPDATE GroupMember gm SET gm.role = :role WHERE gm.groupId = :groupId AND gm.userId = :userId") + void updateRole(@Param("groupId") String groupId, @Param("userId") String userId, @Param("role") String role); + + @Modifying + @Query("UPDATE GroupMember gm SET gm.isMuted = :isMuted WHERE gm.groupId = :groupId AND gm.userId = :userId") + void updateMuteStatus(@Param("groupId") String groupId, @Param("userId") String userId, @Param("isMuted") Boolean isMuted); + + void deleteByGroupId(String groupId); + + void deleteByGroupIdAndUserId(String groupId, String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/GroupRepository.java b/campus-server/src/main/java/com/campus/repository/GroupRepository.java new file mode 100644 index 0000000..2ca534d --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/GroupRepository.java @@ -0,0 +1,38 @@ +package com.campus.repository; + +import com.campus.entity.Group; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GroupRepository extends JpaRepository { + + Page findByNameContaining(String name, Pageable pageable); + + Page findByIsPublicTrue(String name, Pageable pageable); + + Group findByEasemobGroupId(String easemobGroupId); + + @Query("SELECT g FROM Group g JOIN GroupMember gm ON g.id = gm.groupId " + + "WHERE gm.userId = :userId AND gm.role <> 'PENDING'") + List findGroupsByMemberId(@Param("userId") String userId); + + @Query("SELECT g FROM Group g JOIN GroupMember gm ON g.id = gm.groupId " + + "WHERE gm.userId = :userId AND gm.role <> 'PENDING'") + Page findGroupsByMemberId(@Param("userId") String userId, Pageable pageable); + + @Modifying + @Query("UPDATE Group g SET g.memberCount = g.memberCount + 1 WHERE g.id = :groupId") + void incrementMemberCount(@Param("groupId") String groupId); + + @Modifying + @Query("UPDATE Group g SET g.memberCount = g.memberCount - 1 WHERE g.id = :groupId") + void decrementMemberCount(@Param("groupId") String groupId); +} diff --git a/campus-server/src/main/java/com/campus/repository/IdempotencyRecordRepository.java b/campus-server/src/main/java/com/campus/repository/IdempotencyRecordRepository.java new file mode 100644 index 0000000..80aac63 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/IdempotencyRecordRepository.java @@ -0,0 +1,11 @@ +package com.campus.repository; + +import com.campus.entity.IdempotencyRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface IdempotencyRecordRepository extends JpaRepository { + Optional findByUserIdAndOperationAndIdempotencyKey( + String userId, String operation, String idempotencyKey); +} diff --git a/campus-server/src/main/java/com/campus/repository/LotteryDrawRepository.java b/campus-server/src/main/java/com/campus/repository/LotteryDrawRepository.java new file mode 100644 index 0000000..9f708df --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/LotteryDrawRepository.java @@ -0,0 +1,10 @@ +package com.campus.repository; + +import com.campus.entity.LotteryDraw; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LotteryDrawRepository extends JpaRepository { + List findByUserIdAndDrawDateOrderByDrawNumberAsc(String userId, String drawDate); +} diff --git a/campus-server/src/main/java/com/campus/repository/LotteryPrizeRepository.java b/campus-server/src/main/java/com/campus/repository/LotteryPrizeRepository.java new file mode 100644 index 0000000..37b4263 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/LotteryPrizeRepository.java @@ -0,0 +1,26 @@ +package com.campus.repository; + +import com.campus.entity.LotteryPrize; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.util.List; +import java.util.Optional; + +public interface LotteryPrizeRepository extends JpaRepository { + Optional findByCode(String code); + List findByEnabledTrueOrderBySortAsc(); + List findAllByOrderBySortAsc(); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from LotteryPrize p where p.id = :id and p.deleted = false") + Optional findByIdForUpdate(@Param("id") String id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from LotteryPrize p where p.enabled = true and p.deleted = false " + + "and (p.stock = -1 or p.stock > 0) order by p.sort asc") + List findAvailableForUpdate(); +} diff --git a/campus-server/src/main/java/com/campus/repository/MajorRepository.java b/campus-server/src/main/java/com/campus/repository/MajorRepository.java new file mode 100644 index 0000000..3e8aa1b --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MajorRepository.java @@ -0,0 +1,17 @@ +package com.campus.repository; + +import com.campus.entity.Major; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MajorRepository extends JpaRepository { + + List findByCollegeId(String collegeId); + + List findByDegreeType(String degreeType); + + List findByNameContaining(String name); +} diff --git a/campus-server/src/main/java/com/campus/repository/MallProductRepository.java b/campus-server/src/main/java/com/campus/repository/MallProductRepository.java new file mode 100644 index 0000000..7eca8d1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MallProductRepository.java @@ -0,0 +1,21 @@ +package com.campus.repository; + +import com.campus.entity.MallProduct; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.util.List; +import java.util.Optional; + +public interface MallProductRepository extends JpaRepository { + Optional findByCode(String code); + List findByEnabledTrueOrderBySortAsc(); + List findAllByOrderBySortAsc(); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from MallProduct p where p.code = :code and p.deleted = false") + Optional findByCodeForUpdate(@Param("code") String code); +} diff --git a/campus-server/src/main/java/com/campus/repository/MatchActionRepository.java b/campus-server/src/main/java/com/campus/repository/MatchActionRepository.java new file mode 100644 index 0000000..4bb8283 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MatchActionRepository.java @@ -0,0 +1,12 @@ +package com.campus.repository; + +import com.campus.entity.MatchAction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MatchActionRepository extends JpaRepository { + Optional findByUserIdAndTargetUserId(String userId, String targetUserId); + List findByUserId(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/MatchPairRepository.java b/campus-server/src/main/java/com/campus/repository/MatchPairRepository.java new file mode 100644 index 0000000..ee1f57e --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MatchPairRepository.java @@ -0,0 +1,12 @@ +package com.campus.repository; + +import com.campus.entity.MatchPair; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MatchPairRepository extends JpaRepository { + Optional findByUserLowIdAndUserHighId(String userLowId, String userHighId); + List findByUserLowIdOrUserHighIdOrderByCreatedAtDesc(String userLowId, String userHighId); +} diff --git a/campus-server/src/main/java/com/campus/repository/MatchPreferenceRepository.java b/campus-server/src/main/java/com/campus/repository/MatchPreferenceRepository.java new file mode 100644 index 0000000..b332a8f --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MatchPreferenceRepository.java @@ -0,0 +1,10 @@ +package com.campus.repository; + +import com.campus.entity.MatchPreference; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MatchPreferenceRepository extends JpaRepository { + Optional findByUserId(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/MomentCommentLikeRepository.java b/campus-server/src/main/java/com/campus/repository/MomentCommentLikeRepository.java new file mode 100644 index 0000000..00a890a --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MomentCommentLikeRepository.java @@ -0,0 +1,36 @@ +package com.campus.repository; + +import com.campus.entity.MomentCommentLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MomentCommentLikeRepository extends JpaRepository { + + /** + * 检查是否已点赞 + */ + boolean existsByCommentIdAndUserId(String commentId, String userId); + + /** + * 查找点赞记录 + */ + Optional findByCommentIdAndUserId(String commentId, String userId); + + /** + * 统计评论点赞数 + */ + long countByCommentId(String commentId); + + /** + * 删除点赞记录 + */ + void deleteByCommentIdAndUserId(String commentId, String userId); + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query(value = "INSERT INTO moment_comment_like (id, comment_id, user_id, created_at, updated_at, deleted) VALUES (:id, :first, :second, NOW(6), NOW(6), 0) ON DUPLICATE KEY UPDATE deleted = 0, updated_at = NOW(6)", nativeQuery = true) + int upsertActive(@org.springframework.data.repository.query.Param("id") String id, + @org.springframework.data.repository.query.Param("first") String first, + @org.springframework.data.repository.query.Param("second") String second); +} diff --git a/campus-server/src/main/java/com/campus/repository/MomentCommentRepository.java b/campus-server/src/main/java/com/campus/repository/MomentCommentRepository.java new file mode 100644 index 0000000..cfb9dd9 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MomentCommentRepository.java @@ -0,0 +1,73 @@ +package com.campus.repository; + +import com.campus.entity.MomentComment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Date; + +@Repository +public interface MomentCommentRepository extends JpaRepository { + + /** 写入前锁定目标行,明细和计数在同一事务内串行更新。 */ + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("SELECT e FROM MomentComment e WHERE e.id = :id AND e.deleted = false") + java.util.Optional findByIdForInteraction(@org.springframework.data.repository.query.Param("id") String id); + + @Query(value = "SELECT moment_id, SUM(POW(2, -TIMESTAMPDIFF(SECOND, created_at, :now) / 172800.0)) " + + "FROM moment_comment WHERE deleted = 0 AND status = 'ACTIVE' AND moment_id IN (:momentIds) " + + "AND created_at >= :since AND created_at <= :now GROUP BY moment_id", nativeQuery = true) + List sumRecentHeatByMomentIds(@Param("momentIds") List momentIds, + @Param("since") Date since, @Param("now") Date now); + + @Query("SELECT DISTINCT c.momentId FROM MomentComment c WHERE c.deleted = false " + + "AND c.status = 'ACTIVE' AND c.userId = :userId AND c.createdAt >= :since") + List findRecentMomentIdsByUser(@Param("userId") String userId, @Param("since") Date since); + + /** + * 查询动态的所有评论(分页,按时间倒序) + */ + Page findByMomentIdAndStatusOrderByCreatedAtDesc(String momentId, String status, Pageable pageable); + + /** + * 查询一级评论(parentId为NULL) + */ + Page findByMomentIdAndParentIdIsNullAndStatusOrderByCreatedAtDesc(String momentId, String status, Pageable pageable); + + /** + * 查询回复评论 + */ + List findByParentIdAndStatusOrderByCreatedAtAsc(String parentId, String status); + + /** + * 统计评论数 + */ + long countByMomentIdAndStatus(String momentId, String status); + + /** + * 统计一级评论数 + */ + long countByMomentIdAndParentIdIsNullAndStatus(String momentId, String status); + + /** + * 查询用户的评论 + */ + Page findByUserIdAndStatusOrderByCreatedAtDesc(String userId, String status, Pageable pageable); + + /** + * 查询一级评论及其前N条回复(优化查询) + */ + @Query("SELECT c FROM MomentComment c WHERE c.deleted = false AND c.momentId = :momentId AND c.parentId IS NULL AND c.status = :status ORDER BY c.createdAt DESC") + Page findParentCommentsWithPage(@Param("momentId") String momentId, @Param("status") String status, Pageable pageable); + + /** + * 批量查询回复 + */ + @Query("SELECT c FROM MomentComment c WHERE c.deleted = false AND c.parentId IN :parentIds AND c.status = :status ORDER BY c.createdAt ASC") + List findRepliesByParentIds(@Param("parentIds") List parentIds, @Param("status") String status); +} diff --git a/campus-server/src/main/java/com/campus/repository/MomentFavoriteRepository.java b/campus-server/src/main/java/com/campus/repository/MomentFavoriteRepository.java new file mode 100644 index 0000000..cb98fe8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MomentFavoriteRepository.java @@ -0,0 +1,43 @@ +package com.campus.repository; + +import com.campus.entity.MomentFavorite; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Date; + +public interface MomentFavoriteRepository extends JpaRepository { + Optional findByMomentIdAndUserId(String momentId, String userId); + boolean existsByMomentIdAndUserId(String momentId, String userId); + long countByMomentIdAndDeletedFalse(String momentId); + + @Query("SELECT f.momentId FROM MomentFavorite f WHERE f.deleted = false " + + "AND f.userId = :userId AND f.createdAt >= :since") + List findRecentMomentIdsByUser(@Param("userId") String userId, @Param("since") Date since); + + @Query("SELECT f.momentId, COUNT(f) FROM MomentFavorite f " + + "WHERE f.deleted = false AND f.momentId IN :momentIds GROUP BY f.momentId") + List countByMomentIds(@Param("momentIds") Collection momentIds); + + @Query(value = "SELECT f FROM MomentFavorite f, Moment m " + + "WHERE f.momentId = m.id AND f.userId = :userId AND m.deleted = false " + + "ORDER BY f.createdAt DESC", + countQuery = "SELECT COUNT(f) FROM MomentFavorite f, Moment m " + + "WHERE f.momentId = m.id AND f.userId = :userId AND m.deleted = false") + Page findActiveByUserIdOrderByCreatedAtDesc(@Param("userId") String userId, Pageable pageable); + + @Query("SELECT f FROM MomentFavorite f WHERE f.momentId IN :momentIds AND f.userId = :userId") + List findByMomentIdInAndUserId(@Param("momentIds") Collection momentIds, + @Param("userId") String userId); + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query(value = "INSERT INTO moment_favorite (id, moment_id, user_id, created_at, updated_at, deleted) VALUES (:id, :first, :second, NOW(6), NOW(6), 0) ON DUPLICATE KEY UPDATE deleted = 0, updated_at = NOW(6)", nativeQuery = true) + int upsertActive(@org.springframework.data.repository.query.Param("id") String id, + @org.springframework.data.repository.query.Param("first") String first, + @org.springframework.data.repository.query.Param("second") String second); +} diff --git a/campus-server/src/main/java/com/campus/repository/MomentLikeRepository.java b/campus-server/src/main/java/com/campus/repository/MomentLikeRepository.java new file mode 100644 index 0000000..a992404 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MomentLikeRepository.java @@ -0,0 +1,72 @@ +package com.campus.repository; + +import com.campus.entity.MomentLike; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Date; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +@Repository +public interface MomentLikeRepository extends JpaRepository { + + /** + * 检查是否已点赞 + */ + boolean existsByMomentIdAndUserId(String momentId, String userId); + + /** + * 查找点赞记录 + */ + Optional findByMomentIdAndUserId(String momentId, String userId); + + /** + * 统计点赞数 + */ + long countByMomentId(String momentId); + + /** + * 查询动态的所有点赞(分页) + */ + List findByMomentId(String momentId); + + Page findByMomentId(String momentId, Pageable pageable); + + /** + * 统计用户点赞的动态数 + */ + long countByUserId(String userId); + + @Query(value = "SELECT moment_id, SUM(POW(2, -TIMESTAMPDIFF(SECOND, created_at, :now) / 172800.0)) " + + "FROM moment_like WHERE deleted = 0 AND moment_id IN (:momentIds) AND created_at >= :since AND created_at <= :now " + + "GROUP BY moment_id", nativeQuery = true) + List sumRecentHeatByMomentIds(@Param("momentIds") Collection momentIds, + @Param("since") Date since, @Param("now") Date now); + + @Query("SELECT ml.momentId FROM MomentLike ml WHERE ml.deleted = false AND ml.userId = :userId " + + "AND ml.createdAt >= :since") + List findRecentMomentIdsByUser(@Param("userId") String userId, @Param("since") Date since); + + /** + * 批量查询用户在多个动态上的点赞状态 + */ + @Query("SELECT ml FROM MomentLike ml WHERE ml.momentId IN :momentIds AND ml.userId = :userId") + List findByMomentIdInAndUserId(@Param("momentIds") Collection momentIds, @Param("userId") String userId); + + /** + * 删除用户的点赞记录 + */ + void deleteByMomentIdAndUserId(String momentId, String userId); + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query(value = "INSERT INTO moment_like (id, moment_id, user_id, created_at, updated_at, deleted) VALUES (:id, :first, :second, NOW(6), NOW(6), 0) ON DUPLICATE KEY UPDATE deleted = 0, updated_at = NOW(6)", nativeQuery = true) + int upsertActive(@org.springframework.data.repository.query.Param("id") String id, + @org.springframework.data.repository.query.Param("first") String first, + @org.springframework.data.repository.query.Param("second") String second); +} diff --git a/campus-server/src/main/java/com/campus/repository/MomentRepository.java b/campus-server/src/main/java/com/campus/repository/MomentRepository.java new file mode 100644 index 0000000..94d774b --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MomentRepository.java @@ -0,0 +1,239 @@ +package com.campus.repository; + +import com.campus.entity.Moment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import javax.persistence.LockModeType; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +@Repository +public interface MomentRepository extends JpaRepository { + + /** 写入前锁定目标行,明细和计数在同一事务内串行更新。 */ + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("SELECT e FROM Moment e WHERE e.id = :id AND e.deleted = false") + java.util.Optional findByIdForInteraction(@org.springframework.data.repository.query.Param("id") String id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.id = :id AND m.deleted = false") + Optional findByIdForView(@Param("id") String id); + + @Query(value = "SELECT * FROM moment WHERE id = :id LIMIT 1", nativeQuery = true) + Optional findIncludingDeleted(@Param("id") String id); + + @Modifying + @Query(value = "UPDATE moment SET deleted = false, review_status = 'approved', review_reason = NULL WHERE id = :id", nativeQuery = true) + int restoreApproved(@Param("id") String id); + + Page findByUserId(String userId, Pageable pageable); + + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.userId = :userId AND m.deleted = false AND m.reviewStatus = 'approved'") + List findApprovedByUserId(@Param("userId") String userId); + + long countByUserId(String userId); + + /** + * 动态数口径(不含话题讨论):积分/任务里"发布动态"只统计真正的动态。 + * 讨论发布门槛低,计入会被刷。 + */ + long countByUserIdAndContentSourceNot(String userId, String contentSource); + + Page findByScope(String scope, Pageable pageable); + + /** + * 公开动态流口径:只返回 {@link Moment#SOURCE_FEED}(话题讨论只进所属话题页)。 + * + *

老数据(迁移前)的 {@code content_source} 可能为空串,用 {@code <> 'TOPIC'} 而不是 + * {@code = 'FEED'},避免历史行被误过滤掉。

+ */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = :reviewStatus " + + "AND (m.contentSource IS NULL OR m.contentSource <> 'TOPIC')") + Page findFeedMoments(@Param("reviewStatus") String reviewStatus, Pageable pageable); + + /** 公开动态流口径:指定 scope + 审核状态,同样排除话题讨论。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = :reviewStatus " + + "AND m.scope = :scope AND (m.contentSource IS NULL OR m.contentSource <> 'TOPIC')") + Page findFeedMomentsByScope(@Param("reviewStatus") String reviewStatus, + @Param("scope") String scope, Pageable pageable); + + /** 个性化推荐候选池:仅公开校园动态,限制候选规模并按新鲜度预排序。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = 'approved' " + + "AND m.scope = 'CAMPUS' AND (m.contentSource IS NULL OR m.contentSource <> 'TOPIC') " + + "AND m.createdAt >= :since ORDER BY m.createdAt DESC, m.id DESC") + List findRecommendationCandidates(@Param("since") Date since, Pageable pageable); + + /** 近期热榜候选集:校园公开动态,互动热度由近期明细聚合后在服务层计算。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = 'approved' " + + "AND m.scope = 'CAMPUS' AND (m.contentSource IS NULL OR m.contentSource <> 'TOPIC') " + + "AND (:category IS NULL OR m.category = :category) " + + "ORDER BY m.createdAt DESC, m.id DESC") + List findHotCandidates(@Param("category") String category, Pageable pageable); + + /** 他人主页口径:只返回已过审动态(本人主页走 findFeedMomentsByUser),排除话题讨论。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.userId = :userId " + + "AND m.reviewStatus = :reviewStatus AND (m.anonymous = false OR m.anonymous IS NULL) AND (m.contentSource IS NULL OR m.contentSource <> 'TOPIC')") + Page findFeedMomentsByUserAndReviewStatus(@Param("userId") String userId, + @Param("reviewStatus") String reviewStatus, + Pageable pageable); + + /** 本人主页/我的动态口径:全部审核状态,排除话题讨论。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.userId = :userId " + + "AND (m.contentSource IS NULL OR m.contentSource <> 'TOPIC')") + Page findFeedMomentsByUser(@Param("userId") String userId, Pageable pageable); + + /** 话题页讨论列表(最新排序由 Pageable 的 sort 控制):只按 topic_id 归属,不看 tags。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = 'approved' " + + "AND m.topicId = :topicId") + Page findApprovedByTopicId(@Param("topicId") String topicId, Pageable pageable); + + /** 话题页讨论列表(最热):热度公式与动态热榜一致。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = 'approved' " + + "AND m.topicId = :topicId " + + "ORDER BY (m.likeCount + 2 * m.commentCount + 3 * m.shareCount + m.viewCount / 10) DESC, m.createdAt DESC") + Page findApprovedByTopicIdOrderByHot(@Param("topicId") String topicId, Pageable pageable); + + /** 我的讨论:本人发的讨论(含未过审,本人可见原则),可按话题筛选。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.userId = :userId " + + "AND m.contentSource = 'TOPIC' AND (:topicId IS NULL OR m.topicId = :topicId)") + Page findMyDiscussions(@Param("userId") String userId, + @Param("topicId") String topicId, Pageable pageable); + + /** 话题合并:把源话题下的讨论整体迁到目标话题,避免合并后讨论失联。 */ + @Modifying + @Query("UPDATE Moment m SET m.topicId = :targetId WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.topicId = :sourceId AND m.deleted = false") + int reassignTopic(@Param("sourceId") String sourceId, @Param("targetId") String targetId); + + Page findByIsYouthVideoTrue(Pageable pageable); + + Page findByUserIdAndIsYouthVideoTrue(String userId, Pageable pageable); + + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.userId = :userId AND m.isYouthVideo = true AND m.reviewStatus = 'approved' AND (m.anonymous = false OR m.anonymous IS NULL)") + Page findPublicYouthVideosByUser(@Param("userId") String userId, Pageable pageable); + + /** 话题聚合使用的已审核动态池。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = 'approved' ORDER BY m.createdAt DESC") + List findApprovedForTopicAggregation(Pageable pageable); + + /** + * 动态域:按「单个标签」精确匹配已审核动态(话题详情页专用入口)。 + * + *

匹配规则与 {@code moment.tags} 的存储格式一致:逗号分隔的字符串, + * 允许标签前后有空格。注意 FIND_IN_SET 无法走索引,当前数据量下可接受; + * 动态量级上来后应改为关系表(见实现方案 P2-2)。

+ */ + @Query(value = "SELECT * FROM moment WHERE deleted = 0 AND user_id NOT IN (SELECT id FROM user WHERE account_status='DEACTIVATING') AND review_status = 'approved' " + + "AND FIND_IN_SET(:tag, REPLACE(tags, ' ', '')) > 0 ORDER BY created_at DESC", + countQuery = "SELECT COUNT(*) FROM moment WHERE deleted = 0 AND user_id NOT IN (SELECT id FROM user WHERE account_status='DEACTIVATING') AND review_status = 'approved' " + + "AND FIND_IN_SET(:tag, REPLACE(tags, ' ', '')) > 0", + nativeQuery = true) + Page findApprovedByExactTag(@Param("tag") String tag, Pageable pageable); + + /** 同城动态:只返回已审核的动态(排除话题讨论)。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = 'approved' " + + "AND m.city IS NOT NULL AND LOWER(TRIM(m.city)) = LOWER(TRIM(:city)) " + + "AND (m.contentSource IS NULL OR m.contentSource <> 'TOPIC') " + + "ORDER BY m.createdAt DESC") + Page findApprovedByCity(@Param("city") String city, Pageable pageable); + + /** 同城动态:按城市和省份查询(排除话题讨论)。 */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.reviewStatus = 'approved' " + + "AND m.city IS NOT NULL AND LOWER(TRIM(m.city)) = LOWER(TRIM(:city)) " + + "AND m.province IS NOT NULL AND LOWER(TRIM(m.province)) = LOWER(TRIM(:province)) " + + "AND (m.contentSource IS NULL OR m.contentSource <> 'TOPIC') " + + "ORDER BY m.createdAt DESC") + Page findApprovedByCityAndProvince(@Param("city") String city, + @Param("province") String province, + Pageable pageable); + /** 通过 VOD 视频ID 查找 Moment */ + Optional findByVodVideoId(String vodVideoId); + + /** + * 通过 VOD 视频ID 找内容,**只取最早一条**。 + * + *

为什么不用 {@link #findByVodVideoId}:该方法返回 {@code Optional},一旦同一个 + * {@code vod_video_id} 被多条内容引用(历史上没有唯一约束,青春号视频完全可能发生), + * Hibernate 会抛 {@code NonUniqueResultException},把整个 VOD 回调打成失败。 + * VOD 回调与话题讨论的视频补拉都改用这个方法兜住脏数据。

+ */ + Optional findFirstByVodVideoIdOrderByCreatedAtAsc(String vodVideoId); + + /** + * 视频 ID 是否已被占用(话题讨论发布时的唯一性校验)。 + * + *

只做应用层校验,**不加数据库唯一索引**:存量青春号数据可能已有重复, + * 加索引前必须先按 V13/V3 的流程备份去重。

+ */ + boolean existsByVodVideoId(String vodVideoId); + + /** + * 话题讨论视频补拉(Sweeper 使用):有 {@code vodVideoId} 但播放地址还没回填、 + * 且发布时间早于 {@code createdBefore} 的讨论。 + * + *

加了时间窗口是为了避开"刚发布、转码还没开始"的正常情况。

+ */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.contentSource = 'TOPIC' " + + "AND m.vodVideoId IS NOT NULL AND m.vodVideoId <> '' " + + "AND (m.playUrl IS NULL OR m.playUrl = '') " + + "AND m.createdAt <= :createdBefore ORDER BY m.createdAt ASC") + List findTopicDiscussionsAwaitingVideo(@Param("createdBefore") Date createdBefore, Pageable pageable); + + List findByReviewStatusAndScheduledPublishAtBefore(String reviewStatus, Date deadline); + + List findByCityIsNull(); + + @Modifying + @Query("UPDATE Moment m SET m.shareCount = m.shareCount + 1 WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.id = :momentId AND m.deleted = false") + int incrementShareCount(@Param("momentId") String momentId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Moment m SET m.viewCount = m.viewCount + 1 WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.id = :momentId AND m.deleted = false") + int incrementViewCount(@Param("momentId") String momentId); + + /** + * 按点赞数排序的青春号视频(仅未看过) + */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.isYouthVideo = true AND m.reviewStatus = 'APPROVED' " + + "AND NOT EXISTS (SELECT r FROM VideoPlayRecord r WHERE r.videoId = m.id AND r.userId = :userId) " + + "ORDER BY m.likeCount DESC") + Page findYouthVideosUnwatchedOrderByLikeCount(@Param("userId") String userId, Pageable pageable); + + /** + * 按浏览数排序的青春号视频(仅未看过) + */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.isYouthVideo = true AND m.reviewStatus = 'APPROVED' " + + "AND NOT EXISTS (SELECT r FROM VideoPlayRecord r WHERE r.videoId = m.id AND r.userId = :userId) " + + "ORDER BY m.viewCount DESC") + Page findYouthVideosUnwatchedOrderByViewCount(@Param("userId") String userId, Pageable pageable); + + /** + * 按发布时间排序的青春号视频(仅未看过) + */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.isYouthVideo = true AND m.reviewStatus = 'APPROVED' " + + "AND NOT EXISTS (SELECT r FROM VideoPlayRecord r WHERE r.videoId = m.id AND r.userId = :userId) " + + "ORDER BY m.createdAt DESC") + Page findYouthVideosUnwatchedOrderByCreatedAt(@Param("userId") String userId, Pageable pageable); + + /** + * 关注用户发布的青春号视频(仅未看过) + */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.isYouthVideo = true AND m.reviewStatus = 'APPROVED' " + + "AND m.userId IN :followedIds " + + "AND NOT EXISTS (SELECT r FROM VideoPlayRecord r WHERE r.videoId = m.id AND r.userId = :userId) " + + "ORDER BY m.createdAt DESC") + Page findYouthVideosFromFollowedUsersUnwatched(@Param("userId") String userId, @Param("followedIds") List followedIds, Pageable pageable); + + /** + * 非关注用户发布的青春号视频 + */ + @Query("SELECT m FROM Moment m WHERE m.userId IN (SELECT visibilityUser.id FROM User visibilityUser WHERE visibilityUser.accountStatus IS NULL OR visibilityUser.accountStatus <> 'DEACTIVATING') AND m.deleted = false AND m.isYouthVideo = true AND m.reviewStatus = 'APPROVED' AND m.userId NOT IN :excludedIds ORDER BY m.createdAt DESC") + Page findYouthVideosNotFromUsers(@Param("excludedIds") List excludedIds, Pageable pageable); +} diff --git a/campus-server/src/main/java/com/campus/repository/MomentViewRecordRepository.java b/campus-server/src/main/java/com/campus/repository/MomentViewRecordRepository.java new file mode 100644 index 0000000..9a7720b --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/MomentViewRecordRepository.java @@ -0,0 +1,10 @@ +package com.campus.repository; + +import com.campus.entity.MomentViewRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MomentViewRecordRepository extends JpaRepository { + Optional findByMomentIdAndUserId(String momentId, String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/NotificationPreferenceRepository.java b/campus-server/src/main/java/com/campus/repository/NotificationPreferenceRepository.java new file mode 100644 index 0000000..f769420 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/NotificationPreferenceRepository.java @@ -0,0 +1,7 @@ +package com.campus.repository; +import com.campus.entity.NotificationPreference; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +public interface NotificationPreferenceRepository extends JpaRepository { + Optional findByUserId(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/NotificationRepository.java b/campus-server/src/main/java/com/campus/repository/NotificationRepository.java new file mode 100644 index 0000000..1932d6b --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/NotificationRepository.java @@ -0,0 +1,31 @@ +package com.campus.repository; + +import com.campus.entity.Notification; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public interface NotificationRepository extends JpaRepository { + + Page findByUserId(String userId, Pageable pageable); + + Page findByUserIdAndIsRead(String userId, Boolean isRead, Pageable pageable); + + Page findByUserIdAndType(String userId, String type, Pageable pageable); + + long countByUserIdAndIsReadFalse(String userId); + + /** 检查是否已存在相同类型的通知(防止取消点赞后再点赞重复通知) */ + boolean existsByUserIdAndTypeAndSenderIdAndRelatedId(String userId, String type, String senderId, String relatedId); + + @Modifying + @Transactional + @Query("UPDATE Notification n SET n.isRead = true WHERE n.userId = :userId AND n.isRead = false") + int markAllAsReadByUserId(@Param("userId") String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/NotificationTemplateRepository.java b/campus-server/src/main/java/com/campus/repository/NotificationTemplateRepository.java new file mode 100644 index 0000000..aa86efe --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/NotificationTemplateRepository.java @@ -0,0 +1,7 @@ +package com.campus.repository; +import com.campus.entity.NotificationTemplate; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +public interface NotificationTemplateRepository extends JpaRepository { + Optional findByCode(String code); +} diff --git a/campus-server/src/main/java/com/campus/repository/PointRuleRepository.java b/campus-server/src/main/java/com/campus/repository/PointRuleRepository.java new file mode 100644 index 0000000..6811f08 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/PointRuleRepository.java @@ -0,0 +1,32 @@ +package com.campus.repository; + +import com.campus.entity.PointRule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PointRuleRepository extends JpaRepository { + + /** + * 根据规则编码查询 + */ + Optional findByCode(String code); + + /** + * 查询所有启用的规则 + */ + List findByEnabledTrueOrderBySortAsc(); + + /** + * 根据类型查询规则 + */ + List findByTypeAndEnabledTrue(String type); + + /** + * 检查规则编码是否存在 + */ + boolean existsByCode(String code); +} diff --git a/campus-server/src/main/java/com/campus/repository/PointTransactionRepository.java b/campus-server/src/main/java/com/campus/repository/PointTransactionRepository.java new file mode 100644 index 0000000..6f069ff --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/PointTransactionRepository.java @@ -0,0 +1,69 @@ +package com.campus.repository; + +import com.campus.entity.PointTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Date; +import java.util.List; + +@Repository +public interface PointTransactionRepository extends JpaRepository { + + /** + * 查询用户的积分变动记录 + */ + Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); + + /** + * 查询用户今日的积分变动记录 + */ + @Query("SELECT pt FROM PointTransaction pt WHERE pt.deleted = false AND pt.userId = :userId " + + "AND pt.createdAt >= :startOfDay AND pt.createdAt < :endOfDay " + + "ORDER BY pt.createdAt DESC") + List findTodayByUserId( + @Param("userId") String userId, + @Param("startOfDay") Date startOfDay, + @Param("endOfDay") Date endOfDay); + + /** + * 统计用户今日某个规则获得的积分 + */ + @Query("SELECT COALESCE(SUM(pt.points), 0) FROM PointTransaction pt " + + "WHERE pt.deleted = false AND pt.userId = :userId AND pt.ruleCode = :ruleCode " + + "AND pt.type = 'EARN' AND pt.createdAt >= :startOfDay AND pt.createdAt < :endOfDay") + Integer sumTodayPointsByRule( + @Param("userId") String userId, + @Param("ruleCode") String ruleCode, + @Param("startOfDay") Date startOfDay, + @Param("endOfDay") Date endOfDay); + + /** + * 统计用户累计某个规则获得的积分 + */ + @Query("SELECT COALESCE(SUM(pt.points), 0) FROM PointTransaction pt " + + "WHERE pt.deleted = false AND pt.userId = :userId AND pt.ruleCode = :ruleCode AND pt.type = 'EARN'") + Integer sumTotalPointsByRule( + @Param("userId") String userId, + @Param("ruleCode") String ruleCode); + + /** + * 根据关联类型和ID查询 + */ + List findByRelatedTypeAndRelatedId(String relatedType, String relatedId); + + /** + * 查询给某条内容点赞的用户获得的积分 + */ + @Query("SELECT COALESCE(SUM(pt.points), 0) FROM PointTransaction pt " + + "WHERE pt.deleted = false AND pt.relatedType = :relatedType AND pt.relatedId = :relatedId " + + "AND pt.ruleCode = :ruleCode AND pt.type = 'EARN'") + Integer sumPointsByRelated( + @Param("relatedType") String relatedType, + @Param("relatedId") String relatedId, + @Param("ruleCode") String ruleCode); +} diff --git a/campus-server/src/main/java/com/campus/repository/ReportRepository.java b/campus-server/src/main/java/com/campus/repository/ReportRepository.java new file mode 100644 index 0000000..e03457e --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ReportRepository.java @@ -0,0 +1,22 @@ +package com.campus.repository; + +import com.campus.entity.Report; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ReportRepository extends JpaRepository { + + long countByStatus(String status); + + List findByReporterIdOrderByCreatedAtDesc(String reporterId); + + Page findByStatusOrderByCreatedAtAsc(String status, Pageable pageable); + + boolean existsByReporterIdAndTargetTypeAndTargetIdAndStatusIn( + String reporterId, String targetType, String targetId, List statuses); +} diff --git a/campus-server/src/main/java/com/campus/repository/RewardIssuanceLogRepository.java b/campus-server/src/main/java/com/campus/repository/RewardIssuanceLogRepository.java new file mode 100644 index 0000000..3549a39 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/RewardIssuanceLogRepository.java @@ -0,0 +1,11 @@ +package com.campus.repository; + +import com.campus.entity.RewardIssuanceLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface RewardIssuanceLogRepository extends JpaRepository { + boolean existsBySourceTypeAndSourceIdAndRewardType(String sourceType, String sourceId, String rewardType); + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/SensitiveWordRepository.java b/campus-server/src/main/java/com/campus/repository/SensitiveWordRepository.java new file mode 100644 index 0000000..6e690a2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/SensitiveWordRepository.java @@ -0,0 +1,15 @@ +package com.campus.repository; + +import com.campus.entity.SensitiveWord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SensitiveWordRepository extends JpaRepository { + + List findByEnabledTrue(); + + List findByTypeAndEnabledTrue(String type); +} diff --git a/campus-server/src/main/java/com/campus/repository/ShareRequestRecordRepository.java b/campus-server/src/main/java/com/campus/repository/ShareRequestRecordRepository.java new file mode 100644 index 0000000..58765c2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/ShareRequestRecordRepository.java @@ -0,0 +1,42 @@ +package com.campus.repository; + +import com.campus.entity.ShareRequestRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import javax.persistence.LockModeType; +import java.util.Optional; + +@Repository +public interface ShareRequestRecordRepository extends JpaRepository { + + @Modifying + @Query(value = "INSERT IGNORE INTO share_request " + + "(id, sender_id, request_type, request_id, request_hash, status, deleted, created_at, updated_at) " + + "VALUES (:id, :senderId, :requestType, :requestId, :requestHash, 'PROCESSING', 0, NOW(6), NOW(6))", + nativeQuery = true) + int insertIfAbsent(@Param("id") String id, + @Param("senderId") String senderId, + @Param("requestType") String requestType, + @Param("requestId") String requestId, + @Param("requestHash") String requestHash); + + @org.springframework.data.jpa.repository.Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ShareRequestRecord r WHERE r.senderId = :senderId " + + "AND r.requestType = :requestType AND r.requestId = :requestId AND r.deleted = false") + Optional findForUpdate(@Param("senderId") String senderId, + @Param("requestType") String requestType, + @Param("requestId") String requestId); + + @org.springframework.data.jpa.repository.Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ShareRequestRecord r WHERE r.id = :id AND r.deleted = false") + Optional findByIdForUpdate(@Param("id") String id); + + @Modifying + @Query(value = "DELETE FROM share_request WHERE status <> 'PROCESSING' " + + "AND updated_at < DATE_SUB(NOW(6), INTERVAL 90 DAY)", nativeQuery = true) + int deleteExpiredCompletedRecords(); +} diff --git a/campus-server/src/main/java/com/campus/repository/SysDictItemRepository.java b/campus-server/src/main/java/com/campus/repository/SysDictItemRepository.java new file mode 100644 index 0000000..516d929 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/SysDictItemRepository.java @@ -0,0 +1,17 @@ +package com.campus.repository; + +import com.campus.entity.SysDictItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SysDictItemRepository extends JpaRepository { + + List findByDictIdOrderBySortOrderAsc(String dictId); + + List findByDictIdAndStatusOrderBySortOrderAsc(String dictId, Boolean status); + + void deleteByDictId(String dictId); +} diff --git a/campus-server/src/main/java/com/campus/repository/SysDictRepository.java b/campus-server/src/main/java/com/campus/repository/SysDictRepository.java new file mode 100644 index 0000000..71b661c --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/SysDictRepository.java @@ -0,0 +1,13 @@ +package com.campus.repository; + +import com.campus.entity.SysDict; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SysDictRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/campus-server/src/main/java/com/campus/repository/TaskClaimRepository.java b/campus-server/src/main/java/com/campus/repository/TaskClaimRepository.java new file mode 100644 index 0000000..f22ec92 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/TaskClaimRepository.java @@ -0,0 +1,12 @@ +package com.campus.repository; + +import com.campus.entity.TaskClaim; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TaskClaimRepository extends JpaRepository { + Optional findByUserIdAndTaskId(String userId, String taskId); + List findByUserId(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/TaskDefinitionRepository.java b/campus-server/src/main/java/com/campus/repository/TaskDefinitionRepository.java new file mode 100644 index 0000000..2c438ab --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/TaskDefinitionRepository.java @@ -0,0 +1,13 @@ +package com.campus.repository; + +import com.campus.entity.TaskDefinition; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TaskDefinitionRepository extends JpaRepository { + Optional findByCode(String code); + List findByEnabledTrueOrderBySortAsc(); + List findAllByOrderBySortAsc(); +} diff --git a/campus-server/src/main/java/com/campus/repository/TopicAliasRepository.java b/campus-server/src/main/java/com/campus/repository/TopicAliasRepository.java new file mode 100644 index 0000000..5f3d2e6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/TopicAliasRepository.java @@ -0,0 +1,26 @@ +package com.campus.repository; + +import com.campus.entity.TopicAlias; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +@Repository +public interface TopicAliasRepository extends JpaRepository { + Optional findByAlias(String alias); + + /** 含已软删:{@code uk_topic_alias} 唯一索引包含软删行,查重必须用它。 */ + @Query(value = "SELECT * FROM topic_alias WHERE alias = :alias LIMIT 1", nativeQuery = true) + Optional findByAliasIncludingDeleted(@Param("alias") String alias); + + List findByTopicId(String topicId); + + /** 话题删除时给它的别名统一加后缀,释放别名名称(uk_topic_alias 唯一索引包含软删行)。 */ + @Modifying + @Query(value = "UPDATE topic_alias SET alias = CONCAT(alias, :suffix) WHERE topic_id = :topicId", nativeQuery = true) + int appendSuffixToAliases(@Param("topicId") String topicId, @Param("suffix") String suffix); +} diff --git a/campus-server/src/main/java/com/campus/repository/TopicFavoriteRepository.java b/campus-server/src/main/java/com/campus/repository/TopicFavoriteRepository.java new file mode 100644 index 0000000..2345e7b --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/TopicFavoriteRepository.java @@ -0,0 +1,52 @@ +package com.campus.repository; + +import com.campus.entity.TopicFavorite; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface TopicFavoriteRepository extends JpaRepository { + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("SELECT e FROM TopicFavorite e WHERE e.userId = :first AND e.topicName = :second AND e.deleted = false") + Optional findForInteraction(@org.springframework.data.repository.query.Param("first") String first, @org.springframework.data.repository.query.Param("second") String second); + Optional findByUserIdAndTopicName(String userId, String topicName); + + /** 我收藏的话题,按收藏时间倒序("我的"页用)。 */ + List findByUserIdOrderByCreatedAtDesc(String userId); + + /** 删除/合并话题时按名字取全部收藏行。 */ + List findByTopicName(String topicName); + + long countByTopicName(String topicName); + + /** 批量统计收藏数:返回 [topicName, count]。 */ + @Query("SELECT f.topicName, COUNT(f) FROM TopicFavorite f " + + "WHERE f.topicName IN :topicNames GROUP BY f.topicName") + List countGroupByTopicNameIn(@Param("topicNames") Collection topicNames); + + /** + * 幂等插入:已存在则不做任何事。 + * + *

为什么不用 {@code save()} + catch 唯一键异常:JPA 里唯一键冲突会把事务标记成 + * rollback-only,即使 catch 住,提交时仍会抛 {@code UnexpectedRollbackException}。 + * 用原生 upsert 可以让"并发首次收藏"安静地成功,返回 1 表示新插入、0 表示已存在。

+ */ + @Modifying + @Query(value = "INSERT INTO topic_favorite (id, user_id, topic_id, topic_name, created_at, updated_at, deleted) " + + "VALUES (:id, :userId, :topicId, :topicName, NOW(6), NOW(6), b'0') " + + "ON DUPLICATE KEY UPDATE topic_id = COALESCE(topic_id, VALUES(topic_id)), deleted = 0", nativeQuery = true) + int insertIgnore(@Param("id") String id, @Param("userId") String userId, + @Param("topicName") String topicName, @Param("topicId") String topicId); + + /** 话题删除时把按「名字」关联的收藏一起改名,否则同名新话题会继承旧话题的收藏数。 */ + @Modifying + @Query(value = "UPDATE topic_favorite SET topic_name = :to WHERE topic_name = :from", nativeQuery = true) + int renameTopicName(@Param("from") String from, @Param("to") String to); +} diff --git a/campus-server/src/main/java/com/campus/repository/TopicFollowRepository.java b/campus-server/src/main/java/com/campus/repository/TopicFollowRepository.java new file mode 100644 index 0000000..45165b6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/TopicFollowRepository.java @@ -0,0 +1,49 @@ +package com.campus.repository; + +import com.campus.entity.TopicFollow; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +@Repository +public interface TopicFollowRepository extends JpaRepository { + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("SELECT e FROM TopicFollow e WHERE e.userId = :first AND e.topicName = :second AND e.deleted = false") + Optional findForInteraction(@org.springframework.data.repository.query.Param("first") String first, @org.springframework.data.repository.query.Param("second") String second); + Optional findByUserIdAndTopicName(String userId, String topicName); + List findByUserId(String userId); + List findByTopicNameIn(Collection topicNames); + List findByTopicName(String topicName); + long countByTopicName(String topicName); + + /** 批量统计关注数:[topicName, count]。 */ + @Query("SELECT f.topicName, COUNT(f) FROM TopicFollow f " + + "WHERE f.topicName IN :topicNames GROUP BY f.topicName") + List countGroupByTopicNameIn(@Param("topicNames") Collection topicNames); + + /** 批量统计窗口内的新增关注数(话题热度里的"增长"项)。 */ + @Query("SELECT f.topicName, COUNT(f) FROM TopicFollow f " + + "WHERE f.topicName IN :topicNames AND f.createdAt >= :since GROUP BY f.topicName") + List countRecentGroupByTopicNameIn(@Param("topicNames") Collection topicNames, + @Param("since") Date since); + + /** + * 话题删除时把按「名字」关联的关注一起改名。 + * 否则同名新话题会直接继承旧话题的关注数。 + */ + @Modifying + @Query(value = "UPDATE topic_follow SET topic_name = :to WHERE topic_name = :from", nativeQuery = true) + int renameTopicName(@Param("from") String from, @Param("to") String to); + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query(value = "INSERT INTO topic_follow (id, user_id, topic_id, topic_name, created_at, updated_at, deleted) VALUES (:id, :first, :topicId, :second, NOW(6), NOW(6), 0) ON DUPLICATE KEY UPDATE topic_id = COALESCE(topic_id, VALUES(topic_id)), deleted = 0, updated_at = NOW(6)", nativeQuery = true) + int upsertActive(@org.springframework.data.repository.query.Param("id") String id, + @org.springframework.data.repository.query.Param("first") String first, + @org.springframework.data.repository.query.Param("second") String second, @Param("topicId") String topicId); +} diff --git a/campus-server/src/main/java/com/campus/repository/TopicHotSnapshotRepository.java b/campus-server/src/main/java/com/campus/repository/TopicHotSnapshotRepository.java new file mode 100644 index 0000000..691a127 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/TopicHotSnapshotRepository.java @@ -0,0 +1,20 @@ +package com.campus.repository; + +import com.campus.entity.TopicHotSnapshot; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.util.Date; +import java.util.List; + +@Repository +public interface TopicHotSnapshotRepository extends JpaRepository { + List findByCapturedAtBetween(Date from, Date to); + + /** 话题删除时把按「名字」关联的热度快照一起改名(否则同名新话题会继承旧热度曲线)。 */ + @Modifying + @Query(value = "UPDATE topic_hot_snapshot SET topic_name = :to WHERE topic_name = :from", nativeQuery = true) + int renameTopicName(@Param("from") String from, @Param("to") String to); +} diff --git a/campus-server/src/main/java/com/campus/repository/TopicRepository.java b/campus-server/src/main/java/com/campus/repository/TopicRepository.java new file mode 100644 index 0000000..e930c36 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/TopicRepository.java @@ -0,0 +1,29 @@ +package com.campus.repository; + +import com.campus.entity.Topic; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +@Repository +public interface TopicRepository extends JpaRepository { + Optional findByName(String name); + + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT t FROM Topic t WHERE t.name = :name AND t.deleted = false") + Optional findByNameForInteraction(@Param("name") String name); + + + /** + * 含已软删:{@code uk_topic_name} 唯一索引包含软删行, + * 创建/改名前查重必须用它,否则会漏判并撞唯一键(表现为 500)。 + */ + @Query(value = "SELECT * FROM topic WHERE name = :name LIMIT 1", nativeQuery = true) + Optional findByNameIncludingDeleted(@Param("name") String name); + + List findByStatus(String status); + List findByCreatedByOrderByCreatedAtDesc(String createdBy); +} diff --git a/campus-server/src/main/java/com/campus/repository/TopicVisitRepository.java b/campus-server/src/main/java/com/campus/repository/TopicVisitRepository.java new file mode 100644 index 0000000..78271ca --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/TopicVisitRepository.java @@ -0,0 +1,36 @@ +package com.campus.repository; + +import com.campus.entity.TopicVisit; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +@Repository +public interface TopicVisitRepository extends JpaRepository { + Optional findByUserIdAndTopicName(String userId, String topicName); + List findByUserIdOrderByLastVisitedAtDesc(String userId); + List findByTopicName(String topicName); + + /** 批量统计访问人数(每用户一行,天然去重):[topicName, count]。 */ + @Query("SELECT v.topicName, COUNT(v) FROM TopicVisit v " + + "WHERE v.topicName IN :topicNames GROUP BY v.topicName") + List countGroupByTopicNameIn(@Param("topicNames") Collection topicNames); + + /** 批量统计窗口内的访问人数(话题热度里的"增长"项)。 */ + @Query("SELECT v.topicName, COUNT(v) FROM TopicVisit v " + + "WHERE v.topicName IN :topicNames AND v.lastVisitedAt >= :since GROUP BY v.topicName") + List countRecentGroupByTopicNameIn(@Param("topicNames") Collection topicNames, + @Param("since") Date since); + + /** 话题删除时把按「名字」关联的访问记录一起改名(否则同名新话题会继承旧访问数)。 */ + @Modifying + @Query(value = "UPDATE topic_visit SET topic_name = :to WHERE topic_name = :from", nativeQuery = true) + int renameTopicName(@Param("from") String from, @Param("to") String to); +} diff --git a/campus-server/src/main/java/com/campus/repository/UniversityRepository.java b/campus-server/src/main/java/com/campus/repository/UniversityRepository.java new file mode 100644 index 0000000..a332f69 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UniversityRepository.java @@ -0,0 +1,60 @@ +package com.campus.repository; + +import com.campus.entity.University; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UniversityRepository extends JpaRepository { + + List findByProvince(String province); + + List findByCity(String city); + + List findByLevel(String level); + + List findByType(String type); + + List findByNameContaining(String name); + + Page findByNameContaining(String name, Pageable pageable); + + Optional findByName(String name); + + List findByProvinceAndCity(String province, String city); + + @Query("SELECT u FROM University u WHERE u.deleted = false AND " + + "(:province IS NULL OR u.province = :province) AND " + + "(:city IS NULL OR u.city = :city) AND " + + "(:level IS NULL OR u.level = :level) AND " + + "(:type IS NULL OR u.type = :type)") + List findByConditions(@Param("province") String province, + @Param("city") String city, + @Param("level") String level, + @Param("type") String type); + + @Query("SELECT u FROM University u WHERE u.deleted = false AND " + + "(:province IS NULL OR u.province = :province) AND " + + "(:city IS NULL OR u.city = :city) AND " + + "(:level IS NULL OR u.level = :level) AND " + + "(:type IS NULL OR u.type = :type)") + Page findByConditions(@Param("province") String province, + @Param("city") String city, + @Param("level") String level, + @Param("type") String type, + Pageable pageable); + + /** + * 查询所有有经纬度的大学 + */ + @Query("SELECT u FROM University u WHERE u.deleted = false " + + "AND u.latitude IS NOT NULL AND u.longitude IS NOT NULL") + List findWithCoordinates(); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserAchievementRepository.java b/campus-server/src/main/java/com/campus/repository/UserAchievementRepository.java new file mode 100644 index 0000000..b54cff0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserAchievementRepository.java @@ -0,0 +1,10 @@ +package com.campus.repository; +import com.campus.entity.UserAchievement; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.util.*; +public interface UserAchievementRepository extends JpaRepository { + boolean existsByUserIdAndAchievementCode(String userId, String achievementCode); + @Query(value="select * from user_achievement where user_id = ?1", nativeQuery=true) + List findIncludingDeleted(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserBlockedMomentRepository.java b/campus-server/src/main/java/com/campus/repository/UserBlockedMomentRepository.java new file mode 100644 index 0000000..3701df7 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserBlockedMomentRepository.java @@ -0,0 +1,13 @@ +package com.campus.repository; + +import com.campus.entity.UserBlockedMoment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserBlockedMomentRepository extends JpaRepository { + Optional findByUserIdAndMomentId(String userId, String momentId); + List findByUserId(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserDeviceTokenRepository.java b/campus-server/src/main/java/com/campus/repository/UserDeviceTokenRepository.java new file mode 100644 index 0000000..1f4a127 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserDeviceTokenRepository.java @@ -0,0 +1,20 @@ +package com.campus.repository; + +import com.campus.entity.UserDeviceToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserDeviceTokenRepository extends JpaRepository { + + Optional findByUserIdAndDeviceToken(String userId, String deviceToken); + + List findByUserId(String userId); + + @Transactional + void deleteByDeviceToken(String deviceToken); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserEntitlementRepository.java b/campus-server/src/main/java/com/campus/repository/UserEntitlementRepository.java new file mode 100644 index 0000000..2f9edc8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserEntitlementRepository.java @@ -0,0 +1,10 @@ +package com.campus.repository; + +import com.campus.entity.UserEntitlement; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UserEntitlementRepository extends JpaRepository { + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserPointsRepository.java b/campus-server/src/main/java/com/campus/repository/UserPointsRepository.java new file mode 100644 index 0000000..291a53b --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserPointsRepository.java @@ -0,0 +1,40 @@ +package com.campus.repository; + +import com.campus.entity.UserPoints; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import javax.persistence.LockModeType; + +@Repository +public interface UserPointsRepository extends JpaRepository { + + Optional findByUserId(String userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select up from UserPoints up where up.userId = :userId and up.deleted = false") + Optional findByUserIdForUpdate(@Param("userId") String userId); + + /** + * 查询积分排名前N的用户 + */ + @Query("SELECT up FROM UserPoints up WHERE up.deleted = false ORDER BY up.points DESC") + List findTopByPoints(Pageable pageable); + + /** + * 查询经验值排名前N的用户 + */ + @Query("SELECT up FROM UserPoints up WHERE up.deleted = false ORDER BY up.experience DESC") + List findTopByExperience(Pageable pageable); + + /** + * 批量查询用户积分 + */ + List findByUserIdIn(List userIds); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserPrivacySettingRepository.java b/campus-server/src/main/java/com/campus/repository/UserPrivacySettingRepository.java new file mode 100644 index 0000000..fb4e806 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserPrivacySettingRepository.java @@ -0,0 +1,11 @@ +package com.campus.repository; + +import com.campus.entity.UserPrivacySetting; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserPrivacySettingRepository extends JpaRepository { + + Optional findByUserId(String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserRepository.java b/campus-server/src/main/java/com/campus/repository/UserRepository.java new file mode 100644 index 0000000..e231f6f --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserRepository.java @@ -0,0 +1,216 @@ +package com.campus.repository; + +import com.campus.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import javax.persistence.LockModeType; + +public interface UserRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from User u where u.id = :id and u.deleted = false") + Optional findByIdForUpdate(@Param("id") String id); + + /** + * 查询手机号对应的全部历史记录。 + * + * 必须显式包含 deleted=true 的记录,供注册流程清理旧逻辑注销数据;同时返回 List, + * 避免历史脏数据再次触发 NonUniqueResultException。 + */ + @Query(value = "SELECT * FROM user WHERE phone = :phone ORDER BY created_at DESC", nativeQuery = true) + List findAllByPhoneIncludingDeleted(@Param("phone") String phone); + + @Query("SELECT u FROM User u WHERE u.deleted = false " + + "AND (:keyword IS NULL OR :keyword = '' OR LOWER(u.nickname) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "OR u.phone LIKE CONCAT('%', :keyword, '%') OR LOWER(u.number) LIKE LOWER(CONCAT('%', :keyword, '%'))) " + + "AND (:accountStatus IS NULL OR :accountStatus = '' OR u.accountStatus = :accountStatus) " + + "AND (:isAdmin IS NULL OR u.isAdmin = :isAdmin) " + + "AND (:isReviewer IS NULL OR u.isReviewer = :isReviewer)") + Page searchForAdmin(@Param("keyword") String keyword, + @Param("accountStatus") String accountStatus, + @Param("isAdmin") Boolean isAdmin, + @Param("isReviewer") Boolean isReviewer, + Pageable pageable); + + @Query("select u from User u where u.accountStatus = 'DEACTIVATING' and u.scheduledDeletionAt <= :now order by u.scheduledDeletionAt, u.id") + List findExpiredDeactivations(@Param("now") java.util.Date now, Pageable pageable); + + long countByAccountStatus(String accountStatus); + + long countByIsAdminTrue(); + + long countByIsAdminTrueAndAccountStatus(String accountStatus); + + long countByIsReviewerTrue(); + + List findByAccountStatusAndScheduledDeletionAtBefore(String accountStatus, java.util.Date deadline); + + long countByUniversityIdAndDeletedFalse(String universityId); + + long countByUniversityIdAndXuexinCertifiedTrueAndDeletedFalse(String universityId); + + long countByUniversityNameAndDeletedFalse(String universityName); + + long countByUniversityNameAndXuexinCertifiedTrueAndDeletedFalse(String universityName); + + @Query("SELECT u FROM User u WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId AND u.universityName = :school") + Page findBySchoolExclude(@Param("school") String school, + @Param("excludeId") String excludeId, + Pageable pageable); + + /** + * 同校候选(排除自己 + 已是好友/已发申请的用户) + */ + @Query("SELECT u FROM User u WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId AND u.universityName = :school " + + "AND u.id NOT IN :excludeIds") + Page findBySchoolExcludeFriends(@Param("school") String school, + @Param("excludeId") String excludeId, + @Param("excludeIds") List excludeIds, + Pageable pageable); + + @Query("SELECT u FROM User u WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId ORDER BY FUNCTION('RAND')") + Page findRandomUsers(@Param("excludeId") String excludeId, Pageable pageable); + + List findByIdIn(Collection ids); + + // ===== 交友模块扩展 ===== + + /** + * 关键词搜索:昵称/学校/专业模糊匹配,手机号和用户编号精确匹配。 + */ + @Query("SELECT u FROM User u WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId " + + "AND (LOWER(u.nickname) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "OR LOWER(u.universityName) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "OR LOWER(u.majorName) LIKE LOWER(CONCAT('%', :keyword, '%')) " + + "OR u.phone = :keyword OR u.number = :keyword)") + Page searchByKeyword(@Param("keyword") String keyword, + @Param("excludeId") String excludeId, + Pageable pageable); + + /** + * 多维筛选 + */ + @Query("SELECT u FROM User u WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId AND u.id NOT IN :excludeIds " + + "AND (:school IS NULL OR u.universityName = :school) " + + "AND (:gender IS NULL OR u.gender = :gender) " + + "AND (:major IS NULL OR LOWER(u.majorName) = LOWER(:major)) " + + "AND (:education IS NULL OR u.education = :education) " + + "AND (:grade IS NULL OR u.grade = :grade) " + + "AND (:hometown IS NULL OR u.hometown = :hometown) " + + "AND (:location IS NULL OR u.location = :location)") + Page filterUsers(@Param("school") String school, + @Param("gender") String gender, + @Param("major") String major, + @Param("education") String education, + @Param("grade") String grade, + @Param("hometown") String hometown, + @Param("location") String location, + @Param("excludeId") String excludeId, + @Param("excludeIds") List excludeIds, + Pageable pageable); + + /** + * 多维筛选 + 共同兴趣(interests 有交集) + */ + @Query(value = "SELECT DISTINCT u FROM User u JOIN u.interests i WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId AND u.id NOT IN :excludeIds " + + "AND (:school IS NULL OR u.universityName = :school) " + + "AND (:gender IS NULL OR u.gender = :gender) " + + "AND (:major IS NULL OR LOWER(u.majorName) = LOWER(:major)) " + + "AND (:education IS NULL OR u.education = :education) " + + "AND (:grade IS NULL OR u.grade = :grade) " + + "AND (:hometown IS NULL OR u.hometown = :hometown) " + + "AND (:location IS NULL OR u.location = :location) " + + "AND i IN :interests", + countQuery = "SELECT COUNT(DISTINCT u) FROM User u JOIN u.interests i WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId AND u.id NOT IN :excludeIds " + + "AND (:school IS NULL OR u.universityName = :school) " + + "AND (:gender IS NULL OR u.gender = :gender) " + + "AND (:major IS NULL OR LOWER(u.majorName) = LOWER(:major)) " + + "AND (:education IS NULL OR u.education = :education) " + + "AND (:grade IS NULL OR u.grade = :grade) " + + "AND (:hometown IS NULL OR u.hometown = :hometown) " + + "AND (:location IS NULL OR u.location = :location) " + + "AND i IN :interests") + Page filterUsersByInterests(@Param("school") String school, + @Param("gender") String gender, + @Param("major") String major, + @Param("education") String education, + @Param("grade") String grade, + @Param("hometown") String hometown, + @Param("location") String location, + @Param("interests") List interests, + @Param("excludeId") String excludeId, + @Param("excludeIds") List excludeIds, + Pageable pageable); + + /** + * 同校候选池(用于推荐,按 id 稳定排序,保证「换一批」去重结果稳定) + */ + @Query("SELECT u FROM User u WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId AND u.universityName = :school ORDER BY u.id ASC") + List findCandidatesBySchool(@Param("school") String school, + @Param("excludeId") String excludeId, + Pageable pageable); + + /** 心动匹配候选池:偏好和已操作对象由 MatchingService 继续过滤。 */ + @Query("SELECT u FROM User u WHERE u.deleted = false AND (u.accountStatus IS NULL OR u.accountStatus <> 'DEACTIVATING') AND u.id != :excludeId ORDER BY u.id ASC") + List findMatchCandidates(@Param("excludeId") String excludeId, Pageable pageable); + + /** + * 获取某大学当前最大的学号(用于初始化 Redis 计数器) + */ + @Query("SELECT MAX(u.number) FROM User u WHERE u.deleted = false AND u.universityId = :universityId AND u.number IS NOT NULL") + String findMaxNumberByUniversityId(@Param("universityId") String universityId); + + /** + * 学号是否已被占用(含软删行:软删行仍然占用 number 唯一索引,必须把 deleted 行算进来)。 + */ + @Query(value = "SELECT COUNT(*) FROM user WHERE number = :number", nativeQuery = true) + long countByNumberIncludingDeleted(@Param("number") String number); + + /** + * 通过环信用户名反查用户(toEasemobUsername 截断最后一位,需 LIKE 匹配) + */ + @Query(value = "SELECT * FROM user WHERE deleted = false AND REPLACE(id, '-', '') LIKE CONCAT(:hexPrefix, '%') LIMIT 1", nativeQuery = true) + Optional findByEasemobHexPrefix(@Param("hexPrefix") String hexPrefix); + @Query("SELECT u.universityId, COUNT(u), SUM(CASE WHEN u.xuexinCertified = true THEN 1 ELSE 0 END) " + + "FROM User u WHERE u.deleted = false AND u.universityId IS NOT NULL GROUP BY u.universityId") + List countUsersGroupedByUniversityId(); + + @Query("SELECT u.universityName, COUNT(u), SUM(CASE WHEN u.xuexinCertified = true THEN 1 ELSE 0 END) " + + "FROM User u WHERE u.deleted = false AND u.universityName IS NOT NULL GROUP BY u.universityName") + List countUsersGroupedByUniversityName(); + + /** + * 查找附近用户(基于Haversine距离公式) + */ + @Query(value = "SELECT *, (6371000 * 2 * ASIN(SQRT(POWER(SIN((:lat - latitude) * PI() / 180 / 2), 2) + " + + "COS(:lat * PI() / 180) * COS(latitude * PI() / 180) * " + + "POWER(SIN((:lng - longitude) * PI() / 180 / 2), 2)))) AS distance " + + "FROM user WHERE deleted = false AND latitude IS NOT NULL AND longitude IS NOT NULL AND id != :excludeId " + + "HAVING distance < :radius ORDER BY distance", nativeQuery = true) + List findNearbyUsers(@Param("excludeId") String excludeId, + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radius") int radius); + + /** + * 查找同校附近用户 + */ + @Query(value = "SELECT *, (6371000 * 2 * ASIN(SQRT(POWER(SIN((:lat - latitude) * PI() / 180 / 2), 2) + " + + "COS(:lat * PI() / 180) * COS(latitude * PI() / 180) * " + + "POWER(SIN((:lng - longitude) * PI() / 180 / 2), 2)))) AS distance " + + "FROM user WHERE deleted = false AND latitude IS NOT NULL AND longitude IS NOT NULL AND id != :excludeId " + + "AND university_name = :school HAVING distance < :radius ORDER BY distance", nativeQuery = true) + List findNearbyUsersBySchool(@Param("school") String school, + @Param("excludeId") String excludeId, + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radius") int radius); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserRestrictionRepository.java b/campus-server/src/main/java/com/campus/repository/UserRestrictionRepository.java new file mode 100644 index 0000000..17669f3 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserRestrictionRepository.java @@ -0,0 +1,26 @@ +package com.campus.repository; + +import com.campus.entity.UserRestriction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +@Repository +public interface UserRestrictionRepository extends JpaRepository { + Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); + + @Query("select r from UserRestriction r where r.userId = :userId " + + "and r.status = 'ACTIVE' and r.startsAt <= :now " + + "and (r.expiresAt is null or r.expiresAt > :now) " + + "and r.restrictionType in :types") + List findActive(@Param("userId") String userId, + @Param("types") Collection types, + @Param("now") Date now); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserSessionRepository.java b/campus-server/src/main/java/com/campus/repository/UserSessionRepository.java new file mode 100644 index 0000000..f39f4d6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserSessionRepository.java @@ -0,0 +1,14 @@ +package com.campus.repository; + +import com.campus.entity.UserSession; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserSessionRepository extends JpaRepository { + List findByUserIdAndStatusOrderByLastSeenAtDesc(String userId, String status); + Optional findByTokenHash(String tokenHash); +} diff --git a/campus-server/src/main/java/com/campus/repository/UserTaskProgressRepository.java b/campus-server/src/main/java/com/campus/repository/UserTaskProgressRepository.java new file mode 100644 index 0000000..b626260 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/UserTaskProgressRepository.java @@ -0,0 +1,23 @@ +package com.campus.repository; + +import com.campus.entity.UserTaskProgress; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.util.List; +import java.util.Optional; + +public interface UserTaskProgressRepository extends JpaRepository { + Optional findByUserIdAndTaskCodeAndCycleKey(String userId, String taskCode, String cycleKey); + List findByUserId(String userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from UserTaskProgress p where p.userId = :userId and p.taskCode = :taskCode " + + "and p.cycleKey = :cycleKey and p.deleted = false") + Optional findForUpdate(@Param("userId") String userId, + @Param("taskCode") String taskCode, + @Param("cycleKey") String cycleKey); +} diff --git a/campus-server/src/main/java/com/campus/repository/VideoLikeRepository.java b/campus-server/src/main/java/com/campus/repository/VideoLikeRepository.java new file mode 100644 index 0000000..65d7b86 --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/VideoLikeRepository.java @@ -0,0 +1,15 @@ +package com.campus.repository; + +import com.campus.entity.VideoLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface VideoLikeRepository extends JpaRepository { + + Optional findByVideoIdAndUserId(String videoId, String userId); + + boolean existsByVideoIdAndUserIdAndIsLikedTrue(String videoId, String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/VideoPlayRecordRepository.java b/campus-server/src/main/java/com/campus/repository/VideoPlayRecordRepository.java new file mode 100644 index 0000000..f534e3b --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/VideoPlayRecordRepository.java @@ -0,0 +1,23 @@ +package com.campus.repository; + +import com.campus.entity.VideoPlayRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface VideoPlayRecordRepository extends JpaRepository { + + Optional findByVideoIdAndUserId(String videoId, String userId); + + /** + * 批量查询用户在多个视频上的播放进度 + */ + @Query("SELECT r FROM VideoPlayRecord r WHERE r.videoId IN :videoIds AND r.userId = :userId") + List findByVideoIdInAndUserId(@Param("videoIds") Collection videoIds, @Param("userId") String userId); +} diff --git a/campus-server/src/main/java/com/campus/repository/XuexinReportRepository.java b/campus-server/src/main/java/com/campus/repository/XuexinReportRepository.java new file mode 100644 index 0000000..1d82f6e --- /dev/null +++ b/campus-server/src/main/java/com/campus/repository/XuexinReportRepository.java @@ -0,0 +1,61 @@ +package com.campus.repository; + +import com.campus.entity.XuexinReport; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Date; +import java.util.List; +import java.util.Optional; + +@Repository +public interface XuexinReportRepository extends JpaRepository { + + /** + * 按判重键查材料。 + * + *

⚠️ 本表不做软删,正常路径命中率 100%;仍保留 {@link #findByDedupKeyIncludingDeleted} + * 作为兜底,防止将来有人误用软删后重演「查不到却插不进去」的 500(AGENTS §7.21)。

+ */ + Optional findByDedupKey(String dedupKey); + + /** 含软删行查判重键,兜底用。 */ + @Query(value = "SELECT * FROM xuexin_report WHERE dedup_key = :dedupKey LIMIT 1", nativeQuery = true) + Optional findByDedupKeyIncludingDeleted(@Param("dedupKey") String dedupKey); + + /** 当前用户生效中的认证材料(activeMarker 非空即 CONFIRMED)。 */ + Optional findByUserIdAndActiveMarkerIsNotNull(String userId); + + Optional findByIdAndUserId(String id, String userId); + + boolean existsByUserId(String userId); + + /** + * 原子确认:仅当材料仍是 PENDING 且属于该用户时才成功。 + * + *

返回 0 表示材料已被其他请求确认、已过期删除或不属于该用户,调用方应抛业务异常。 + * 不能用「先查再改」,否则并发下两个请求都能读到 PENDING。

+ */ + @Modifying + @Query("UPDATE XuexinReport r SET r.status = 'CONFIRMED', r.confirmedAt = :now, " + + "r.activeMarker = r.userId, r.expireAt = NULL " + + "WHERE r.id = :id AND r.userId = :userId AND r.status = 'PENDING'") + int markConfirmed(@Param("id") String id, @Param("userId") String userId, @Param("now") Date now); + + /** + * 把该用户此前生效的认证材料置为 SUPERSEDED 并释放唯一键。 + * + *

必须先于 {@link #markConfirmed} 执行,否则 + * {@code uk_xuexin_report_active_user} 会冲突。

+ */ + @Modifying + @Query("UPDATE XuexinReport r SET r.activeMarker = NULL, r.status = 'SUPERSEDED', " + + "r.supersededAt = :now WHERE r.userId = :userId AND r.activeMarker IS NOT NULL") + int supersedeActive(@Param("userId") String userId, @Param("now") Date now); + + /** 查询过期临时材料,供任务先删 OSS PDF、再逐条物理删除数据库记录。 */ + List findByStatusAndExpireAtBefore(String status, Date before); +} diff --git a/campus-server/src/main/java/com/campus/service/AccountDeactivationCleanupService.java b/campus-server/src/main/java/com/campus/service/AccountDeactivationCleanupService.java new file mode 100644 index 0000000..d82c117 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AccountDeactivationCleanupService.java @@ -0,0 +1,46 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import java.util.*; + +/** 到期账号逐个隔离;清单未核实前保留全部数据,禁止误报清理成功。 */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AccountDeactivationCleanupService { + private final UserRepository users; + private final JdbcTemplate jdbc; + private final AccountDeletionWorker worker; + + public java.util.Map preview(String uid) { + java.util.Map result=new java.util.LinkedHashMap<>(); + result.put("coolingPeriodDays",7); + result.put("ownedGroupCount",jdbc.queryForObject("SELECT COUNT(*) FROM chat_group WHERE owner_id=? AND deleted=false",Integer.class,uid)); + result.put("hasPendingPayments",jdbc.queryForObject("SELECT COUNT(*) FROM activity_payment_order WHERE user_id=? AND deleted=false AND provider<>'UNINTEGRATED' AND status IN ('PENDING','PAID')",Integer.class,uid)>0); + result.put("message","注销后主页和动态将隐藏,七天内重新登录将自动取消注销;七天到期后,您作为群主的群聊将解散,账号数据、订单、支付和争议记录及个人资源将清理,无法恢复"); + return result; + } + + public void requireNoPendingPayments(String userId) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM activity_payment_order WHERE user_id = ? AND deleted = false AND provider <> 'UNINTEGRATED' AND status IN ('PENDING','PAID')", Integer.class, userId); + if (count != null && count > 0) throw new BusinessException("存在未完成的支付或退款,请处理后再注销账号"); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean cleanup(String userId) { + User user = users.findByIdForUpdate(userId).orElse(null); + if (user == null || !"DEACTIVATING".equals(user.getAccountStatus()) || user.getScheduledDeletionAt() == null + || user.getScheduledDeletionAt().after(new Date())) return false; + jdbc.update("INSERT INTO account_deletion_batch (id, user_id, status, attempts, created_at, updated_at) VALUES (?, ?, 'PENDING_INVENTORY', 0, NOW(), NOW()) ON DUPLICATE KEY UPDATE attempts = attempts + 1, updated_at = NOW()", UUID.randomUUID().toString(), userId); + Integer ready=jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_batch WHERE user_id=? AND status IN ('READY','PROCESSING')",Integer.class,userId); + if (ready == null || ready == 0) worker.inventory(userId); + return false; + } +} diff --git a/campus-server/src/main/java/com/campus/service/AccountDeactivationScheduler.java b/campus-server/src/main/java/com/campus/service/AccountDeactivationScheduler.java new file mode 100644 index 0000000..53751e3 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AccountDeactivationScheduler.java @@ -0,0 +1,27 @@ +package com.campus.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AccountDeactivationScheduler { + private final UserService userService; + private final AccountDeletionWorker worker; + private final AccountDeletionReviewService reviewService; + + @org.springframework.beans.factory.annotation.Value("${app.account-deletion.cleanup-enabled:false}") + private boolean cleanupEnabled; + + @Scheduled(cron = "0 */10 * * * ?", zone = "Asia/Shanghai") + public void purgeExpiredAccounts() { + reviewService.expireReceipts(); + if (!cleanupEnabled) return; + int count = userService.purgeExpiredDeactivations(); + worker.runPending(); + if (count > 0) log.info("已清理超过注销冷静期的账号: count={}", count); + } +} diff --git a/campus-server/src/main/java/com/campus/service/AccountDeletionFinalizer.java b/campus-server/src/main/java/com/campus/service/AccountDeletionFinalizer.java new file mode 100644 index 0000000..0e50c7a --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AccountDeletionFinalizer.java @@ -0,0 +1,31 @@ +package com.campus.service; + +import com.campus.repository.UserRepository; +import com.campus.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Date; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AccountDeletionFinalizer { + private final UserRepository users; + private final UserAccountCleanupService cleanup; + private final JdbcTemplate jdbc; + + @Transactional + public void finish(String uid, String batchId) { + User user=users.findByIdForUpdate(uid).orElse(null); + if(user==null || !"DEACTIVATING".equals(user.getAccountStatus()) || user.getScheduledDeletionAt()==null || user.getScheduledDeletionAt().after(new Date())) return; + if(jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource WHERE batch_id=? AND status<>'DONE'",Integer.class,batchId)>0) return; + Integer resourceCount=jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource WHERE batch_id=?",Integer.class,batchId); + Integer retries=jdbc.queryForObject("SELECT COALESCE(SUM(GREATEST(attempts-1,0)),0) FROM account_deletion_resource WHERE batch_id=?",Integer.class,batchId); + cleanup.deleteUserData(uid); + jdbc.update("INSERT INTO account_deletion_receipt(id,completed_at,resource_count,retry_count,result) VALUES(?,NOW(),?,?, 'COMPLETED')",batchId,resourceCount==null?0:resourceCount,retries==null?0:retries); + jdbc.update("DELETE FROM account_deletion_resource WHERE batch_id=?",batchId); + jdbc.update("DELETE FROM account_deletion_batch WHERE id=?",batchId); + } +} diff --git a/campus-server/src/main/java/com/campus/service/AccountDeletionReviewService.java b/campus-server/src/main/java/com/campus/service/AccountDeletionReviewService.java new file mode 100644 index 0000000..15bdce6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AccountDeletionReviewService.java @@ -0,0 +1,60 @@ +package com.campus.service; + +import com.campus.common.*; +import com.campus.util.*; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly=true) +public class AccountDeletionReviewService { + private final JdbcTemplate jdbc; + private final AuthorizationService authorization; + private final ClickGuardService clicks; + private final AuditLogService audit; + public PageResult> list(int page,int size) { + authorization.requireAdmin(); + org.springframework.data.domain.Pageable pageable=PageUtils.request(page,size); + List> rows=jdbc.queryForList("SELECT id,batch_id,status,attempts,created_at FROM account_deletion_resource WHERE type='REVIEW' AND status<>'DONE' ORDER BY created_at,id LIMIT ? OFFSET ?",pageable.getPageSize(),pageable.getOffset()); + Long count=jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource WHERE type='REVIEW' AND status<>'DONE'",Long.class); + return PageResult.of(rows,count==null?0:count,pageable.getPageNumber()+1,pageable.getPageSize()); + } + public PageResult> receipts(int page,int size) { + authorization.requireAdmin(); + org.springframework.data.domain.Pageable pageable=PageUtils.request(page,size); + List> rows=jdbc.queryForList("SELECT id,completed_at,resource_count,retry_count,result FROM account_deletion_receipt ORDER BY completed_at DESC,id LIMIT ? OFFSET ?",pageable.getPageSize(),pageable.getOffset()); + Long count=jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_receipt",Long.class); + return PageResult.of(rows,count==null?0:count,pageable.getPageNumber()+1,pageable.getPageSize()); + } + @Transactional + public void expireReceipts() { + jdbc.update("DELETE FROM account_deletion_receipt WHERE completed_at300) throw new BusinessException("请填写5至300字的资源核实依据"); + if(!Arrays.asList("KEEP_SHARED","DELETE_OSS","DELETE_VOD").contains(decision)) throw new BusinessException("资源处理决定不合法"); + clicks.ensureNotTooFrequent("account-deletion-review",operator,taskId); + List> rows=jdbc.queryForList("SELECT r.resource_id,r.status,b.status batch_status FROM account_deletion_resource r JOIN account_deletion_batch b ON b.id=r.batch_id WHERE r.id=? AND r.type='REVIEW' FOR UPDATE",taskId); + if(rows.isEmpty()) throw new BusinessException("待核实资源不存在"); + Map row=rows.get(0); + if("DONE".equals(row.get("status"))) throw new BusinessException("该资源已处理"); + if("PROCESSING".equals(row.get("batch_status"))) throw new BusinessException("清理批次正在执行,请稍后重试"); + if("KEEP_SHARED".equals(decision)) jdbc.update("UPDATE account_deletion_resource SET status='DONE',updated_at=NOW() WHERE id=?",taskId); + else { + String type="DELETE_OSS".equals(decision)?"OSS":"VOD"; + String resource=row.get("resource_id").toString(); + if("VOD".equals(type) && !resource.matches("[a-zA-Z0-9]{16,64}")) throw new BusinessException("视频资源标识不合法"); + jdbc.update("UPDATE account_deletion_resource SET type=?,status='PENDING',next_attempt_at=NOW(),updated_at=NOW() WHERE id=?",type,taskId); + } + // 审计不复制原始认证 URL、签名参数及人工输入的可能敏感信息。 + audit.record("ACCOUNT_DELETION_RESOURCE_REVIEW","ACCOUNT_DELETION_RESOURCE",taskId,"REVIEW",decision,"已核实资源归属,依据长度="+reason.trim().length()); + } +} diff --git a/campus-server/src/main/java/com/campus/service/AccountDeletionWorker.java b/campus-server/src/main/java/com/campus/service/AccountDeletionWorker.java new file mode 100644 index 0000000..1ba10a5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AccountDeletionWorker.java @@ -0,0 +1,159 @@ +package com.campus.service; + +import com.campus.config.OssProperties; +import com.campus.util.OssUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; +import java.net.URI; + +@Service +@Slf4j +@RequiredArgsConstructor +public class AccountDeletionWorker { + private final JdbcTemplate jdbc; + private final EasemobService im; + private final VodService vod; + private final ObjectProvider oss; + private final OssProperties ossProperties; + private final ObjectMapper mapper; + private final AccountDeletionFinalizer finalizer; + + public void inventory(String uid) { + for (Map group : jdbc.queryForList("SELECT easemob_group_id FROM chat_group WHERE owner_id = ?", uid)) + add(uid, "IM_GROUP", (String) group.get("easemob_group_id")); + for (Map group : jdbc.queryForList("SELECT g.easemob_group_id FROM chat_group g JOIN group_member m ON m.group_id=g.id WHERE m.user_id=? AND g.owner_id<>?", uid, uid)) + add(uid, "IM_MEMBER", (String) group.get("easemob_group_id")); + for (Map moment : jdbc.queryForList("SELECT vod_video_id, media_urls, video_original_url, video_cover_url FROM moment WHERE user_id=?", uid)) { + String videoId=(String) moment.get("vod_video_id"); + if(videoId!=null && jdbc.queryForObject("SELECT COUNT(*) FROM moment WHERE user_id<>? AND vod_video_id=?",Integer.class,uid,videoId)>0) add(uid,"REVIEW",videoId); + else add(uid, "VOD", videoId); + Object media = moment.get("media_urls"); + if (media != null && !media.toString().trim().isEmpty()) { + try { + if(media.toString().trim().startsWith("[")) { + for (Object url : mapper.readValue(media.toString(), List.class)) addOss(uid, url.toString()); + } else for(String url : media.toString().split(",")) addOss(uid,url.trim().replaceAll("^`|`$","")); + } + catch (Exception ex) { throw new IllegalStateException("媒体资源清单无法解析"); } + } + if(videoId == null || videoId.trim().isEmpty()) { + addOss(uid, (String) moment.get("video_original_url")); + addOss(uid, (String) moment.get("video_cover_url")); + } + } + for (Map user : jdbc.queryForList("SELECT avatar FROM user WHERE id=?", uid)) addOss(uid, (String) user.get("avatar")); + for (Map group : jdbc.queryForList("SELECT avatar FROM chat_group WHERE owner_id=?",uid)) addOss(uid,(String)group.get("avatar")); + for (Map report : jdbc.queryForList("SELECT report_pdf_url FROM xuexin_report WHERE user_id=?", uid)) { + String url=(String)report.get("report_pdf_url"); + if(url!=null && jdbc.queryForObject("SELECT COUNT(*) FROM xuexin_report WHERE user_id<>? AND report_pdf_url=?",Integer.class,uid,url)>0) add(uid,"REVIEW",url); + else addOss(uid,url); + } + for (Map activity : jdbc.queryForList("SELECT cover_image FROM activity WHERE creator_id=?",uid)) addOss(uid,(String)activity.get("cover_image")); + for (Map message : jdbc.queryForList("SELECT media_url FROM chat_message WHERE sender_id=?",uid)) addOss(uid,(String)message.get("media_url")); + for (Map topic : jdbc.queryForList("SELECT cover_url FROM topic WHERE created_by=?",uid)) add(uid,"REVIEW",(String)topic.get("cover_url")); + inventoryLegacy(uid); + add(uid, "IM_USER", uid); + jdbc.update("UPDATE account_deletion_batch SET status='READY', updated_at=NOW() WHERE user_id=?", uid); + } + + /** 历史表按存在性读取;未知草稿结构保留为待核实,不推测资源归属。 */ + private void inventoryLegacy(String uid) { + if (tableExists("youth_video")) { + for (Map row : jdbc.queryForList("SELECT video_url,cover_url FROM youth_video WHERE creator_id=?",uid)) { + add(uid,"REVIEW",(String)row.get("video_url")); + add(uid,"REVIEW",(String)row.get("cover_url")); + } + } + if (tableExists("ephemeral_messages")) { + for (Map row : jdbc.queryForList("SELECT media_url FROM ephemeral_messages WHERE sender_id=?",uid)) + add(uid,"REVIEW",(String)row.get("media_url")); + } + if (tableExists("content_draft_version")) { + for (Map row : jdbc.queryForList("SELECT id,payload FROM content_draft_version WHERE user_id=?",uid)) { + Object payload=row.get("payload"); + if(payload==null) continue; + try { inventoryDraft(uid,mapper.readTree(payload.toString())); } + catch(Exception ex) { throw new IllegalStateException("草稿资源清单无法解析"); } + } + } + } + + private boolean tableExists(String table) { + Integer count=jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?",Integer.class,table); + return count!=null && count>0; + } + + private void inventoryDraft(String uid,com.fasterxml.jackson.databind.JsonNode node) { + if(node.isObject()) { + java.util.Iterator> fields=node.fields(); + while(fields.hasNext()) { + java.util.Map.Entry field=fields.next(); + if("vodVideoId".equals(field.getKey()) && field.getValue().isTextual()) add(uid,"REVIEW",field.getValue().asText()); + else inventoryDraft(uid,field.getValue()); + } + } else if(node.isArray()) { for(com.fasterxml.jackson.databind.JsonNode child:node) inventoryDraft(uid,child); } + else if(node.isTextual() && (node.asText().startsWith("https://") || node.asText().startsWith("http://"))) add(uid,"REVIEW",node.asText()); + } + + private void addOss(String uid, String url) { + if (url == null || url.trim().isEmpty()) return; + // 仅接管当前 bucket 域名;其他域名可能是 VOD 或公共资源,不推测其所有权。 + try { + URI uri = URI.create(url); + String domain = ossProperties.getDomain(); + if (domain == null) throw new IllegalStateException(); + URI configured = URI.create(domain.startsWith("http") ? domain : "https://" + domain); + if (!Objects.equals(uri.getHost(), configured.getHost())) { add(uid,"REVIEW",url); return; } + String path = uri.getPath(); + if (path == null || !path.matches("/(avatars|images|videos|docs)/[0-9]{4}/[0-9]{2}/[0-9]{2}/[0-9a-fA-F-]{32,36}\\.[a-zA-Z0-9]+")) + throw new IllegalStateException(); + // 默认头像等非上传路径交由人工核实,绝不擅删。 + Integer references = jdbc.queryForObject("SELECT (SELECT COUNT(*) FROM user WHERE id<>? AND avatar=?) + (SELECT COUNT(*) FROM moment WHERE user_id<>? AND (media_urls LIKE CONCAT('%',?,'%') OR video_original_url=? OR video_cover_url=?))",Integer.class,uid,url,uid,url,url,url); + if(references != null && references>0) { add(uid,"REVIEW",url); return; } + add(uid, "OSS", url); + } catch (Exception ex) { add(uid, "REVIEW", url); } + } + + private void add(String uid, String type, String resource) { + if (resource == null || resource.trim().isEmpty()) return; + jdbc.update("INSERT IGNORE INTO account_deletion_resource (id,batch_id,type,resource_id,resource_hash,status,attempts,next_attempt_at,created_at,updated_at) SELECT ?,id,?,?,SHA2(?,256),'PENDING',0,NOW(),NOW(),NOW() FROM account_deletion_batch WHERE user_id=?", + UUID.randomUUID().toString(), type, resource, resource, uid); + } + + public void runPending() { + jdbc.update("UPDATE account_deletion_batch SET status='READY' WHERE status='PROCESSING' AND updated_at batch : jdbc.queryForList("SELECT id,user_id FROM account_deletion_batch WHERE status='READY' AND NOT EXISTS (SELECT 1 FROM account_deletion_resource r WHERE r.batch_id=account_deletion_batch.id AND r.type='REVIEW' AND r.status<>'DONE') ORDER BY updated_at LIMIT 100")) { + String batchId = batch.get("id").toString(), uid = batch.get("user_id").toString(); + if(jdbc.update("UPDATE account_deletion_batch SET status='PROCESSING',updated_at=NOW() WHERE id=? AND status='READY'",batchId)!=1) continue; + try { + for (Map task : jdbc.queryForList("SELECT id,type,resource_id FROM account_deletion_resource WHERE batch_id=? AND status<>'DONE' AND next_attempt_at<=NOW() ORDER BY CASE type WHEN 'IM_USER' THEN 1 ELSE 0 END,created_at", batchId)) { + String id = task.get("id").toString(), type = task.get("type").toString(), resource = task.get("resource_id").toString(); + try { + if ("REVIEW".equals(type)) throw new IllegalStateException(); + if (!Arrays.asList("IM_GROUP","IM_MEMBER","IM_USER","VOD","OSS").contains(type)) throw new IllegalStateException(); + if ("IM_USER".equals(type) && jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource WHERE batch_id=? AND type<>'IM_USER' AND status<>'DONE'", Integer.class,batchId)>0) continue; + if (type.startsWith("IM_")) { if (!im.deleteAccountResource(type, resource, uid)) throw new IllegalStateException(); } + else if ("VOD".equals(type)) vod.deleteAccountVideo(resource); + else if ("OSS".equals(type)) { OssUtil util=oss.getIfAvailable(); if(util==null) throw new IllegalStateException(); util.deleteAccountFile(resource); } + jdbc.update("UPDATE account_deletion_resource SET status='DONE',attempts=attempts+1,updated_at=NOW() WHERE id=?",id); + } catch (Exception ex) { + jdbc.update("UPDATE account_deletion_resource SET attempts=attempts+1,next_attempt_at=DATE_ADD(NOW(),INTERVAL 1 HOUR),updated_at=NOW() WHERE id=?",id); + log.warn("注销资源清理待重试: taskId={}, type={}",id,type); + } + } + if (jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource WHERE batch_id=? AND status<>'DONE'",Integer.class,batchId)==0) { + try { finalizer.finish(uid,batchId); } catch(Exception ex) { log.warn("注销本地清理待重试: batchId={}",batchId); } + } + } catch (Exception ex) { + log.warn("注销批次处理失败,后续重试: batchId={}",batchId); + } finally { + jdbc.update("UPDATE account_deletion_batch SET status='READY',updated_at=NOW() WHERE id=? AND status='PROCESSING'",batchId); + } + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/AchievementMetricsService.java b/campus-server/src/main/java/com/campus/service/AchievementMetricsService.java new file mode 100644 index 0000000..a864aa8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AchievementMetricsService.java @@ -0,0 +1,42 @@ +package com.campus.service; +import com.campus.entity.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import javax.persistence.EntityManager; +import java.util.*; +import java.time.*; +@Service +@RequiredArgsConstructor +@Transactional(readOnly=true) +public class AchievementMetricsService { + private final EntityManager em; + public Map measure(User user) { + String id=user.getId(); + Map m=new HashMap<>(); + m.put("ACTIVITY_EXPERT", count("select count(a) from Activity a where a.deleted=false and a.reviewStatus='approved' and a.status <> 'CANCELLED' and (a.creatorId=:uid or exists (select p.id from ActivityParticipant p where p.activityId=a.id and p.userId=:uid and p.deleted=false and p.status='CONFIRMED'))",id)); + m.put("MOMENT_EXPERT", count("select count(m) from Moment m where m.deleted=false and m.userId=:uid and m.reviewStatus='approved' and (m.contentSource is null or m.contentSource <> 'TOPIC') and m.isYouthVideo=false",id)); + List points=em.createQuery("select p from UserPoints p where p.userId=:uid",UserPoints.class).setParameter("uid",id).getResultList(); + // 签到流水恢复历史最长连续天数,避免后台核对前断签导致漏授予。 + List checkins=em.createQuery("select p.createdAt from PointTransaction p where p.userId=:uid and p.ruleCode='DAILY_CHECK_IN' and p.deleted=false order by p.createdAt",java.util.Date.class).setParameter("uid",id).getResultList(); + Set days=new TreeSet<>(); + for(java.util.Date date:checkins) days.add(Instant.ofEpochMilli(date.getTime()).atZone(ZoneId.of("Asia/Shanghai")).toLocalDate()); + long streak=0, longest=0; LocalDate previous=null; + for(LocalDate day:days) {streak=previous!=null && previous.plusDays(1).equals(day)?streak+1:1;longest=Math.max(longest,streak);previous=day;} + if(!points.isEmpty()) longest=Math.max(longest,points.get(0).getConsecutiveCheckInDays()); + m.put("CHECKIN_EXPERT",longest); + List friends=em.createQuery("select f from Friendship f where f.status='ACCEPTED' and (f.userId=:uid or f.friendId=:uid)",Friendship.class).setParameter("uid",id).getResultList(); + Set unique=new HashSet<>(); + for(Friendship f:friends) unique.add(id.equals(f.getUserId())?f.getFriendId():f.getUserId()); + unique.remove(id); + m.put("FRIEND_EXPERT",(long)unique.size()); + m.put("GIFT_EXPERT",count("select coalesce(sum(g.count),0) from GiftRecord g where g.senderId=:uid and g.deleted=false",id)); + boolean complete=Boolean.TRUE.equals(user.getXuexinCertified()) && filled(user.getNickname()) && filled(user.getAvatar()) && filled(user.getUniversityId()) && filled(user.getMajorName()) && filled(user.getGrade()); + m.put("CAMPUS_EXPERT",complete?1L:0L); + m.put("POPULAR_EXPERT",count("select count(l) from MomentLike l, Moment m where l.momentId=m.id and m.userId=:uid and l.userId<>:uid and l.deleted=false and m.deleted=false and m.reviewStatus='approved' and (m.contentSource is null or m.contentSource<>'TOPIC')",id)); + m.put("SHARE_EXPERT",count("select count(c) from ChatMessage c where c.senderId=:uid and c.messageType='SHARE_CARD' and c.deleted=false",id)); + return m; + } + private long count(String query,String uid) { return ((Number)em.createQuery(query).setParameter("uid",uid).getSingleResult()).longValue(); } + private boolean filled(String value) { return value!=null && !value.trim().isEmpty(); } +} diff --git a/campus-server/src/main/java/com/campus/service/AchievementScheduler.java b/campus-server/src/main/java/com/campus/service/AchievementScheduler.java new file mode 100644 index 0000000..ff5d7b0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AchievementScheduler.java @@ -0,0 +1,27 @@ +package com.campus.service; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.*; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +@Component +@RequiredArgsConstructor +@Slf4j +public class AchievementScheduler { + private final UserRepository users; + private final AchievementService achievements; + @Scheduled(fixedDelay=60000, initialDelay=60000) + public void reconcile() { + int page=0; + Page batch; + do { + batch=users.findAll(PageRequest.of(page++,100,Sort.by("id"))); + for(User user:batch) { + try {achievements.evaluate(user.getId());} + catch(Exception ex) {log.warn("成就核对失败,下轮重试: userId={}",user.getId(),ex);} + } + } while(batch.hasNext()); + } +} diff --git a/campus-server/src/main/java/com/campus/service/AchievementService.java b/campus-server/src/main/java/com/campus/service/AchievementService.java new file mode 100644 index 0000000..b072b84 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AchievementService.java @@ -0,0 +1,81 @@ +package com.campus.service; +import com.campus.common.BusinessException; +import com.campus.dto.response.*; +import com.campus.entity.*; +import com.campus.repository.*; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import java.util.*; +import java.util.stream.Collectors; +@Service +@RequiredArgsConstructor +@Transactional(readOnly=true) +public class AchievementService { + private final AchievementDefinitionRepository definitions; + private final UserAchievementRepository awards; + private final UserRepository users; + private final AchievementMetricsService metrics; + public AchievementOverviewDTO overview(String status,String categoryCode) { + String uid=currentUser(); + if(!Arrays.asList("ALL","LOCKED","UNLOCKED").contains(status)) throw new BusinessException("成就状态参数不合法"); + List catalog=definitions.findByEnabledTrueOrderBySortAscCodeAsc(); + if(categoryCode!=null && catalog.stream().noneMatch(d->categoryCode.equals(d.getCategoryCode()))) throw new BusinessException("成就分类不存在"); + User user=users.findById(uid).orElseThrow(()->new BusinessException("用户不存在")); + Map progress=metrics.measure(user); + Map obtained=awards.findIncludingDeleted(uid).stream().filter(a->!Boolean.TRUE.equals(a.getDeleted())).collect(Collectors.toMap(UserAchievement::getAchievementCode,a->a)); + AchievementOverviewDTO result=new AchievementOverviewDTO(); + Map groups=new LinkedHashMap<>(); + for(AchievementDefinition d:catalog) { + AchievementDTO dto=toDTO(d,obtained.get(d.getCode()),progress.getOrDefault(d.getCode(),0L)); + result.setTotalCount(result.getTotalCount()+1); + if(dto.isUnlocked()) result.setUnlockedCount(result.getUnlockedCount()+1); + if(categoryCode!=null && !categoryCode.equals(d.getCategoryCode())) continue; + AchievementOverviewDTO.Category group=groups.computeIfAbsent(d.getCategoryCode(),key->{AchievementOverviewDTO.Category c=new AchievementOverviewDTO.Category();c.setCode(key);c.setName(d.getCategoryName());return c;}); + group.setTotalCount(group.getTotalCount()+1); + if(dto.isUnlocked()) group.setUnlockedCount(group.getUnlockedCount()+1); + if("ALL".equals(status) || dto.isUnlocked()=="UNLOCKED".equals(status)) group.getAchievements().add(dto); + } + result.setLockedCount(result.getTotalCount()-result.getUnlockedCount()); + result.setCompletionPercent(result.getTotalCount()==0?0:result.getUnlockedCount()*100/result.getTotalCount()); + result.setTitle(result.getTotalCount()>0 && result.getLockedCount()==0?"徽章大师":"成就收藏家"); + result.setNextTitle(result.getLockedCount()>0?"徽章大师":null); + result.setCategories(new ArrayList<>(groups.values())); + return result; + } + public AchievementDTO detail(String code) { + return overview("ALL",null).getCategories().stream().flatMap(c->c.getAchievements().stream()).filter(d->d.getCode().equals(code)).findFirst().orElseThrow(()->new BusinessException("成就不存在")); + } + /** 后台逐用户独立事务;用户行锁串行化多实例重复授予。 */ + @Transactional + public void evaluate(String uid) { + User user=users.findByIdForUpdate(uid).orElse(null); + if(user==null) return; + Map progress=metrics.measure(user); + Map existing=awards.findIncludingDeleted(uid).stream().collect(Collectors.toMap(UserAchievement::getAchievementCode,a->a)); + for(AchievementDefinition d:definitions.findByEnabledTrueOrderBySortAscCodeAsc()) { + Long value=progress.get(d.getCode()); + if(value==null || d.getTargetValue()==null || d.getTargetValue()<=0 || value 0) log.info("已自动归档结束活动: count={}", count); + } +} diff --git a/campus-server/src/main/java/com/campus/service/ActivityOperationLogService.java b/campus-server/src/main/java/com/campus/service/ActivityOperationLogService.java new file mode 100644 index 0000000..9c145c0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ActivityOperationLogService.java @@ -0,0 +1,30 @@ +package com.campus.service; + +import com.campus.entity.ActivityOperationLog; +import com.campus.repository.ActivityOperationLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ActivityOperationLogService { + private final ActivityOperationLogRepository repository; + + @Transactional + public void record(String activityId, String operatorId, String operation, String targetId, String detail) { + ActivityOperationLog log = new ActivityOperationLog(); + log.setActivityId(activityId); + log.setOperatorId(operatorId == null ? "SYSTEM" : operatorId); + log.setOperation(operation); + log.setTargetId(targetId); + log.setDetail(detail); + repository.save(log); + } + + @Transactional(readOnly = true) + public List list(String activityId) { + return repository.findByActivityIdOrderByCreatedAtDesc(activityId); + } +} diff --git a/campus-server/src/main/java/com/campus/service/ActivityRegistrationService.java b/campus-server/src/main/java/com/campus/service/ActivityRegistrationService.java new file mode 100644 index 0000000..6f338f3 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ActivityRegistrationService.java @@ -0,0 +1,532 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.ActivityRegistrationRequest; +import com.campus.dto.request.ActivityTicketRequest; +import com.campus.dto.response.ActivityRegistrationDTO; +import com.campus.dto.response.ActivityPaymentOrderDTO; +import com.campus.dto.response.ActivityTicketDTO; +import com.campus.entity.Activity; +import com.campus.entity.ActivityRegistration; +import com.campus.entity.ActivityPaymentOrder; +import com.campus.entity.ActivityTicket; +import com.campus.entity.User; +import com.campus.repository.ActivityParticipantRepository; +import com.campus.repository.ActivityRegistrationRepository; +import com.campus.repository.ActivityPaymentOrderRepository; +import com.campus.repository.ActivityRepository; +import com.campus.repository.ActivityTicketRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.Date; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; + +@Service +@RequiredArgsConstructor +public class ActivityRegistrationService { + + private final ActivityRepository activityRepository; + private final ActivityService activityService; + private final ActivityTicketRepository ticketRepository; + private final ActivityRegistrationRepository registrationRepository; + private final ActivityPaymentOrderRepository paymentOrderRepository; + private final ActivityParticipantRepository participantRepository; + private final ActivityOperationLogService operationLogService; + + @Transactional(readOnly = true) + public List listTickets(String activityId) { + return ticketRepository.findByActivityIdAndEnabledTrueOrderBySortOrderAsc(activityId) + .stream().map(this::toTicketDTO).collect(Collectors.toList()); + } + + @Transactional + public ActivityTicketDTO createTicket(String activityId, ActivityTicketRequest request) { + Activity activity = getActivity(activityId); + requireOwner(activity); + ActivityTicket ticket = new ActivityTicket(); + ticket.setActivityId(activityId); + ticket.setName(request.getName().trim()); + ticket.setDescription(request.getDescription()); + ticket.setPrice(request.getPrice().setScale(2, RoundingMode.HALF_UP)); + ticket.setCapacity(request.getCapacity()); + ticket.setSortOrder(request.getSortOrder() == null ? 0 : request.getSortOrder()); + return toTicketDTO(ticketRepository.save(ticket)); + } + + @Transactional + public ActivityRegistrationDTO register(String activityId, ActivityRegistrationRequest request) { + User user = UserContext.getCurrentUser(); + if (user == null) { + throw new BusinessException("请先登录"); + } + Activity activity = activityRepository.findByIdForUpdate(activityId) + .orElseThrow(() -> new BusinessException("活动不存在")); + if (activity.getCreatorId().equals(user.getId())) { + throw new BusinessException("不能报名自己创建的活动"); + } + if (!"approved".equalsIgnoreCase(activity.getReviewStatus()) + || !"ACTIVE".equalsIgnoreCase(activity.getStatus())) { + throw new BusinessException("活动当前不可报名"); + } + Date now = new Date(); + if (activity.getRegistrationStartAt() != null && now.before(activity.getRegistrationStartAt())) { + throw new BusinessException("活动报名尚未开始"); + } + if (activity.getRegistrationDeadline() != null && now.after(activity.getRegistrationDeadline())) { + throw new BusinessException("活动报名已截止"); + } + + ActivityTicket ticket = ticketRepository.findByIdForUpdate(request.getTicketId()) + .orElseThrow(() -> new BusinessException("票档不存在")); + if (!activityId.equals(ticket.getActivityId()) || !Boolean.TRUE.equals(ticket.getEnabled())) { + throw new BusinessException("票档不可用于该活动"); + } + int quantity = request.getQuantity(); + if (quantity > 20) { + throw new BusinessException("单次报名数量不能超过20人"); + } + if (registrationRepository.existsByActivityIdAndUserIdAndStatusIn( + activityId, user.getId(), Arrays.asList("PENDING_PAYMENT", "CONFIRMED"))) { + throw new BusinessException("你已经报名或有待支付订单"); + } + + BigDecimal ticketPrice = ticket.getPrice() == null ? BigDecimal.ZERO : ticket.getPrice(); + BigDecimal total = ticketPrice.multiply(BigDecimal.valueOf(quantity)).setScale(2, RoundingMode.HALF_UP); + int soldCount = ticket.getSoldCount() == null ? 0 : ticket.getSoldCount(); + int currentCount = activity.getCurrentCount() == null ? 0 : activity.getCurrentCount(); + if (ticket.getCapacity() != null && soldCount + quantity > ticket.getCapacity()) { + throw new BusinessException("该票档名额已满"); + } + if (activity.getMaxCount() != null && currentCount + quantity > activity.getMaxCount()) { + throw new BusinessException("活动报名人数已满"); + } + + ActivityRegistration registration = new ActivityRegistration(); + registration.setActivityId(activityId); + registration.setTicketId(ticket.getId()); + registration.setUserId(user.getId()); + registration.setQuantity(quantity); + registration.setTotalAmount(total); + registration.setAttendeeName(request.getAttendeeName().trim()); + registration.setAttendeePhone(request.getAttendeePhone().trim()); + registration.setUniversityName(request.getUniversityName()); + registration.setEmergencyContact(request.getEmergencyContact()); + registration.setEmergencyPhone(request.getEmergencyPhone()); + + if (total.compareTo(BigDecimal.ZERO) == 0) { + registration.setStatus("CONFIRMED"); + registration.setPaymentStatus("PAID"); + registration.setReviewStatus("APPROVED"); + confirmFreeRegistration(activity, ticket, user.getId(), quantity, registration.getAttendeeName()); + } else { + registration.setStatus("PENDING_PAYMENT"); + registration.setPaymentStatus("UNPAID"); + registration.setReviewStatus("PENDING"); + } + registration = registrationRepository.save(registration); + operationLogService.record(activityId, user.getId(), "REGISTER", registration.getId(), + "quantity=" + quantity); + return toRegistrationDTO(registration); + } + + @Transactional(readOnly = true) + public List listMine() { + User user = UserContext.getCurrentUser(); + if (user == null) { + throw new BusinessException("请先登录"); + } + return registrationRepository.findByUserIdOrderByCreatedAtDesc(user.getId()) + .stream().map(this::toRegistrationDTO).collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public ActivityRegistrationDTO getMine(String registrationId) { + User user = UserContext.getCurrentUser(); + if (user == null) { + throw new BusinessException("请先登录"); + } + ActivityRegistration registration = registrationRepository.findById(registrationId) + .orElseThrow(() -> new BusinessException("报名单不存在")); + if (!user.getId().equals(registration.getUserId())) { + throw new BusinessException("无权查看该报名单"); + } + return toRegistrationDTO(registration); + } + + @Transactional(readOnly = true) + public List listForActivity(String activityId) { + Activity activity = getActivity(activityId); + requireOwner(activity); + return registrationRepository.findByActivityIdOrderByCreatedAtDesc(activityId) + .stream().map(this::toRegistrationDTO).collect(Collectors.toList()); + } + + @Transactional + public ActivityRegistrationDTO cancel(String registrationId) { + User user = UserContext.getCurrentUser(); + if (user == null) { + throw new BusinessException("请先登录"); + } + ActivityRegistration registration = registrationRepository.findById(registrationId) + .orElseThrow(() -> new BusinessException("报名单不存在")); + if (!user.getId().equals(registration.getUserId())) { + throw new BusinessException("无权操作该报名单"); + } + if ("CANCELLED".equalsIgnoreCase(registration.getStatus())) { + throw new BusinessException("报名单已取消"); + } + + Activity cancellationActivity = activityRepository.findById(registration.getActivityId()) + .orElseThrow(() -> new BusinessException("活动不存在")); + if (cancellationActivity.getFreeCancellationDeadline() != null + && new Date().after(cancellationActivity.getFreeCancellationDeadline())) { + String rule = cancellationActivity.getCancellationRule(); + throw new BusinessException(rule == null || rule.trim().isEmpty() + ? "已超过免费取消截止时间,请联系活动创建者处理" + : "已超过免费取消截止时间:" + rule); + } + + if ("CONFIRMED".equalsIgnoreCase(registration.getStatus())) { + Activity activity = activityRepository.findByIdForUpdate(registration.getActivityId()) + .orElseThrow(() -> new BusinessException("活动不存在")); + ActivityTicket ticket = ticketRepository.findByIdForUpdate(registration.getTicketId()) + .orElseThrow(() -> new BusinessException("票档不存在")); + int quantity = registration.getQuantity() == null ? 0 : registration.getQuantity(); + int soldCount = ticket.getSoldCount() == null ? 0 : ticket.getSoldCount(); + int currentCount = activity.getCurrentCount() == null ? 0 : activity.getCurrentCount(); + ticket.setSoldCount(Math.max(0, soldCount - quantity)); + ticketRepository.save(ticket); + activity.setCurrentCount(Math.max(0, currentCount - quantity)); + activityRepository.save(activity); + + participantRepository.findFirstByActivityIdAndUserIdAndStatusOrderByJoinedAtDesc( + registration.getActivityId(), user.getId(), "CONFIRMED") + .ifPresent(participant -> { + participant.setStatus("CANCELLED"); + participantRepository.save(participant); + }); + } + + registration.setStatus("CANCELLED"); + if ("PAID".equalsIgnoreCase(registration.getPaymentStatus())) { + registration.setPaymentStatus("REFUNDED"); + } + ActivityRegistration saved = registrationRepository.save(registration); + operationLogService.record(registration.getActivityId(), user.getId(), "CANCEL_REGISTRATION", + registration.getId(), null); + paymentOrderRepository.findByRegistrationIdAndUserId(registrationId, user.getId()) + .filter(order -> "PENDING".equalsIgnoreCase(order.getStatus())) + .ifPresent(order -> { + order.setStatus("CANCELLED"); + paymentOrderRepository.save(order); + }); + return toRegistrationDTO(saved); + } + + @Transactional + public String generateCheckInCode(String activityId) { + Activity activity = getActivity(activityId); + requireOwner(activity); + String code = String.format("%06d", new SecureRandom().nextInt(1_000_000)); + activity.setCheckInCodeHash(hash(code)); + activityRepository.save(activity); + operationLogService.record(activityId, UserContext.getCurrentUserId(), "GENERATE_CHECK_IN_CODE", + null, "签到码已更新"); + return code; + } + + @Transactional + public ActivityRegistrationDTO checkIn(String registrationId, String code) { + User user = UserContext.getCurrentUser(); + if (user == null) throw new BusinessException("请先登录"); + ActivityRegistration registration = registrationRepository.findById(registrationId) + .orElseThrow(() -> new BusinessException("报名单不存在")); + if (!user.getId().equals(registration.getUserId())) throw new BusinessException("无权操作该报名单"); + if (!"CONFIRMED".equalsIgnoreCase(registration.getStatus())) throw new BusinessException("报名尚未确认"); + if (registration.getCheckedInAt() != null) return toRegistrationDTO(registration); + Activity activity = getActivity(registration.getActivityId()); + if (activity.getCheckInCodeHash() == null || !activity.getCheckInCodeHash().equals(hash(code.trim()))) { + throw new BusinessException("签到码错误"); + } + registration.setCheckedInAt(new Date()); + registration.setCheckedInBy(user.getId()); + registration = registrationRepository.save(registration); + operationLogService.record(activity.getId(), user.getId(), "CHECK_IN", registrationId, null); + return toRegistrationDTO(registration); + } + + @Transactional(readOnly = true) + public byte[] exportRegistrations(String activityId) { + Activity activity = getActivity(activityId); + requireOwner(activity); + StringBuilder csv = new StringBuilder("registrationId,userId,attendeeName,attendeePhone,quantity,status,paymentStatus,reviewStatus,checkedInAt\r\n"); + for (ActivityRegistration item : registrationRepository.findByActivityIdOrderByCreatedAtDesc(activityId)) { + csv.append(csv(item.getId())).append(',').append(csv(item.getUserId())).append(',') + .append(csv(item.getAttendeeName())).append(',').append(csv(item.getAttendeePhone())).append(',') + .append(item.getQuantity()).append(',').append(csv(item.getStatus())).append(',') + .append(csv(item.getPaymentStatus())).append(',').append(csv(item.getReviewStatus())).append(',') + .append(csv(item.getCheckedInAt() == null ? null : item.getCheckedInAt().toString())).append("\r\n"); + } + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + /** 活动创建者审核票档报名单。免费报名已经在报名时确认,收费报名只先推进审核状态,仍需支付后才能成为参与者。 */ + @Transactional + public ActivityRegistrationDTO review(String registrationId, String action, String reason) { + User owner = UserContext.getCurrentUser(); + if (owner == null) throw new BusinessException("请先登录"); + + ActivityRegistration registration = registrationRepository.findById(registrationId) + .orElseThrow(() -> new BusinessException("报名单不存在")); + Activity activity = activityRepository.findByIdForUpdate(registration.getActivityId()) + .orElseThrow(() -> new BusinessException("活动不存在")); + if (!owner.getId().equals(activity.getCreatorId())) { + throw new BusinessException("只有活动创建者可以审核报名"); + } + + String normalized = normalizeReviewAction(action); + String current = registration.getReviewStatus(); + if (current == null) current = "CONFIRMED".equalsIgnoreCase(registration.getStatus()) ? "APPROVED" : "PENDING"; + + if ("APPROVE".equals(normalized)) { + if ("APPROVED".equalsIgnoreCase(current)) return toRegistrationDTO(registration); + if ("CANCELLED".equalsIgnoreCase(registration.getStatus()) + || "REJECTED".equalsIgnoreCase(registration.getStatus()) + || "REMOVED".equalsIgnoreCase(registration.getStatus())) { + throw new BusinessException("该报名单已经结束,不能再次通过"); + } + registration.setReviewStatus("APPROVED"); + registration.setReviewReason(normalizeReason(reason)); + // 收费报名当前仍是待支付状态,不能伪造支付成功或重复占用名额。 + } else { + if (("REJECT".equals(normalized) && "REJECTED".equalsIgnoreCase(current)) + || ("REMOVE".equals(normalized) && "REMOVED".equalsIgnoreCase(current))) { + return toRegistrationDTO(registration); + } + if ("CONFIRMED".equalsIgnoreCase(registration.getStatus())) { + releaseConfirmedRegistration(registration); + activityService.syncParticipantReviewByOwner(registration.getActivityId(), registration.getUserId(), + normalized, reason); + if ("PAID".equalsIgnoreCase(registration.getPaymentStatus())) { + registration.setPaymentStatus("REFUNDED"); + } + } else { + paymentOrderRepository.findByRegistrationId(registrationId) + .filter(order -> "PENDING".equalsIgnoreCase(order.getStatus())) + .ifPresent(order -> { + order.setStatus("CANCELLED"); + paymentOrderRepository.save(order); + }); + } + registration.setStatus("REMOVE".equals(normalized) ? "REMOVED" : "REJECTED"); + registration.setReviewStatus("REMOVE".equals(normalized) ? "REMOVED" : "REJECTED"); + registration.setReviewReason(normalizeReason(reason)); + } + return toRegistrationDTO(registrationRepository.save(registration)); + } + + private void releaseConfirmedRegistration(ActivityRegistration registration) { + ActivityTicket ticket = ticketRepository.findByIdForUpdate(registration.getTicketId()) + .orElseThrow(() -> new BusinessException("票档不存在")); + int quantity = registration.getQuantity() == null ? 0 : registration.getQuantity(); + int soldCount = ticket.getSoldCount() == null ? 0 : ticket.getSoldCount(); + ticket.setSoldCount(Math.max(0, soldCount - quantity)); + ticketRepository.save(ticket); + } + + private String normalizeReviewAction(String action) { + String normalized = action == null ? "" : action.trim().toUpperCase(); + if ("APPROVED".equals(normalized)) normalized = "APPROVE"; + if ("REJECTED".equals(normalized)) normalized = "REJECT"; + if ("REMOVED".equals(normalized)) normalized = "REMOVE"; + if (!"APPROVE".equals(normalized) && !"REJECT".equals(normalized) && !"REMOVE".equals(normalized)) { + throw new BusinessException("审核动作必须是 APPROVE、REJECT 或 REMOVE"); + } + return normalized; + } + + private String normalizeReason(String reason) { + if (reason == null) return null; + String value = reason.trim(); + return value.isEmpty() ? null : value; + } + + @Transactional + public ActivityPaymentOrderDTO createPaymentOrder(String registrationId) { + User user = UserContext.getCurrentUser(); + if (user == null) { + throw new BusinessException("请先登录"); + } + ActivityRegistration registration = registrationRepository.findById(registrationId) + .orElseThrow(() -> new BusinessException("报名单不存在")); + if (!user.getId().equals(registration.getUserId())) { + throw new BusinessException("无权操作该报名单"); + } + if (!"PENDING_PAYMENT".equalsIgnoreCase(registration.getStatus()) + || !"UNPAID".equalsIgnoreCase(registration.getPaymentStatus())) { + throw new BusinessException("该报名单当前无需支付"); + } + if (registration.getTotalAmount() == null + || registration.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new BusinessException("免费报名无需创建支付订单"); + } + + ActivityPaymentOrder order = paymentOrderRepository + .findByRegistrationIdAndUserId(registrationId, user.getId()) + .orElseGet(() -> { + ActivityPaymentOrder newOrder = new ActivityPaymentOrder(); + newOrder.setRegistrationId(registrationId); + newOrder.setUserId(user.getId()); + newOrder.setAmount(registration.getTotalAmount().setScale(2, RoundingMode.HALF_UP)); + newOrder.setStatus("PENDING"); + newOrder.setProvider("UNINTEGRATED"); + return paymentOrderRepository.save(newOrder); + }); + return toPaymentOrderDTO(order); + } + + @Transactional(readOnly = true) + public ActivityPaymentOrderDTO getPaymentOrder(String registrationId) { + User user = UserContext.getCurrentUser(); + if (user == null) { + throw new BusinessException("请先登录"); + } + ActivityPaymentOrder order = paymentOrderRepository + .findByRegistrationIdAndUserId(registrationId, user.getId()) + .orElseThrow(() -> new BusinessException("支付订单不存在")); + return toPaymentOrderDTO(order); + } + + @Transactional + public ActivityPaymentOrderDTO cancelPaymentOrder(String registrationId) { + User user = UserContext.getCurrentUser(); + if (user == null) { + throw new BusinessException("请先登录"); + } + ActivityPaymentOrder order = paymentOrderRepository + .findByRegistrationIdAndUserId(registrationId, user.getId()) + .orElseThrow(() -> new BusinessException("支付订单不存在")); + if ("CANCELLED".equalsIgnoreCase(order.getStatus())) { + return toPaymentOrderDTO(order); + } + if (!"PENDING".equalsIgnoreCase(order.getStatus())) { + throw new BusinessException("当前支付订单不可取消"); + } + cancel(registrationId); + return paymentOrderRepository.findById(order.getId()) + .map(this::toPaymentOrderDTO) + .orElseThrow(() -> new BusinessException("支付订单不存在")); + } + + private void confirmFreeRegistration(Activity activity, ActivityTicket ticket, + String userId, int quantity, String attendeeName) { + // 复用旧报名流程,确保活动群、通知和积分等既有副作用保持一致。 + activityService.joinActivity(activity.getId(), userId, + "票档:" + ticket.getName() + ";报名人:" + attendeeName); + + int soldCount = ticket.getSoldCount() == null ? 0 : ticket.getSoldCount(); + ticket.setSoldCount(soldCount + quantity); + ticketRepository.save(ticket); + int currentCount = activity.getCurrentCount() == null ? 0 : activity.getCurrentCount(); + int heat = activity.getHeat() == null ? 0 : activity.getHeat(); + // joinActivity 已计入第一位报名人,多人报名只补齐其余数量。 + int extraQuantity = Math.max(0, quantity - 1); + activity.setCurrentCount(currentCount + extraQuantity); + activity.setHeat(heat + extraQuantity); + if (extraQuantity > 0) { + activityRepository.save(activity); + } + } + + private Activity getActivity(String activityId) { + return activityRepository.findById(activityId) + .orElseThrow(() -> new BusinessException("活动不存在")); + } + + private void requireOwner(Activity activity) { + User user = UserContext.getCurrentUser(); + if (user == null || !activity.getCreatorId().equals(user.getId())) { + throw new BusinessException("只有活动创建者可以管理票档"); + } + } + + private ActivityTicketDTO toTicketDTO(ActivityTicket ticket) { + ActivityTicketDTO dto = new ActivityTicketDTO(); + dto.setId(ticket.getId()); + dto.setActivityId(ticket.getActivityId()); + dto.setName(ticket.getName()); + dto.setDescription(ticket.getDescription()); + dto.setPrice(ticket.getPrice()); + dto.setCapacity(ticket.getCapacity()); + dto.setSoldCount(ticket.getSoldCount()); + dto.setRemainingCount(ticket.getCapacity() == null ? null : Math.max(0, ticket.getCapacity() - ticket.getSoldCount())); + dto.setSortOrder(ticket.getSortOrder()); + dto.setEnabled(ticket.getEnabled()); + return dto; + } + + private ActivityRegistrationDTO toRegistrationDTO(ActivityRegistration registration) { + ActivityRegistrationDTO dto = new ActivityRegistrationDTO(); + dto.setId(registration.getId()); + dto.setActivityId(registration.getActivityId()); + dto.setTicketId(registration.getTicketId()); + dto.setUserId(registration.getUserId()); + dto.setQuantity(registration.getQuantity()); + dto.setTotalAmount(registration.getTotalAmount()); + dto.setAttendeeName(registration.getAttendeeName()); + dto.setAttendeePhone(registration.getAttendeePhone()); + dto.setUniversityName(registration.getUniversityName()); + dto.setEmergencyContact(registration.getEmergencyContact()); + dto.setEmergencyPhone(registration.getEmergencyPhone()); + dto.setStatus(registration.getStatus()); + dto.setPaymentStatus(registration.getPaymentStatus()); + dto.setReviewStatus(registration.getReviewStatus() != null + ? registration.getReviewStatus() + : ("CONFIRMED".equalsIgnoreCase(registration.getStatus()) ? "APPROVED" : "PENDING")); + dto.setReviewReason(registration.getReviewReason()); + dto.setCheckedIn(registration.getCheckedInAt() != null); + dto.setCheckedInAt(registration.getCheckedInAt()); + dto.setCreatedAt(registration.getCreatedAt()); + return dto; + } + + private String hash(String value) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte b : bytes) hex.append(String.format("%02x", b)); + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException(ex); + } + } + + private String csv(Object value) { + if (value == null) return ""; + return "\"" + value.toString().replace("\"", "\"\"") + "\""; + } + + private ActivityPaymentOrderDTO toPaymentOrderDTO(ActivityPaymentOrder order) { + ActivityPaymentOrderDTO dto = new ActivityPaymentOrderDTO(); + dto.setId(order.getId()); + dto.setRegistrationId(order.getRegistrationId()); + dto.setUserId(order.getUserId()); + dto.setAmount(order.getAmount()); + dto.setStatus(order.getStatus()); + dto.setProvider(order.getProvider()); + dto.setPaymentRequired(true); + dto.setCreatedAt(order.getCreatedAt()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/ActivityService.java b/campus-server/src/main/java/com/campus/service/ActivityService.java new file mode 100644 index 0000000..74bee50 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ActivityService.java @@ -0,0 +1,1510 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.ActivityQueryRequest; +import com.campus.dto.request.ActivityRequest; +import com.campus.dto.response.ActivityDTO; +import com.campus.dto.response.ActivityParticipantDTO; +import com.campus.entity.Activity; +import com.campus.entity.ActivityParticipant; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.repository.ActivityRepository; +import com.campus.repository.ActivityParticipantRepository; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupRepository; +import com.campus.util.PageUtils; +import com.campus.util.UserContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityService { + + private final ActivityRepository activityRepository; + private final ActivityParticipantRepository participantRepository; + private final ContentReviewService contentReviewService; + private final ObjectMapper objectMapper; + private final PointsService pointsService; + private final com.campus.service.PushService pushService; + private final NotificationService notificationService; + private final com.campus.repository.UserRepository userRepository; + private final EasemobService easemobService; + private final GroupRepository groupRepository; + private final GroupMemberRepository groupMemberRepository; + private final GroupAnnouncementRepository groupAnnouncementRepository; + private final ChatConversationRepository chatConversationRepository; + private final PrivacySettingsService privacySettingsService; + private final AuthorizationService authorizationService; + private final UserRestrictionService userRestrictionService; + private final ActivityOperationLogService operationLogService; + private final com.campus.service.location.ActivityLocationService activityLocationService; + + /** 同一用户在同一活动上最多可被拒绝的次数,可用 app.activity.max-reject-count 覆盖。 */ + @Value("${app.activity.max-reject-count:3}") + private int maxRejectCount = 3; + + /** 同一用户在同一活动上最多可撤回的次数,可用 app.activity.max-cancel-count 覆盖。 */ + @Value("${app.activity.max-cancel-count:3}") + private int maxCancelCount = 3; + + public PageResult list(String type, String userSchool, int page, int pageSize, String userId) { + Pageable pageable = PageUtils.request(page, pageSize); + String filterType = normalizeActivityTypeFilter(type); + Page activityPage = activityRepository.findByTypeAndScope( + filterType, userSchool, pageable); + return PageResult.from(activityPage.map(a -> toDTO(a, userId))); + } + + public PageResult search(ActivityQueryRequest request, String userSchool, int page, int pageSize, String userId) { + Pageable pageable = PageUtils.request(page, pageSize); + + String sortBy = request.getSortBy(); + boolean useDynamicHeat = "HEAT".equalsIgnoreCase(sortBy) || "DYNAMIC".equalsIgnoreCase(sortBy); + + Date periodStart = parsePeriodStart(request.getPeriod()); + String filterType = normalizeActivityTypeFilter(request.getType()); + Page activityPage; + + if (useDynamicHeat) { + int candidateSize = Math.min(Math.max(pageSize * 3, 100), 500); + Pageable candidatePageable = PageRequest.of(0, candidateSize); + Page candidatePage = activityRepository.findByConditionsWithPeriodAndScope( + filterType, + request.getStatus(), + request.getReviewStatus(), + request.getCreatorId(), + request.getKeyword(), + periodStart, + userSchool, + candidatePageable + ); + + java.util.List sorted = candidatePage.getContent().stream() + .sorted((a, b) -> Double.compare( + calculateDynamicScore(b), + calculateDynamicScore(a))) + .collect(Collectors.toList()); + + int safePage = PageUtils.normalizePage(page); + int safeSize = PageUtils.normalizeSize(pageSize); + int fromIndex = Math.min((safePage - 1) * safeSize, sorted.size()); + int toIndex = Math.min(fromIndex + safeSize, sorted.size()); + java.util.List pageData = sorted.subList(fromIndex, toIndex); + + java.util.List dtoList = pageData.stream().map(a -> { + ActivityDTO dto = toDTO(a, userId); + dto.setDynamicScore(calculateDynamicScore(a)); + return dto; + }).collect(Collectors.toList()); + + return PageResult.of(dtoList, (long) sorted.size(), page, pageSize); + } else { + activityPage = activityRepository.findByConditionsWithPeriodAndScope( + filterType, + request.getStatus(), + request.getReviewStatus(), + request.getCreatorId(), + request.getKeyword(), + periodStart, + userSchool, + pageable + ); + return PageResult.from(activityPage.map(a -> { + ActivityDTO dto = toDTO(a, userId); + dto.setDynamicScore(calculateDynamicScore(a)); + return dto; + })); + } + } + + /** + * 解析周期参数,返回该周期的起始时间 + * TODAY: 今天 00:00 + * WEEK: 本周一 00:00 + * MONTH: 本月1号 00:00 + * null: 不限制 + */ + private Date parsePeriodStart(String period) { + if (period == null || period.isEmpty() || "ALL".equalsIgnoreCase(period)) { + return null; + } + java.util.Calendar cal = java.util.Calendar.getInstance(); + cal.set(java.util.Calendar.HOUR_OF_DAY, 0); + cal.set(java.util.Calendar.MINUTE, 0); + cal.set(java.util.Calendar.SECOND, 0); + cal.set(java.util.Calendar.MILLISECOND, 0); + + switch (period.toUpperCase()) { + case "TODAY": + return cal.getTime(); + case "WEEK": + cal.set(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.MONDAY); + // 如果本周一在未来(比如今天是周日),回退到上一周 + if (cal.getTime().after(new Date())) { + cal.add(java.util.Calendar.DAY_OF_MONTH, -7); + } + return cal.getTime(); + case "MONTH": + cal.set(java.util.Calendar.DAY_OF_MONTH, 1); + return cal.getTime(); + default: + return null; + } + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public void incrementViewCount(String activityId) { + Activity activity = getByIdEntity(activityId); + activity.setViewCount((activity.getViewCount() == null ? 0 : activity.getViewCount()) + 1); + activity.setHeat((activity.getHeat() == null ? 0 : activity.getHeat()) + 1); + activityRepository.save(activity); + } + + @Cacheable(value = "activities", key = "'detail:' + #id") + public ActivityDTO getById(String id) { + Activity activity = activityRepository.findById(id) + .orElseThrow(() -> new BusinessException("活动不存在")); + if (Boolean.TRUE.equals(activity.getDeleted())) { + throw new BusinessException("活动不存在"); + } + if (privacySettingsService.isBlocked(UserContext.getCurrentUserId(), activity.getCreatorId())) { + throw new BusinessException("该活动不可见"); + } + ActivityDTO dto = toDTO(activity); + dto.setDynamicScore(calculateDynamicScore(activity)); + return dto; + } + + @Cacheable(value = "activities", key = "'detail:' + #id + ':' + #userId + ':' + #userSchool") + public ActivityDTO getById(String id, String userId, String userSchool) { + Activity activity = activityRepository.findById(id) + .orElseThrow(() -> new BusinessException("活动不存在")); + if (Boolean.TRUE.equals(activity.getDeleted())) { + throw new BusinessException("活动不存在"); + } + if (privacySettingsService.isBlocked(userId, activity.getCreatorId())) { + throw new BusinessException("该活动不可见"); + } + + if (!canViewActivity(activity, userSchool)) { + throw new BusinessException("您无权查看该活动"); + } + + ActivityDTO dto = toDTO(activity); + dto.setDynamicScore(calculateDynamicScore(activity)); + fillMyParticipantState(dto, id, userId); + return dto; + } + + private boolean canViewActivity(Activity activity, String userSchool) { + if (userSchool == null || userSchool.isEmpty()) { + return true; + } + String scope = activity.getScope(); + if (scope == null || scope.isEmpty() || "ALL".equals(scope)) { + return true; + } + if (scope.equals(userSchool)) { + return true; + } + if (scope.startsWith("[") && scope.endsWith("]")) { + try { + List schools = objectMapper.readValue(scope, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + return schools.contains(userSchool); + } catch (JsonProcessingException e) { + return false; + } + } + return false; + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityDTO quitActivity(String activityId, String userId) { + // 行锁串行化,保证报名人数与报名记录一致 + Activity activity = activityRepository.findByIdForUpdate(activityId) + .orElseThrow(() -> new BusinessException("活动不存在")); + + if (userId == null || userId.trim().isEmpty()) { + throw new BusinessException("请先登录"); + } + + // 待审核申请可以被本人撤回:不占名额、未入群,只把状态置为 CANCELLED + List pendingParticipants = participantRepository + .findByActivityIdAndUserIdAndStatus(activityId, userId, "PENDING"); + if (!pendingParticipants.isEmpty()) { + // 撤回额度只在「真正撤回一次申请」时 +1;历史脏数据下同用户可能有多条 PENDING,只计一次 + boolean counted = false; + for (ActivityParticipant participant : pendingParticipants) { + participant.setStatus("CANCELLED"); + if (!counted) { + int cancelledCount = participant.getCancelCount() == null ? 0 : participant.getCancelCount(); + participant.setCancelCount(cancelledCount + 1); + counted = true; + } + } + participantRepository.saveAll(pendingParticipants); + notifyApplyCancelled(activity, userId); + return toDTO(activity, userId); + } + + // 取消全部 CONFIRMED 记录:正常数据只有一条,历史脏数据一次点按即可自愈 + List confirmedParticipants = participantRepository + .findByActivityIdAndUserIdAndStatus(activityId, userId, "CONFIRMED"); + if (confirmedParticipants.isEmpty()) { + throw new BusinessException("您未报名该活动"); + } + + for (ActivityParticipant participant : confirmedParticipants) { + participant.setStatus("CANCELLED"); + } + participantRepository.saveAll(confirmedParticipants); + + int currentCount = activity.getCurrentCount() == null ? 0 : activity.getCurrentCount(); + if (currentCount > 0) { + activity.setCurrentCount(currentCount - 1); + activityRepository.save(activity); + } + + // 推送:通知活动发布者有人退出 + if (!userId.equals(activity.getCreatorId())) { + Optional userOpt = userRepository.findById(userId); + if (userOpt.isPresent()) { + com.campus.entity.User user = userOpt.get(); + pushService.pushActivityQuit( + activity.getCreatorId(), + user.getNickname(), + user.getAvatar(), + userId, + activityId, + activity.getDisplayTitle() + ); + + // 创建应用内通知 + notificationService.createNotification( + activity.getCreatorId(), + "ACTIVITY_QUIT", + "活动退出通知", + user.getNickname() + " 退出了你的活动「" + activity.getDisplayTitle() + "」", + user.getAvatar(), + "/activity/" + activityId, + activityId, + userId + ); + } + } + + // 同步退出活动群组 + removeFromActivityGroup(activity, userId); + + return toDTO(activity, userId); + } + + /** 申请人撤回待审核申请后通知活动创建者,避免创建者打开列表才发现少了人。 */ + private void notifyApplyCancelled(Activity activity, String userId) { + if (userId.equals(activity.getCreatorId())) { + return; + } + userRepository.findById(userId).ifPresent(user -> { + pushService.pushActivityApplyCancelled(activity.getCreatorId(), user.getNickname(), + user.getAvatar(), userId, activity.getId(), activity.getDisplayTitle()); + notificationService.createNotification(activity.getCreatorId(), "ACTIVITY_APPLY_CANCELLED", + "报名申请已取消", user.getNickname() + " 取消了活动「" + activity.getDisplayTitle() + + "」的报名申请", user.getAvatar(), "/activity/" + activity.getId(), + activity.getId(), userId); + }); + } + + public List getParticipants(String activityId) { + List participants = participantRepository + .findByActivityIdAndStatus(activityId, "CONFIRMED"); + return participants.stream() + .map(p -> { + ActivityParticipantDTO dto = toParticipantDTO(p); + userRepository.findById(p.getUserId()).ifPresent(user -> { + dto.setUserName(user.getNickname()); + dto.setUserAvatar(user.getAvatar()); + }); + return dto; + }) + .collect(Collectors.toList()); + } + + /** 活动创建者查看全部报名申请(包含待审核、拒绝、移出和主动取消记录)。 */ + public List getParticipantsForManagement(String activityId) { + Activity activity = getByIdEntity(activityId); + requireActivityOwner(activity); + return participantRepository.findByActivityIdOrderByJoinedAtDesc(activityId).stream() + .map(p -> { + ActivityParticipantDTO dto = toParticipantDTO(p); + userRepository.findById(p.getUserId()).ifPresent(user -> { + dto.setUserName(user.getNickname()); + dto.setUserAvatar(user.getAvatar()); + }); + return dto; + }) + .collect(Collectors.toList()); + } + + /** + * 提交需要活动创建者审核的报名申请。 + * 全局报名审核后这是唯一报名入口(/join 已拒绝),必须与审核、退出互斥,故取活动行锁。 + */ + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityParticipantDTO applyActivity(String activityId, String userId, String remark) { + if (userId == null || userId.trim().isEmpty()) { + throw new BusinessException("请先登录"); + } + // 行锁串行化同一活动的申请/审核/退出,避免并发重复申请撞唯一键 + Activity activity = activityRepository.findByIdForUpdate(activityId) + .orElseThrow(() -> new BusinessException("活动不存在")); + if (activity.getCreatorId().equals(userId)) { + throw new BusinessException("不能报名自己创建的活动"); + } + if (!"approved".equalsIgnoreCase(activity.getReviewStatus()) + || !"ACTIVE".equalsIgnoreCase(activity.getStatus())) { + throw new BusinessException("活动当前不可报名"); + } + ensureRegistrationOpen(activity); + + Optional existing = participantRepository + .findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(activityId, userId); + if (existing.isPresent() + && ("PENDING".equalsIgnoreCase(existing.get().getStatus()) + || "CONFIRMED".equalsIgnoreCase(existing.get().getStatus()))) { + throw new BusinessException("你已经提交报名或报名已通过"); + } + + ActivityParticipant participant = existing.orElseGet(ActivityParticipant::new); + // 拒绝额度与撤回额度互相独立:任一用尽即不允许再申请 + int rejectedCount = participant.getRejectCount() == null ? 0 : participant.getRejectCount(); + int cancelledCount = participant.getCancelCount() == null ? 0 : participant.getCancelCount(); + if (rejectedCount >= maxRejectCount || cancelledCount >= maxCancelCount) { + log.info("[ACTIVITY_APPLY] 报名额度已用尽: activityId={}, userId={}, rejectCount={}/{}, cancelCount={}/{}", + activityId, userId, rejectedCount, maxRejectCount, cancelledCount, maxCancelCount); + throw new BusinessException("报名次数已达上限"); + } + // 新建的报名行 applyCount 字段默认值是 1,必须按「首次申请」从 0 起算,否则首次申请会被记成 2 次 + int appliedCount = existing.isPresent() && participant.getApplyCount() != null + ? participant.getApplyCount() : 0; + participant.setActivityId(activityId); + participant.setUserId(userId); + participant.setStatus("PENDING"); + participant.setJoinedAt(new Date()); + participant.setRemark(remark); + // applyCount 仅用于展示「第 N 次申请」,拦截依据是 rejectCount / cancelCount + participant.setApplyCount(appliedCount + 1); + + ActivityParticipant saved; + try { + saved = participantRepository.saveAndFlush(participant); + } catch (DataIntegrityViolationException e) { + // 并发下两个请求同时插入会撞 uk_ap_activity_user;语义上等同于重复申请,不能漏成 500 + throw new BusinessException("你已提交过报名申请,请等待审核"); + } + + userRepository.findById(userId).ifPresent(user -> { + // 申请尚未通过审核,必须用「申请」文案,不能复用「报名参加了」 + pushService.pushActivityApply(activity.getCreatorId(), user.getNickname(), user.getAvatar(), + userId, activityId, activity.getDisplayTitle()); + notificationService.createNotification(activity.getCreatorId(), "ACTIVITY_JOIN_REQUEST", + "活动报名申请", user.getNickname() + " 申请参加你的活动「" + activity.getDisplayTitle() + "」", + user.getAvatar(), "/activity/" + activityId, activityId, userId); + }); + return toParticipantDTO(saved); + } + + /** 报名截止校验:口径与详情页一致(registrationEndTime 即 registrationDeadline),为空表示不限制。 */ + private void ensureRegistrationOpen(Activity activity) { + Date deadline = activity.getRegistrationDeadline(); + if (deadline != null && deadline.before(new Date())) { + throw new BusinessException("报名已截止"); + } + } + + /** 活动创建者通过、拒绝或移出一个报名者。 */ + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityParticipantDTO reviewParticipant(String activityId, String participantId, + String action, String reason) { + Activity activity = activityRepository.findByIdForUpdate(activityId) + .orElseThrow(() -> new BusinessException("活动不存在")); + requireActivityOwner(activity); + ActivityParticipant participant = participantRepository.findById(participantId) + .orElseThrow(() -> new BusinessException("报名记录不存在")); + if (!activityId.equals(participant.getActivityId())) { + throw new BusinessException("报名记录不属于该活动"); + } + return changeParticipantReview(activity, participant, normalizeParticipantAction(action), reason); + } + + /** 报名单审核时同步更新旧参与者模型。没有旧参与者记录时保持兼容并跳过。 */ + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public void syncParticipantReviewByOwner(String activityId, String userId, String action, String reason) { + Activity activity = activityRepository.findByIdForUpdate(activityId) + .orElseThrow(() -> new BusinessException("活动不存在")); + requireActivityOwner(activity); + participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(activityId, userId) + .ifPresent(participant -> changeParticipantReview( + activity, participant, normalizeParticipantAction(action), reason)); + } + + private ActivityParticipantDTO changeParticipantReview(Activity activity, ActivityParticipant participant, + String action, String reason) { + String oldStatus = participant.getStatus(); + if ("APPROVE".equals(action)) { + if ("CONFIRMED".equalsIgnoreCase(oldStatus)) return toParticipantDTO(participant); + if (!"PENDING".equalsIgnoreCase(oldStatus)) { + throw new BusinessException("该报名申请已经处理,不能再次通过"); + } + int currentCount = activity.getCurrentCount() == null ? 0 : activity.getCurrentCount(); + if (activity.getMaxCount() != null && currentCount >= activity.getMaxCount()) { + throw new BusinessException("活动报名人数已满"); + } + addToActivityGroup(activity, participant.getUserId()); + participant.setStatus("CONFIRMED"); + activity.setCurrentCount(currentCount + 1); + activity.setHeat((activity.getHeat() == null ? 0 : activity.getHeat()) + 1); + activityRepository.save(activity); + pointsService.onJoinActivity(participant.getUserId(), activity.getId(), activity.getCreatorId()); + notifyParticipantReview(activity, participant.getUserId(), true, false, reason); + } else { + boolean wasConfirmed = "CONFIRMED".equalsIgnoreCase(oldStatus); + boolean wasPending = "PENDING".equalsIgnoreCase(oldStatus); + boolean rejecting = "REJECT".equals(action); + if (("REJECT".equals(action) && "REJECTED".equalsIgnoreCase(oldStatus)) + || ("REMOVE".equals(action) && "REMOVED".equalsIgnoreCase(oldStatus))) { + return toParticipantDTO(participant); + } + if (wasConfirmed) { + int currentCount = activity.getCurrentCount() == null ? 0 : activity.getCurrentCount(); + activity.setCurrentCount(Math.max(0, currentCount - 1)); + activityRepository.save(activity); + removeFromActivityGroup(activity, participant.getUserId()); + } + participant.setStatus(rejecting ? "REJECTED" : "REMOVED"); + // 只有「拒绝待审核申请」才消耗拒绝额度;对已通过者执行 REJECT 属移出语义,不计数 + if (rejecting && wasPending) { + int rejectedCount = participant.getRejectCount() == null ? 0 : participant.getRejectCount(); + participant.setRejectCount(rejectedCount + 1); + } + notifyParticipantReview(activity, participant.getUserId(), false, "REMOVE".equals(action), reason); + } + participant.setRemark(appendReviewReason(participant.getRemark(), reason)); + return toParticipantDTO(participantRepository.save(participant)); + } + + private void notifyParticipantReview(Activity activity, String userId, boolean approved, + boolean removed, String reason) { + String type = approved ? "ACTIVITY_JOIN_APPROVED" : removed ? "ACTIVITY_MEMBER_REMOVED" : "ACTIVITY_JOIN_REJECTED"; + String title = approved ? "活动报名已通过" : removed ? "已被移出活动" : "活动报名未通过"; + String content = approved + ? "你参加活动「" + activity.getDisplayTitle() + "」的报名已通过" + : (removed ? "你已被移出活动「" : "你参加活动「") + activity.getDisplayTitle() + "」" + + (removed ? "" : "的报名未通过") + + (reason == null || reason.trim().isEmpty() ? "" : ":" + reason.trim()); + java.util.Map extra = new java.util.HashMap<>(); + extra.put("type", type); + extra.put("relatedId", activity.getId()); + pushService.pushToUser(userId, title, content, extra); + notificationService.createNotification(userId, type, title, content, null, + "/activity/" + activity.getId(), activity.getId(), activity.getCreatorId()); + } + + private String normalizeParticipantAction(String action) { + String normalized = action == null ? "" : action.trim().toUpperCase(); + if ("APPROVED".equals(normalized)) normalized = "APPROVE"; + if ("REJECTED".equals(normalized)) normalized = "REJECT"; + if ("REMOVED".equals(normalized)) normalized = "REMOVE"; + if (!"APPROVE".equals(normalized) && !"REJECT".equals(normalized) && !"REMOVE".equals(normalized)) { + throw new BusinessException("审核动作必须是 APPROVE、REJECT 或 REMOVE"); + } + return normalized; + } + + private String appendReviewReason(String remark, String reason) { + if (reason == null || reason.trim().isEmpty()) return remark; + String value = "审核意见:" + reason.trim(); + return remark == null || remark.trim().isEmpty() ? value : remark + "\n" + value; + } + + private void requireActivityOwner(Activity activity) { + String userId = UserContext.getCurrentUserId(); + if (userId == null || !userId.equals(activity.getCreatorId())) { + throw new BusinessException("只有活动创建者可以管理报名"); + } + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityDTO joinActivity(String activityId, String userId, String remark) { + // 行锁串行化同一活动的加入/退出/报名审核,避免并发重复报名与人数错乱 + Activity activity = activityRepository.findByIdForUpdate(activityId) + .orElseThrow(() -> new BusinessException("活动不存在")); + + if (userId == null || userId.trim().isEmpty()) { + throw new BusinessException("请先登录"); + } + + // 创建者不能加入自己的活动 + if (activity.getCreatorId().equals(userId)) { + throw new BusinessException("不能加入自己创建的活动"); + } + + if (!"approved".equalsIgnoreCase(activity.getReviewStatus())) { + throw new BusinessException("活动未通过审核,无法报名"); + } + + if (!"ACTIVE".equalsIgnoreCase(activity.getStatus())) { + throw new BusinessException("活动已结束或已关闭,无法报名"); + } + + // 复用历史上任意状态的一行,保证同一活动同一用户只有一条报名记录 + ActivityParticipant existingParticipant = participantRepository + .findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(activityId, userId) + .orElse(null); + + if (existingParticipant != null && "CONFIRMED".equalsIgnoreCase(existingParticipant.getStatus())) { + throw new BusinessException("您已经报名过该活动"); + } + + int currentCount = activity.getCurrentCount() == null ? 0 : activity.getCurrentCount(); + if (activity.getMaxCount() != null && currentCount >= activity.getMaxCount()) { + throw new BusinessException("活动报名人数已满"); + } + + // 与历史行为一致:仅“首次报名”发放积分与报名通知(退出后重新加入不重复发放) + boolean firstJoin = existingParticipant == null + || !"CANCELLED".equalsIgnoreCase(existingParticipant.getStatus()); + + // 先同步加入活动群组(环信),成功后再写业务DB,避免DB写入成功但环信加群失败的不一致 + addToActivityGroup(activity, userId); + + ActivityParticipant participant = existingParticipant != null + ? existingParticipant + : new ActivityParticipant(); + participant.setActivityId(activityId); + participant.setUserId(userId); + participant.setStatus("CONFIRMED"); + participant.setJoinedAt(new Date()); + participant.setRemark(remark); + participantRepository.save(participant); + + activity.setCurrentCount(currentCount + 1); + activity.setHeat((activity.getHeat() == null ? 0 : activity.getHeat()) + 1); + activityRepository.save(activity); + + if (firstJoin) { + // 触发积分:参加活动 + pointsService.onJoinActivity(userId, activityId, activity.getCreatorId()); + + // 推送:通知活动发布者有人报名 + Optional participantUserOpt = userRepository.findById(userId); + if (participantUserOpt.isPresent()) { + com.campus.entity.User participantUser = participantUserOpt.get(); + pushService.pushActivityJoin( + activity.getCreatorId(), + participantUser.getNickname(), + participantUser.getAvatar(), + userId, + activityId, + activity.getDisplayTitle() + ); + notificationService.createNotification( + activity.getCreatorId(), + "ACTIVITY_JOIN", + "活动报名通知", + participantUser.getNickname() + " 报名参加了你的活动「" + activity.getDisplayTitle() + "」", + participantUser.getAvatar(), + "/activity/" + activityId, + activityId, + userId + ); + } + } + + return toDTO(activity, userId); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityDTO create(ActivityRequest request, String creatorId) { + userRestrictionService.ensureAllowed(creatorId, "PUBLISH"); + activityLocationService.applyConfirmedLocation(request, creatorId, "create"); + validateActivityTimes(request); + Activity activity = new Activity(); + activity.setTitle(request.getTitle()); + activity.setDescription(request.getDescription()); + activity.setType(normalizeActivityTypeValue(request.getType())); + activity.setLocation(request.getLocation()); + copyExtendedFields(activity, request); + activity.setEventTime(request.getEventTime()); + activity.setEndTime(request.getEndTime()); + activity.setMaxCount(request.getMaxCount()); + activity.setTags(serializeTags(request.getTags())); + activity.setScope(resolveScope(request.getScope(), creatorId)); + activity.setCoverImage(request.getCoverImage()); + activity.setCreatorId(creatorId); + activity.setStatus("ACTIVE"); + activity.setReviewStatus("pending"); + activity = activityRepository.save(activity); + operationLogService.record(activity.getId(), creatorId, "CREATE", null, "创建活动并提交审核"); + + // 自动创建活动群组 + try { + Group group = createActivityGroup(activity, creatorId); + if (group != null) { + activity.setGroupId(group.getId()); + activity = activityRepository.save(activity); + } + } catch (Exception e) { + log.warn("活动群组创建失败,不影响活动创建: activityId={}", activity.getId(), e); + } + + // 同步审核 + contentReviewService.reviewActivitySync( + activity.getId(), + activity.getTitle(), + activity.getDescription(), + activity.getCoverImage() + ); + + // 重新查询获取审核后的状态 + activity = activityRepository.findById(activity.getId()).orElse(activity); + + // 触发积分:创建组局活动 + if ("approved".equals(activity.getReviewStatus())) { + pointsService.onCreateActivity(creatorId, activity.getId()); + } + + return toDTO(activity); + } + + /** + * 为活动创建关联群组 + */ + private Group createActivityGroup(Activity activity, String creatorId) { + Group group = new Group(); + group.setName(activity.getDisplayTitle()); + group.setDescription("「" + activity.getDisplayTitle() + "」活动群聊"); + group.setOwnerId(creatorId); + group.setMaxMembers(Math.max(activity.getMaxCount() != null ? activity.getMaxCount() : 200, 200)); + group.setMemberCount(1); + group.setIsPublic(false); + // 活动群组不走「申请-审批」流程,但不等于谁都能进: + // 入群资格由 GroupService 依据 source=ACTIVITY + activity_id 校验「必须是活动创建者或已报名(CONFIRMED)用户」。 + group.setIsApprovalRequired(false); + group.setSource(Group.SOURCE_ACTIVITY); + group.setActivityId(activity.getId()); + + // 先确保群主已注册到环信(前端登录后环信注册是异步的,可能尚未完成, + // 否则 createGroup 会因 owner 不存在而失败,导致 easemobGroupId 为 null) + easemobService.registerUser(creatorId, "campus_" + creatorId); + + // 创建环信群组(仅群主一人) + String easemobGroupId = easemobService.createGroup( + activity.getDisplayTitle(), + "活动群聊", + creatorId, + Collections.emptyList() + ); + group.setEasemobGroupId(easemobGroupId); + group = groupRepository.save(group); + + // 创建群主记录 + GroupMember ownerMember = new GroupMember(); + ownerMember.setGroupId(group.getId()); + ownerMember.setUserId(creatorId); + ownerMember.setRole("OWNER"); + ownerMember.setJoinTime(new Date()); + groupMemberRepository.save(ownerMember); + + log.info("活动群组创建成功: activityId={}, groupId={}, easemobGroupId={}", + activity.getId(), group.getId(), easemobGroupId); + return group; + } + + /** + * 将用户加入活动关联的群组 + */ + private void addToActivityGroup(Activity activity, String userId) { + if (activity.getGroupId() == null) return; + Group group = groupRepository.findById(activity.getGroupId()).orElse(null); + if (group == null) return; + + if (groupMemberRepository.existsByGroupIdAndUserId(activity.getGroupId(), userId)) { + return; // 已在群内 + } + + // 先调用环信API将用户加入群组,成功后再写业务DB + if (group.getEasemobGroupId() != null) { + // 先确保该用户已注册到环信,否则 addGroupMembers 会因用户不存在而失败 + easemobService.registerUser(userId, "campus_" + userId); + boolean easemobSuccess = easemobService.addGroupMembers( + group.getEasemobGroupId(), Collections.singletonList(userId)); + if (!easemobSuccess) { + log.error("环信加群失败,拒绝写入GroupMember: activityId={}, groupId={}, userId={}", + activity.getId(), activity.getGroupId(), userId); + throw new RuntimeException("加入群组失败,请稍后重试"); + } + } + + GroupMember member = new GroupMember(); + member.setGroupId(activity.getGroupId()); + member.setUserId(userId); + member.setRole("MEMBER"); + member.setJoinTime(new Date()); + groupMemberRepository.save(member); + + group.setMemberCount(group.getMemberCount() + 1); + groupRepository.save(group); + + log.info("用户加入活动群组: activityId={}, groupId={}, userId={}", activity.getId(), activity.getGroupId(), userId); + } + + /** + * 将用户从活动关联的群组中移除 + */ + private void removeFromActivityGroup(Activity activity, String userId) { + if (activity.getGroupId() == null) return; + try { + Group group = groupRepository.findById(activity.getGroupId()).orElse(null); + if (group == null) return; + + Optional memberOpt = groupMemberRepository + .findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(activity.getGroupId(), userId); + if (!memberOpt.isPresent()) return; + + groupMemberRepository.delete(memberOpt.get()); + + // 从环信群组移除 + if (group.getEasemobGroupId() != null) { + easemobService.removeGroupMember(group.getEasemobGroupId(), userId); + } + + if (group.getMemberCount() > 0) { + group.setMemberCount(group.getMemberCount() - 1); + groupRepository.save(group); + } + + log.info("用户退出活动群组: activityId={}, groupId={}, userId={}", activity.getId(), activity.getGroupId(), userId); + } catch (Exception e) { + log.warn("退出活动群组失败: activityId={}, userId={}", activity.getId(), userId, e); + } + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityDTO update(String id, ActivityRequest request) { + Activity activity = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(activity.getCreatorId()); + activityLocationService.applyConfirmedLocation(request, UserContext.getCurrentUserId(), id); + validateActivityTimes(request); + activity.setTitle(request.getTitle()); + activity.setDescription(request.getDescription()); + activity.setType(normalizeActivityTypeValue(request.getType())); + activity.setLocation(request.getLocation()); + copyExtendedFields(activity, request); + activity.setEventTime(request.getEventTime()); + activity.setEndTime(request.getEndTime()); + activity.setMaxCount(request.getMaxCount()); + activity.setTags(serializeTags(request.getTags())); + activity.setScope(resolveScope(request.getScope(), activity.getCreatorId())); + activity.setCoverImage(request.getCoverImage()); + activity = activityRepository.save(activity); + operationLogService.record(activity.getId(), UserContext.getCurrentUserId(), "UPDATE", null, "编辑活动信息"); + return toDTO(activity); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public void delete(String id) { + Activity activity = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(activity.getCreatorId()); + activity.setDeleted(true); + activityRepository.save(activity); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public void deleteMyActivity(String id, String userId) { + Activity activity = getByIdEntity(id); + if (!userId.equals(activity.getCreatorId())) { + throw new BusinessException("无权删除他人创建的活动"); + } + activity.setDeleted(true); + activityRepository.save(activity); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public void cancelMyActivity(String id, String userId) { + Activity activity = getByIdEntity(id); + if (!userId.equals(activity.getCreatorId())) { + throw new BusinessException("无权取消他人创建的活动"); + } + activity.setStatus("CANCELLED"); + activityRepository.save(activity); + operationLogService.record(id, userId, "CANCEL", null, "创建者取消活动"); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityDTO withdraw(String id) { + Activity activity = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(activity.getCreatorId()); + if ("CANCELLED".equals(activity.getStatus()) || "ENDED".equals(activity.getStatus())) { + throw new com.campus.common.BusinessException("当前活动状态不可撤回"); + } + activity.setStatus("WITHDRAWN"); + activity.setReviewStatus("withdrawn"); + activity = activityRepository.save(activity); + operationLogService.record(id, UserContext.getCurrentUserId(), "WITHDRAW", null, "撤回活动"); + return toDTO(activity); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityDTO resubmit(String id) { + Activity activity = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(activity.getCreatorId()); + if (!"WITHDRAWN".equals(activity.getStatus()) + && !"rejected".equalsIgnoreCase(activity.getReviewStatus())) { + throw new com.campus.common.BusinessException("只有已撤回或审核未通过的活动可以重新提交"); + } + activity.setStatus("ACTIVE"); + activity.setReviewStatus("pending"); + activity.setReviewReason(null); + activity = activityRepository.save(activity); + operationLogService.record(id, UserContext.getCurrentUserId(), "RESUBMIT", null, "重新提交审核"); + return toDTO(activity); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityDTO adminCancel(String id, String reason) { + authorizationService.requireAdmin(); + Activity activity = getByIdEntity(id); + activity.setStatus("CANCELLED"); + activity = activityRepository.save(activity); + operationLogService.record(id, UserContext.getCurrentUserId(), "ADMIN_CANCEL", null, reason); + return toDTO(activity); + } + + @Transactional(readOnly = true) + public java.util.List operationLogs(String id) { + Activity activity = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(activity.getCreatorId()); + return operationLogService.list(id); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public int archiveEndedActivities() { + Date now = new Date(); + LinkedHashMap candidates = new LinkedHashMap<>(); + activityRepository.findByStatusAndEndTimeBefore("ACTIVE", now) + .forEach(activity -> candidates.put(activity.getId(), activity)); + activityRepository.findByStatusAndEndTimeIsNullAndEventTimeBefore("ACTIVE", now) + .forEach(activity -> candidates.put(activity.getId(), activity)); + + int archivedCount = candidates.size(); + for (Activity activity : candidates.values()) { + archiveActivityAndDissolveGroup(activity, true); + } + + // 活动已经结束但上次环信调用失败时,groupId 会被保留;本轮继续解散。 + activityRepository.findByStatusAndGroupIdIsNotNull("ENDED").stream() + .filter(activity -> !candidates.containsKey(activity.getId())) + .forEach(activity -> archiveActivityAndDissolveGroup(activity, false)); + return archivedCount; + } + + private void archiveActivityAndDissolveGroup(Activity activity, boolean newlyEnded) { + if (newlyEnded) { + activity.setStatus("ENDED"); + activityRepository.save(activity); + operationLogService.record(activity.getId(), "SYSTEM", "AUTO_ARCHIVE", null, + "活动时间已结束"); + } + + if (activity.getGroupId() == null || activity.getGroupId().trim().isEmpty()) { + return; + } + + try { + dissolveEndedActivityGroup(activity); + } catch (Exception e) { + // 保留 groupId,下一轮定时任务会再次尝试;单个活动失败不影响其他活动归档。 + log.error("活动结束后解散群聊失败,将自动重试: activityId={}, groupId={}", + activity.getId(), activity.getGroupId(), e); + } + } + + /** + * 解散结束活动的群聊。先删除环信群,成功后再清理本地会话、成员和群记录。 + * 若外部删除成功但本地事务失败,下次重试时环信 404 会按成功处理。 + */ + private void dissolveEndedActivityGroup(Activity activity) { + String groupId = activity.getGroupId(); + Optional groupOptional = groupRepository.findById(groupId); + if (!groupOptional.isPresent()) { + activity.setGroupId(null); + activityRepository.save(activity); + log.warn("活动关联群已不存在,已清理活动关联: activityId={}, groupId={}", activity.getId(), groupId); + return; + } + + Group group = groupOptional.get(); + String easemobGroupId = group.getEasemobGroupId(); + if (easemobGroupId != null && !easemobGroupId.trim().isEmpty() + && !easemobService.deleteGroup(easemobGroupId)) { + log.warn("环信群解散失败,保留本地群数据等待重试: activityId={}, groupId={}, easemobGroupId={}", + activity.getId(), groupId, easemobGroupId); + return; + } + + chatConversationRepository.deleteByPeerId(groupId); + groupAnnouncementRepository.deleteByGroupId(groupId); + groupMemberRepository.deleteByGroupId(groupId); + groupRepository.delete(group); + activity.setGroupId(null); + activityRepository.save(activity); + operationLogService.record(activity.getId(), "SYSTEM", "AUTO_DISSOLVE_GROUP", groupId, + "活动结束,群聊已自动解散"); + log.info("活动群聊已自动解散: activityId={}, groupId={}, easemobGroupId={}", + activity.getId(), groupId, easemobGroupId); + } + + @Transactional + @CacheEvict(value = "activities", allEntries = true) + public ActivityDTO reviewActivity(String id, boolean approved, String reason) { + authorizationService.requireReviewerOrAdmin(); + Activity activity = getByIdEntity(id); + activity.setReviewStatus(approved ? "approved" : "rejected"); + activity.setReviewReason(reason); + if (!approved) { + activity.setStatus("INACTIVE"); + } + activity = activityRepository.save(activity); + + // 推送:通知活动发布者审核结果 + String reviewType = approved ? "ACTIVITY_REVIEW_APPROVED" : "ACTIVITY_REVIEW_REJECTED"; + String reviewTitle = approved ? "活动审核通过" : "活动审核未通过"; + String displayTitle = activity.getDisplayTitle(); + String reviewContent = approved + ? "你的活动「" + displayTitle + "」已通过审核" + : "你的活动「" + displayTitle + "」审核未通过" + (reason != null && !reason.isEmpty() ? ":" + reason : ""); + pushService.pushActivityReview( + activity.getCreatorId(), + approved, + id, + displayTitle, + reason + ); + + // 创建应用内通知 + notificationService.createNotification( + activity.getCreatorId(), + reviewType, + reviewTitle, + reviewContent, + null, + "/activity/" + id, + id, + null + ); + + return toDTO(activity); + } + + private Activity getByIdEntity(String id) { + Activity activity = activityRepository.findById(id) + .orElseThrow(() -> new BusinessException("活动不存在")); + if (Boolean.TRUE.equals(activity.getDeleted())) { + throw new BusinessException("活动不存在"); + } + return activity; + } + + private ActivityDTO toDTO(Activity activity) { + ActivityDTO dto = new ActivityDTO(); + dto.setId(activity.getId()); + dto.setTitle(activity.getTitle() != null && !activity.getTitle().isEmpty() + ? activity.getTitle() + : activity.getDisplayTitle()); + dto.setDescription(activity.getDescription()); + applyActivityTypeFields(dto, activity.getType()); + dto.setLocation(activity.getLocation()); + dto.setLatitude(activity.getLatitude()); + dto.setLongitude(activity.getLongitude()); + dto.setContactName(activity.getContactName()); + dto.setContactPhone(activity.getContactPhone()); + dto.setRules(activity.getRules()); + dto.setSchedule(activity.getSchedule()); + dto.setRegistrationStartAt(activity.getRegistrationStartAt()); + dto.setRegistrationStartTime(activity.getRegistrationStartAt()); + dto.setRegistrationDeadline(activity.getRegistrationDeadline()); + dto.setRegistrationEndTime(activity.getRegistrationDeadline()); + dto.setFreeCancellationDeadline(activity.getFreeCancellationDeadline()); + dto.setCancellationRule(activity.getCancellationRule()); + if (activity.getLatitude() != null && activity.getLongitude() != null + && UserContext.getCurrentUser() != null + && UserContext.getCurrentUser().getLatitude() != null + && UserContext.getCurrentUser().getLongitude() != null) { + dto.setDistanceMeters(calculateDistanceMeters( + UserContext.getCurrentUser().getLatitude(), UserContext.getCurrentUser().getLongitude(), + activity.getLatitude(), activity.getLongitude())); + } + dto.setEventTime(activity.getEventTime()); + dto.setStartTime(activity.getEventTime()); + dto.setEndTime(activity.getEndTime()); + dto.setCurrentCount(activity.getCurrentCount()); + dto.setMaxCount(activity.getMaxCount()); + dto.setCreatorId(activity.getCreatorId()); + userRepository.findById(activity.getCreatorId()).ifPresent(user -> { + dto.setCreatorName(user.getNickname()); + dto.setCreatorAvatar(user.getAvatar()); + }); + dto.setTags(deserializeTags(activity.getTags())); + dto.setHeat(activity.getHeat()); + dto.setViewCount(activity.getViewCount()); + dto.setStatus(activity.getStatus()); + dto.setReviewStatus(activity.getReviewStatus()); + dto.setReviewReason(activity.getReviewReason()); + dto.setScope(activity.getScope()); + dto.setCoverImage(activity.getCoverImage()); + dto.setGroupId(activity.getGroupId()); + // 查询群组的环信ID + if (activity.getGroupId() != null) { + groupRepository.findById(activity.getGroupId()) + .ifPresent(g -> dto.setGroupEasemobId(g.getEasemobGroupId())); + } + dto.setCreatedAt(activity.getCreatedAt()); + dto.setUpdatedAt(activity.getUpdatedAt()); + return dto; + } + + static void applyActivityTypeFields(ActivityDTO dto, String rawType) { + String typeCode = normalizeActivityTypeValue(rawType); + String legacyClientTypeCode = legacyClientTypeCode(typeCode); + String typeLabel = activityTypeLabel(typeCode); + dto.setType(legacyClientTypeCode); + dto.setActivityType(legacyClientTypeCode); + dto.setCategoryCode(typeCode); + // 已上线移动端使用历史单数 sport 做本地筛选;新客户端读 categoryCode。 + dto.setCategory(legacyClientTypeCode); + dto.setTypeLabel(typeLabel); + dto.setCategoryLabel(typeLabel); + } + + private static String legacyClientTypeCode(String canonicalTypeCode) { + return "sports".equals(canonicalTypeCode) ? "sport" : canonicalTypeCode; + } + + private void copyExtendedFields(Activity activity, ActivityRequest request) { + activity.setLatitude(request.getLatitude()); + activity.setLongitude(request.getLongitude()); + activity.setContactName(request.getContactName()); + activity.setContactPhone(request.getContactPhone()); + activity.setRules(request.getRules()); + activity.setSchedule(request.getSchedule()); + activity.setRegistrationStartAt(request.getRegistrationStartAt()); + activity.setRegistrationDeadline(request.getRegistrationDeadline()); + activity.setFreeCancellationDeadline(request.getFreeCancellationDeadline()); + activity.setCancellationRule(request.getCancellationRule()); + } + + static void validateActivityTimes(ActivityRequest request) { + com.campus.service.location.ActivityLocationService.validateCoordinates(request.getLatitude(), request.getLongitude()); + Date start = request.getRegistrationStartAt(); + Date deadline = request.getRegistrationDeadline(); + Date event = request.getEventTime(); + Date end = request.getEndTime(); + if (start != null && deadline != null && start.after(deadline)) { + throw new com.campus.common.BusinessException("报名开始时间不能晚于报名截止时间"); + } + if (deadline != null && event != null && deadline.after(event)) { + throw new com.campus.common.BusinessException("报名截止时间不能晚于活动开始时间"); + } + if (event != null && end != null && event.after(end)) { + throw new com.campus.common.BusinessException("活动开始时间不能晚于活动结束时间"); + } + if (request.getFreeCancellationDeadline() != null && event != null + && request.getFreeCancellationDeadline().after(event)) { + throw new com.campus.common.BusinessException("免费取消截止时间不能晚于活动开始时间"); + } + if ((request.getLatitude() == null) != (request.getLongitude() == null)) { + throw new com.campus.common.BusinessException("活动经纬度必须同时提供"); + } + } + + static double calculateDistanceMeters(double lat1, double lon1, double lat2, double lon2) { + double earthRadius = 6371000.0; + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + return Math.round(earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * 10.0) / 10.0; + } + + private ActivityDTO toDTO(Activity activity, String userId) { + ActivityDTO dto = toDTO(activity); + fillMyParticipantState(dto, activity.getId(), userId); + return dto; + } + + /** + * 填充「我」在该活动中的报名状态。 + * 详情页按钮状态与「审核中撤回」入口都依赖它:isJoined 只在 CONFIRMED 时为 true, + * 无法区分「没报名」和「已申请待审核」,因此额外返回 myParticipantStatus。 + */ + private void fillMyParticipantState(ActivityDTO dto, String activityId, String userId) { + if (userId == null || userId.trim().isEmpty()) { + return; + } + dto.setIsJoined(participantRepository + .existsByActivityIdAndUserIdAndStatus(activityId, userId, "CONFIRMED")); + participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(activityId, userId) + .ifPresent(participant -> { + dto.setMyParticipantId(participant.getId()); + dto.setMyParticipantStatus(participant.getStatus()); + dto.setMyReviewReason(extractReviewReason(participant)); + dto.setMyRejectCount(participant.getRejectCount()); + dto.setMyCancelCount(participant.getCancelCount()); + }); + } + + /** 从备注中解析「审核意见:xxx」;只有被拒绝或被移出的记录才有审核意见。 */ + private String extractReviewReason(ActivityParticipant participant) { + if (!"REJECTED".equalsIgnoreCase(participant.getStatus()) + && !"REMOVED".equalsIgnoreCase(participant.getStatus())) { + return null; + } + String remark = participant.getRemark(); + if (remark == null || !remark.contains("审核意见:")) { + return null; + } + return remark.substring(remark.lastIndexOf("审核意见:") + "审核意见:".length()).trim(); + } + + /** + * 动态热度评分算法 + * + * 综合热度 = (参与分 + 浏览分 + 临近度加分) × 时间衰减系数 + * + * 1. 参与分:currentCount × 10 + 报满率 × 30 + * - 每报名1人+10,越接近报满额外加成(最多+30) + * 2. 浏览分:viewCount × 2 + * 3. 活动临近度: + * - 活动在未来24h内开始:+80,且越近越高 + * - 活动在未来24-48h开始:+50 + * - 活动正在进行中(eventTime已过但status=ACTIVE):+60 + * - 活动已结束:-50(大幅衰减) + * - 没有活动时间:+20(默认中性分) + * 4. 时间衰减:1 / (1 + 0.005 × 距创建小时数) + * - 约每8.3天衰减50%,对校园活动周期较友好 + * - 避免老活动永远霸占首页 + */ + private double calculateDynamicScore(Activity activity) { + Date now = new Date(); + + // 1. 参与分 + double participationScore = 0; + int current = activity.getCurrentCount() == null ? 0 : activity.getCurrentCount(); + int max = activity.getMaxCount() == null ? 1 : activity.getMaxCount(); + if (max <= 0) max = 1; + participationScore = current * 10.0 + Math.min((double) current / max, 1.0) * 30.0; + + // 2. 浏览分 + int viewC = activity.getViewCount() == null ? 0 : activity.getViewCount(); + double interactionScore = viewC * 2.0; + + // 3. 活动临近度加分 + double proximityScore = 20.0; // 默认中性分 + Date eventTime = activity.getEventTime(); + String status = activity.getStatus(); + if (eventTime != null) { + long hoursDiff = (eventTime.getTime() - now.getTime()) / (1000 * 60 * 60); + if ("ENDED".equals(status)) { + // 已结束 + long hoursSinceEnd = Math.max(0, -hoursDiff); + proximityScore = Math.max(-50, 30 - hoursSinceEnd * 0.5); + } else if (hoursDiff < 0) { + // 活动时间已过但还未标记结束(进行中/刚结束) + long hoursSinceStart = -hoursDiff; + if (hoursSinceStart < 4) { + proximityScore = 60.0; // 4小时内,认为正在进行 + } else { + proximityScore = Math.max(-30, 40 - hoursSinceStart * 0.3); + } + } else if (hoursDiff <= 24) { + // 24小时内即将开始:临近度最高 + proximityScore = 80 - hoursDiff * 1.2; // 越接近开始越高 + } else if (hoursDiff <= 48) { + // 24-48小时内 + proximityScore = 50 - (hoursDiff - 24) * 1.0; + } else if (hoursDiff <= 168) { + // 一周内 + proximityScore = 30 - (hoursDiff - 48) * 0.1; + } else { + // 一周之后才开始 + proximityScore = 10.0; + } + } + + // 4. 时间衰减系数(基于创建时间) + double decayFactor = 1.0; + if (activity.getCreatedAt() != null) { + long hoursSinceCreated = (now.getTime() - activity.getCreatedAt().getTime()) / (1000 * 60 * 60); + if (hoursSinceCreated > 0) { + decayFactor = 1.0 / (1.0 + 0.005 * hoursSinceCreated); + } + } + + // 5. 审核状态权重(未通过的大幅降权) + double reviewFactor = 1.0; + if ("rejected".equals(activity.getReviewStatus())) { + reviewFactor = 0.1; + } else if ("pending".equals(activity.getReviewStatus())) { + reviewFactor = 0.5; + } + + // 综合计算 + double rawScore = (participationScore + interactionScore + proximityScore) * decayFactor * reviewFactor; + + // 加上基础 heat 字段的兜底(保留历史热度) + double heatBase = (activity.getHeat() == null ? 0 : activity.getHeat()) * 2.0; + + return rawScore + heatBase; + } + + private String serializeTags(List tags) { + if (tags == null || tags.isEmpty()) { + return "[]"; + } + try { + return objectMapper.writeValueAsString(tags); + } catch (JsonProcessingException e) { + return "[]"; + } + } + + private List deserializeTags(String tagsJson) { + if (tagsJson == null || tagsJson.isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(tagsJson, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + } catch (JsonProcessingException e) { + return Collections.emptyList(); + } + } + + private ActivityParticipantDTO toParticipantDTO(ActivityParticipant participant) { + ActivityParticipantDTO dto = new ActivityParticipantDTO(); + dto.setId(participant.getId()); + dto.setActivityId(participant.getActivityId()); + dto.setUserId(participant.getUserId()); + dto.setStatus(participant.getStatus()); + dto.setJoinedAt(participant.getJoinedAt()); + dto.setRemark(participant.getRemark()); + dto.setApplyCount(participant.getApplyCount()); + dto.setRejectCount(participant.getRejectCount()); + dto.setCancelCount(participant.getCancelCount()); + dto.setReviewReason(extractReviewReason(participant)); + return dto; + } + + @Cacheable(value = "activities", key = "'creator:' + #creatorId") + public List listByCreatorId(String creatorId) { + return activityRepository.findByCreatorId(creatorId, PageRequest.of(0, 100)) + .getContent().stream() + .map(this::toDTO) + .collect(Collectors.toList()); + } + + public PageResult listByCreatorIdWithPage(String creatorId, int page, int pageSize) { + Pageable pageable = PageUtils.request(page, pageSize); + Page activityPage = activityRepository.findByCreatorId(creatorId, pageable); + return PageResult.from(activityPage.map(this::toDTO)); + } + + public PageResult listJoinedActivities(String userId, int page, int pageSize) { + Pageable pageable = PageUtils.request(page, pageSize); + Page activityPage = participantRepository.findJoinedActivitiesByUserId(userId, pageable); + return PageResult.from(activityPage.map(this::toDTO)); + } + + @Cacheable(value = "activities", key = "'active:' + #type + ':' + #search + ':' + #userSchool") + public List listActive(String type, String search, String userSchool) { + Page activityPage = activityRepository.findByStatusAndScope("ACTIVE", userSchool, + PageRequest.of(0, 100)); + List activities = activityPage.getContent(); + String filterType = normalizeActivityTypeFilter(type); + if (filterType != null) { + activities = activities.stream() + .filter(a -> filterType.equals(normalizeActivityTypeValue(a.getType()))) + .collect(Collectors.toList()); + } + if (search != null && !search.isEmpty()) { + activities = activities.stream() + .filter(a -> (a.getDescription() != null && a.getDescription().contains(search)) + || (a.getLocation() != null && a.getLocation().contains(search))) + .collect(Collectors.toList()); + } + return activities.stream().map(this::toDTO).collect(Collectors.toList()); + } + + private String resolveScope(String scope, String creatorId) { + String normalizedScope = normalizeActivityScope(scope); + if ("ALL".equals(normalizedScope)) { + return "ALL"; + } + if ("本校".equals(normalizedScope)) { + Optional creatorOpt = userRepository.findById(creatorId); + if (creatorOpt.isPresent()) { + return creatorOpt.get().getUniversityName(); + } + return "ALL"; + } + return normalizedScope; + } + + static String normalizeActivityScope(String scope) { + if (scope == null) { + return "ALL"; + } + String value = scope.trim(); + if (value.isEmpty() + || "all".equalsIgnoreCase(value) + || "public".equalsIgnoreCase(value) + || "全部".equals(value) + || "公开".equals(value) + || "所有人".equals(value) + || "全部学校".equals(value)) { + return "ALL"; + } + return value; + } + + private String normalizeActivityTypeFilter(String type) { + if (type == null) { + return null; + } + String value = type.trim(); + if (value.isEmpty() || "all".equalsIgnoreCase(value) || "全部".equals(value)) { + return null; + } + return normalizeActivityTypeValue(value); + } + + static String normalizeActivityTypeValue(String type) { + String value = type == null ? "" : type.trim(); + String lower = value.toLowerCase(java.util.Locale.ROOT); + switch (lower) { + case "sport": + case "sports": + case "运动": + case "运动健身": + case "体育": + case "体育运动": + return "sports"; + case "study": + case "学习": + case "学习交流": + return "study"; + case "party": + case "offline": + case "gathering": + case "聚会": + case "线下聚会": + return "party"; + case "food": + case "美食": + case "餐饮": + case "餐饮美食": + return "food"; + case "travel": + case "trip": + case "出行": + case "旅行": + return "travel"; + case "game": + case "ent": + case "entertainment": + case "游戏": + case "娱乐": + case "休闲娱乐": + return "game"; + case "volunteer": + case "charity": + case "志愿": + case "志愿服务": + case "公益": + case "公益志愿": + return "volunteer"; + case "other": + case "others": + case "其他": + return "other"; + default: + return "other"; + } + } + + static String activityTypeLabel(String type) { + String typeCode = normalizeActivityTypeValue(type); + switch (typeCode) { + case "sports": + return "运动"; + case "study": + return "学习"; + case "party": + return "聚会"; + case "food": + return "干饭"; + case "travel": + return "出行"; + case "game": + return "游戏"; + case "volunteer": + return "志愿"; + case "other": + return "其他"; + default: + return typeCode; + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/AdminService.java b/campus-server/src/main/java/com/campus/service/AdminService.java new file mode 100644 index 0000000..b88f1d1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AdminService.java @@ -0,0 +1,188 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.common.PageResult; +import com.campus.dto.request.AdminAccountStatusRequest; +import com.campus.dto.request.AdminRoleUpdateRequest; +import com.campus.dto.request.AdminPasswordResetRequest; +import com.campus.dto.response.AdminUserDTO; +import com.campus.entity.User; +import com.campus.repository.ActivityRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.ReportRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AdminService { + + private final UserRepository userRepository; + private final ActivityRepository activityRepository; + private final AnnouncementRepository announcementRepository; + private final ReportRepository reportRepository; + private final UserService userService; + private final AuthorizationService authorizationService; + private final AuditLogService auditLogService; + + @Transactional(readOnly = true) + public PageResult listUsers(String keyword, String accountStatus, + Boolean isAdmin, Boolean isReviewer, + int page, int size) { + authorizationService.requireAdmin(); + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), 100); + String normalizedStatus = normalizeOptionalStatus(accountStatus); + Page users = userRepository.searchForAdmin(trimToNull(keyword), normalizedStatus, + isAdmin, isReviewer, + PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt"))); + return PageResult.of(users.getContent().stream().map(this::toDTO) + .collect(java.util.stream.Collectors.toList()), + users.getTotalElements(), safePage, safeSize); + } + + @Transactional(readOnly = true) + public AdminUserDTO getUser(String id) { + authorizationService.requireAdmin(); + return toDTO(findUser(id)); + } + + @Transactional + public AdminUserDTO updateRoles(String id, AdminRoleUpdateRequest request) { + authorizationService.requireAdmin(); + User current = UserContext.getCurrentUser(); + User target = findUser(id); + boolean removingAdmin = Boolean.TRUE.equals(target.getIsAdmin()) + && !Boolean.TRUE.equals(request.getIsAdmin()); + if (current.getId().equals(target.getId()) && removingAdmin) { + throw new ForbiddenException("管理员不能撤销自己的管理员权限"); + } + if (removingAdmin && userRepository.countByIsAdminTrueAndAccountStatus("ACTIVE") <= 1) { + throw new BusinessException("系统至少需要保留一名启用状态的管理员"); + } + String before = roleSummary(target); + target.setIsAdmin(request.getIsAdmin()); + target.setIsReviewer(request.getIsReviewer()); + target = userRepository.save(target); + auditLogService.record("USER_ROLE_UPDATE", "USER", target.getId(), + before, roleSummary(target), null); + return toDTO(target); + } + + @Transactional + public AdminUserDTO updateAccountStatus(String id, AdminAccountStatusRequest request) { + authorizationService.requireAdmin(); + User current = UserContext.getCurrentUser(); + User target = findUser(id); + String status = normalizeRequiredStatus(request.getAccountStatus()); + if (current.getId().equals(target.getId()) && "DISABLED".equals(status)) { + throw new ForbiddenException("管理员不能停用自己的账号"); + } + if (Boolean.TRUE.equals(target.getIsAdmin()) && "DISABLED".equals(status) + && userRepository.countByIsAdminTrueAndAccountStatus("ACTIVE") <= 1) { + throw new BusinessException("系统至少需要保留一名启用状态的管理员"); + } + String before = "accountStatus=" + target.getAccountStatus(); + target.setAccountStatus(status); + target = userRepository.save(target); + if ("DISABLED".equals(status)) { + userService.kickout(target.getId()); + } + auditLogService.record("USER_STATUS_UPDATE", "USER", target.getId(), + before, "accountStatus=" + status, request.getReason()); + return toDTO(target); + } + + public void kickout(String id) { + authorizationService.requireAdmin(); + User current = UserContext.getCurrentUser(); + User target = findUser(id); + if (current.getId().equals(target.getId())) { + throw new ForbiddenException("管理员不能通过后台强制下线自己"); + } + userService.kickout(target.getId()); + auditLogService.record("USER_KICKOUT", "USER", target.getId(), null, + "sessionInvalidated=true", null); + } + + @Transactional + public void resetPassword(String id, AdminPasswordResetRequest request) { + authorizationService.requireAdmin(); + User target = findUser(id); + userService.adminResetPassword(target.getId(), request.getNewPassword()); + auditLogService.record("USER_PASSWORD_RESET", "USER", target.getId(), null, + "passwordReset=true,sessionInvalidated=true", request.getReason()); + } + + @Transactional(readOnly = true) + public Map dashboard() { + authorizationService.requireAdmin(); + Map result = new LinkedHashMap<>(); + result.put("totalUsers", userRepository.count()); + result.put("activeUsers", userRepository.countByAccountStatus("ACTIVE")); + result.put("disabledUsers", userRepository.countByAccountStatus("DISABLED")); + result.put("admins", userRepository.countByIsAdminTrue()); + result.put("reviewers", userRepository.countByIsReviewerTrue()); + result.put("pendingActivities", activityRepository.countByReviewStatus("pending")); + result.put("pendingAnnouncements", announcementRepository.countByStatus("pending")); + result.put("pendingReports", reportRepository.countByStatus("PENDING")); + return result; + } + + private User findUser(String id) { + return userRepository.findById(id) + .orElseThrow(() -> new BusinessException("用户不存在")); + } + + private String normalizeOptionalStatus(String status) { + if (status == null || status.trim().isEmpty()) return null; + return normalizeRequiredStatus(status); + } + + private String normalizeRequiredStatus(String status) { + String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT); + if (!"ACTIVE".equals(normalized) && !"DISABLED".equals(normalized)) { + throw new BusinessException("账号状态必须是 ACTIVE 或 DISABLED"); + } + return normalized; + } + + private String trimToNull(String value) { + if (value == null || value.trim().isEmpty()) return null; + return value.trim(); + } + + private AdminUserDTO toDTO(User user) { + AdminUserDTO dto = new AdminUserDTO(); + dto.setId(user.getId()); + dto.setNumber(user.getNumber()); + dto.setNickname(user.getNickname()); + dto.setPhone(user.getPhone()); + dto.setAvatar(user.getAvatar()); + dto.setUniversityName(user.getUniversityName()); + dto.setMajorName(user.getMajorName()); + dto.setXuexinCertified(user.getXuexinCertified()); + dto.setIsReviewer(Boolean.TRUE.equals(user.getIsReviewer())); + dto.setIsAdmin(Boolean.TRUE.equals(user.getIsAdmin())); + dto.setAccountStatus(user.getAccountStatus() == null ? "ACTIVE" : user.getAccountStatus()); + dto.setCreatedAt(user.getCreatedAt()); + dto.setUpdatedAt(user.getUpdatedAt()); + return dto; + } + + private String roleSummary(User user) { + return "isAdmin=" + Boolean.TRUE.equals(user.getIsAdmin()) + + ",isReviewer=" + Boolean.TRUE.equals(user.getIsReviewer()); + } +} diff --git a/campus-server/src/main/java/com/campus/service/AliOcrService.java b/campus-server/src/main/java/com/campus/service/AliOcrService.java new file mode 100644 index 0000000..b97e13c --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AliOcrService.java @@ -0,0 +1,454 @@ +package com.campus.service; + +import com.aliyun.ocr_api20210707.Client; +import com.aliyun.ocr_api20210707.models.RecognizeAdvancedRequest; +import com.aliyun.ocr_api20210707.models.RecognizeAdvancedResponse; +import com.aliyun.teaopenapi.models.Config; +import com.campus.common.BusinessException; +import com.campus.config.OcrProperties; +import com.campus.dto.response.ChsiReportDTO; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 阿里云OCR文字识别服务 + * + * 核心功能: + * 1. 调用阿里云 RecognizeAdvanced(全文识别高精版)接口 + * 2. 学信网认证只接收 PDF;文本层不足时渲染首页再识别 + * 3. 解析OCR返回结果,提取学信网报告关键信息 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AliOcrService { + + private static final Pattern NON_NAME_TEXT = Pattern.compile( + "证书|报告|专业|学历|层次|学校|学制|出生|性别|身份证|学信网"); + + private final OcrProperties ocrProperties; + private final ChsiReportParser chsiReportParser; + private final ChsiPdfService chsiPdfService; + private final Gson gson = new Gson(); + + private Client client; + + /** + * 初始化OCR客户端(懒加载) + */ + private synchronized Client getClient() throws Exception { + if (client == null) { + Config config = new Config() + .setAccessKeyId(ocrProperties.getAccessKeyId()) + .setAccessKeySecret(ocrProperties.getAccessKeySecret()) + .setEndpoint(ocrProperties.getEndpoint()) + .setRegionId(ocrProperties.getRegionId()); + config.setConnectTimeout(ocrProperties.getConnectTimeout()); + config.setReadTimeout(ocrProperties.getReadTimeout()); + client = new Client(config); + } + return client; + } + + // ==================== 核心识别接口 ==================== + + /** + * 识别学信网报告 PDF。优先读取 PDF 文本层;字段不足时渲染首页后调用 OCR。 + * + * @param file 学信网下载的单页 PDF + * @return 结构化的学信网报告信息 + */ + public ChsiReportDTO recognizeChsiReport(MultipartFile file) { + ChsiReportDTO result = new ChsiReportDTO(); + try { + ChsiPdfService.PdfContent pdf = chsiPdfService.read(file); + log.info("开始识别学信网报告PDF: size={}KB", file.getSize() / 1024); + List textLines = pdf.getTextLines(); + List rawLines = new ArrayList<>(textLines); + ChsiReportDTO parsed = chsiReportParser.parse(textLines); + if (!hasAllReportFields(parsed)) { + byte[] pageImage = chsiPdfService.renderFirstPage(pdf); + String ocrRawText = recognizeAdvanced(new ByteArrayInputStream(pageImage)); + List ocrLines = parseOcrResultToLines(ocrRawText); + rawLines.addAll(ocrLines); + parsed = mergeParsedFields(parsed, chsiReportParser.parse(ocrLines)); + } + result.setRawLines(new ArrayList<>(new LinkedHashSet<>(rawLines))); + copyParsedFields(parsed, result); + + // 校验:必须是真实的学信网报告(标题/CHSI + 至少姓名+学校) + validateChsiReport(result, result.getRawLines()); + + if (result.isValidChsiReport()) { + result.setSuccess(true); + log.info("学信网报告PDF识别完成"); + } else { + result.setSuccess(false); + if (result.getErrorMsg() == null) { + result.setErrorMsg("上传的 PDF 不是有效的学信网验证报告"); + } + log.warn("上传的 PDF 不是有效的学信网报告"); + } + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("学信网报告识别失败", e); + result.setSuccess(false); + result.setErrorMsg("识别失败: " + e.getMessage()); + } + return result; + } + + /** + * 通用OCR识别(返回原始文本)- 调试用 + * + * @param file 上传的图片文件 + * @return OCR识别出的所有文本行 + */ + public List recognizeRaw(MultipartFile file) { + try { + String ocrRawText = recognizeAdvanced(file.getInputStream()); + return parseOcrResultToLines(ocrRawText); + } catch (Exception e) { + log.error("通用OCR识别失败", e); + throw new RuntimeException("OCR识别失败: " + e.getMessage(), e); + } + } + + // ==================== 阿里云OCR调用 ==================== + + /** + * 调用阿里云 RecognizeAdvanced(全文识别高精版)接口 + * + * @param inputStream 图片输入流 + * @return OCR接口返回的原始JSON字符串(Data字段内容) + */ + private String recognizeAdvanced(InputStream inputStream) throws Exception { + Client client = getClient(); + + RecognizeAdvancedRequest request = new RecognizeAdvancedRequest() + .setBody(inputStream) + // 自动旋转(拍照可能倾斜) + .setNeedRotate(true) + // 按顺序输出文字块 + .setNeedSortPage(true) + // 启用分段功能 + .setParagraph(true) + // 成行返回 + .setRow(true) + // 输出表格识别结果(学信网报告可能含表格) + .setOutputTable(true); + + RecognizeAdvancedResponse response = client.recognizeAdvanced(request); + + if (response == null || response.getBody() == null) { + throw new RuntimeException("OCR接口返回空响应"); + } + + if (response.getStatusCode() != null && response.getStatusCode() != 200) { + throw new RuntimeException("OCR接口调用失败,状态码: " + response.getStatusCode()); + } + + String data = response.getBody().getData(); + if (data == null || data.isEmpty()) { + log.warn("OCR识别Data字段为空"); + return "{}"; + } + + return data; + } + + // ==================== OCR结果解析 ==================== + + /** + * 解析OCR返回的Data JSON,提取文本行列表 + * + * 阿里云 RecognizeAdvanced 返回数据结构(简化): + * { + * "content": "全文合并文本", + * "prism_wordsInfo": [ + * {"word": "姓名", "pos": {...}}, + * {"word": "张三", "pos": {...}} + * ], + * "prism_tablesInfo": [...], + * "paragraphs": [...] + * } + */ + List parseOcrResultToLines(String ocrDataJson) { + List lines = new ArrayList<>(); + try { + JsonObject dataObj = gson.fromJson(ocrDataJson, JsonObject.class); + if (dataObj == null) return lines; + + // 表格摘要可能漏掉单元格之外的值,不能用它替代完整的文字行。 + List structuredLines = extractLinesFromTableHeadTail(dataObj); + lines.addAll(structuredLines); + + // 识别词的位置用于恢复「专业 具体专业」「层次 本科」等同一行的键值对。 + if (dataObj.has("prism_wordsInfo")) { + JsonArray wordsArray = dataObj.getAsJsonArray("prism_wordsInfo"); + if (wordsArray != null && wordsArray.size() > 0) { + lines.addAll(mergeWordsByRow(wordsArray)); + } + } + + // 没有位置数据时使用 OCR 原有行,不把全文 content 拼成一个字段。 + if (lines.isEmpty() && dataObj.has("prism_rowsInfo")) { + for (JsonElement row : dataObj.getAsJsonArray("prism_rowsInfo")) { + if (row.isJsonObject() && row.getAsJsonObject().has("word")) { + lines.add(row.getAsJsonObject().get("word").getAsString()); + } + } + } + if (lines.isEmpty() && dataObj.has("content")) { + String content = dataObj.get("content").getAsString(); + if (content != null && !content.isEmpty()) { + String[] rawLines = content.split("\\r?\\n"); + for (String line : rawLines) { + String trimmed = line.trim(); + if (!trimmed.isEmpty()) { + lines.add(trimmed); + } + } + } + } + + } catch (Exception e) { + log.error("解析OCR结果JSON失败", e); + } + return new ArrayList<>(new LinkedHashSet<>(lines)); + } + + /** + * 将 prism_wordsInfo 中的识别词按行合并 + * 基于 y 坐标(纵坐标)判断是否在同一行 + */ + private List mergeWordsByRow(JsonArray wordsArray) { + List words = new ArrayList<>(); + + // 提取所有词及其位置 + for (JsonElement elem : wordsArray) { + try { + JsonObject wordObj = elem.getAsJsonObject(); + String word = wordObj.has("word") ? wordObj.get("word").getAsString() : ""; + if (word.isEmpty()) continue; + + int y = 0; + int x = 0; + int height = 20; + if (wordObj.has("pos")) { + JsonElement pos = wordObj.get("pos"); + if (pos.isJsonArray() && pos.getAsJsonArray().size() >= 4) { + JsonArray corners = pos.getAsJsonArray(); + x = corners.get(0).getAsJsonObject().get("x").getAsInt(); + y = corners.get(0).getAsJsonObject().get("y").getAsInt(); + height = Math.max(1, corners.get(3).getAsJsonObject().get("y").getAsInt() - y); + } else if (pos.isJsonObject()) { + JsonObject point = pos.getAsJsonObject(); + x = point.get("x").getAsInt(); + y = point.get("y").getAsInt(); + } + } + words.add(new WordItem(word, x, y, height)); + } catch (Exception ignored) {} + } + + List lines = new ArrayList<>(); + if (words.isEmpty()) return lines; + + words.sort(Comparator.comparingInt(w -> w.y)); + List> rows = new ArrayList<>(); + for (WordItem word : words) { + List row = rows.isEmpty() ? null : rows.get(rows.size() - 1); + if (row == null || Math.abs(word.y - row.get(0).y) > Math.max(3, row.get(0).height / 2)) { + row = new ArrayList<>(); + rows.add(row); + } + row.add(word); + } + for (List row : rows) { + row.sort(Comparator.comparingInt(w -> w.x)); + StringBuilder line = new StringBuilder(); + for (WordItem word : row) { + if (line.length() > 0) line.append(' '); + line.append(word.word); + } + lines.add(line.toString()); + } + + return lines; + } + + /** + * 从 tableHeadTail 字段中提取结构化文本行 + * + * 结构(示例): + * "tableHeadTail": [ + * {"tableId":0, "head":[...], "tail":["姓名 陈东川", "学校名称 山东财经大学", ...]} + * ] + * + * 返回 head + tail 中所有非空的行元素 + */ + private List extractLinesFromTableHeadTail(JsonObject dataObj) { + List lines = new ArrayList<>(); + if (!dataObj.has("tableHeadTail")) return lines; + + JsonElement tablesElem = dataObj.get("tableHeadTail"); + if (tablesElem == null || !tablesElem.isJsonArray()) return lines; + + JsonArray tablesArr = tablesElem.getAsJsonArray(); + if (tablesArr.size() == 0) return lines; + + for (JsonElement tableElem : tablesArr) { + if (tableElem == null || !tableElem.isJsonObject()) continue; + JsonObject tableObj = tableElem.getAsJsonObject(); + + // head 部分(表头、标题) + addStringsFromArray(lines, tableObj, "head"); + // tail 部分(表尾,通常包含"字段 值"对) + addStringsFromArray(lines, tableObj, "tail"); + } + return lines; + } + + private void addStringsFromArray(List lines, JsonObject parent, String field) { + if (!parent.has(field)) return; + JsonElement elem = parent.get(field); + if (elem == null || !elem.isJsonArray()) return; + JsonArray arr = elem.getAsJsonArray(); + for (JsonElement item : arr) { + if (item == null || item.isJsonNull()) continue; + try { + String value = item.getAsString(); + if (value != null) { + value = value.trim(); + if (!value.isEmpty()) lines.add(value); + } + } catch (Exception ignored) {} + } + } + + /** + * OCR词项(带位置) + */ + private static class WordItem { + String word; + int x; + int y; + int height; + + WordItem(String word, int x, int y, int height) { + this.word = word; + this.x = x; + this.y = y; + this.height = height; + } + } + + /** + * 拷贝解析结果字段到返回DTO + */ + private void copyParsedFields(ChsiReportDTO from, ChsiReportDTO to) { + to.setReportName(from.getReportName()); + to.setReportUpdatedDate(from.getReportUpdatedDate()); + to.setName(from.getName()); + to.setGender(from.getGender()); + to.setBirthDate(from.getBirthDate()); + to.setEthnicity(from.getEthnicity()); + to.setSchool(from.getSchool()); + to.setLevel(from.getLevel()); + to.setBranchCampus(from.getBranchCampus()); + to.setDepartment(from.getDepartment()); + to.setMajor(from.getMajor()); + to.setStudyDuration(from.getStudyDuration()); + to.setEducationCategory(from.getEducationCategory()); + to.setStudyForm(from.getStudyForm()); + to.setEnrollmentDate(from.getEnrollmentDate()); + to.setDepartureDate(from.getDepartureDate()); + to.setStatus(from.getStatus()); + to.setOnlineVerificationCode(from.getOnlineVerificationCode()); + } + + /** PDF 文本层优先,OCR 仅补齐文本层未提取出的字段。 */ + private ChsiReportDTO mergeParsedFields(ChsiReportDTO text, ChsiReportDTO ocr) { + if (!hasText(text.getReportName())) text.setReportName(ocr.getReportName()); + if (!hasText(text.getReportUpdatedDate())) text.setReportUpdatedDate(ocr.getReportUpdatedDate()); + if (!hasText(text.getName())) text.setName(ocr.getName()); + if (!hasText(text.getGender())) text.setGender(ocr.getGender()); + if (!hasText(text.getBirthDate())) text.setBirthDate(ocr.getBirthDate()); + if (!hasText(text.getEthnicity())) text.setEthnicity(ocr.getEthnicity()); + if (!hasText(text.getSchool())) text.setSchool(ocr.getSchool()); + if (!hasText(text.getLevel())) text.setLevel(ocr.getLevel()); + if (!hasText(text.getBranchCampus())) text.setBranchCampus(ocr.getBranchCampus()); + if (!hasText(text.getDepartment())) text.setDepartment(ocr.getDepartment()); + if (!hasText(text.getMajor())) text.setMajor(ocr.getMajor()); + if (!hasText(text.getStudyDuration())) text.setStudyDuration(ocr.getStudyDuration()); + if (!hasText(text.getEducationCategory())) text.setEducationCategory(ocr.getEducationCategory()); + if (!hasText(text.getStudyForm())) text.setStudyForm(ocr.getStudyForm()); + if (!hasText(text.getEnrollmentDate())) text.setEnrollmentDate(ocr.getEnrollmentDate()); + if (!hasText(text.getDepartureDate())) text.setDepartureDate(ocr.getDepartureDate()); + if (!hasText(text.getStatus())) text.setStatus(ocr.getStatus()); + if (!hasText(text.getOnlineVerificationCode())) { + text.setOnlineVerificationCode(ocr.getOnlineVerificationCode()); + } + return text; + } + + private static boolean hasAllReportFields(ChsiReportDTO dto) { + return hasText(dto.getReportName()) && hasText(dto.getName()) && hasText(dto.getSchool()) + && hasText(dto.getReportUpdatedDate()) && hasText(dto.getGender()) + && hasText(dto.getBirthDate()) && hasText(dto.getEthnicity()) + && hasText(dto.getLevel()) && hasText(dto.getMajor()) + && hasText(dto.getStudyDuration()) && hasText(dto.getEducationCategory()) + && hasText(dto.getStudyForm()) && hasText(dto.getBranchCampus()) + && hasText(dto.getDepartment()) && hasText(dto.getEnrollmentDate()) + && hasText(dto.getStatus()) && hasText(dto.getDepartureDate()) + && hasText(dto.getOnlineVerificationCode()); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + /** 按新版《教育部学籍在线验证报告》的固定字段校验解析完整性。 */ + void validateChsiReport(ChsiReportDTO dto, List textLines) { + if (!"教育部学籍在线验证报告".equals(dto.getReportName())) { + dto.setValidChsiReport(false); + dto.setErrorMsg("上传的 PDF 不是有效的学信网验证报告"); + return; + } + + List missing = new ArrayList<>(); + if (!hasText(dto.getName()) || NON_NAME_TEXT.matcher(dto.getName()).find()) missing.add("姓名"); + if (!hasText(dto.getSchool())) missing.add("学校名称"); + if (!hasText(dto.getLevel())) missing.add("层次"); + if (!hasText(dto.getMajor())) missing.add("专业"); + if (!hasText(dto.getEnrollmentDate())) missing.add("入学日期"); + if (!hasText(dto.getStatus())) missing.add("学籍状态"); + if (!hasText(dto.getOnlineVerificationCode())) missing.add("在线验证码"); + if (!missing.isEmpty()) { + dto.setValidChsiReport(false); + dto.setErrorMsg("报告识别不完整,缺少:" + String.join(" ", missing) + + "。请重新从学信网下载原始 PDF 后上传"); + return; + } + + dto.setValidChsiReport(true); + } +} diff --git a/campus-server/src/main/java/com/campus/service/AnnouncementService.java b/campus-server/src/main/java/com/campus/service/AnnouncementService.java new file mode 100644 index 0000000..f8b9455 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AnnouncementService.java @@ -0,0 +1,330 @@ +package com.campus.service; + +import com.campus.common.PageResult; +import com.campus.dto.request.AnnouncementRequest; +import com.campus.dto.response.AnnouncementDTO; +import com.campus.entity.Announcement; +import com.campus.repository.AnnouncementRepository; +import com.campus.util.PageUtils; +import com.campus.util.UserContext; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnnouncementService { + + private final AnnouncementRepository announcementRepository; + private final ObjectMapper objectMapper; + private final ContentReviewService contentReviewService; + private final PushService pushService; + private final AuthorizationService authorizationService; + private final UserRestrictionService userRestrictionService; + + @Cacheable(value = "announcements", key = "'list:' + #type + ':' + #status + ':' + #page + ':' + #pageSize") + public PageResult list(String type, String status, int page, int pageSize) { + Pageable pageable = PageUtils.request(page, pageSize); + Page announcementPage; + if (type != null && status != null) { + announcementPage = announcementRepository.findByTypeAndStatus(type, status, pageable); + } else if (type != null) { + announcementPage = announcementRepository.findByType(type, pageable); + } else if (status != null) { + announcementPage = announcementRepository.findByStatus(status, pageable); + } else { + announcementPage = announcementRepository.findAll(pageable); + } + return PageResult.from(announcementPage.map(this::toDTO)); + } + + @Cacheable(value = "announcements", key = "'listByUniversity:' + #universityId + ':' + #type + ':' + #page + ':' + #pageSize") + public PageResult listByUniversity(String universityId, String type, int page, int pageSize) { + Pageable pageable = PageUtils.request(page, pageSize); + Page announcementPage = type != null + ? announcementRepository.findByTypeAndScopeTypeOrUniversityIdContaining(type, "all", universityId, pageable) + : announcementRepository.findByScopeTypeOrUniversityIdContaining("all", universityId, pageable); + return PageResult.from(announcementPage.map(this::toDTO)); + } + + @Cacheable(value = "announcements", key = "'detail:' + #id") + public AnnouncementDTO getById(String id) { + Announcement announcement = getByIdEntity(id); + return toDTO(announcement); + } + + @Transactional + @CacheEvict(value = "announcements", allEntries = true) + public void incrementViewCount(String id) { + Announcement announcement = getByIdEntity(id); + announcement.setViewCount((announcement.getViewCount() == null ? 0 : announcement.getViewCount()) + 1); + announcementRepository.save(announcement); + } + + @Transactional + @CacheEvict(value = "announcements", allEntries = true) + public AnnouncementDTO create(AnnouncementRequest request) { + String creatorId = UserContext.getCurrentUserId(); + userRestrictionService.ensureAllowed(creatorId, "PUBLISH"); + Announcement announcement = new Announcement(); + announcement.setType(request.getType() != null ? request.getType() : "general"); + announcement.setTitle(request.getTitle()); + announcement.setSubtitle(request.getSubtitle()); + announcement.setDescription(request.getDescription()); + announcement.setContent(request.getContent()); + announcement.setEventDate(request.getEventDate()); + announcement.setEventTime(request.getEventTime()); + announcement.setLocation(request.getLocation()); + announcement.setOrganizer(request.getOrganizer()); + announcement.setCapacity(request.getCapacity()); + announcement.setCoverImage(request.getCoverImage()); + announcement.setImages(serializeImages(request.getImages())); + announcement.setScopeType(request.getScopeType() != null ? request.getScopeType() : "all"); + announcement.setUniversityIds(serializeUniversityIds(request.getUniversityIds())); + announcement.setCreatorId(creatorId); + announcement.setScheduledPublishAt(request.getScheduledPublishAt()); + announcement.setStatus("pending"); + announcement = announcementRepository.save(announcement); + + contentReviewService.reviewAnnouncement( + announcement.getId(), + announcement.getTitle(), + announcement.getContent(), + announcement.getImages() + ); + + return toDTO(announcement); + } + + @Transactional + @CacheEvict(value = "announcements", allEntries = true) + public AnnouncementDTO update(String id, AnnouncementRequest request) { + Announcement announcement = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(announcement.getCreatorId()); + if (request.getType() != null) { + announcement.setType(request.getType()); + } + if (request.getTitle() != null) { + announcement.setTitle(request.getTitle()); + } + if (request.getSubtitle() != null) { + announcement.setSubtitle(request.getSubtitle()); + } + if (request.getDescription() != null) { + announcement.setDescription(request.getDescription()); + } + if (request.getContent() != null) { + announcement.setContent(request.getContent()); + } + if (request.getEventDate() != null) { + announcement.setEventDate(request.getEventDate()); + } + if (request.getEventTime() != null) { + announcement.setEventTime(request.getEventTime()); + } + if (request.getLocation() != null) { + announcement.setLocation(request.getLocation()); + } + if (request.getOrganizer() != null) { + announcement.setOrganizer(request.getOrganizer()); + } + if (request.getCapacity() != null) { + announcement.setCapacity(request.getCapacity()); + } + if (request.getCoverImage() != null) { + announcement.setCoverImage(request.getCoverImage()); + } + if (request.getImages() != null) { + announcement.setImages(serializeImages(request.getImages())); + } + if (request.getScopeType() != null) { + announcement.setScopeType(request.getScopeType()); + } + if (request.getUniversityIds() != null) { + announcement.setUniversityIds(serializeUniversityIds(request.getUniversityIds())); + } + announcement = announcementRepository.save(announcement); + return toDTO(announcement); + } + + /** + * 更新公告(专用 DTO,支持真正的部分更新,非空才覆盖) + * + * @param id 公告ID + * @param request 更新请求体 + * @return 更新后的公告 + */ + @Transactional + @CacheEvict(value = "announcements", allEntries = true) + public AnnouncementDTO update(String id, com.campus.dto.request.AnnouncementUpdateRequest request) { + Announcement announcement = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(announcement.getCreatorId()); + if (request.getType() != null) { + announcement.setType(request.getType()); + } + if (request.getTitle() != null) { + announcement.setTitle(request.getTitle()); + } + if (request.getSubtitle() != null) { + announcement.setSubtitle(request.getSubtitle()); + } + if (request.getDescription() != null) { + announcement.setDescription(request.getDescription()); + } + if (request.getContent() != null) { + announcement.setContent(request.getContent()); + } + if (request.getEventDate() != null) { + announcement.setEventDate(request.getEventDate()); + } + if (request.getEventTime() != null) { + announcement.setEventTime(request.getEventTime()); + } + if (request.getLocation() != null) { + announcement.setLocation(request.getLocation()); + } + if (request.getOrganizer() != null) { + announcement.setOrganizer(request.getOrganizer()); + } + if (request.getCapacity() != null) { + announcement.setCapacity(request.getCapacity()); + } + if (request.getCoverImage() != null) { + announcement.setCoverImage(request.getCoverImage()); + } + if (request.getImages() != null) { + announcement.setImages(serializeImages(request.getImages())); + } + if (request.getScopeType() != null) { + announcement.setScopeType(request.getScopeType()); + } + if (request.getUniversityIds() != null) { + announcement.setUniversityIds(serializeUniversityIds(request.getUniversityIds())); + } + announcement = announcementRepository.save(announcement); + return toDTO(announcement); + } + + @Transactional + @CacheEvict(value = "announcements", allEntries = true) + public void delete(String id) { + authorizationService.requireAdmin(); + Announcement announcement = getByIdEntity(id); + announcement.setDeleted(true); + announcementRepository.save(announcement); + } + + @Transactional + @CacheEvict(value = "announcements", allEntries = true) + public void deleteMyAnnouncement(String id, String userId) { + Announcement announcement = getByIdEntity(id); + if (!userId.equals(announcement.getCreatorId())) { + throw new RuntimeException("无权删除他人创建的公告"); + } + announcement.setDeleted(true); + announcementRepository.save(announcement); + } + + @Transactional + @CacheEvict(value = "announcements", allEntries = true) + public AnnouncementDTO review(String id, String status, String reason) { + authorizationService.requireReviewerOrAdmin(); + Announcement announcement = getByIdEntity(id); + announcement.setStatus(status); + announcement.setReviewReason(reason); + announcement = announcementRepository.save(announcement); + + // 审核通过时推送公告 + if ("approved".equals(status)) { + pushService.pushAnnouncement( + announcement.getTitle(), + announcement.getContent(), + announcement.getId() + ); + } + + return toDTO(announcement); + } + + private Announcement getByIdEntity(String id) { + return announcementRepository.findById(id) + .orElseThrow(() -> new RuntimeException("公告不存在")); + } + + @SneakyThrows + private String serializeImages(List imagesList) { + if (imagesList == null || imagesList.isEmpty()) { + return null; + } + return objectMapper.writeValueAsString(imagesList); + } + + @SneakyThrows + private List deserializeImages(String imagesJson) { + if (imagesJson == null || imagesJson.isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(imagesJson, new TypeReference>() {}); + } catch (Exception e) { + return Collections.singletonList(imagesJson); + } + } + + @SneakyThrows + private String serializeUniversityIds(List universityIds) { + if (universityIds == null || universityIds.isEmpty()) { + return null; + } + return objectMapper.writeValueAsString(universityIds); + } + + @SneakyThrows + private List deserializeUniversityIds(String universityIdsJson) { + if (universityIdsJson == null || universityIdsJson.isEmpty()) { + return Collections.emptyList(); + } + return objectMapper.readValue(universityIdsJson, new TypeReference>() {}); + } + + private AnnouncementDTO toDTO(Announcement announcement) { + AnnouncementDTO dto = new AnnouncementDTO(); + dto.setId(announcement.getId()); + dto.setType(announcement.getType()); + dto.setTitle(announcement.getTitle()); + dto.setSubtitle(announcement.getSubtitle()); + dto.setDescription(announcement.getDescription()); + dto.setContent(announcement.getContent()); + dto.setEventDate(announcement.getEventDate()); + dto.setEventTime(announcement.getEventTime()); + dto.setLocation(announcement.getLocation()); + dto.setOrganizer(announcement.getOrganizer()); + dto.setCapacity(announcement.getCapacity()); + dto.setCoverImage(announcement.getCoverImage()); + dto.setImages(deserializeImages(announcement.getImages())); + dto.setStatus(announcement.getStatus()); + dto.setReviewReason(announcement.getReviewReason()); + dto.setScopeType(announcement.getScopeType()); + dto.setUniversityIds(deserializeUniversityIds(announcement.getUniversityIds())); + dto.setViewCount(announcement.getViewCount()); + dto.setRegisteredCount(announcement.getRegisteredCount()); + dto.setCreatorId(String.valueOf(announcement.getCreatorId())); + dto.setScheduledPublishAt(announcement.getScheduledPublishAt()); + dto.setCreatedAt(announcement.getCreatedAt()); + dto.setUpdatedAt(announcement.getUpdatedAt()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/AreaService.java b/campus-server/src/main/java/com/campus/service/AreaService.java new file mode 100644 index 0000000..7cb34eb --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AreaService.java @@ -0,0 +1,130 @@ +package com.campus.service; + +import com.campus.dto.response.AreaDTO; +import com.campus.entity.Area; +import com.campus.repository.AreaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AreaService { + + private final AreaRepository areaRepository; + + /** + * 获取所有省份 + */ + @Cacheable(value = "areas", key = "'provinces'") + public List getProvinces() { + List provinces = areaRepository.findByLevelAndIsdelOrderByHotDesc(1, 0); + return provinces.stream().map(this::toDTO).collect(Collectors.toList()); + } + + /** + * 根据父级ID获取子地区 + */ + public List getChildrenByParentId(Integer parentId) { + List children = areaRepository.findByAreAreaIdAndIsdelOrderByHotDesc(parentId, 0); + return children.stream().map(this::toDTO).collect(Collectors.toList()); + } + + /** + * 获取完整的省市区三级树形结构 + */ + public List getAreaTree() { + List allAreas = areaRepository.findByIsdel(0); + + Map> areaMap = allAreas.stream() + .collect(Collectors.groupingBy(area -> area.getAreAreaId() == null ? 0 : area.getAreAreaId())); + + List provinces = areaMap.getOrDefault(0, new ArrayList<>()); + return provinces.stream() + .sorted((a, b) -> (b.getHot() == null ? 0 : b.getHot()) - (a.getHot() == null ? 0 : a.getHot())) + .map(province -> buildTree(province, areaMap)) + .collect(Collectors.toList()); + } + + /** + * 递归构建树形结构 + */ + private AreaDTO buildTree(Area area, Map> areaMap) { + AreaDTO dto = toDTO(area); + List children = areaMap.getOrDefault(area.getAreaId(), new ArrayList<>()); + if (!children.isEmpty()) { + children.sort((a, b) -> (b.getHot() == null ? 0 : b.getHot()) - (a.getHot() == null ? 0 : a.getHot())); + dto.setChildren(children.stream() + .map(child -> buildTree(child, areaMap)) + .collect(Collectors.toList())); + } + return dto; + } + + /** + * 根据ID获取地区详情 + */ + public AreaDTO getAreaById(Integer areaId) { + Area area = areaRepository.findById(areaId) + .orElseThrow(() -> new RuntimeException("地区不存在")); + return toDTO(area); + } + + /** + * 根据编码获取地区 + */ + public AreaDTO getAreaByCode(String code) { + Area area = areaRepository.findByCodeAndIsdel(code, 0); + if (area == null) { + throw new RuntimeException("地区不存在"); + } + return toDTO(area); + } + + /** + * 获取热门地区 + */ + public List getHotAreas() { + List hotAreas = areaRepository.findByHotAndIsdelOrderByHotDesc(1, 0); + return hotAreas.stream().map(this::toDTO).collect(Collectors.toList()); + } + + /** + * 搜索地区 + */ + public List searchAreas(String keyword) { + if (!StringUtils.hasText(keyword)) { + return new ArrayList<>(); + } + List areas = areaRepository.findByNameContainingAndIsdel(keyword, 0); + return areas.stream().map(this::toDTO).collect(Collectors.toList()); + } + + private AreaDTO toDTO(Area area) { + AreaDTO dto = new AreaDTO(); + dto.setAreaId(area.getAreaId()); + dto.setAreAreaId(area.getAreAreaId()); + dto.setName(area.getName()); + dto.setShortName(area.getShortName()); + dto.setCode(area.getCode()); + dto.setHot(area.getHot()); + dto.setIsdel(area.getIsdel()); + dto.setLevel(area.getLevel()); + dto.setLng(area.getLng()); + dto.setLat(area.getLat()); + dto.setLetter(area.getLetter()); + dto.setPinyin(area.getPinyin()); + dto.setFirstChar(area.getFirstChar()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/AuditLogService.java b/campus-server/src/main/java/com/campus/service/AuditLogService.java new file mode 100644 index 0000000..d996726 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AuditLogService.java @@ -0,0 +1,92 @@ +package com.campus.service; + +import com.campus.common.PageResult; +import com.campus.dto.response.AdminAuditLogDTO; +import com.campus.entity.AdminAuditLog; +import com.campus.entity.User; +import com.campus.repository.AdminAuditLogRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Locale; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AuditLogService { + + private final AdminAuditLogRepository repository; + private final AuthorizationService authorizationService; + + @Transactional(propagation = Propagation.MANDATORY) + public AdminAuditLog record(String action, String targetType, String targetId, + String beforeSummary, String afterSummary, String remark) { + User operator = UserContext.getCurrentUser(); + AdminAuditLog log = new AdminAuditLog(); + log.setOperatorId(operator == null ? "SYSTEM" : operator.getId()); + log.setOperatorName(operator == null ? "SYSTEM" : safeOperatorName(operator)); + log.setAction(normalize(action)); + log.setTargetType(normalize(targetType)); + log.setTargetId(trim(targetId, 100)); + log.setBeforeSummary(trim(beforeSummary, 1000)); + log.setAfterSummary(trim(afterSummary, 1000)); + log.setRemark(trim(remark, 500)); + return repository.save(log); + } + + @Transactional(readOnly = true) + public PageResult list(String operatorId, String action, String targetType, + String targetId, int page, int size) { + authorizationService.requireAdmin(); + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), 100); + Page data = repository.search(trim(operatorId, 36), normalizeOptional(action), + normalizeOptional(targetType), trim(targetId, 100), + PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt"))); + return PageResult.of(data.getContent().stream().map(this::toDTO).collect(Collectors.toList()), + data.getTotalElements(), safePage, safeSize); + } + + private AdminAuditLogDTO toDTO(AdminAuditLog log) { + AdminAuditLogDTO dto = new AdminAuditLogDTO(); + dto.setId(log.getId()); + dto.setOperatorId(log.getOperatorId()); + dto.setOperatorName(log.getOperatorName()); + dto.setAction(log.getAction()); + dto.setTargetType(log.getTargetType()); + dto.setTargetId(log.getTargetId()); + dto.setBeforeSummary(log.getBeforeSummary()); + dto.setAfterSummary(log.getAfterSummary()); + dto.setRemark(log.getRemark()); + dto.setCreatedAt(log.getCreatedAt()); + return dto; + } + + private String safeOperatorName(User user) { + return user.getNickname() == null || user.getNickname().trim().isEmpty() + ? user.getPhone() : user.getNickname(); + } + + private String normalizeOptional(String value) { + String trimmed = trim(value, 80); + return trimmed == null ? null : trimmed.toUpperCase(Locale.ROOT); + } + + private String normalize(String value) { + String normalized = normalizeOptional(value); + if (normalized == null) throw new IllegalArgumentException("审计动作和目标类型不能为空"); + return normalized; + } + + private String trim(String value, int maxLength) { + if (value == null || value.trim().isEmpty()) return null; + String result = value.trim(); + return result.length() > maxLength ? result.substring(0, maxLength) : result; + } +} diff --git a/campus-server/src/main/java/com/campus/service/AuthorizationService.java b/campus-server/src/main/java/com/campus/service/AuthorizationService.java new file mode 100644 index 0000000..46391c8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/AuthorizationService.java @@ -0,0 +1,46 @@ +package com.campus.service; + +import com.campus.common.ForbiddenException; +import com.campus.entity.User; +import com.campus.util.UserContext; +import org.springframework.stereotype.Service; + +/** 统一的后台权限判断,避免各控制器各自实现并产生口径差异。 */ +@Service +public class AuthorizationService { + + public boolean isAdmin(User user) { + return user != null && Boolean.TRUE.equals(user.getIsAdmin()); + } + + public boolean isReviewerOrAdmin(User user) { + return isAdmin(user) || (user != null && Boolean.TRUE.equals(user.getIsReviewer())); + } + + public boolean isCurrentAdmin() { + return isAdmin(UserContext.getCurrentUser()); + } + + public void requireAdmin() { + if (!isCurrentAdmin()) { + throw new ForbiddenException("仅管理员可以执行该操作"); + } + } + + public void requireReviewerOrAdmin() { + if (!isReviewerOrAdmin(UserContext.getCurrentUser())) { + throw new ForbiddenException("仅审核员或管理员可以执行该操作"); + } + } + + public void requireOwnerOrAdmin(String ownerId) { + requireOwnerOrAdmin(ownerId, "无权操作他人的内容"); + } + + public void requireOwnerOrAdmin(String ownerId, String message) { + User current = UserContext.getCurrentUser(); + if (current == null || (!current.getId().equals(ownerId) && !isAdmin(current))) { + throw new ForbiddenException(message); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/CaptchaService.java b/campus-server/src/main/java/com/campus/service/CaptchaService.java new file mode 100644 index 0000000..a07e7d7 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/CaptchaService.java @@ -0,0 +1,234 @@ +package com.campus.service; + +import com.aliyun.dysmsapi20170525.Client; +import com.aliyun.dysmsapi20170525.models.SendSmsRequest; +import com.aliyun.dysmsapi20170525.models.SendSmsResponse; +import com.aliyun.teaopenapi.models.Config; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +@Service +@Slf4j +public class CaptchaService { + + @Value("${aliyun.sms.access-key-id}") + private String accessKeyId; + + @Value("${aliyun.sms.access-key-secret}") + private String accessKeySecret; + + @Value("${aliyun.sms.sign-name}") + private String signName; + + @Value("${aliyun.sms.template-code}") + private String templateCode; + + @Value("${aliyun.sms.region-id}") + private String regionId; + + @Value("${security.captcha.universal-enabled:false}") + private boolean universalCaptchaEnabled; + + private final RedisTemplate redisTemplate; + private final RedisRetryExecutor redisRetryExecutor; + private Client client; + + private static final String CAPTCHA_PREFIX = "captcha:"; + private static final String CAPTCHA_COUNT_PREFIX = "captcha:count:"; + private static final int CAPTCHA_EXPIRE_MINUTES = 5; // 验证码5分钟过期 + private static final int MAX_CAPTCHA_PER_PHONE_PER_DAY = 10; // 每个手机号每天最多发送10次 + + public CaptchaService(RedisTemplate redisTemplate, + RedisRetryExecutor redisRetryExecutor) { + this.redisTemplate = redisTemplate; + this.redisRetryExecutor = redisRetryExecutor; + } + + @PostConstruct + public void initClient() { + try { + Config config = new Config(); + config.setAccessKeyId(accessKeyId); + config.setAccessKeySecret(accessKeySecret); + config.setRegionId(regionId); + + this.client = new Client(config); + log.info("阿里云短信客户端初始化成功"); + } catch (Exception e) { + log.error("阿里云短信客户端初始化失败: {}", e.getMessage()); + } + } + + /** + * 发送验证码 + */ + public boolean sendCaptcha(String phoneNumber) { + // 检查发送频率 临时注释 +// if (!checkSendFrequency(phoneNumber)) { +// log.warn("验证码发送频率过高,手机号: {}", phoneNumber); +// return false; +// } + + // 生成6位数字验证码 + String captcha = generateCaptcha(); + + // 发送到阿里云短信 + boolean sent = sendSms(phoneNumber, captcha); + + if (sent) { + try { + redisRetryExecutor.execute("captcha-save", () -> { + saveCaptcha(phoneNumber, captcha); + incrementSendCount(phoneNumber); + }); + log.info("验证码发送并存储成功,手机号: {}", maskPhone(phoneNumber)); + return true; + } catch (org.springframework.dao.DataAccessException ex) { + log.error("验证码存储服务不可用,手机号: {}", maskPhone(phoneNumber), ex); + throw new com.campus.common.ServiceUnavailableException("验证码服务暂时不可用,请稍后重试", ex); + } + } + + return false; + } + + /** + * 验证验证码 + */ + public boolean verifyCaptcha(String phoneNumber, String captcha) { + String key = CAPTCHA_PREFIX + phoneNumber; + Object storedCaptcha; + try { + storedCaptcha = redisRetryExecutor.execute("captcha-read", + () -> redisTemplate.opsForValue().get(key)); + } catch (Exception ex) { + log.warn("验证码校验依赖存储异常,手机号: {}", maskPhone(phoneNumber), ex); + throw new com.campus.common.ServiceUnavailableException("验证码服务暂时不可用,请稍后重试", ex); + } + + if (storedCaptcha == null) { + log.warn("验证码不存在或已过期,手机号: {}", phoneNumber); + return false; + } + + boolean isValid = storedCaptcha.toString().equals(captcha); + if (isValid) { + try { + // 验证成功后删除验证码;删除失败只记录日志,不反过来判为验证码错误。 + redisRetryExecutor.execute("captcha-delete", () -> redisTemplate.delete(key)); + } catch (Exception ex) { + log.warn("验证码删除失败,手机号: {}", phoneNumber, ex); + } + log.info("验证码验证成功,手机号: {}", phoneNumber); + } else { + log.warn("验证码错误,手机号: {}", maskPhone(phoneNumber)); + } + return isValid; + } + + public boolean verifyLoginCaptcha(String phoneNumber, String captcha) { + return (universalCaptchaEnabled && "111111".equals(captcha)) + || verifyCaptcha(phoneNumber, captcha); + } + + /** + * 生成6位数字验证码 + */ + private String generateCaptcha() { + Random random = new Random(); + int captcha = 100000 + random.nextInt(900000); + return String.valueOf(captcha); + } + + /** + * 发送短信 + */ + private boolean sendSms(String phoneNumber, String captcha) { + try { + if (client == null) { + log.error("阿里云短信客户端未初始化"); + return false; + } + + SendSmsRequest request = new SendSmsRequest(); + request.setPhoneNumbers(phoneNumber); + request.setSignName(signName); + request.setTemplateCode(templateCode); + request.setTemplateParam("{\"code\":\"" + captcha + "\"}"); + + SendSmsResponse response = client.sendSms(request); + + if ("OK".equals(response.getBody().getCode())) { + log.info("短信发送成功,RequestId: {}", response.getBody().getRequestId()); + return true; + } else { + log.error("短信发送失败,Code: {}, Message: {}", + response.getBody().getCode(), response.getBody().getMessage()); + return false; + } + } catch (Exception e) { + log.error("短信发送异常: {}", e.getMessage()); + return false; + } + } + + /** + * 存储验证码到Redis + */ + private void saveCaptcha(String phoneNumber, String captcha) { + String key = CAPTCHA_PREFIX + phoneNumber; + ValueOperations valueOps = redisTemplate.opsForValue(); + valueOps.set(key, captcha, CAPTCHA_EXPIRE_MINUTES, TimeUnit.MINUTES); + } + + /** + * 检查发送频率 + */ + private boolean checkSendFrequency(String phoneNumber) { + String countKey = CAPTCHA_COUNT_PREFIX + phoneNumber; + ValueOperations valueOps = redisTemplate.opsForValue(); + + Object countObj = valueOps.get(countKey); + int count = countObj != null ? Integer.parseInt(countObj.toString()) : 0; + + return count < MAX_CAPTCHA_PER_PHONE_PER_DAY; + } + + /** + * 增加发送次数 + */ + private void incrementSendCount(String phoneNumber) { + String countKey = CAPTCHA_COUNT_PREFIX + phoneNumber; + ValueOperations valueOps = redisTemplate.opsForValue(); + + Object countObj = valueOps.get(countKey); + int count = countObj != null ? Integer.parseInt(countObj.toString()) : 0; + + valueOps.set(countKey, count + 1, getSecondsUntilMidnight(), TimeUnit.SECONDS); + } + + /** + * 获取距离当天午夜剩余秒数 + */ + private long getSecondsUntilMidnight() { + long now = System.currentTimeMillis(); + long midnight = now + (24 * 60 * 60 * 1000); + midnight = midnight - (midnight % (24 * 60 * 60 * 1000)); + return midnight - now; + } + + private String maskPhone(String phone) { + if (phone == null || phone.length() < 7) return "***"; + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } +} diff --git a/campus-server/src/main/java/com/campus/service/ChatService.java b/campus-server/src/main/java/com/campus/service/ChatService.java new file mode 100644 index 0000000..574676e --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ChatService.java @@ -0,0 +1,1137 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.common.PageResult; +import com.campus.common.ServiceUnavailableException; +import com.campus.dto.request.ChatMessageRequest; +import com.campus.dto.request.ConversationSettingsRequest; +import com.campus.dto.response.ChatMessageDTO; +import com.campus.dto.response.EasemobConfigDTO; +import com.campus.dto.response.ShareCardDTO; +import com.campus.entity.ChatConversation; +import com.campus.entity.ChatMessage; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.ChatMessageRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatService { + + /** 站内分享卡片消息类型:content 存卡片 JSON 快照,会话列表与推送只展示文本摘要。 */ + public static final String MESSAGE_TYPE_SHARE_CARD = "SHARE_CARD"; + + private final EasemobService easemobService; + private final ChatMessageRepository chatMessageRepository; + private final ChatConversationRepository chatConversationRepository; + private final GroupRepository groupRepository; + private final GroupMemberRepository groupMemberRepository; + private final UserRepository userRepository; + private final FriendshipRepository friendshipRepository; + private final PushService pushService; + private final ObjectMapper objectMapper; + + /** + * Whether REST send endpoints should forward through the EasyMob server SDK. + * The new React Native client already sends real-time messages through its SDK, + * so the REST endpoint only persists and updates local state by default. + */ + @Value("${chat.rest-send-forward-im:false}") + private boolean forwardRestMessagesViaEasemob; + private final PrivacySettingsService privacySettingsService; + private final UserRestrictionService userRestrictionService; + private final com.campus.repository.ChatReportRepository chatReportRepository; + private final AuthorizationService authorizationService; + private final AuditLogService auditLogService; + + // ==================== DTO 转换 ==================== + + private ChatMessageDTO toDTO(ChatMessage m) { + ChatMessageDTO dto = new ChatMessageDTO(); + dto.setSeqId(m.getSeqId()); + dto.setId(m.getMessageId()); + dto.setConversationId(m.getConversationId()); + dto.setSenderId(m.getSenderId()); + dto.setContent(m.getContent()); + dto.setMessageType(m.getMessageType()); + if (MESSAGE_TYPE_SHARE_CARD.equals(m.getMessageType())) { + try { + ShareCardDTO card = objectMapper.readValue(m.getContent(), ShareCardDTO.class); + dto.setShareCard(card); + dto.setContent("[分享] " + (card.getTitle() == null ? "内容暂不可查看" : card.getTitle())); + } catch (Exception ex) { + log.warn("分享卡片解析失败,按不可查看消息降级: messageId={}, error={}", + m.getMessageId(), ex.getMessage()); + dto.setContent("[分享] 内容暂不可查看"); + dto.setShareCard(null); + } + } + dto.setMediaUrl(m.getMediaUrl()); + dto.setDuration(m.getDuration()); + dto.setIsRead(m.getIsRead()); + dto.setReadAt(m.getReadAt()); + dto.setCreatedAt(m.getCreatedAt()); + return dto; + } + + private List toDTOList(List messages) { + return messages.stream().map(this::toDTO).collect(Collectors.toList()); + } + + // ==================== 配置 ==================== + + public EasemobConfigDTO getConfig() { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new RuntimeException("用户未登录"); + } + + EasemobConfigDTO config = new EasemobConfigDTO(); + config.setAppKey(easemobService.getAppKey()); + String hex = currentUserId.replace("-", ""); + config.setUsername(("u" + hex).substring(0, Math.min(32, hex.length() + 1))); + config.setPassword("campus_" + currentUserId); + + return config; + } + + public boolean registerCurrentUser() { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new RuntimeException("用户未登录"); + } + + String password = "campus_" + currentUserId; + boolean result = easemobService.registerUser(currentUserId, password); + if (result) { + log.info("用户 {} 注册到环信成功", currentUserId); + } else { + log.warn("用户 {} 注册到环信失败", currentUserId); + } + return result; + } + + public boolean sendSystemMessage(String toUserId, String content) { + return easemobService.sendTextMessage("system", toUserId, content); + } + + // ===================== 消息发送 ===================== + + @Transactional + public ChatMessageDTO sendPrivateMessage(ChatMessageRequest request) { + String senderId = UserContext.getCurrentUserId(); + if (senderId == null) { + throw new RuntimeException("用户未登录"); + } + userRestrictionService.ensureAllowed(senderId, "MESSAGE"); + + String peerId = request.getPeerId(); + String content = request.getContent(); + String messageType = request.getMessageType() != null ? request.getMessageType() : "TEXT"; + + if (MESSAGE_TYPE_SHARE_CARD.equalsIgnoreCase(messageType)) { + throw new BusinessException("不支持的消息类型"); + } + + if (peerId == null || peerId.trim().isEmpty()) { + throw new RuntimeException("接收用户不能为空"); + } + if (!userRepository.existsById(peerId)) { + throw new RuntimeException("接收用户不存在"); + } + if (isBlocked(senderId, peerId)) { + throw new RuntimeException("无法向黑名单用户发送消息"); + } + if (!isAcceptedRelation(senderId, peerId) + && !privacySettingsService.allowsStrangerMessage(peerId)) { + throw new RuntimeException("对方已关闭陌生人消息"); + } + + String conversationId = buildPrivateConversationId(senderId, peerId); + + ChatMessage message = new ChatMessage(); + message.setConversationId(conversationId); + message.setSenderId(senderId); + message.setContent(content); + message.setMessageType(messageType); + message.setMediaUrl(request.getMediaUrl()); + message.setDuration(request.getDuration()); + message.setIsRead(false); + message = chatMessageRepository.save(message); + + boolean success = !forwardRestMessagesViaEasemob || easemobService.sendTextMessage(senderId, peerId, content, message.getMessageId()); + if (success) { + String displayContent = content; + if ("VOICE".equalsIgnoreCase(messageType)) { + displayContent = "[语音]"; + } else if ("IMAGE".equalsIgnoreCase(messageType)) { + displayContent = "[图片]"; + } else if ("VIDEO".equalsIgnoreCase(messageType)) { + displayContent = "[视频]"; + } + updateConversationLastMessage(senderId, conversationId, "PRIVATE", displayContent, messageType, request.getDuration(), false, true); + updateConversationLastMessage(peerId, conversationId, "PRIVATE", displayContent, messageType, request.getDuration(), true, false); + + // 远程推送:通知接收方有新消息 + try { + User sender = userRepository.findById(senderId).orElse(null); + String senderNickname = sender != null && sender.getNickname() != null + ? sender.getNickname() : "新消息"; + pushService.pushChatMessage(peerId, senderId, senderNickname, displayContent); + } catch (Exception e) { + log.warn("[推送] 私聊消息推送失败 -> senderId={}, peerId={}, error={}", + senderId, peerId, e.getMessage()); + } + } + + return toDTO(message); + } + + /** + * 站内分享卡片消息(私聊)。除登录、账号限制与拉黑校验外, + * 分享目标必须是已接受的好友,不沿用普通消息的陌生人消息开关。 + * + * @param cardJson 卡片 JSON 快照,落库到 content 供聊天页渲染 + * @param displayText 会话列表与推送展示的摘要,如「[分享] 标题」 + */ + @Transactional + public ChatMessageDTO sendSharePrivateMessage(String peerId, String cardJson, String displayText) { + String senderId = UserContext.getCurrentUserId(); + if (senderId == null) { + throw new BusinessException("用户未登录"); + } + userRestrictionService.ensureAllowed(senderId, "MESSAGE"); + + if (peerId == null || peerId.trim().isEmpty()) { + throw new BusinessException("接收用户不能为空"); + } + if (peerId.equals(senderId)) { + throw new BusinessException("不能分享给自己"); + } + if (!userRepository.existsById(peerId)) { + throw new BusinessException("接收用户不存在"); + } + if (!isAcceptedRelation(senderId, peerId) || isBlocked(senderId, peerId)) { + throw new BusinessException("只能分享给好友"); + } + + String conversationId = buildPrivateConversationId(senderId, peerId); + + String messageId = UUID.randomUUID().toString(); + boolean success = easemobService.sendShareCardMessage( + senderId, peerId, displayText, messageId, cardJson); + if (!success) { + throw new ServiceUnavailableException("分享服务暂不可用,请稍后重试"); + } + + ChatMessage message = new ChatMessage(); + message.setMessageId(messageId); + message.setConversationId(conversationId); + message.setSenderId(senderId); + message.setContent(cardJson); + message.setMessageType(MESSAGE_TYPE_SHARE_CARD); + message.setIsRead(false); + message = chatMessageRepository.save(message); + + updateConversationLastMessage(senderId, conversationId, "PRIVATE", displayText, + MESSAGE_TYPE_SHARE_CARD, null, false, true); + updateConversationLastMessage(peerId, conversationId, "PRIVATE", displayText, + MESSAGE_TYPE_SHARE_CARD, null, true, false); + + try { + User sender = userRepository.findById(senderId).orElse(null); + String senderNickname = sender != null && sender.getNickname() != null + ? sender.getNickname() : "新消息"; + pushService.pushChatMessage(peerId, senderId, senderNickname, displayText); + } catch (Exception e) { + log.warn("[推送] 分享卡片推送失败 -> senderId={}, peerId={}, error={}", + senderId, peerId, e.getMessage()); + } + + ChatMessageDTO response = toDTO(message); + response.setPeerId(peerId); + response.setConversationType("PRIVATE"); + return response; + } + + private boolean isAcceptedRelation(String userA, String userB) { + return friendshipRepository.findByUserIdAndFriendId(userA, userB) + .map(f -> "ACCEPTED".equals(f.getStatus())) + .orElseGet(() -> friendshipRepository.findByUserIdAndFriendId(userB, userA) + .map(f -> "ACCEPTED".equals(f.getStatus())) + .orElse(false)); + } + + private boolean isBlocked(String userA, String userB) { + return friendshipRepository.findByUserIdAndFriendId(userA, userB) + .map(f -> "BLOCKED".equals(f.getStatus())) + .orElseGet(() -> friendshipRepository.findByUserIdAndFriendId(userB, userA) + .map(f -> "BLOCKED".equals(f.getStatus())) + .orElse(false)); + } + + @Transactional + public ChatMessageDTO sendGroupMessage(String groupId, String content) { + String senderId = UserContext.getCurrentUserId(); + if (senderId == null) { + throw new RuntimeException("用户未登录"); + } + userRestrictionService.ensureAllowed(senderId, "MESSAGE"); + + return sendGroupMessageInternal(groupId, senderId, content, "TEXT"); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ChatMessageDTO sendGroupAnnouncementNotification( + String groupId, String senderId, String content) { + userRestrictionService.ensureAllowed(senderId, "MESSAGE"); + return sendGroupMessageInternal(groupId, senderId, content, "GROUP_ANNOUNCEMENT"); + } + + /** + * 站内分享卡片消息(群聊)。群权限校验与 {@link #sendGroupMessage} 一致: + * 在群内、非待审核、未被禁言。 + * + * @param cardJson 卡片 JSON 快照,落库到 content 供聊天页渲染 + * @param displayText 群会话最后一条消息与推送展示的摘要 + */ + @Transactional + public ChatMessageDTO sendGroupShareMessage(String groupId, String cardJson, String displayText) { + String senderId = UserContext.getCurrentUserId(); + if (senderId == null) { + throw new BusinessException("用户未登录"); + } + userRestrictionService.ensureAllowed(senderId, "MESSAGE"); + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + GroupMember senderMember = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, senderId) + .orElseThrow(() -> new ForbiddenException("不在群内")); + if ("PENDING".equals(senderMember.getRole())) { + throw new BusinessException("入群申请尚未通过"); + } + if (Boolean.TRUE.equals(senderMember.getIsMuted())) { + throw new ForbiddenException("你已被群禁言"); + } + if (group.getEasemobGroupId() == null) { + throw new ServiceUnavailableException("群聊服务尚未就绪,请稍后重试"); + } + + String messageId = UUID.randomUUID().toString(); + boolean success = easemobService.sendGroupShareCardMessage( + senderId, group.getEasemobGroupId(), displayText, messageId, cardJson); + if (!success) { + throw new ServiceUnavailableException("分享服务暂不可用,请稍后重试"); + } + + ChatMessage message = new ChatMessage(); + message.setMessageId(messageId); + message.setConversationId(groupId); + message.setSenderId(senderId); + message.setContent(cardJson); + message.setMessageType(MESSAGE_TYPE_SHARE_CARD); + message.setIsRead(false); + message = chatMessageRepository.save(message); + + ChatMessageDTO response = toDTO(message); + response.setPeerId(groupId); + response.setConversationType("GROUP"); + String senderNickname = senderMember.getNickname(); + if (!StringUtils.hasText(senderNickname)) { + try { + User sender = userRepository.findById(senderId).orElse(null); + senderNickname = sender != null ? sender.getNickname() : null; + } catch (Exception e) { + log.warn("[群聊] 查询发送者信息失败 -> senderId={}, error={}", senderId, e.getMessage()); + } + } + if (StringUtils.hasText(senderNickname)) { + response.setSenderGroupNickname(senderNickname.trim()); + } + + group.setLastMessage(displayText); + group.setLastMessageTime(new Date()); + groupRepository.save(group); + + String pushNickname = response.getSenderGroupNickname() != null + ? response.getSenderGroupNickname() : "群消息"; + + List members = groupMemberRepository.findByGroupId(groupId); + for (GroupMember member : members) { + if ("PENDING".equals(member.getRole())) continue; + boolean isSender = senderId.equals(member.getUserId()); + updateConversationLastMessage( + member.getUserId(), groupId, "GROUP", displayText, + MESSAGE_TYPE_SHARE_CARD, null, !isSender, isSender); + if (!isSender) { + try { + pushService.pushGroupChatMessage( + member.getUserId(), senderId, pushNickname, groupId, displayText); + } catch (Exception ignored) { + // 单个成员推送失败不影响其他成员 + } + } + } + + return response; + } + + private ChatMessageDTO sendGroupMessageInternal( + String groupId, String senderId, String content, String messageType) { + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new RuntimeException("群组不存在")); + + GroupMember senderMember = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, senderId) + .orElseThrow(() -> new RuntimeException("不在群内")); + if ("PENDING".equals(senderMember.getRole())) { + throw new RuntimeException("入群申请尚未通过"); + } + if (Boolean.TRUE.equals(senderMember.getIsMuted())) { + throw new RuntimeException("你已被群禁言"); + } + + if (group.getEasemobGroupId() == null) { + throw new RuntimeException("群聊服务尚未就绪,请稍后重试"); + } + + ChatMessage message = new ChatMessage(); + message.setConversationId(groupId); + message.setSenderId(senderId); + message.setContent(content); + message.setMessageType(messageType); + message.setIsRead(false); + message = chatMessageRepository.save(message); + + ChatMessageDTO response = toDTO(message); + String senderNickname = senderMember.getNickname(); + if (!StringUtils.hasText(senderNickname)) { + try { + User sender = userRepository.findById(senderId).orElse(null); + senderNickname = sender != null ? sender.getNickname() : null; + } catch (Exception e) { + log.warn("[群聊] 查询发送者信息失败 -> senderId={}, error={}", senderId, e.getMessage()); + } + } + if (StringUtils.hasText(senderNickname)) { + response.setSenderGroupNickname(senderNickname.trim()); + } + + boolean success = !forwardRestMessagesViaEasemob || easemobService.sendGroupMessage(senderId, group.getEasemobGroupId(), content); + if (success) { + group.setLastMessage(content); + group.setLastMessageTime(new Date()); + groupRepository.save(group); + + String pushNickname = response.getSenderGroupNickname() != null + ? response.getSenderGroupNickname() : "群消息"; + + // 为群内每个成员创建/更新群会话(发送者已读、其他人未读+1), + // 保证重装/清缓存后也能从服务端拿到群会话;同时推送其他成员 + List members = groupMemberRepository.findByGroupId(groupId); + for (GroupMember member : members) { + if ("PENDING".equals(member.getRole())) continue; + boolean isSender = senderId.equals(member.getUserId()); + updateConversationLastMessage( + member.getUserId(), groupId, "GROUP", content, messageType, null, !isSender, isSender); + if (!isSender) { + try { + pushService.pushGroupChatMessage( + member.getUserId(), senderId, pushNickname, groupId, content); + } catch (Exception ignored) { + // 单个成员推送失败不影响其他成员 + } + } + } + } + + return response; + } + + // ===================== 聊天记录 ===================== + + public PageResult getPrivateChatHistory(String peerId, Integer page, Integer size) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + + String conversationId = buildPrivateConversationId(userId, peerId); + long clearedSeqId = resolveClearedSeqId(userId, conversationId); + Pageable pageable = PageRequest.of(page != null && page > 0 ? page - 1 : 0, + size != null && size > 0 ? size : 20, historySort()); + + Page pageResult = chatMessageRepository + .findByConversationIdAndSeqIdGreaterThan(conversationId, clearedSeqId, pageable); + Page dtoPage = pageResult.map(this::toDTO); + return PageResult.from(dtoPage); + } + + public PageResult searchPrivateMessages(String peerId, String keyword, int page, int size) { + String userId = UserContext.getCurrentUserId(); + if (keyword == null || keyword.trim().isEmpty()) throw new com.campus.common.BusinessException("搜索关键词不能为空"); + int safePage = Math.max(1, page); + int safeSize = Math.min(Math.max(1, size), 100); + String conversationId = buildPrivateConversationId(userId, peerId); + long clearedSeqId = resolveClearedSeqId(userId, conversationId); + Page data = chatMessageRepository.search(conversationId, + keyword.trim(), clearedSeqId, PageRequest.of(safePage - 1, safeSize)); + return PageResult.from(data.map(this::toDTO)); + } + + public byte[] exportPrivateMessages(String peerId) { + String userId = UserContext.getCurrentUserId(); + String conversationId = buildPrivateConversationId(userId, peerId); + long clearedSeqId = resolveClearedSeqId(userId, conversationId); + List messages = chatMessageRepository + .findByConversationIdAndSeqIdGreaterThanOrderByCreatedAtDesc( + conversationId, clearedSeqId, PageRequest.of(0, 10_000)); + StringBuilder csv = new StringBuilder("messageId,senderId,messageType,content,createdAt\r\n"); + for (ChatMessage message : messages) { + csv.append(csv(message.getMessageId())).append(',').append(csv(message.getSenderId())).append(',') + .append(csv(message.getMessageType())).append(',').append(csv(message.getContent())).append(',') + .append(csv(message.getCreatedAt())).append("\r\n"); + } + return csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + @Transactional + public com.campus.entity.ChatReport reportPrivateMessage(String messageId, String reason) { + String userId = UserContext.getCurrentUserId(); + ChatMessage message = chatMessageRepository.findByMessageId(messageId) + .orElseThrow(() -> new com.campus.common.BusinessException("消息不存在")); + if (userId.equals(message.getSenderId())) throw new com.campus.common.BusinessException("不能举报自己发送的消息"); + if (!message.getConversationId().contains(userId)) throw new com.campus.common.ForbiddenException("无权举报该消息"); + if (chatReportRepository.existsByReporterIdAndMessageId(userId, messageId)) { + throw new com.campus.common.BusinessException("该消息已举报"); + } + com.campus.entity.ChatReport report = new com.campus.entity.ChatReport(); + report.setReporterId(userId); + report.setMessageId(messageId); + report.setReportedUserId(message.getSenderId()); + report.setReason(reason); + return chatReportRepository.save(report); + } + + @Transactional(readOnly = true) + public com.campus.common.PageResult pendingChatReports(int page, int size) { + authorizationService.requireReviewerOrAdmin(); + return com.campus.common.PageResult.from(chatReportRepository.findByStatusOrderByCreatedAtAsc( + "PENDING", PageRequest.of(Math.max(1, page) - 1, Math.min(Math.max(1, size), 100)))); + } + + @Transactional + public com.campus.entity.ChatReport reviewChatReport(String id, boolean accepted, String note) { + authorizationService.requireReviewerOrAdmin(); + com.campus.entity.ChatReport report = chatReportRepository.findById(id) + .orElseThrow(() -> new com.campus.common.BusinessException("私信举报不存在")); + if (!"PENDING".equals(report.getStatus())) throw new com.campus.common.BusinessException("举报已处理"); + report.setStatus(accepted ? "ACCEPTED" : "REJECTED"); + report.setReviewedBy(UserContext.getCurrentUserId()); + report.setReviewNote(note); + report.setReviewedAt(new java.util.Date()); + report = chatReportRepository.save(report); + auditLogService.record("CHAT_REPORT_REVIEW", "CHAT_REPORT", id, "status=PENDING", + "status=" + report.getStatus(), note); + return report; + } + + private String csv(Object value) { + if (value == null) return ""; + return "\"" + value.toString().replace("\"", "\"\"") + "\""; + } + + public PageResult getGroupChatHistory(String groupId, Integer page, Integer size) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + + groupRepository.findById(groupId) + .orElseThrow(() -> new RuntimeException("群组不存在")); + + GroupMember member = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .orElseThrow(() -> new RuntimeException("不在群内")); + if ("PENDING".equals(member.getRole())) { + throw new RuntimeException("入群申请尚未通过"); + } + + long clearedSeqId = resolveClearedSeqId(userId, groupId); + Pageable pageable = PageRequest.of(page != null && page > 0 ? page - 1 : 0, + size != null && size > 0 ? size : 20, historySort()); + + Page pageResult = chatMessageRepository + .findByConversationIdAndSeqIdGreaterThan(groupId, clearedSeqId, pageable); + Page dtoPage = pageResult.map(this::toDTO); + PageResult result = PageResult.from(dtoPage); + populateGroupSenderNicknames(result.getItems(), Collections.singleton(groupId)); + return result; + } + + // ===================== 增量同步(核心新增) ===================== + + /** + * 按会话增量同步:拉取指定会话中 seqId > sinceSeqId 的所有新消息 + */ + public List syncMessages(String peerId, Long sinceSeqId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + + String conversationId = buildPrivateConversationId(userId, peerId); + // 水位线兜底:用户清空后若前端仍以旧的 lastSeqId(或 0)请求增量, + // 必须把 since 抬到水位线,否则会把已清空的旧消息原样拉回来。 + long clearedSeqId = resolveClearedSeqId(userId, conversationId); + long since = Math.max(sinceSeqId != null ? sinceSeqId : 0L, clearedSeqId); + + List newMessages = chatMessageRepository + .findByConversationIdAndSeqIdGreaterThanOrderBySeqIdAsc(conversationId, since); + return newMessages.stream().map(m -> { + ChatMessageDTO dto = toDTO(m); + dto.setPeerId(peerId); + dto.setConversationType("PRIVATE"); + return dto; + }).collect(Collectors.toList()); + } + + /** + * 全局增量同步:拉取所有会话中 seqId > sinceSeqId 的新消息 + * 前端用于断线重连后补拉所有未收到的消息 + */ + public List syncAllMessages(Long sinceSeqId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + + Long since = sinceSeqId != null ? sinceSeqId : 0L; + List newMessages = chatMessageRepository + .findBySeqIdGreaterThanOrderBySeqIdAsc(since); + + // 当前用户所在群组的ID集合,用于判断群聊消息是否与当前用户相关 + Set myGroupIds = groupMemberRepository.findByUserId(userId) + .stream() + .filter(member -> !"PENDING".equals(member.getRole())) + .map(GroupMember::getGroupId) + .collect(Collectors.toSet()); + + // 各会话的清空水位线:全局查询会带回其他用户的消息,因此不能用单一的 since 兜底, + // 必须按「每个会话各自的水位线」过滤(会话 peer_id 与消息 conversation_id 口径一致)。 + Map clearedSeqIds = chatConversationRepository.findByUserId(userId).stream() + .collect(Collectors.toMap(ChatConversation::getPeerId, + c -> c.getClearedSeqId() == null ? 0L : c.getClearedSeqId(), + (a, b) -> a)); + + // 只返回当前用户相关的消息(发送、私聊接收、所在群聊的群消息) + List result = newMessages.stream() + .filter(m -> m.getSeqId() > clearedSeqIds.getOrDefault(m.getConversationId(), 0L)) + .filter(m -> { + String convId = m.getConversationId(); + // 私聊消息:conversationId 为 userId1_userId2,包含当前用户ID + // 群聊消息:conversationId 为群ID,判断当前用户是否在该群内 + return m.getSenderId().equals(userId) + || convId != null && (convId.contains(userId) || myGroupIds.contains(convId)); + }) + .map(m -> { + ChatMessageDTO dto = toDTO(m); + if (myGroupIds.contains(m.getConversationId())) { + dto.setPeerId(m.getConversationId()); + dto.setConversationType("GROUP"); + dto.setConversationId(m.getConversationId()); + } else { + String peerId = resolvePrivatePeerId(m.getConversationId(), userId); + dto.setPeerId(peerId); + dto.setConversationType("PRIVATE"); + // The mobile cache is keyed by peerId for private chats. + dto.setConversationId(peerId); + } + return dto; + }) + .collect(Collectors.toList()); + populateGroupSenderNicknames(result, myGroupIds); + return result; + } + + private void populateGroupSenderNicknames(List messages, Set groupIds) { + Set relevantGroups = messages.stream() + .map(ChatMessageDTO::getConversationId) + .filter(groupIds::contains) + .collect(Collectors.toSet()); + if (relevantGroups.isEmpty()) return; + + Map groupNicknames = new HashMap<>(); + for (GroupMember member : groupMemberRepository.findByGroupIdIn(relevantGroups)) { + if (!"PENDING".equals(member.getRole()) && StringUtils.hasText(member.getNickname())) { + groupNicknames.put(member.getGroupId() + ":" + member.getUserId(), member.getNickname().trim()); + } + } + + Set fallbackUserIds = messages.stream() + .filter(message -> relevantGroups.contains(message.getConversationId())) + .filter(message -> !groupNicknames.containsKey(message.getConversationId() + ":" + message.getSenderId())) + .map(ChatMessageDTO::getSenderId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map userNicknames = new HashMap<>(); + if (!fallbackUserIds.isEmpty()) { + for (User user : userRepository.findAllById(fallbackUserIds)) { + if (StringUtils.hasText(user.getNickname())) { + userNicknames.put(user.getId(), user.getNickname().trim()); + } + } + } + for (ChatMessageDTO message : messages) { + if (relevantGroups.contains(message.getConversationId())) { + String nickname = groupNicknames.get(message.getConversationId() + ":" + message.getSenderId()); + message.setSenderGroupNickname(nickname != null ? nickname : userNicknames.get(message.getSenderId())); + } + } + } + + /** + * 获取全局最大 seqId,前端记录此值用于下次增量同步 + */ + public Long getMaxSeqId() { + return chatMessageRepository.findMaxSeqId(); + } + + /** Resolve the other participant for a private conversation ID. */ + private String resolvePrivatePeerId(String conversationId, String currentUserId) { + if (conversationId == null || currentUserId == null) { + return conversationId; + } + String[] parts = conversationId.split("_"); + if (parts.length == 2) { + return currentUserId.equals(parts[0]) ? parts[1] : parts[0]; + } + return conversationId; + } + // ===================== 已读标记 ===================== + + /** + * 标记会话已读。 + *

+ * 入参 conversationId 对私聊是「对方用户ID」,对群聊是「群ID」——这与 + * chat_conversation.peer_id 的存储口径一致(私聊存 userId1_userId2,群聊存群ID)。 + * 历史上这里对所有会话都套用私聊拼接规则,导致群聊的未读数永远不会归零。 + */ + @Transactional + public void markMessagesAsRead(String conversationId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new com.campus.common.BusinessException("用户未登录"); + } + if (conversationId == null || conversationId.trim().isEmpty()) { + throw new com.campus.common.BusinessException("会话ID不能为空"); + } + + String peerId = resolveStoredPeerId(userId, conversationId.trim()); + if (peerId == null) { + throw unresolvedConversation(conversationId.trim()); + } + + List unreadMessages = chatMessageRepository + .findByConversationIdAndIsReadFalse(peerId); + + Date now = new Date(); + for (ChatMessage message : unreadMessages) { + message.setIsRead(true); + message.setReadAt(now); + } + if (!unreadMessages.isEmpty()) { + chatMessageRepository.saveAll(unreadMessages); + } + + chatConversationRepository.findByUserIdAndPeerId(userId, peerId) + .ifPresent(conversation -> { + conversation.setUnreadCount(0); + chatConversationRepository.save(conversation); + }); + } + + /** + * 把前端传入的会话标识翻译成 chat_conversation.peer_id: + * 群聊(存在该群且当前用户是未待审成员)→ 原样返回群ID; + * 群存在但当前用户是 PENDING / 非成员 → 返回 null(由调用方拒绝,无群内权限); + * 私聊对方用户不存在(含已解散群ID)→ 返回 null(由调用方按私聊文案拒绝); + * 其余按私聊规则拼接 conversationId。 + */ + private String resolveStoredPeerId(String userId, String conversationId) { + if (groupRepository.existsById(conversationId)) { + return groupMemberRepository + .findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(conversationId, userId) + .map(member -> "PENDING".equals(member.getRole()) ? null : conversationId) + .orElse(null); + } + // 私聊:对方用户不存在时视为会话不存在,不能退化成拼接一个假会话ID静默成功 + if (!userRepository.existsById(conversationId)) { + return null; + } + return buildPrivateConversationId(userId, conversationId); + } + + /** + * resolveStoredPeerId 返回 null 时按会话类型给出准确文案: + * 群聊存在但无权限 → 「入群申请尚未通过」;会话/对方用户不存在 → 「会话不存在」。 + */ + private BusinessException unresolvedConversation(String conversationId) { + return groupRepository.existsById(conversationId) + ? new BusinessException("入群申请尚未通过") + : new BusinessException("会话不存在"); + } + + @Transactional + public void markAllAsRead() { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new com.campus.common.BusinessException("用户未登录"); + } + + // 计数器与消息明细同事务一起清,避免 chat_conversation.unread_count 与逐条已读状态长期不一致 + List conversations = chatConversationRepository.findByUserId(userId); + for (ChatConversation conversation : conversations) { + conversation.setUnreadCount(0); + chatMessageRepository.markAsReadByConversationIdAndUserId(conversation.getPeerId(), userId); + } + chatConversationRepository.saveAll(conversations); + } + + // ===================== 会话管理 ===================== + + public List getConversations() { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + + List conversations = chatConversationRepository + .findByUserIdOrderByLastMessageTimeDesc(userId); + + conversations.sort(Comparator + .comparing((ChatConversation c) -> Boolean.TRUE.equals(c.getIsPinned())).reversed() + .thenComparing(ChatConversation::getLastMessageTime, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 私聊会话的 peerId 实际存储的是 conversationId(格式 userId1_userId2), + // 前端需要的是真正的对方用户ID,在此反解替换 + for (ChatConversation conv : conversations) { + if ("PRIVATE".equals(conv.getConversationType())) { + String conversationId = conv.getPeerId(); + int idx = conversationId.indexOf('_'); + if (idx > 0) { + String id1 = conversationId.substring(0, idx); + int secondStart = idx + 1; + String id2 = secondStart < conversationId.length() + ? conversationId.substring(secondStart) : ""; + String actualPeerId = id1.equals(userId) ? id2 : id1; + if (!actualPeerId.isEmpty()) { + conv.setPeerId(actualPeerId); + } + } + } + } + + // 批量加载私聊对方的用户信息,填充昵称和头像 + List peerIds = conversations.stream() + .filter(c -> "PRIVATE".equals(c.getConversationType())) + .map(ChatConversation::getPeerId) + .distinct() + .collect(Collectors.toList()); + if (!peerIds.isEmpty()) { + Map userMap = userRepository.findAllById(peerIds) + .stream() + .collect(Collectors.toMap(User::getId, u -> u)); + for (ChatConversation conv : conversations) { + if ("PRIVATE".equals(conv.getConversationType())) { + User peerUser = userMap.get(conv.getPeerId()); + if (peerUser != null) { + conv.setPeerName(peerUser.getNickname()); + conv.setPeerAvatar(peerUser.getAvatar()); + } + } + } + } + + // 批量加载群聊的群名称 + List groupIds = conversations.stream() + .filter(c -> "GROUP".equals(c.getConversationType())) + .map(ChatConversation::getPeerId) + .distinct() + .collect(Collectors.toList()); + if (!groupIds.isEmpty()) { + Map groupMap = groupRepository.findAllById(groupIds) + .stream() + .collect(Collectors.toMap(Group::getId, g -> g)); + for (ChatConversation conv : conversations) { + if ("GROUP".equals(conv.getConversationType())) { + Group group = groupMap.get(conv.getPeerId()); + if (group != null) { + conv.setPeerName(group.getName()); + } + } + } + } + + return conversations; + } + + /** + * 更新当前用户自己的会话设置。私聊数据库中以 conversationId 保存 peerId, + * 这里统一接收真正的对方用户 ID,避免前端感知内部存储格式。 + */ + @Transactional + public ChatConversation updateConversationSettings(ConversationSettingsRequest request) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + if (request == null || request.getPeerId() == null || request.getPeerId().trim().isEmpty()) { + throw new RuntimeException("会话对象不能为空"); + } + String type = request.getConversationType() == null + ? "PRIVATE" : request.getConversationType().trim().toUpperCase(); + if (!("PRIVATE".equals(type) || "GROUP".equals(type))) { + throw new RuntimeException("conversationType 必须是 PRIVATE 或 GROUP"); + } + + String storedPeerId = "PRIVATE".equals(type) + ? buildPrivateConversationId(userId, request.getPeerId()) + : request.getPeerId(); + ChatConversation conversation = chatConversationRepository.findByUserIdAndPeerId(userId, storedPeerId) + .orElseThrow(() -> new RuntimeException("会话不存在")); + if (request.getPinned() != null) { + conversation.setIsPinned(request.getPinned()); + } + if (request.getMuted() != null) { + conversation.setIsMuted(request.getMuted()); + } + conversation = chatConversationRepository.save(conversation); + ChatConversation response = copyConversation(conversation); + if ("PRIVATE".equals(type)) { + response.setPeerId(request.getPeerId()); + } + return response; + } + + private ChatConversation copyConversation(ChatConversation source) { + ChatConversation target = new ChatConversation(); + target.setId(source.getId()); + target.setDeleted(source.getDeleted()); + target.setCreatedAt(source.getCreatedAt()); + target.setUpdatedAt(source.getUpdatedAt()); + target.setPeerId(source.getPeerId()); + target.setUserId(source.getUserId()); + target.setConversationType(source.getConversationType()); + target.setGroupName(source.getGroupName()); + target.setLastMessage(source.getLastMessage()); + target.setLastMessageType(source.getLastMessageType()); + target.setLastMessageDuration(source.getLastMessageDuration()); + target.setLastMessageTime(source.getLastMessageTime()); + target.setUnreadCount(source.getUnreadCount()); + target.setIsPinned(source.getIsPinned()); + target.setIsMuted(source.getIsMuted()); + target.setClearedSeqId(source.getClearedSeqId()); + target.setPeerName(source.getPeerName()); + target.setPeerAvatar(source.getPeerAvatar()); + return target; + } + + @Transactional + public void deleteConversation(String peerId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + + // 私聊:peerId 为对方用户ID,需反解为 conversationId(格式 userId1_userId2) + String conversationId = buildPrivateConversationId(userId, peerId); + chatConversationRepository.deleteByUserIdAndPeerId(userId, conversationId); + + // 群聊/活动:peerId 即为群ID,直接按 peerId 删除 + chatConversationRepository.deleteByUserIdAndPeerId(userId, peerId); + } + + // ===================== 消息删除 ===================== + + @Transactional + public void deleteMessage(String messageId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + + ChatMessage message = chatMessageRepository.findByMessageId(messageId) + .orElseThrow(() -> new RuntimeException("消息不存在")); + + if (!userId.equals(message.getSenderId())) { + throw new RuntimeException("无权删除该消息"); + } + + chatMessageRepository.delete(message); + } + + /** + * 清空当前用户在该会话中的聊天记录。 + * + *

只把「自己那一行」chat_conversation 的 cleared_seq_id 抬到当前最大 seq_id, + * 绝不删除共享的 chat_message —— 私聊的 conversation_id 是双方共用的 id1_id2、 + * 群聊是群ID,一份记录服务所有参与者,删除它会让别人也丢失记录。抬高水位线只影响本人。 + * + *

入参口径与 markMessagesAsRead 一致:群聊传群ID、私聊传对方用户ID。 + */ + @Transactional + public void clearConversationMessages(String conversationId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new BusinessException("用户未登录"); + } + if (conversationId == null || conversationId.trim().isEmpty()) { + throw new BusinessException("会话ID不能为空"); + } + + // ① 权限校验 + 入参口径统一:群聊要求「群存在 + 本人是成员 + 非 PENDING」, + // 私聊反解为内部 conversationId(id1_id2);PENDING/非成员返回 null。 + String storedPeerId = resolveStoredPeerId(userId, conversationId.trim()); + if (storedPeerId == null) { + throw unresolvedConversation(conversationId.trim()); + } + + // ② 定格水位线:当前最大 seq_id。此后新消息 seq_id 更大,仍然可见。 + Long maxSeqId = chatMessageRepository.findMaxSeqIdByConversationId(storedPeerId); + long clearedSeqId = maxSeqId == null ? 0L : maxSeqId; + + // ③ 只写自己那一行,不触碰 chat_message + Optional existing = + chatConversationRepository.findByUserIdAndPeerId(userId, storedPeerId); + if (!existing.isPresent()) { + if (clearedSeqId == 0L) { + // 本来就没有消息:幂等返回,避免产生「幽灵空会话」占满会话列表 + return; + } + // 兜底:有消息却没有会话行(历史脏数据),补建一行 + ChatConversation created = new ChatConversation(); + created.setUserId(userId); + created.setPeerId(storedPeerId); + created.setConversationType(groupRepository.existsById(storedPeerId) ? "GROUP" : "PRIVATE"); + created.setUnreadCount(0); + created.setClearedSeqId(clearedSeqId); + chatConversationRepository.save(created); + return; + } + + ChatConversation conversation = existing.get(); + conversation.setClearedSeqId(clearedSeqId); + conversation.setUnreadCount(0); // 同时解决「红点还在但没有消息可读」 + conversation.setLastMessage(null); // 会话列表不再显示已清空的旧摘要 + conversation.setLastMessageType(null); + conversation.setLastMessageDuration(null); + chatConversationRepository.save(conversation); + } + + // ===================== 工具方法 ===================== + + /** + * 取该用户在该会话的清空水位线;会话行不存在或字段为 NULL 时一律按 0(未清空)处理。 + * 会话的 peer_id 与 chat_message.conversation_id 口径一致(私聊 id1_id2、群聊 群ID), + * 因此可以直接用 peer_id 关联消息。 + */ + private long resolveClearedSeqId(String userId, String peerId) { + return chatConversationRepository.findByUserIdAndPeerId(userId, peerId) + .map(ChatConversation::getClearedSeqId) + .orElse(0L); + } + + /** + * 历史消息排序:created_at 精度有限、消息密集发送时可能并列,只按 createdAt 排序会使 + * 并列行的顺序不确定,分页时出现同一条消息跨页重复或漏读。补 seqId(自增主键, + * 唯一且与时间同序)作次级排序键。 + */ + private Sort historySort() { + return Sort.by(Sort.Direction.ASC, "createdAt").and(Sort.by(Sort.Direction.ASC, "seqId")); + } + + private String buildPrivateConversationId(String userId1, String userId2) { + if (userId1.compareTo(userId2) < 0) { + return userId1 + "_" + userId2; + } + return userId2 + "_" + userId1; + } + + @Transactional + public void updateConversationLastMessage(String userId, String peerId, String conversationType, String content) { + updateConversationLastMessage(userId, peerId, conversationType, content, null, null, false); + } + + @Transactional + public void updateConversationLastMessage(String userId, String peerId, String conversationType, String content, String messageType, Integer duration) { + updateConversationLastMessage(userId, peerId, conversationType, content, messageType, duration, false); + } + + @Transactional + public void updateConversationLastMessage(String userId, String peerId, String conversationType, String content, String messageType, Integer duration, boolean incrementUnread) { + updateConversationLastMessage(userId, peerId, conversationType, content, messageType, duration, incrementUnread, false); + } + + /** + * @param incrementUnread 收到别人消息时未读数 +1 + * @param resetUnread 自己在会话里发消息时把未读数归零(只累加不清零会让红点永远留在发送方) + */ + @Transactional + public void updateConversationLastMessage(String userId, String peerId, String conversationType, String content, String messageType, Integer duration, boolean incrementUnread, boolean resetUnread) { + ChatConversation conversation = chatConversationRepository + .findByUserIdAndPeerId(userId, peerId) + .orElseGet(() -> { + ChatConversation newConv = new ChatConversation(); + newConv.setUserId(userId); + newConv.setPeerId(peerId); + newConv.setConversationType(conversationType); + return newConv; + }); + + String displayContent = content; + if ("VOICE".equalsIgnoreCase(messageType)) { + displayContent = "[语音]"; + } else if ("IMAGE".equalsIgnoreCase(messageType)) { + displayContent = "[图片]"; + } else if ("VIDEO".equalsIgnoreCase(messageType)) { + displayContent = "[视频]"; + } + + conversation.setLastMessage(displayContent); + conversation.setLastMessageType(messageType); + conversation.setLastMessageDuration(duration); + conversation.setLastMessageTime(new Date()); + if (resetUnread) { + conversation.setUnreadCount(0); + } else if (incrementUnread) { + conversation.setUnreadCount(conversation.getUnreadCount() != null ? conversation.getUnreadCount() + 1 : 1); + } + chatConversationRepository.save(conversation); + } +} diff --git a/campus-server/src/main/java/com/campus/service/ChsiOnlineVerifier.java b/campus-server/src/main/java/com/campus/service/ChsiOnlineVerifier.java new file mode 100644 index 0000000..5247c64 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ChsiOnlineVerifier.java @@ -0,0 +1,678 @@ +package com.campus.service; + +import com.campus.dto.response.ChsiReportDTO; +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.Proxy; +import java.net.URLEncoder; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +/** + * 学信网「在线查验」验证器 + * + * 流程: + * 1. 从图片二维码解码出 vcode(例如 ABJ7WGJACQ6SFTMX 或 #ABJ7WGJACQ6SFTMX#) + * 或完整 URL https://www.chsi.com.cn/xlcx/bg.do?vcode=XXX&srcid=bgcx + * 2. 用 vcode 拼装查验 URL 请求 https://www.chsi.com.cn/xlcx/bg.do?vcode=XXX&srcid=bgcx + * 3. 解析返回 HTML: + * - 页面包含"教育部学籍在线验证报告"/"教育部学历证书电子注册备案表"标题 → 真报告 + * - 页面显示"报告不存在/已失效" → 假报告/过期 + * 4. 用 Jsoup 从表格里提取 姓名/学校/层次/专业/入学日期/学籍状态 等字段 + * + * 字段来源比阿里云 OCR 更准确(官方原始 HTML) + */ +@Slf4j +@Deprecated +public class ChsiOnlineVerifier { + + /** 学信网学籍在线验证页面 */ + private static final String CHSI_BASE_URL = "https://www.chsi.com.cn/xlcx/bg.do"; + /** 学信网首页(用于走初始 Cookie + Referer) */ + private static final String CHSI_HOME = "https://www.chsi.com.cn/"; + private static final Pattern VCODE_PATTERN = Pattern.compile("vcode=([A-Za-z0-9.\\-_]{6,40})"); + /** 也支持单独的 12-20 位大写字母+数字+点 形式的验证码(扫文字内容 #ABJ7WGJACQ6SFTMX#) */ + private static final Pattern RAW_VCODE_PATTERN = Pattern.compile("[A-Z0-9.]{10,24}"); + + /** 请求超时(毫秒) */ + private static final int TIMEOUT_MS = 12_000; + + /** + * 浏览器 UA(学信网 412 基本都是 UA/请求头太简单被挡,用完整桌面 Chrome 头) + */ + private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + private static final String ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"; + private static final String ACCEPT_LANG = "zh-CN,zh;q=0.9"; + + /** 简单内存缓存的初始 Cookie(不用每次都重新 GET 首页拿) */ + private static volatile String cachedInitCookie; + private static volatile long cachedInitCookieAt; + private static final long COOKIE_TTL_MS = 25 * 60_000L; + + /** Chrome 126 发送的 cipher suites 优先顺序(只保留 JDK8 支持的),用来伪装 TLS ClientHello 指纹 */ + private static final String[] CHROME_CIPHERS_ORDERED = new String[] { + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", + "TLS_RSA_WITH_AES_128_GCM_SHA256", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_CBC_SHA", + "TLS_RSA_WITH_AES_256_CBC_SHA" + }; + + /** 自定义 SSLSocketFactory:强制 TLSv1.2、按 Chrome 顺序启用 cipher suites、启用 SNI */ + private static final SSLSocketFactory CHROME_STYLE_SSL_FACTORY; + static { + SSLSocketFactory f; + try { + SSLContext ctx = SSLContext.getInstance("TLSv1.2"); + ctx.init(null, null, new SecureRandom()); + final SSLSocketFactory delegate = ctx.getSocketFactory(); + final List available = Arrays.asList(delegate.getSupportedCipherSuites()); + final List chosen = new ArrayList<>(); + for (String c : CHROME_CIPHERS_ORDERED) { + if (available.contains(c)) chosen.add(c); + } + // 再加 JDK 默认剩下的(避免缺套件握手失败) + for (String c : delegate.getDefaultCipherSuites()) { + if (!chosen.contains(c)) chosen.add(c); + } + final String[] chosenArr = chosen.toArray(new String[0]); + f = new SSLSocketFactory() { + @Override public String[] getDefaultCipherSuites() { return chosenArr; } + @Override public String[] getSupportedCipherSuites() { return chosenArr; } + + private javax.net.ssl.SSLSocket tune(javax.net.ssl.SSLSocket s) { + try { + s.setEnabledProtocols(new String[]{"TLSv1.2"}); + s.setEnabledCipherSuites(chosenArr); + // 伪装扩展:开启 server_name(通常底层自带)、status_request、signed_cert_timestamp、renegotiation_info + try { s.setUseClientMode(true); } catch (Exception ignored) {} + } catch (Exception ignored) {} + return s; + } + @Override public java.net.Socket createSocket() throws IOException { return tune((javax.net.ssl.SSLSocket) delegate.createSocket()); } + @Override public java.net.Socket createSocket(java.net.Socket s, String host, int port, boolean autoClose) throws IOException { return tune((javax.net.ssl.SSLSocket) delegate.createSocket(s, host, port, autoClose)); } + @Override public java.net.Socket createSocket(String host, int port) throws IOException { return tune((javax.net.ssl.SSLSocket) delegate.createSocket(host, port)); } + @Override public java.net.Socket createSocket(String host, int port, java.net.InetAddress localAddr, int localPort) throws IOException { return tune((javax.net.ssl.SSLSocket) delegate.createSocket(host, port, localAddr, localPort)); } + @Override public java.net.Socket createSocket(java.net.InetAddress host, int port) throws IOException { return tune((javax.net.ssl.SSLSocket) delegate.createSocket(host, port)); } + @Override public java.net.Socket createSocket(java.net.InetAddress address, int port, java.net.InetAddress localAddress, int localPort) throws IOException { return tune((javax.net.ssl.SSLSocket) delegate.createSocket(address, port, localAddress, localPort)); } + }; + } catch (Exception e) { + f = HttpsURLConnection.getDefaultSSLSocketFactory(); + log.warn("构造 Chrome-style SSLSocketFactory 失败,回退 JDK 默认,可能会 412", e); + } + CHROME_STYLE_SSL_FACTORY = f; + } + + /** + * 从二维码解码文本里提取 vcode + * + * 支持多种输入: + * 1. https://www.chsi.com.cn/xlcx/bg.do?vcode=ABJ7WGJACQ6SFTMX&srcid=bgcx + * 2. vcode=ABJ7WGJACQ6SFTMX + * 3. #ABJ7WGJACQ6SFTMX# + * 4. ABJ7WG.JACQ6SFTMX (纯字符串) + * + * @return 提取出的 vcode;无法识别返回 null + */ + public String extractVcode(String qrText) { + if (qrText == null) return null; + qrText = qrText.trim(); + if (qrText.isEmpty()) return null; + + // 1. URL / vcode= 形式 + Matcher m = VCODE_PATTERN.matcher(qrText); + if (m.find()) { + String v = m.group(1); + log.debug("从二维码提取 vcode(URL形式): {}", v); + return v; + } + + // 2. 纯 vcode(去掉 # / 空格后再试) + String cleaned = qrText.replaceAll("[#\\s]", ""); + m = RAW_VCODE_PATTERN.matcher(cleaned); + if (m.find()) { + String v = m.group(0); + // 必须是典型校验码长度 10~24,排除过短 + if (v.length() >= 10) { + log.debug("从二维码提取 vcode(纯码形式): {}", v); + return v; + } + } + return null; + } + + /** + * 用 vcode 去学信网在线查验,并解析返回的报告内容 + * + * @param vcode 从二维码提取出的校验码 + * @return 永远返回非 null 的 DTO;通过 {@link ChsiReportDTO#isSuccess()} 判定是否真的识别成功 + */ + public ChsiReportDTO verify(String vcode) { + if (vcode == null || vcode.isEmpty()) { + return fail("在线验证码为空"); + } + + String url; + try { + url = CHSI_BASE_URL + "?vcode=" + URLEncoder.encode(vcode, "UTF-8") + "&srcid=bgcx"; + } catch (Exception e) { + return fail("构造请求URL失败"); + } + + log.info("学信网在线查验: vcode={}", vcode); + + // 1) 先拿初始 Cookie(走学信网首页预热),避免学信网 412 Precondition Failed + String cookie = getInitCookie(false); + + // 2) 请求 bg.do,最多重试 1 次:如果 412/403/429/网络异常 → 强制刷新 Cookie 再试 + RawHttpResult http = null; + int lastStatus = 0; + String lastErr = null; + for (int attempt = 0; attempt < 2; attempt++) { + try { + http = httpGetRaw(url, cookie, CHSI_HOME); + lastStatus = http.status; + // 合并 Set-Cookie + String merged = mergeCookies(cookie, http.setCookieHeader); + if (merged != null) cookie = merged; + + if (lastStatus >= 200 && lastStatus < 400) { + break; + } + if ((lastStatus == 412 || lastStatus == 403 || lastStatus == 429) && attempt == 0) { + log.warn("学信网在线查验 status={},刷新 Cookie 后重试: vcode={}", lastStatus, vcode); + cookie = getInitCookie(true); + continue; + } + lastErr = "HTTP " + lastStatus; + break; + } catch (IOException e) { + lastErr = e.getMessage(); + if (attempt == 0) { + log.debug("学信网在线查验网络异常,刷新 Cookie 后重试: vcode={}, err={}", vcode, lastErr); + cookie = getInitCookie(true); + continue; + } + log.warn("学信网在线查验网络失败: vcode={}, err={}", vcode, lastErr); + break; + } + } + + Document doc = null; + if (http != null && http.bodyBytes != null && http.bodyBytes.length > 0) { + String charset = http.charset != null ? http.charset : "UTF-8"; + String html; + try { html = new String(http.bodyBytes, charset); } + catch (Exception e) { html = new String(http.bodyBytes, java.nio.charset.StandardCharsets.UTF_8); } + doc = Jsoup.parse(html, CHSI_BASE_URL); + } + + if (doc == null) { + if (lastStatus == 412) { + return fail("学信网临时拒绝了本次查验(412),请稍后重试或改为拍照上传"); + } + if (lastStatus == 403) { + return fail("学信网拒绝访问(403),请稍后重试或改为拍照上传"); + } + return fail("学信网在线查验失败,请稍后重试或改为拍照上传"); + } + + String htmlTitle = doc.title(); + String bodyText = doc.body() == null ? "" : doc.body().text(); + + // 1. 失效/不存在的典型提示(放在前面,命中直接返回错误,不伪装成成功) + boolean invalidReport = containsAny(bodyText, + "报告不存在", + "报告已过期", + "报告失效", + "没有找到相关报告", + "验证码错误", + "校验码错误", + "在线验证码错误", + "未查询到相关报告" + ); + if (invalidReport) { + log.warn("学信网在线查验: 报告不存在/已失效。title={}, vcode={}", htmlTitle, vcode); + return fail("未查询到有效学信网报告,报告可能已失效或验证码错误"); + } + + // 2. 校验:必须是合法的学信网报告页面 + boolean validReportPage = containsAny(bodyText, + "教育部学籍在线验证报告", + "教育部学历证书电子注册备案表", + "教育部学历认证报告", + "学籍在线验证报告", + "学历证书电子注册备案表" + ); + if (!validReportPage) { + log.warn("学信网在线查验: 页面不是合法报告页。title={}, body(前200)= {}", + htmlTitle, bodyText.length() > 200 ? bodyText.substring(0, 200) : bodyText); + return fail("未识别到有效的学信网报告内容,请确认二维码正确"); + } + + // 3. 从 HTML 表格解析字段 + Map kv = parseKeyValues(doc); + log.debug("学信网在线查验解析到 key/value 对: {}", kv); + + ChsiReportDTO result = new ChsiReportDTO(); + result.setName(pick(kv, "姓名")); + result.setGender(pick(kv, "性别")); + result.setBirthDate(formatYM(pick(kv, "出生日期", "出生年月"))); + result.setSchool(pick(kv, "学校名称", "学校", "院校", "培养单位", "毕业院校")); + result.setLevel(pick(kv, "层次", "学历", "学历层次")); + result.setDepartment(pick(kv, "系所", "分院", "院系", "学院", "系别", "所在学院")); + result.setMajor(pick(kv, "专业", "专业名称", "所学专业")); + result.setStudyDuration(withYear(pick(kv, "学制"))); + result.setEnrollmentDate(formatYM(pick(kv, "入学日期", "入学时间", "入学年月"))); + result.setDepartureDate(formatYMD(pick(kv, "离校日期"))); + result.setStatus(pick(kv, "学籍状态", "当前状态", "状态")); + result.setOnlineVerificationCode(pick(kv, "在线验证码")); + result.setEthnicity(pick(kv, "民族")); + result.setBranchCampus(pick(kv, "分院")); + result.setEducationCategory(pick(kv, "学历类别")); + result.setStudyForm(pick(kv, "学习形式")); + result.setValidChsiReport(true); + result.setSuccess(true); + + // 合法性最终校验:至少有姓名+学校 + if (result.getName() == null || result.getName().length() < 2 + || result.getSchool() == null || result.getSchool().length() < 4) { + log.warn("学信网在线查验通过但字段不全: name={}, school={}", result.getName(), result.getSchool()); + // 页面标题已通过,但没读到字段可能是页面改版 → 依然标记为 valid=true 只是字段少, + // 让用户在前端 Step2 补填 + result.setValidChsiReport(true); + result.setSuccess(true); + } + + return result; + } + + // ==================== 内部辅助 ==================== + + /** 统一构造失败 DTO */ + private static ChsiReportDTO fail(String msg) { + ChsiReportDTO d = new ChsiReportDTO(); + d.setSuccess(false); + d.setValidChsiReport(false); + d.setErrorMsg(msg); + return d; + } + + /** + * 预热初始 Cookie:GET 学信网首页,取到 Set-Cookie(__jsluid_h 等)。 + * 25 分钟内命中本地缓存,避免每次查验都 GET 首页。 + */ + private String getInitCookie(boolean forceRefresh) { + long now = System.currentTimeMillis(); + if (!forceRefresh && cachedInitCookie != null && (now - cachedInitCookieAt) < COOKIE_TTL_MS) { + return cachedInitCookie; + } + try { + RawHttpResult r = httpGetRaw(CHSI_HOME, "", null); + String cookie = r.setCookieHeader == null ? "" : r.setCookieHeader; + // 合并缓存上一次非空 Cookie(防止这次拿到空的,但之前有) + if (!forceRefresh && cachedInitCookie != null && cookie.isEmpty()) { + return cachedInitCookie; + } + cachedInitCookie = cookie; + cachedInitCookieAt = now; + log.debug("刷新学信网初始Cookie: {}", cookie.isEmpty() ? "" : "yes"); + return cookie; + } catch (IOException e) { + log.warn("学信网首页预热Cookie失败,直接用空Cookie请求: err={}", e.getMessage()); + if (cachedInitCookie != null) return cachedInitCookie; + return ""; + } + } + + // ==================== 原始 HTTP 请求层(完全可控 TLS/请求头/解压) ==================== + + /** httpGetRaw 的返回值:status + body 字节(不解压的原始字节由内部解压返回) + Set-Cookie 头 + charset */ + private static final class RawHttpResult { + int status; + byte[] bodyBytes; + String setCookieHeader; // 多个 Set-Cookie 合并成 "A=1; B=2" 形式 + String charset; + } + + /** + * 用 JDK HttpsURLConnection + 自定义 SSLSocketFactory(Chrome 风格 TLS 指纹)发起 HTTPS GET。 + * - 只接受 gzip/deflate(故意不发 br,JDK8 自带解压不了 Brotli) + * - 内部自动解压 gzip / deflate 响应体 + * - 自动跟随 3xx 重定向,并在重定向链中累积 Set-Cookie + * + * @return 非空 RawHttpResult + */ + private RawHttpResult httpGetRaw(String urlStr, String cookie, String referer) throws IOException { + RawHttpResult result = new RawHttpResult(); + URL u = new URL(urlStr); + String host = u.getHost(); + + // 最多跟随 6 次 3xx + int redirects = 0; + while (redirects < 6) { + HttpURLConnection conn = (HttpURLConnection) u.openConnection(Proxy.NO_PROXY); + if (conn instanceof HttpsURLConnection) { + HttpsURLConnection hs = (HttpsURLConnection) conn; + hs.setSSLSocketFactory(CHROME_STYLE_SSL_FACTORY); + hs.setHostnameVerifier((hostname, session) -> + HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session)); + } + conn.setConnectTimeout(TIMEOUT_MS); + conn.setReadTimeout(TIMEOUT_MS); + conn.setInstanceFollowRedirects(false); // 手动跟随,保证重定向时 Cookie/Referer 可控 + conn.setUseCaches(false); + conn.setDoInput(true); + conn.setDoOutput(false); + + conn.setRequestProperty("Host", host); + conn.setRequestProperty("User-Agent", UA); + conn.setRequestProperty("Accept", ACCEPT); + conn.setRequestProperty("Accept-Language", ACCEPT_LANG); + conn.setRequestProperty("Accept-Encoding", "gzip, deflate"); // JDK8 无原生 Brotli 解压,不发 br! + conn.setRequestProperty("Cache-Control", "no-cache"); + conn.setRequestProperty("Pragma", "no-cache"); + conn.setRequestProperty("Connection", "keep-alive"); + conn.setRequestProperty("Upgrade-Insecure-Requests", "1"); + conn.setRequestProperty("TE", "trailers"); + conn.setRequestProperty("Sec-Ch-Ua", "\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\""); + conn.setRequestProperty("Sec-Ch-Ua-Mobile", "?0"); + conn.setRequestProperty("Sec-Ch-Ua-Platform", "\"Windows\""); + conn.setRequestProperty("Sec-Fetch-Dest", "document"); + conn.setRequestProperty("Sec-Fetch-Mode", "navigate"); + conn.setRequestProperty("Sec-Fetch-Site", referer == null ? "none" : "same-origin"); + conn.setRequestProperty("Sec-Fetch-User", "?1"); + if (referer != null && !referer.isEmpty()) { + conn.setRequestProperty("Referer", referer); + } + if (cookie != null && !cookie.isEmpty()) { + conn.setRequestProperty("Cookie", cookie); + } + + int status; + try { + status = conn.getResponseCode(); + } catch (IOException ioEx) { + conn.disconnect(); + throw ioEx; + } + + // 1) 先把这条响应的 Set-Cookie 合并进累积 cookie + List scs = conn.getHeaderFields().get("Set-Cookie"); + if (scs != null && !scs.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (String s : scs) { + if (s == null) continue; + if (sb.length() > 0) sb.append(", "); + sb.append(s); + } + String merged = mergeCookies(cookie, sb.toString()); + if (merged != null) { + cookie = merged; + } + } + result.setCookieHeader = cookie; + + // 2) 3xx → 跟随 + if (status >= 300 && status < 400 && status != 304) { + String loc = conn.getHeaderField("Location"); + if (loc == null) break; + URL next = new URL(u, loc); + String nextHost = next.getHost(); + u = next; + host = nextHost; + redirects++; + referer = u.toString(); + // 跨 host 的话 cookie 就别带了(简化处理) + if (!host.equalsIgnoreCase(nextHost)) { + cookie = ""; + } + conn.disconnect(); + continue; + } + + result.status = status; + + // 3) 读 body(错误流也要读,不然 4xx/5xx 拿不到内容) + InputStream is = null; + try { + if (status >= 400) { + is = conn.getErrorStream(); + } + if (is == null) { + try { is = conn.getInputStream(); } catch (IOException ignored) {} + } + if (is != null) { + // 解压 gzip / deflate + String ce = conn.getHeaderField("Content-Encoding"); + if (ce != null) ce = ce.trim().toLowerCase(); + if ("gzip".equals(ce) || "x-gzip".equals(ce)) { + is = new GZIPInputStream(is); + } else if ("deflate".equals(ce)) { + is = new InflaterInputStream(is, new Inflater(true)); + } + // 探测 charset(Content-Type) + String ct = conn.getContentType(); + String charset = "UTF-8"; + if (ct != null) { + Matcher cm = Pattern.compile("charset=([^;\\s]+)", Pattern.CASE_INSENSITIVE).matcher(ct); + if (cm.find()) charset = cm.group(1).trim(); + } + result.charset = charset; + + ByteArrayOutputStream bos = new ByteArrayOutputStream(64 * 1024); + byte[] buf = new byte[8192]; + int n; + while ((n = is.read(buf)) > 0) { + bos.write(buf, 0, n); + } + result.bodyBytes = bos.toByteArray(); + } else { + result.bodyBytes = new byte[0]; + } + } finally { + if (is != null) try { is.close(); } catch (Exception ignored) {} + conn.disconnect(); + } + return result; + } + // 超过重定向上限 + result.status = 0; + result.bodyBytes = new byte[0]; + result.setCookieHeader = cookie; + result.charset = "UTF-8"; + return result; + } + + + /** 把新的 Set-Cookie 合并进已有 Cookie(只是简单地去重并保留最后值) */ + private static String mergeCookies(String existing, String setCookie) { + if (setCookie == null || setCookie.isEmpty()) return null; + Map map = new java.util.LinkedHashMap<>(); + if (existing != null && !existing.isEmpty()) { + for (String seg : existing.split(";")) { + String s = seg.trim(); + if (s.isEmpty()) continue; + int eq = s.indexOf('='); + if (eq > 0) map.put(s.substring(0, eq).trim(), s.substring(eq + 1).trim()); + } + } + // Set-Cookie: NAME=value; Path=/; HttpOnly → 只取第一个 NAME=value + for (String line : setCookie.split(",|;\\s*(?=[A-Za-z0-9_]+=)")) { + String s = line.trim(); + if (s.isEmpty()) continue; + int eq = s.indexOf('='); + if (eq <= 0) continue; + String name = s.substring(0, eq).trim(); + // 跳过属性,如 Path/Domain/HttpOnly/Expires + if (name.equalsIgnoreCase("Path") || name.equalsIgnoreCase("Domain") + || name.equalsIgnoreCase("HttpOnly") || name.equalsIgnoreCase("Secure") + || name.equalsIgnoreCase("SameSite") || name.equalsIgnoreCase("Expires") + || name.equalsIgnoreCase("Max-Age")) continue; + String val = s.substring(eq + 1).trim(); + map.put(name, val); + } + if (map.isEmpty()) return null; + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : map.entrySet()) { + if (sb.length() > 0) sb.append("; "); + sb.append(e.getKey()).append('=').append(e.getValue()); + } + return sb.toString(); + } + + /** + * 解析 HTML 里的 label/value 对: + * + * 学信网常见两种结构: + * 1)

里的每行:姓名陈东川 + * 2) 任何包含 "姓名 XXX" 这种格式的文本行 + * + * 统一解析成 Map + */ + private Map parseKeyValues(Document doc) { + Map kv = new HashMap<>(); + + // 1. 先从 /
里拿(最常见的报告表格) + Elements trs = doc.select("table tr"); + for (Element tr : trs) { + Elements tds = tr.select("td,th"); + for (int i = 0; i < tds.size() - 1; i += 2) { + String k = cleanText(tds.get(i).text()); + String v = cleanText(tds.get(i + 1).text()); + if (!k.isEmpty() && !v.isEmpty()) { + // key 只保留前 8 字(过滤 冒号 等噪声) + k = normalizeKey(k); + if (k.length() <= 10) kv.putIfAbsent(k, v); + } + } + } + + // 2. 兜底:从每行文本里的 "label:value" 结构解析 + Elements allBlocks = doc.select("div, li, p, span"); + for (Element blk : allBlocks) { + String text = cleanText(blk.text()); + if (text.isEmpty() || text.length() > 200) continue; + String[] lines = text.split("\\r?\\n|\\s{2,}"); + for (String line : lines) { + line = line.trim(); + // 形如"姓名:陈东川"、"学校名称 山东财经大学"(冒号或2+空格分隔) + String[] seg = line.split("[::]|\\s{2,}", 2); + if (seg.length == 2) { + String k = normalizeKey(seg[0]); + String v = seg[1].trim(); + if (!k.isEmpty() && k.length() <= 10 && !v.isEmpty()) { + kv.putIfAbsent(k, v); + } + } + } + } + + return kv; + } + + private String pick(Map kv, String... keys) { + for (String key : keys) { + // 精确匹配 + String v = kv.get(key); + if (v != null && !v.isEmpty()) return cleanText(v); + // 模糊包含 + for (Map.Entry e : kv.entrySet()) { + if (e.getKey().contains(key) || key.contains(e.getKey())) { + String val = cleanText(e.getValue()); + if (!val.isEmpty()) return val; + } + } + } + return null; + } + + private String normalizeKey(String k) { + if (k == null) return ""; + k = k.replaceAll("[\\s::*\\-·]+", ""); + return k.trim(); + } + + private String cleanText(String s) { + if (s == null) return ""; + s = s.replaceAll("\\u00a0", " ") // 去掉 nbsp + .replaceAll("[\\r\\n]+", " ") + .replaceAll("\\s+", " ") + .trim(); + return s; + } + + private boolean containsAny(String text, String... keywords) { + if (text == null) return false; + for (String kw : keywords) { + if (text.contains(kw)) return true; + } + return false; + } + + private String formatYM(String s) { + if (s == null) return null; + s = s.trim(); + Matcher m = Pattern.compile("(\\d{4})[年\\.\\-/](\\d{1,2})(?:[月\\.\\-/](\\d{1,2}))?").matcher(s); + if (m.find()) { + try { + int y = Integer.parseInt(m.group(1)); + int mm = Integer.parseInt(m.group(2)); + return String.format("%04d-%02d", y, mm); + } catch (Exception ignored) {} + } + return s.isEmpty() ? null : s; + } + + private String formatYMD(String s) { + if (s == null) return null; + Matcher m = Pattern.compile("(\\d{4})[年.\\-/](\\d{1,2})[月.\\-/](\\d{1,2})").matcher(s.trim()); + if (!m.find()) return null; + try { + return String.format("%04d-%02d-%02d", Integer.parseInt(m.group(1)), + Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3))); + } catch (NumberFormatException e) { + return null; + } + } + + private String withYear(String s) { + if (s == null) return null; + s = s.trim(); + if (s.isEmpty()) return null; + if (s.matches("\\d+\\.?\\d*")) { + return s + "年"; + } + return s; + } +} diff --git a/campus-server/src/main/java/com/campus/service/ChsiPdfService.java b/campus-server/src/main/java/com/campus/service/ChsiPdfService.java new file mode 100644 index 0000000..ef85324 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ChsiPdfService.java @@ -0,0 +1,120 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import lombok.Getter; +import org.apache.pdfbox.io.MemoryUsageSetting; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** 学信网单页 PDF 的安全校验、文本提取与首页渲染。 */ +@Service +public class ChsiPdfService { + + @Value("${app.xuexin.pdf.max-size-mb:10}") + private long maxSizeMb = 10; + + @Value("${app.xuexin.pdf.render-dpi:300}") + private float renderDpi = 300F; + + public PdfContent read(MultipartFile file) { + validateBasic(file); + try { + byte[] bytes = file.getBytes(); + validateHeader(bytes); + try (PDDocument document = PDDocument.load( + new ByteArrayInputStream(bytes), "", MemoryUsageSetting.setupTempFileOnly())) { + if (document.isEncrypted()) { + throw new BusinessException("PDF 已加密,请上传未加密的学信网原始 PDF"); + } + if (document.getNumberOfPages() != 1) { + throw new BusinessException("仅支持单页学信网验证报告 PDF"); + } + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setSortByPosition(true); + stripper.setStartPage(1); + stripper.setEndPage(1); + return new PdfContent(bytes, toLines(stripper.getText(document))); + } + } catch (BusinessException e) { + throw e; + } catch (org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException e) { + throw new BusinessException("PDF 已加密,请上传未加密的学信网原始 PDF"); + } catch (IOException | RuntimeException e) { + throw new BusinessException("PDF 文件无法解析,请重新从学信网下载后上传"); + } + } + + public byte[] renderFirstPage(PdfContent content) { + try (PDDocument document = PDDocument.load( + new ByteArrayInputStream(content.getBytes()), "", MemoryUsageSetting.setupTempFileOnly()); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + PDFRenderer renderer = new PDFRenderer(document); + BufferedImage image = renderer.renderImageWithDPI(0, renderDpi, ImageType.RGB); + if (!ImageIO.write(image, "png", output)) { + throw new IOException("PNG encoder unavailable"); + } + return output.toByteArray(); + } catch (IOException e) { + throw new BusinessException("PDF 文件无法解析,请重新从学信网下载后上传"); + } + } + + private void validateBasic(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException("PDF 文件不能为空"); + } + if (file.getSize() > maxSizeMb * 1024L * 1024L) { + throw new BusinessException("PDF 文件不能超过 " + maxSizeMb + "MB"); + } + String fileName = file.getOriginalFilename(); + if (fileName == null || !fileName.toLowerCase(Locale.ROOT).endsWith(".pdf")) { + throw new BusinessException("请上传从学信网下载的 PDF 文件"); + } + if (!"application/pdf".equalsIgnoreCase(file.getContentType())) { + throw new BusinessException("请上传从学信网下载的 PDF 文件"); + } + } + + private static void validateHeader(byte[] bytes) { + if (bytes.length < 5 + || !"%PDF-".equals(new String(bytes, 0, 5, StandardCharsets.US_ASCII))) { + throw new BusinessException("请上传从学信网下载的 PDF 文件"); + } + } + + private static List toLines(String text) { + List result = new ArrayList<>(); + if (text == null) return result; + for (String line : text.split("\\R")) { + String value = line.trim(); + if (!value.isEmpty()) result.add(value); + } + return result; + } + + @Getter + public static class PdfContent { + private final byte[] bytes; + private final List textLines; + + PdfContent(byte[] bytes, List textLines) { + this.bytes = bytes; + this.textLines = textLines; + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/ChsiReportParser.java b/campus-server/src/main/java/com/campus/service/ChsiReportParser.java new file mode 100644 index 0000000..a01ea4c --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ChsiReportParser.java @@ -0,0 +1,759 @@ +package com.campus.service; + +import com.campus.dto.response.ChsiReportDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.time.DateTimeException; +import java.time.LocalDate; + +/** + * 学信网报告OCR结果解析器 + * + * 负责从OCR识别出的文本行中,提取学信网报告的关键字段: + * 字段范围严格以新版《教育部学籍在线验证报告》为准。 + * + * 解析策略: + * 1. 先合并所有文本行为一个完整字符串,用于正则全文匹配 + * 2. 针对每个字段,使用「标签关键字 + 冒号/空格分隔」的模式匹配 + * 3. 处理OCR常见错误:冒号识别为空格、OCR错字(如"姓"识别为"性"等) + * 4. 字段值在标签同行或下一行都要搜索 + */ +@Slf4j +@Component +public class ChsiReportParser { + + // ==================== 预编译正则 ==================== + + /** + * 标签与值之间的分隔符字符集(覆盖 OCR 常见变体): + * 半角冒号 : / 全角冒号 : / 半角空格 / 全角空格  / 制表符 TAB + * 注意不使用 \s(避免跨行匹配 \n 导致标签吞掉下一行内容) + */ + private static final String SEP = "[::  \\t]"; + private static final String SEP_PLUS = SEP + "+"; + private static final String SEP_STAR = SEP + "*"; + private static final Pattern DATE_VALUE_PATTERN = Pattern.compile( + "(\\d{4})[年.\\-/](\\d{1,2})[月.\\-/](\\d{1,2})[日号]?"); + + // 姓名:允许 空格/冒号 分隔,也允许 OCR 丢分隔符直接"姓名张三" + private static final Pattern[] NAME_PATTERNS = { + Pattern.compile("姓\\s*名" + SEP_PLUS + "([\\u4e00-\\u9fa5·]{2,10})"), + Pattern.compile("^姓\\s*名" + SEP_STAR + "([\\u4e00-\\u9fa5·]{2,10})"), + Pattern.compile("姓\\s*名([\\u4e00-\\u9fa5·]{2,10})"), + }; + + // 性别 + private static final Pattern[] GENDER_PATTERNS = { + Pattern.compile("性\\s*别" + SEP_PLUS + "(男|女)"), + Pattern.compile("^性\\s*别" + SEP_STAR + "(男|女)"), + Pattern.compile("性\\s*别(男|女)"), + }; + + // 出生日期。必须带字段标签,避免误取页面顶部的更新日期。 + private static final Pattern[] BIRTH_PATTERNS = { + Pattern.compile("出\\s*生\\s*日\\s*期" + SEP_PLUS + "(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + Pattern.compile("^出\\s*生\\s*日\\s*期" + SEP_STAR + "(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + Pattern.compile("出\\s*生\\s*日\\s*期(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + }; + + // 学校/院校/培养单位/学校名称(学信网实际使用"学校名称") + private static final Pattern[] SCHOOL_PATTERNS = { + Pattern.compile("(?:学\\s*校\\s*名\\s*称|学\\s*校|院\\s*校|培\\s*养\\s*单\\s*位|毕\\s*业\\s*院\\s*校)" + SEP_PLUS + "([\\u4e00-\\u9fa5()()·\\-]{4,40})"), + Pattern.compile("^(?:学\\s*校\\s*名\\s*称|学\\s*校|院\\s*校)" + SEP_STAR + "([\\u4e00-\\u9fa5()()·\\-]{4,40})"), + Pattern.compile("(?:学\\s*校\\s*名\\s*称|学\\s*校|院\\s*校|培\\s*养\\s*单\\s*位|毕\\s*业\\s*院\\s*校)([\\u4e00-\\u9fa5()()·\\-]{4,30})"), + }; + + // 层次。只认报告中的“层次”,避免把“学历类别”误识别为层次。 + private static final Pattern[] LEVEL_PATTERNS = { + Pattern.compile("层\\s*次" + SEP_PLUS + "([\\u4e00-\\u9fa5A-Za-z\\s]{2,20})"), + Pattern.compile("^层\\s*次" + SEP_STAR + "([\\u4e00-\\u9fa5A-Za-z\\s]{2,20})"), + Pattern.compile("层\\s*次([\\u4e00-\\u9fa5A-Za-z\\s]{2,15})"), + }; + + // 层次枚举值(用于全文搜索兜底匹配) + private static final String[] LEVEL_KEYWORDS = { + "博士研究生", "硕士研究生", "研究生", "本科", "专科", "高职(专科)", "高职(专科)", "高职", + "高起本", "专升本", "第二学士学位", "普通本科", "成人本科", "成人专科", + "大学本科", "大学专科", "中等专科" + }; + + // 系所。新版报告的“分院”是独立字段,不能再混进 department。 + private static final Pattern[] DEPARTMENT_PATTERNS = { + Pattern.compile("系\\s*所" + SEP_PLUS + "([\\u4e00-\\u9fa5()()·\\-]{2,40})"), + Pattern.compile("^系\\s*所" + SEP_STAR + "([\\u4e00-\\u9fa5()()·\\-]{2,40})"), + Pattern.compile("系\\s*所([\\u4e00-\\u9fa5()()·\\-]{2,30})"), + }; + + // 专业:允许"专业 工程管理" / "专业:工程管理" / 紧挨模式(OCR漏了空格)"专业工程管理" + // 紧挨模式长度上限改为 15,避免吞掉后面的学制字段 + private static final Pattern[] MAJOR_PATTERNS = { + Pattern.compile("(?:专\\s*业\\s*名\\s*称|所\\s*学\\s*专\\s*业|专\\s*业)" + SEP_PLUS + "([\\u4e00-\\u9fa5()()·\\-\\dA-Za-z]{2,40})"), + Pattern.compile("^(?:专\\s*业\\s*名\\s*称|所\\s*学\\s*专\\s*业|专\\s*业)" + SEP_STAR + "([\\u4e00-\\u9fa5()()·\\-\\dA-Za-z]{2,40})"), + Pattern.compile("(?:专\\s*业\\s*名\\s*称|所\\s*学\\s*专\\s*业|专\\s*业)([\\u4e00-\\u9fa5()()·\\-\\dA-Za-z]{2,15})"), + }; + + /** 常见专业末尾/关键词(用于 extractMajor 兜底识别) */ + private static final String[] MAJOR_KEYWORDS = { + "工程管理", "工商管理", "公共管理", "行政管理", "财务管理", "市场营销", "人力资源管理", + "计算机科学与技术", "软件工程", "信息工程", "电子信息工程", "通信工程", "自动化", + "土木工程", "机械工程", "电气工程", "建筑学", "会计学", "金融学", "经济学", + "法学", "汉语言文学", "英语", "数学", "物理学", "化学", "生物科学", + "临床医学", "护理学", "药学", "教育学", "学前教育", "设计学", "视觉传达设计" + }; + + /** 常见专业末尾字符(非贪婪停止符) */ + private static final Pattern MAJOR_STOP_PATTERN = + Pattern.compile("(学\\s*制|学\\s*历|院\\s*校|学\\s*校|层\\s*次|分\\s*院|系\\s*所|入\\s*学|毕\\s*业|状\\s*态|学\\s*号|证\\s*书|证\\s*号|身\\s*份|姓\\s*名|性\\s*别|出\\s*生|报\\s*告|民\\s*族|电子注册备案表|学籍在线验证报告)"); + + private static final Pattern REPORT_TEXT_IN_MAJOR = Pattern.compile( + "教育部|电子注册备案表|学籍在线验证报告|学信网|姓\\s*名|性\\s*别|出\\s*生|身\\s*份|学籍状态|在线验证码|\\d{4}[年./-]\\d{1,2}"); + + // 学制 + private static final Pattern[] DURATION_PATTERNS = { + Pattern.compile("学\\s*制" + SEP_PLUS + "(\\d+\\.?\\d*\\s*[年])"), + Pattern.compile("学\\s*制" + SEP_PLUS + "(\\d+\\.?\\d*)"), + Pattern.compile("^(?:学\\s*制)" + SEP_STAR + "(\\d+\\.?\\d*\\s*[年]?)"), + Pattern.compile("学\\s*制(\\d+\\.?\\d*)"), + }; + + // 入学日期。新版报告提供完整年月日,不再降级为年月。 + private static final Pattern[] ENROLLMENT_PATTERNS = { + Pattern.compile("入\\s*学\\s*日\\s*期" + SEP_PLUS + "(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + Pattern.compile("^入\\s*学\\s*日\\s*期" + SEP_STAR + "(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + Pattern.compile("入\\s*学\\s*日\\s*期(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + }; + + // 新版学籍报告只认“离校日期”,不再拿毕业/预计毕业日期兜底。 + private static final Pattern[] DEPARTURE_DATE_PATTERNS = { + Pattern.compile("离\\s*校\\s*日\\s*期" + SEP_PLUS + "(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + Pattern.compile("^离\\s*校\\s*日\\s*期" + SEP_STAR + "(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + Pattern.compile("离\\s*校\\s*日\\s*期(\\d{4})[年\\.\\-\\/](\\d{1,2})[月\\.\\-\\/](\\d{1,2})[日号]?"), + }; + + // 学籍状态 + private static final Pattern[] STATUS_PATTERNS = { + Pattern.compile("(?:学\\s*籍\\s*状\\s*态|当\\s*前\\s*状\\s*态|状\\s*态)" + SEP_PLUS + "([\\u4e00-\\u9fa5()()]{2,15})"), + Pattern.compile("^(?:学\\s*籍\\s*状\\s*态|状\\s*态)" + SEP_STAR + "([\\u4e00-\\u9fa5()()]{2,15})"), + Pattern.compile("(?:学\\s*籍\\s*状\\s*态|当\\s*前\\s*状\\s*态)([\\u4e00-\\u9fa5()()]{2,10})"), + }; + + // 学籍状态枚举值(按长度从长到短排序,长的优先匹配,保证含(毕业)/(注册学籍)的完整值先命中) + private static final String[] STATUS_KEYWORDS = { + "在籍(注册学籍)", + "在籍(注册学籍)", + "不在籍(毕业)", + "不在籍(毕业)", + "不在籍(结业)", + "不在籍(结业)", + "不在籍(肄业)", + "不在籍(肄业)", + "在籍(暂缓注册)", + "在籍(暂缓注册)", + "已毕业", + "注册学籍", + "保留入学资格", + "保留学籍", + "取消学籍", + "研究生", + "在籍", + "毕业", + "结业", + "肄业", + "休学", + "退学", + "不在籍" + }; + + // 在线验证码。刻意不接受证书编号/报告编号等其他标签。 + private static final Pattern[] ONLINE_CODE_PATTERNS = { + Pattern.compile("在\\s*线\\s*验\\s*证\\s*码" + SEP_PLUS + "([A-Za-z0-9.\\-]{10,40})"), + Pattern.compile("^在\\s*线\\s*验\\s*证\\s*码" + SEP_STAR + "([A-Za-z0-9.\\-]{10,40})"), + Pattern.compile("在\\s*线\\s*验\\s*证\\s*码([A-Za-z0-9.\\-]{10,40})"), + }; + + // ==================== 主入口 ==================== + + /** + * 从OCR文本行中解析学信网报告关键字段 + * + * @param textLines OCR识别出的所有文本行 + * @return 填充了关键字段的DTO(未匹配到的字段保持null) + */ + public ChsiReportDTO parse(List textLines) { + ChsiReportDTO result = new ChsiReportDTO(); + if (textLines == null || textLines.isEmpty()) { + return result; + } + + // 合并为带换行的完整文本,用于跨行正则匹配 + String fullText = String.join("\n", textLines); + // 压缩版(去除多余空格),用于更宽松的匹配 + String compactText = fullText.replaceAll("[ \\t]+", ""); + + // 逐字段提取 + result.setReportName(fullText.replaceAll("\\s+", "").contains("教育部学籍在线验证报告") + ? "教育部学籍在线验证报告" : null); + result.setReportUpdatedDate(extractDateAfterLabel(textLines, "更新日期")); + result.setName(extractName(fullText, compactText, textLines)); + result.setGender(extractGender(fullText, compactText, textLines)); + result.setBirthDate(extractBirthDate(fullText, compactText, textLines)); + result.setEthnicity(extractSimpleField(textLines, "民族", 20)); + result.setSchool(extractSchool(fullText, compactText, textLines)); + result.setLevel(extractLevel(fullText, compactText, textLines)); + result.setBranchCampus(extractSimpleField(textLines, "分院", 40)); + result.setDepartment(extractDepartment(fullText, compactText, textLines)); + result.setMajor(extractMajor(fullText, compactText, textLines)); + result.setStudyDuration(extractDuration(fullText, compactText, textLines)); + result.setEducationCategory(extractSimpleField(textLines, "学历类别", 30)); + result.setStudyForm(extractSimpleField(textLines, "学习形式", 30)); + result.setEnrollmentDate(extractEnrollmentDate(fullText, compactText, textLines)); + result.setDepartureDate(extractDepartureDate(fullText, compactText, textLines)); + result.setStatus(extractStatus(fullText, compactText, textLines)); + result.setOnlineVerificationCode(extractOnlineVerificationCode(fullText, compactText, textLines)); + + log.debug("学信网字段解析完成: reportName={}, requiredFieldsReady={}", + result.getReportName(), result.getName() != null && result.getSchool() != null + && result.getLevel() != null && result.getMajor() != null); + + return result; + } + + // ==================== 各字段提取方法 ==================== + + private String extractName(String fullText, String compactText, List lines) { + // 1. 标准格式匹配 + String value = matchPatterns(NAME_PATTERNS, fullText); + if (value != null) return cleanValue(value); + value = matchPatterns(NAME_PATTERNS, compactText); + if (value != null) return cleanValue(value); + + // 2. 跨行查找:"姓名"在一行,值在下一行 + return findValueAfterLabel(lines, "姓名", 10); + } + + private String extractGender(String fullText, String compactText, List lines) { + String value = matchPatterns(GENDER_PATTERNS, fullText); + if (value != null) return value; + value = matchPatterns(GENDER_PATTERNS, compactText); + if (value != null) return value; + + // 跨行 + String after = findValueAfterLabel(lines, "性别", 2); + if (after != null && (after.contains("男") || after.contains("女"))) { + return after.contains("男") ? "男" : "女"; + } + + // 全文搜男/女 + if (fullText.contains("男") && !fullText.contains("女")) return "男"; + if (fullText.contains("女") && !fullText.contains("男")) return "女"; + + return null; + } + + private String extractBirthDate(String fullText, String compactText, List lines) { + // 1. 正则提取 + for (Pattern p : BIRTH_PATTERNS) { + Matcher m = p.matcher(fullText); + if (m.find()) { + String date = formatMatchedDate(m); + if (date != null) return date; + } + } + for (Pattern p : BIRTH_PATTERNS) { + Matcher m = p.matcher(compactText); + if (m.find()) { + String date = formatMatchedDate(m); + if (date != null) return date; + } + } + + // 2. 跨行找出生日期后面的值 + String after = findValueAfterLabel(lines, "出生日期", 16); + if (after != null) { + Matcher m = DATE_VALUE_PATTERN.matcher(after); + if (m.find()) return formatDate(m.group(1), m.group(2), m.group(3)); + } + return null; + } + + private String extractSchool(String fullText, String compactText, List lines) { + String value = matchPatterns(SCHOOL_PATTERNS, fullText); + if (value != null) return cleanValue(value); + value = matchPatterns(SCHOOL_PATTERNS, compactText); + if (value != null) return cleanValue(value); + + // 跨行:学校/院校标签后下一行(空值跳过) + String[] labels = {"学校名称", "学校", "院校", "培养单位", "毕业院校"}; + for (String label : labels) { + String after = findValueAfterLabel(lines, label, 20); + if (after != null && after.length() >= 4) return cleanValue(after); + } + + // 兜底:搜索"大学/学院/职业技术学院"结尾的行 + for (String line : lines) { + String trimmed = line.trim(); + // 不能包含其他标签关键字,防止匹配错 + if (trimmed.endsWith("大学") || trimmed.endsWith("学院") + || trimmed.contains("职业技术") || trimmed.contains("高等专科")) { + if (trimmed.length() >= 4 + && !trimmed.contains("系所") + && !trimmed.contains("分院") + && !trimmed.contains("系") + && !trimmed.contains("专业")) { + return cleanValue(trimmed); + } + } + } + + return null; + } + + private String extractLevel(String fullText, String compactText, List lines) { + String value = matchPlausibleValue(LEVEL_PATTERNS, fullText, this::cleanLevelValue); + if (value != null) return value; + value = matchPlausibleValue(LEVEL_PATTERNS, compactText, this::cleanLevelValue); + if (value != null) return value; + + // 跨行查找:在 "层次/学历" 标签后面取值 + String[] labels = {"层次", "学历", "学历层次", "层次值"}; + for (String label : labels) { + String after = findValueAfterLabel(lines, label, 10); + if (after != null) { + String cleaned = cleanLevelValue(after); + if (cleaned != null) return cleaned; + } + } + + // 上下文兜底:在 "学校名称/学校" 之后、"专业/学制" 之前的区间内,搜索层次关键字 + { + int schoolEnd = -1, majorStart = fullText.length(); + Pattern schoolPat = Pattern.compile("(?:学\\s*校\\s*名\\s*称|学\\s*校|院\\s*校)"); + Matcher sm = schoolPat.matcher(fullText); + if (sm.find()) schoolEnd = sm.end(); + Pattern majorPat = Pattern.compile("(?:专\\s*业|学\\s*制|系\\s*所|分\\s*院)"); + Matcher mm = majorPat.matcher(fullText); + if (mm.find()) majorStart = mm.start(); + + if (schoolEnd >= 0 && majorStart > schoolEnd) { + String slice = fullText.substring(schoolEnd, majorStart); + for (String kw : LEVEL_KEYWORDS) { + if (slice.contains(kw)) return kw; + } + } + } + + // 全局兜底:全文搜索层次关键字(按优先级排序,命中第一个即返回) + for (String kw : LEVEL_KEYWORDS) { + if (fullText.contains(kw)) return kw; + } + + return null; + } + + /** + * 层次值清理:去掉OCR常见误识别的拖尾,如"本科 区"→"本科"、"本科·"→"本科"、"本 科"→"本科" + */ + private String cleanLevelValue(String value) { + value = cleanValue(value); + if (value == null) return null; + // 去掉内部空格(OCR可能把"本科"识别成"本 科") + String noSpace = value.replaceAll("[\\s ]+", ""); + // 只接受字段开头的层次,避免从报告标题或其他字段中捡出误识别值。 + for (String kw : LEVEL_KEYWORDS) { + if (noSpace.startsWith(kw)) return kw; + } + return null; + } + + private String extractDepartment(String fullText, String compactText, List lines) { + String value = matchPatterns(DEPARTMENT_PATTERNS, fullText); + if (value != null) return cleanValue(value); + value = matchPatterns(DEPARTMENT_PATTERNS, compactText); + if (value != null) return cleanValue(value); + + return extractSimpleField(lines, "系所", 40); + } + + private String extractMajor(String fullText, String compactText, List lines) { + // 1. 标准正则匹配 + String value = matchPlausibleValue(MAJOR_PATTERNS, fullText, this::cleanMajorValue); + if (value != null) return value; + value = matchPlausibleValue(MAJOR_PATTERNS, compactText, this::cleanMajorValue); + if (value != null) return value; + + // 2. 跨行查找(包含 分院/系所 这种空标签跳过后继续读的情况) + String[] labels = {"专业名称", "所学专业", "专业"}; + for (String label : labels) { + String after = findValueAfterLabel(lines, label, 25); + if (after != null && after.length() >= 2) { + String cleaned = cleanMajorValue(after); + if (cleaned != null) return cleaned; + } + } + + // 3. 兜底:遍历 lines 找包含常见专业关键词的行(OCR 丢标签的情况), + // 或行首就是专业名(2-15 汉字 + 结尾不含"学"等) + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) continue; + + // 3a) 精确命中 MAJOR_KEYWORDS 内的已知专业名(最高优先级) + for (String kw : MAJOR_KEYWORDS) { + if (trimmed.contains(kw)) { + return kw; + } + } + } + + return null; + } + + /** + * 专业值清理:用 MAJOR_STOP_PATTERN 把"学制/层次/学院..."等尾部截断, + * 并去掉开头常见的噪声(空格、冒号、"专业"字样) + */ + private String cleanMajorValue(String value) { + if (value == null) return null; + value = value.trim(); + if (value.isEmpty()) return null; + // 去掉开头 "专业"/"专业:"/"专业 " + value = value.replaceAll("^专\\s*业" + SEP_STAR, "").trim(); + // 去掉尾部标签截断 + Matcher m = MAJOR_STOP_PATTERN.matcher(value); + if (m.find()) { + if (m.start() == 0) return null; + value = value.substring(0, m.start()); + } + value = cleanValue(value); + return isPlausibleMajor(value) ? value : null; + } + + public static boolean isPlausibleMajor(String value) { + if (value == null) return false; + String trimmed = value.trim(); + return trimmed.length() >= 2 && trimmed.length() <= 30 + && !trimmed.contains("\n") && !trimmed.contains(":") && !trimmed.contains(":") + && !REPORT_TEXT_IN_MAJOR.matcher(trimmed).find() + && !MAJOR_STOP_PATTERN.matcher(trimmed).find(); + } + + public static boolean isPlausibleLevel(String value) { + if (value == null) return false; + String trimmed = value.trim(); + if (trimmed.length() < 2 || trimmed.length() > 25 + || REPORT_TEXT_IN_MAJOR.matcher(trimmed).find() + || trimmed.contains("报告") || trimmed.contains("证书")) return false; + for (String keyword : LEVEL_KEYWORDS) { + if (trimmed.contains(keyword)) return true; + } + return false; + } + + private String extractDuration(String fullText, String compactText, List lines) { + for (Pattern p : DURATION_PATTERNS) { + Matcher m = p.matcher(fullText); + if (m.find()) { + String val = m.group(1).trim(); + if (!val.endsWith("年")) val = val + "年"; + return val; + } + } + for (Pattern p : DURATION_PATTERNS) { + Matcher m = p.matcher(compactText); + if (m.find()) { + String val = m.group(1).trim(); + if (!val.endsWith("年")) val = val + "年"; + return val; + } + } + + String after = findValueAfterLabel(lines, "学制", 5); + if (after != null) { + Matcher m = Pattern.compile("(\\d+\\.?\\d*)").matcher(after); + if (m.find()) { + String val = m.group(1); + if (!val.endsWith("年")) val = val + "年"; + return val; + } + } + + return null; + } + + private String extractEnrollmentDate(String fullText, String compactText, List lines) { + for (Pattern p : ENROLLMENT_PATTERNS) { + Matcher m = p.matcher(fullText); + if (m.find()) { + String date = formatMatchedDate(m); + if (date != null) return date; + } + } + for (Pattern p : ENROLLMENT_PATTERNS) { + Matcher m = p.matcher(compactText); + if (m.find()) { + String date = formatMatchedDate(m); + if (date != null) return date; + } + } + + String[] labels = {"入学日期", "入学时间", "入学年月", "入学"}; + for (String label : labels) { + String after = findValueAfterLabel(lines, label, 15); + if (after != null) { + Matcher m = DATE_VALUE_PATTERN.matcher(after); + if (m.find()) return formatDate(m.group(1), m.group(2), m.group(3)); + } + } + return null; + } + + private String extractDepartureDate(String fullText, String compactText, List lines) { + for (Pattern p : DEPARTURE_DATE_PATTERNS) { + Matcher m = p.matcher(fullText); + if (m.find()) { + return formatMatchedDate(m); + } + } + for (Pattern p : DEPARTURE_DATE_PATTERNS) { + Matcher m = p.matcher(compactText); + if (m.find()) { + return formatMatchedDate(m); + } + } + + String after = findValueAfterLabel(lines, "离校日期", 16); + if (after != null) { + Matcher m = DATE_VALUE_PATTERN.matcher(after); + if (m.find()) return formatDate(m.group(1), m.group(2), m.group(3)); + } + return null; + } + + private String extractStatus(String fullText, String compactText, List lines) { + String value = matchPatterns(STATUS_PATTERNS, fullText); + if (value != null) return cleanValue(value); + value = matchPatterns(STATUS_PATTERNS, compactText); + if (value != null) return cleanValue(value); + + String[] labels = {"学籍状态", "当前状态", "状态"}; + for (String label : labels) { + String after = findValueAfterLabel(lines, label, 10); + if (after != null) { + for (String kw : STATUS_KEYWORDS) { + if (after.contains(kw)) return kw; + } + if (after.length() <= 10) return cleanValue(after); + } + } + + // 兜底:全文搜索状态关键字 + for (String kw : STATUS_KEYWORDS) { + if (fullText.contains(kw)) return kw; + } + + return null; + } + + private String extractOnlineVerificationCode(String fullText, String compactText, List lines) { + String value = matchPatterns(ONLINE_CODE_PATTERNS, fullText); + if (value != null) return value; + value = matchPatterns(ONLINE_CODE_PATTERNS, compactText); + if (value != null) return value; + + String after = findValueAfterLabel(lines, "在线验证码", 40); + if (after != null) { + Matcher m = Pattern.compile("([A-Za-z0-9.\\-]{10,40})").matcher(after); + if (m.find()) return m.group(1); + } + return null; + } + + // ==================== 通用辅助方法 ==================== + + private String extractSimpleField(List lines, String label, int maxChars) { + String value = findValueAfterLabel(lines, label, maxChars); + return cleanValue(value); + } + + private String extractDateAfterLabel(List lines, String label) { + String value = findValueAfterLabel(lines, label, 20); + if (value == null) return null; + Matcher matcher = DATE_VALUE_PATTERN.matcher(value); + return matcher.find() ? formatDate(matcher.group(1), matcher.group(2), matcher.group(3)) : null; + } + + /** + * 依次尝试多个正则,返回第一个匹配到的 group(1) + */ + private String matchPatterns(Pattern[] patterns, String text) { + for (Pattern p : patterns) { + Matcher m = p.matcher(text); + if (m.find()) { + return m.group(1); + } + } + return null; + } + + private String matchPlausibleValue(Pattern[] patterns, String text, + java.util.function.Function cleaner) { + for (Pattern pattern : patterns) { + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + String value = cleaner.apply(matcher.group(1)); + if (value != null) return value; + } + } + return null; + } + + /** + * 跨行查找:找到包含标签的行,取同行中标签后面的内容;若没有,则取后续若干行合并后的内容 + * + * @param lines 所有文本行 + * @param label 标签关键字(可以含模糊空格) + * @param maxCharsToTake 最多取多少字符 + * @return 找到的值,未找到返回 null + */ + private String findValueAfterLabel(List lines, String label, int maxCharsToTake) { + // 构建标签的模糊匹配正则(允许字之间有空格) + StringBuilder fuzzyLabel = new StringBuilder(); + for (int i = 0; i < label.length(); i++) { + if (i > 0) fuzzyLabel.append("\\s*"); + fuzzyLabel.append(label.charAt(i)); + } + Pattern labelPattern = Pattern.compile(fuzzyLabel.toString()); + + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).trim(); + Matcher m = labelPattern.matcher(line); + if (m.find()) { + if (m.start() > 0 && Character.UnicodeScript.of(line.charAt(m.start() - 1)) + == Character.UnicodeScript.HAN) continue; + // 1. 尝试同行取值:去掉标签及冒号后剩余内容 + String after = line.substring(m.end()).replaceAll("^[::  \\t]+", "").trim(); + + if (!after.isEmpty()) { + // 如果同行内容过长,可能是其它标签,只截取到下一个标签模式之前 + return truncateToNextLabel(after, maxCharsToTake); + } + + // 2. 跨行取后续行(最多3行),中间的"纯标签空行"(如 分院 二字单独一行,值在下下一行)直接跳过 + StringBuilder sb = new StringBuilder(); + int skippedEmptyLabels = 0; + final int MAX_SKIP_EMPTY_LABELS = 2; + for (int j = i + 1; j < Math.min(i + 5, lines.size()); j++) { + String next = lines.get(j).trim(); + if (next.isEmpty()) continue; + + boolean nextIsLabel = looksLikeLabel(next); + // 同行里去掉本身的标签名后没值的,视为"空标签行",允许跳过不超过 MAX_SKIP_EMPTY_LABELS 个 + Matcher labelMatcher = labelPattern.matcher(next); + boolean nextIsEmptyLabelLine = nextIsLabel + && next.length() <= 8 + && labelMatcher.reset(next).find() + && labelMatcher.replaceFirst("").replaceAll("^[::  \\t]+", "").trim().isEmpty(); + + if (nextIsEmptyLabelLine && skippedEmptyLabels < MAX_SKIP_EMPTY_LABELS) { + skippedEmptyLabels++; + continue; + } + // 如果下一行明显是另一个非空标签(即它自己是标签且含值),则停止 + if (nextIsLabel && !nextIsEmptyLabelLine) break; + + if (sb.length() > 0) sb.append(" "); + sb.append(next); + if (sb.length() >= maxCharsToTake) break; + } + String value = sb.toString().trim(); + if (!value.isEmpty()) { + return truncateToNextLabel(value, maxCharsToTake); + } + } + } + return null; + } + + /** + * 判断一行是否看起来像标签(包含冒号) + */ + private boolean looksLikeLabel(String line) { + // 如果行很短,或包含常见标签字+冒号,认为是标签 + if (line.length() <= 4 && line.endsWith(":")) return true; + String[] labelPrefixes = {"姓名", "性别", "出生", "学校", "院校", "层次", "学历", + "专业", "所学专业", "院系", "学院", "系", "学制", "入学", "毕业", "离校", "状态", + "学号", "证书", "报告", "证号", "身份", "民族"}; + for (String prefix : labelPrefixes) { + if (line.startsWith(prefix)) return true; + } + return false; + } + + /** + * 截断到下一个标签模式(值中如果包含"某某:"就截断) + */ + private String truncateToNextLabel(String value, int maxChars) { + // 匹配"中文2-4字 + 冒号"作为下一个标签的开始 + Matcher m = Pattern.compile("[\\u4e00-\\u9fa5]{2,4}\\s*[::]").matcher(value); + if (m.find() && m.start() > 0) { + value = value.substring(0, m.start()).trim(); + } + if (value.length() > maxChars) { + value = value.substring(0, maxChars); + } + return cleanValue(value); + } + + /** + * 清理值:去除尾部标点、多余空格,以及【整体被成对括号完全包裹】的残留括号。 + * + * 注意:不会粗暴地把所有尾部括号都砍掉,否则像 "不在籍(毕业)" 这样的值末尾的 + * 全角右括号会被误删,最终变成 "不在籍(毕业"。 + */ + private String cleanValue(String value) { + if (value == null) return null; + value = value.trim(); + if (value.isEmpty()) return null; + // 去除常见尾部标点 + while (!value.isEmpty() && ",。;、,;:|  ".indexOf(value.charAt(value.length() - 1)) >= 0) { + value = value.substring(0, value.length() - 1).trim(); + } + if (value.isEmpty()) return null; + // 只在「整体被一对括号对称包裹」的情况才去括号(比如 "(在籍)" / "(在籍)" 这种) + // 对于 "不在籍(毕业)" 这种括号在字符串中间/后面的,完全不动括号 + if (value.length() >= 3) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + boolean pair = (first == '(' && last == ')') + || (first == '(' && last == ')'); + if (pair) { + value = value.substring(1, value.length() - 1).trim(); + } + } + return value.isEmpty() ? null : value; + } + + private String formatMatchedDate(Matcher matcher) { + if (matcher.groupCount() < 3 || matcher.group(3) == null) { + return null; + } + return formatDate(matcher.group(1), matcher.group(2), matcher.group(3)); + } + + /** 格式化并严格校验为 yyyy-MM-dd。 */ + private String formatDate(String year, String month, String day) { + try { + int y = Integer.parseInt(year); + int m = Integer.parseInt(month); + int d = Integer.parseInt(day); + return LocalDate.of(y, m, d).toString(); + } catch (NumberFormatException | DateTimeException e) { + return null; + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/ClickGuardService.java b/campus-server/src/main/java/com/campus/service/ClickGuardService.java new file mode 100644 index 0000000..b5f926b --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ClickGuardService.java @@ -0,0 +1,119 @@ +package com.campus.service; + +import com.campus.common.TooManyRequestsException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; +import java.util.Collections; + +/** + * 连点守卫:同一用户在同一目标上的短窗口内重复操作直接拒绝。 + * 命中后返回 HTTP 429 +「点击过于频繁,操作失败」,既避免重复提交, + * 也避免后到的请求排在数据库行锁后面白白等待。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ClickGuardService { + + /** 命中时的提示文案,前端直接展示。 */ + public static final String TOO_FREQUENT_MESSAGE = "点击过于频繁,操作失败"; + + /** 组局活动「加入/退出」共用一个窗口:快速来回切换同样会被拦截。 */ + public static final String SCOPE_ACTIVITY_JOIN_QUIT = "activity-join"; + + /** + * 组局活动「提交报名申请」单独一个窗口。 + * 不与加入/退出共用:否则提交申请后立刻撤回会被误判成连点。 + */ + public static final String SCOPE_ACTIVITY_APPLY = "activity-apply"; + + /** 话题收藏(toggle):同一用户对同一话题短窗口内重复点击直接拒绝,避免"收藏又取消"。 */ + public static final String SCOPE_TOPIC_FAVORITE = "topic-favorite"; + + /** 话题讨论发布:同一用户对同一话题短窗口内重复提交直接拒绝(讨论门槛低,最容易被连点)。 */ + public static final String SCOPE_TOPIC_DISCUSSION = "topic-discussion"; + + /** 学信网认证提交:同一用户对同一份认证材料短窗口内重复提交直接拒绝。 */ + public static final String SCOPE_XUEXIN_SUBMIT = "xuexin-submit"; + + /** 群聊邀请:同一用户对着同一目标(群或邀请)短窗口内重复点击直接拒绝。 */ + public static final String SCOPE_GROUP_INVITE_SEND = "group-invite-send"; + + /** 群聊邀请响应(允许/撤回共用):同一用户对同一条邀请的来回点击会被拦截。 */ + public static final String SCOPE_GROUP_INVITE_RESPOND = "group-invite-respond"; + + public static final String SCOPE_MOMENT_LIKE = "moment-like"; + public static final String SCOPE_COMMENT_LIKE = "comment-like"; + public static final String SCOPE_MOMENT_FAVORITE = "moment-favorite"; + public static final String SCOPE_USER_FOLLOW = "user-follow"; + public static final String SCOPE_TOPIC_FOLLOW = "topic-follow"; + public static final String SCOPE_MOMENT_COMMENT = "moment-comment"; + public static final String SCOPE_COMMENT_DELETE = "comment-delete"; + public static final String SCOPE_FRIEND_REQUEST = "friend-request"; + public static final String SCOPE_FRIEND_RESPOND = "friend-respond"; + public static final String SCOPE_FRIEND_CHANGE = "friend-change"; + + private static final String KEY_PREFIX = "click-guard:"; + private static final DefaultRedisScript RATE_LIMIT_SCRIPT = new DefaultRedisScript<>( + "local count = redis.call('INCR', KEYS[1]); " + + "if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]); end; " + + "return count;", Long.class); + public static final String SCOPE_LOCATION_RESOLVE = "location-resolve"; + public static final String SCOPE_LOCATION_CONFIRM = "location-confirm"; + public static final String SCOPE_LOCATION_SUBMIT = "location-submit"; + + private final RedisTemplate redisTemplate; + private final RedisRetryExecutor redisRetryExecutor; + + /** 连点判定窗口(毫秒),默认 2 秒;可用 app.click-guard.window-ms 覆盖。 */ + @Value("${app.click-guard.window-ms:2000}") + private long windowMs; + + /** + * 校验同一用户是否在窗口内重复操作同一目标;命中则抛 {@link TooManyRequestsException}。 + * Redis 不可用时放行(fail-open)并打日志,避免限流设施故障影响正常报名。 + */ + public void ensureNotTooFrequent(String scope, String userId, String targetId) { + if (userId == null || userId.trim().isEmpty()) { + return; + } + String key = KEY_PREFIX + scope + ':' + userId + ':' + (targetId == null ? "-" : targetId); + try { + Boolean first = redisRetryExecutor.execute("click-guard", + () -> redisTemplate.opsForValue().setIfAbsent(key, "1", windowMs, TimeUnit.MILLISECONDS)); + if (Boolean.FALSE.equals(first)) { + throw new TooManyRequestsException(TOO_FREQUENT_MESSAGE); + } + } catch (DataAccessException ex) { + log.warn("连点守卫不可用,本次请求放行: key={}", key, ex); + } + } + + /** + * 高频保护:允许正常的快速状态反转,仅限制同一用户同一互动类型的突发请求。 + * 使用单次 Lua 原子递增,不经过会重试命令的 RedisRetryExecutor。 + */ + public void ensureWithinRate(String scope, String userId, long windowMs, long maxRequests) { + if (userId == null || userId.trim().isEmpty()) return; + String key = KEY_PREFIX + "rate:" + scope + ':' + userId; + try { + Long count = redisTemplate.execute(RATE_LIMIT_SCRIPT, + new org.springframework.data.redis.serializer.StringRedisSerializer(), + new org.springframework.data.redis.serializer.GenericToStringSerializer<>(Long.class), + Collections.singletonList(key), + String.valueOf(windowMs)); + if (count != null && count > maxRequests) { + throw new TooManyRequestsException(TOO_FREQUENT_MESSAGE); + } + } catch (DataAccessException ex) { + log.warn("互动频率保护不可用,本次请求放行: scope={}", scope, ex); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/CommentService.java b/campus-server/src/main/java/com/campus/service/CommentService.java new file mode 100644 index 0000000..33de25c --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/CommentService.java @@ -0,0 +1,43 @@ +package com.campus.service; + +import com.campus.common.PageResult; +import com.campus.dto.request.CommentRequest; +import com.campus.entity.Comment; +import com.campus.repository.CommentRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + + public PageResult getCommentsByPostId(String postId, Pageable pageable) { + Page page = commentRepository.findByPostId(postId, pageable); + return PageResult.from(page); + } + + @Transactional + public Comment createComment(CommentRequest request) { + String authorId = UserContext.getCurrentUserId(); + Comment comment = new Comment(); + comment.setPostId(request.getPostId()); + comment.setAuthorId(authorId); + comment.setContent(request.getContent()); + return commentRepository.save(comment); + } + + @Transactional + public void deleteComment(String id) { + if (!commentRepository.existsById(id)) { + throw new RuntimeException("评论不存在"); + } + commentRepository.deleteById(id); + } +} diff --git a/campus-server/src/main/java/com/campus/service/CommerceAdminService.java b/campus-server/src/main/java/com/campus/service/CommerceAdminService.java new file mode 100644 index 0000000..cc850c1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/CommerceAdminService.java @@ -0,0 +1,160 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +public class CommerceAdminService { + + private final MallProductRepository productRepository; + private final TaskDefinitionRepository taskRepository; + private final LotteryPrizeRepository prizeRepository; + private final AuditLogService auditLogService; + + @Transactional(readOnly = true) + public List products() { + return productRepository.findAllByOrderBySortAsc(); + } + + @Transactional + public MallProduct saveProduct(String code, MallProduct input) { + String normalized = normalizeCode(code); + MallProduct product = productRepository.findByCode(normalized).orElseGet(MallProduct::new); + String before = product.getCode() == null ? null : productSummary(product); + product.setCode(normalized); + product.setName(required(input.getName(), "商品名称")); + product.setDescription(trim(input.getDescription(), 500)); + product.setPricePoints(nonNegative(input.getPricePoints(), "商品价格")); + product.setStock(validStock(input.getStock())); + product.setEnabled(input.getEnabled() == null || input.getEnabled()); + product.setEntitlementType(normalizeOptional(input.getEntitlementType())); + product.setDurationDays(positiveOptional(input.getDurationDays(), "权益天数")); + product.setSort(input.getSort() == null ? 0 : input.getSort()); + product = productRepository.save(product); + auditLogService.record("UPSERT_PRODUCT", "MALL_PRODUCT", product.getId(), before, + productSummary(product), null); + return product; + } + + @Transactional(readOnly = true) + public List tasks() { + return taskRepository.findAllByOrderBySortAsc(); + } + + @Transactional + public TaskDefinition saveTask(String code, TaskDefinition input) { + String normalized = normalizeCode(code); + TaskDefinition task = taskRepository.findByCode(normalized).orElseGet(TaskDefinition::new); + String before = task.getCode() == null ? null : taskSummary(task); + task.setCode(normalized); + task.setName(required(input.getName(), "任务名称")); + task.setDescription(trim(input.getDescription(), 500)); + task.setTarget(positive(input.getTarget(), "任务目标")); + task.setRewardPoints(nonNegative(input.getRewardPoints(), "奖励积分")); + String cycle = normalizeCode(input.getCycle() == null ? "ONCE" : input.getCycle()).toUpperCase(Locale.ROOT); + if (!"ONCE".equals(cycle) && !"DAILY".equals(cycle)) throw new BusinessException("任务周期仅支持 ONCE 或 DAILY"); + task.setCycle(cycle); + task.setEnabled(input.getEnabled() == null || input.getEnabled()); + task.setSort(input.getSort() == null ? 0 : input.getSort()); + task = taskRepository.save(task); + auditLogService.record("UPSERT_TASK", "TASK_DEFINITION", task.getId(), before, + taskSummary(task), null); + return task; + } + + @Transactional(readOnly = true) + public List prizes() { + return prizeRepository.findAllByOrderBySortAsc(); + } + + @Transactional + public LotteryPrize savePrize(String code, LotteryPrize input) { + String normalized = normalizeCode(code); + LotteryPrize prize = prizeRepository.findByCode(normalized).orElseGet(LotteryPrize::new); + String before = prize.getCode() == null ? null : prizeSummary(prize); + prize.setCode(normalized); + prize.setName(required(input.getName(), "奖品名称")); + prize.setWeight(positive(input.getWeight(), "奖品权重")); + prize.setStock(validStock(input.getStock())); + prize.setRewardPoints(nonNegative(input.getRewardPoints(), "奖励积分")); + prize.setEntitlementType(normalizeOptional(input.getEntitlementType())); + prize.setDurationDays(positiveOptional(input.getDurationDays(), "权益天数")); + prize.setEnabled(input.getEnabled() == null || input.getEnabled()); + prize.setSort(input.getSort() == null ? 0 : input.getSort()); + prize = prizeRepository.save(prize); + auditLogService.record("UPSERT_LOTTERY_PRIZE", "LOTTERY_PRIZE", prize.getId(), before, + prizeSummary(prize), null); + return prize; + } + + private String productSummary(MallProduct product) { + return "code=" + product.getCode() + ",price=" + product.getPricePoints() + + ",stock=" + product.getStock() + ",enabled=" + product.getEnabled(); + } + + private String taskSummary(TaskDefinition task) { + return "code=" + task.getCode() + ",target=" + task.getTarget() + ",reward=" + + task.getRewardPoints() + ",cycle=" + task.getCycle() + ",enabled=" + task.getEnabled(); + } + + private String prizeSummary(LotteryPrize prize) { + return "code=" + prize.getCode() + ",weight=" + prize.getWeight() + ",stock=" + + prize.getStock() + ",points=" + prize.getRewardPoints() + ",enabled=" + prize.getEnabled(); + } + + private String normalizeCode(String value) { + String result = required(value, "编码").replace(' ', '_'); + if (result.length() > 50) throw new BusinessException("编码不能超过 50 个字符"); + return result; + } + + private String normalizeOptional(String value) { + String result = trim(value, 50); + return result == null ? null : result.toUpperCase(Locale.ROOT); + } + + private String required(String value, String label) { + String result = trim(value, 100); + if (result == null) throw new BusinessException(label + "不能为空"); + return result; + } + + private String trim(String value, int max) { + if (value == null || value.trim().isEmpty()) return null; + String result = value.trim(); + return result.length() > max ? result.substring(0, max) : result; + } + + private int nonNegative(Integer value, String label) { + if (value == null || value < 0) throw new BusinessException(label + "不能小于 0"); + return value; + } + + private int positive(Integer value, String label) { + if (value == null || value <= 0) throw new BusinessException(label + "必须大于 0"); + return value; + } + + private Integer positiveOptional(Integer value, String label) { + if (value == null) return null; + return positive(value, label); + } + + private int validStock(Integer value) { + if (value == null) return -1; + if (value < -1) throw new BusinessException("库存只能为 -1(不限量)或非负数"); + return value; + } +} diff --git a/campus-server/src/main/java/com/campus/service/CommerceService.java b/campus-server/src/main/java/com/campus/service/CommerceService.java new file mode 100644 index 0000000..f9cca72 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/CommerceService.java @@ -0,0 +1,463 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.CommerceOrder; +import com.campus.entity.LotteryDraw; +import com.campus.entity.LotteryPrize; +import com.campus.entity.IdempotencyRecord; +import com.campus.entity.MallProduct; +import com.campus.entity.Moment; +import com.campus.entity.RewardIssuanceLog; +import com.campus.entity.TaskClaim; +import com.campus.entity.TaskDefinition; +import com.campus.entity.User; +import com.campus.entity.UserEntitlement; +import com.campus.entity.UserTaskProgress; +import com.campus.repository.CommerceOrderRepository; +import com.campus.repository.LotteryDrawRepository; +import com.campus.repository.LotteryPrizeRepository; +import com.campus.repository.IdempotencyRecordRepository; +import com.campus.repository.MallProductRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.RewardIssuanceLogRepository; +import com.campus.repository.TaskClaimRepository; +import com.campus.repository.TaskDefinitionRepository; +import com.campus.repository.UserEntitlementRepository; +import com.campus.repository.UserRepository; +import com.campus.repository.UserTaskProgressRepository; +import lombok.RequiredArgsConstructor; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CommerceService { + + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final int DAILY_DRAW_LIMIT = 3; + + private final PointsService pointsService; + private final MallProductRepository productRepository; + private final CommerceOrderRepository orderRepository; + private final UserEntitlementRepository entitlementRepository; + private final RewardIssuanceLogRepository rewardLogRepository; + private final TaskDefinitionRepository taskRepository; + private final UserTaskProgressRepository progressRepository; + private final TaskClaimRepository taskClaimRepository; + private final LotteryPrizeRepository prizeRepository; + private final LotteryDrawRepository drawRepository; + private final UserRepository userRepository; + private final MomentRepository momentRepository; + private final IdempotencyRecordRepository idempotencyRepository; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public List> listProducts() { + return productRepository.findByEnabledTrueOrderBySortAsc().stream() + .map(this::productMap).collect(Collectors.toList()); + } + + @Transactional + public Map purchase(String userId, String productCode) { + return purchase(userId, productCode, null); + } + + @Transactional + public Map purchase(String userId, String productCode, String idempotencyKey) { + userRepository.findByIdForUpdate(userId).orElseThrow(() -> new BusinessException("用户不存在")); + Map replay = replay(userId, "MALL_PURCHASE", idempotencyKey, productCode); + if (replay != null) return replay; + MallProduct product = productRepository.findByCodeForUpdate(productCode) + .orElseThrow(() -> new BusinessException("商品不存在")); + if (!Boolean.TRUE.equals(product.getEnabled())) throw new BusinessException("商品已下架"); + if (product.getStock() != -1 && product.getStock() <= 0) throw new BusinessException("商品库存不足"); + + CommerceOrder order = new CommerceOrder(); + order.setUserId(userId); + order.setProductId(product.getCode()); + order.setProductName(product.getName()); + order.setPricePoints(product.getPricePoints()); + order.setPaymentMethod("POINTS"); + order = orderRepository.save(order); + + pointsService.spendPoints(userId, PointsService.RULE_REDEEM_PRIZE, product.getPricePoints(), + "MALL", order.getId(), "兑换商城商品:" + product.getName()); + if (product.getStock() != -1) { + product.setStock(product.getStock() - 1); + productRepository.save(product); + } + if (product.getEntitlementType() != null && !product.getEntitlementType().trim().isEmpty()) { + issueEntitlement(userId, "MALL", order.getId(), product.getEntitlementType(), product.getDurationDays()); + recordReward(userId, "MALL", order.getId(), "ENTITLEMENT", product.getEntitlementType()); + } + + Map result = new LinkedHashMap<>(); + result.put("mock", false); + result.put("paymentRequired", false); + result.put("orderId", order.getId()); + result.put("status", order.getStatus()); + result.put("product", productMap(product)); + result.put("paidAmount", product.getPricePoints()); + result.put("currency", "青豆"); + result.put("message", "青豆兑换成功"); + remember(userId, "MALL_PURCHASE", idempotencyKey, productCode, result); + return result; + } + + @Transactional + public List> listTasks(String userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new BusinessException("用户不存在")); + List> result = new ArrayList<>(); + for (TaskDefinition task : taskRepository.findByEnabledTrueOrderBySortAsc()) { + UserTaskProgress progress = synchronizeProgress(user, task); + result.add(taskMap(task, progress)); + } + return result; + } + + @Transactional + public void grantNewcomerRewards(String userId, boolean loginSucceeded) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new BusinessException("用户不存在")); + for (String code : java.util.Arrays.asList("first-login", "complete-profile", "real-name-auth")) { + if ("first-login".equals(code) && !loginSucceeded) continue; + java.util.Optional configured = taskRepository.findByCode(code); + if (!configured.isPresent() || !Boolean.TRUE.equals(configured.get().getEnabled())) continue; + if (actualProgress(code, user) >= configured.get().getTarget()) { + claimTask(userId, code); + } + } + } + + @Transactional + public Map claimTask(String userId, String taskCode) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new BusinessException("用户不存在")); + TaskDefinition task = taskRepository.findByCode(taskCode) + .orElseThrow(() -> new BusinessException("任务不存在")); + if (!Boolean.TRUE.equals(task.getEnabled())) throw new BusinessException("任务已停用"); + + String cycleKey = cycleKey(task); + UserTaskProgress progress = progressRepository.findForUpdate(userId, taskCode, cycleKey) + .orElseGet(() -> synchronizeProgress(user, task)); + progress = synchronizeProgress(user, task, progress); + if (!Boolean.TRUE.equals(progress.getCompleted())) throw new BusinessException("任务尚未完成"); + boolean alreadyClaimed = Boolean.TRUE.equals(progress.getClaimed()); + if (!alreadyClaimed) { + progress.setClaimed(true); + progress.setClaimedAt(new Date()); + progressRepository.save(progress); + + TaskClaim legacy = new TaskClaim(); + legacy.setUserId(userId); + legacy.setTaskId(taskClaimKey(task, cycleKey)); + legacy.setRewardPoints(task.getRewardPoints()); + legacy = taskClaimRepository.save(legacy); + String rewardRuleCode = "real-name-auth".equals(taskCode) + ? PointsService.RULE_REAL_NAME_AUTH : "first-login".equals(taskCode) + ? PointsService.RULE_FIRST_LOGIN : PointsService.RULE_COMPLETE_PROFILE; + com.campus.entity.PointTransaction issued = pointsService.earnPointsWithAmount(userId, rewardRuleCode, + task.getRewardPoints(), "TASK", legacy.getId(), null, "完成任务:" + task.getName()); + if (issued == null) { + throw new BusinessException("该积分功能已禁用"); + } + recordReward(userId, "TASK", legacy.getId(), "POINTS", String.valueOf(task.getRewardPoints())); + } + + Map result = new LinkedHashMap<>(); + result.put("mock", false); + result.put("taskId", taskCode); + result.put("claimed", true); + result.put("alreadyClaimed", alreadyClaimed); + result.put("reward", task.getRewardPoints() + " 青豆"); + result.put("rewardGranted", !alreadyClaimed); + result.put("persistent", true); + result.put("message", alreadyClaimed ? "该任务已领取" : "任务领取成功,青豆已发放"); + return result; + } + + @Transactional(readOnly = true) + public Map lotteryInfo(String userId) { + int used = drawRepository.findByUserIdAndDrawDateOrderByDrawNumberAsc(userId, today()).size(); + Map result = new LinkedHashMap<>(); + result.put("mock", false); + result.put("paymentRequired", false); + result.put("dailyDrawLimit", DAILY_DRAW_LIMIT); + result.put("usedDrawCount", used); + result.put("remainingDrawCount", Math.max(0, DAILY_DRAW_LIMIT - used)); + result.put("prizes", prizeRepository.findByEnabledTrueOrderBySortAsc().stream() + .filter(p -> p.getStock() == -1 || p.getStock() > 0) + .map(this::prizeMap).collect(Collectors.toList())); + result.put("persistent", true); + result.put("message", "抽奖次数、库存、结果和奖励均已持久化"); + return result; + } + + @Transactional + public Map draw(String userId) { + return draw(userId, null); + } + + @Transactional + public Map draw(String userId, String idempotencyKey) { + userRepository.findByIdForUpdate(userId).orElseThrow(() -> new BusinessException("用户不存在")); + String date = today(); + Map replay = replay(userId, "LOTTERY_DRAW", idempotencyKey, date); + if (replay != null) return replay; + int used = drawRepository.findByUserIdAndDrawDateOrderByDrawNumberAsc(userId, date).size(); + if (used >= DAILY_DRAW_LIMIT) throw new BusinessException("今日抽奖次数已用完"); + + List available = prizeRepository.findAvailableForUpdate(); + LotteryPrize prize = selectPrize(available, ThreadLocalRandom.current().nextInt(totalWeight(available))); + if (prize.getStock() != -1) { + prize.setStock(prize.getStock() - 1); + prizeRepository.save(prize); + } + + LotteryDraw draw = new LotteryDraw(); + draw.setUserId(userId); + draw.setDrawDate(date); + draw.setDrawNumber(used + 1); + draw.setPrizeName(prize.getName()); + draw.setPrizeCode(prize.getCode()); + draw.setRewardPoints(prize.getRewardPoints()); + draw = drawRepository.save(draw); + + boolean granted = false; + if (prize.getRewardPoints() > 0) { + pointsService.earnPointsWithAmount(userId, PointsService.RULE_DAILY_CHECK_IN, + prize.getRewardPoints(), "LOTTERY", draw.getId(), null, "幸运转盘奖励"); + recordReward(userId, "LOTTERY", draw.getId(), "POINTS", String.valueOf(prize.getRewardPoints())); + granted = true; + } + if (prize.getEntitlementType() != null && !prize.getEntitlementType().trim().isEmpty()) { + issueEntitlement(userId, "LOTTERY", draw.getId(), prize.getEntitlementType(), prize.getDurationDays()); + recordReward(userId, "LOTTERY", draw.getId(), "ENTITLEMENT", prize.getEntitlementType()); + granted = true; + } + + Map result = new LinkedHashMap<>(); + result.put("mock", false); + result.put("drawNumber", draw.getDrawNumber()); + result.put("prize", prizeMap(prize)); + result.put("rewardGranted", granted); + result.put("persistent", true); + result.put("message", granted ? "抽奖完成,奖励已发放" : "抽奖完成"); + remember(userId, "LOTTERY_DRAW", idempotencyKey, date, result); + return result; + } + + LotteryPrize selectPrize(List prizes, int ticket) { + int total = totalWeight(prizes); + if (ticket < 0 || ticket >= total) throw new IllegalArgumentException("抽奖随机数超出范围"); + int cursor = 0; + for (LotteryPrize prize : prizes) { + cursor += Math.max(0, prize.getWeight()); + if (ticket < cursor) return prize; + } + throw new BusinessException("暂无可用奖品"); + } + + private int totalWeight(List prizes) { + int total = prizes.stream().filter(p -> p.getWeight() != null && p.getWeight() > 0) + .mapToInt(LotteryPrize::getWeight).sum(); + if (total <= 0) throw new BusinessException("暂无可用奖品"); + return total; + } + + private UserTaskProgress synchronizeProgress(User user, TaskDefinition task) { + String key = cycleKey(task); + UserTaskProgress progress = progressRepository + .findByUserIdAndTaskCodeAndCycleKey(user.getId(), task.getCode(), key) + .orElseGet(() -> { + UserTaskProgress created = new UserTaskProgress(); + created.setUserId(user.getId()); + created.setTaskCode(task.getCode()); + created.setCycleKey(key); + return created; + }); + return synchronizeProgress(user, task, progress); + } + + private UserTaskProgress synchronizeProgress(User user, TaskDefinition task, UserTaskProgress progress) { + int actual = actualProgress(task.getCode(), user); + int boundedActual = Math.min(actual, task.getTarget()); + if ("complete-profile".equals(task.getCode())) { + progress.setProgress(boundedActual); + } else { + progress.setProgress(Math.max(progress.getProgress(), boundedActual)); + } + progress.setCompleted(progress.getProgress() >= task.getTarget()); + String claimKey = taskClaimKey(task, cycleKey(task)); + if (taskClaimRepository.findByUserIdAndTaskId(user.getId(), claimKey).isPresent() + || ("ONCE".equals(task.getCycle()) + && taskClaimRepository.findByUserIdAndTaskId(user.getId(), task.getCode()).isPresent())) { + progress.setClaimed(true); + } + return progressRepository.save(progress); + } + + private int actualProgress(String taskCode, User user) { + if ("first-login".equals(taskCode)) return 1; + if ("complete-profile".equals(taskCode)) { + return hasText(user.getHometown()) + && hasText(user.getBio()) + && user.getInterests() != null + && user.getInterests().stream().anyMatch(this::hasText) ? 1 : 0; + } + if ("real-name-auth".equals(taskCode)) { + return Boolean.TRUE.equals(user.getXuexinCertified()) ? 1 : 0; + } + if ("daily-check-in".equals(taskCode)) { + return pointsService.getAccountInfo(user.getId()).getCheckedInToday() ? 1 : 0; + } + // 只统计真正的动态:话题讨论发布门槛低,计入任务会被刷 + if ("publish-moment".equals(taskCode)) { + return (int) Math.min(1, + momentRepository.countByUserIdAndContentSourceNot(user.getId(), Moment.SOURCE_TOPIC)); + } + return 0; + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private String cycleKey(TaskDefinition task) { + return "DAILY".equalsIgnoreCase(task.getCycle()) ? today() : "ONCE"; + } + + private String taskClaimKey(TaskDefinition task, String cycleKey) { + return "DAILY".equalsIgnoreCase(task.getCycle()) ? task.getCode() + ":" + cycleKey : task.getCode(); + } + + private void issueEntitlement(String userId, String sourceType, String sourceId, + String type, Integer durationDays) { + if (type == null || type.trim().isEmpty()) return; + UserEntitlement entitlement = new UserEntitlement(); + entitlement.setUserId(userId); + entitlement.setType(type.trim().toUpperCase()); + entitlement.setSourceType(sourceType); + entitlement.setSourceId(sourceId); + if (durationDays != null && durationDays > 0) { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.DAY_OF_MONTH, durationDays); + entitlement.setExpiresAt(calendar.getTime()); + } + entitlementRepository.save(entitlement); + } + + private void recordReward(String userId, String sourceType, String sourceId, + String rewardType, String value) { + if (rewardLogRepository.existsBySourceTypeAndSourceIdAndRewardType(sourceType, sourceId, rewardType)) return; + RewardIssuanceLog log = new RewardIssuanceLog(); + log.setUserId(userId); + log.setSourceType(sourceType); + log.setSourceId(sourceId); + log.setRewardType(rewardType); + log.setRewardValue(value); + log.setOperator("SYSTEM"); + rewardLogRepository.save(log); + } + + private Map productMap(MallProduct product) { + Map item = new LinkedHashMap<>(); + item.put("id", product.getCode()); + item.put("name", product.getName()); + item.put("description", product.getDescription()); + item.put("price", product.getPricePoints()); + item.put("currency", "青豆"); + item.put("stock", product.getStock()); + item.put("available", Boolean.TRUE.equals(product.getEnabled()) && product.getStock() != 0); + item.put("entitlementType", product.getEntitlementType()); + item.put("durationDays", product.getDurationDays()); + item.put("mock", false); + return item; + } + + private Map taskMap(TaskDefinition task, UserTaskProgress progress) { + Map item = new LinkedHashMap<>(); + item.put("id", task.getCode()); + item.put("name", task.getName()); + item.put("description", task.getDescription()); + item.put("progress", progress.getProgress()); + item.put("target", task.getTarget()); + item.put("completed", progress.getCompleted()); + item.put("claimed", progress.getClaimed()); + item.put("cycle", task.getCycle()); + item.put("rewardPoints", task.getRewardPoints()); + item.put("reward", task.getRewardPoints() + " 青豆"); + item.put("persistent", true); + return item; + } + + private Map prizeMap(LotteryPrize prize) { + Map item = new LinkedHashMap<>(); + item.put("name", prize.getName()); + item.put("code", prize.getCode()); + item.put("rewardPoints", prize.getRewardPoints()); + item.put("entitlementType", prize.getEntitlementType()); + item.put("stock", prize.getStock()); + item.put("mock", false); + return item; + } + + private String today() { + return LocalDate.now(BUSINESS_ZONE).toString(); + } + + private Map replay(String userId, String operation, String key, String fingerprint) { + String normalizedKey = normalizeIdempotencyKey(key); + if (normalizedKey == null) return null; + IdempotencyRecord record = idempotencyRepository + .findByUserIdAndOperationAndIdempotencyKey(userId, operation, normalizedKey).orElse(null); + if (record == null) return null; + if (!record.getRequestFingerprint().equals(fingerprint)) { + throw new BusinessException("同一幂等键不能用于不同请求"); + } + try { + return objectMapper.readValue(record.getResponseJson(), new TypeReference>() { }); + } catch (Exception ex) { + throw new BusinessException("历史幂等结果无法读取", ex); + } + } + + private void remember(String userId, String operation, String key, String fingerprint, + Map response) { + String normalizedKey = normalizeIdempotencyKey(key); + if (normalizedKey == null) return; + try { + IdempotencyRecord record = new IdempotencyRecord(); + record.setUserId(userId); + record.setOperation(operation); + record.setIdempotencyKey(normalizedKey); + record.setRequestFingerprint(fingerprint); + record.setResponseJson(objectMapper.writeValueAsString(response)); + idempotencyRepository.save(record); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("幂等结果保存失败", ex); + } + } + + private String normalizeIdempotencyKey(String key) { + if (key == null || key.trim().isEmpty()) return null; + String normalized = key.trim(); + if (normalized.length() > 100) throw new BusinessException("Idempotency-Key 不能超过 100 个字符"); + return normalized; + } +} diff --git a/campus-server/src/main/java/com/campus/service/ContentAppealService.java b/campus-server/src/main/java/com/campus/service/ContentAppealService.java new file mode 100644 index 0000000..94caa08 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ContentAppealService.java @@ -0,0 +1,183 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.ContentAppealRequest; +import com.campus.dto.request.ContentAppealReviewRequest; +import com.campus.dto.response.ContentAppealDTO; +import com.campus.entity.Activity; +import com.campus.entity.Announcement; +import com.campus.entity.ContentAppeal; +import com.campus.entity.Moment; +import com.campus.repository.ActivityRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.ContentAppealRepository; +import com.campus.repository.MomentRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Date; +import java.util.Locale; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ContentAppealService { + private final ContentAppealRepository repository; + private final MomentRepository momentRepository; + private final ActivityRepository activityRepository; + private final AnnouncementRepository announcementRepository; + private final AuthorizationService authorizationService; + private final NotificationService notificationService; + private final AuditLogService auditLogService; + + @Transactional + public ContentAppealDTO create(ContentAppealRequest request) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) throw new BusinessException("请先登录"); + String type = normalizeType(request.getContentType()); + ContentState state = findContent(type, request.getContentId()); + if (!userId.equals(state.ownerId)) throw new BusinessException("只能申诉自己发布的内容"); + if (!state.rejectedOrHidden) throw new BusinessException("只有被拒绝或下架的内容可以申诉"); + if (repository.existsByUserIdAndContentTypeAndContentIdAndStatus( + userId, type, request.getContentId(), "PENDING")) { + throw new BusinessException("该内容已有待处理申诉"); + } + ContentAppeal appeal = new ContentAppeal(); + appeal.setUserId(userId); + appeal.setContentType(type); + appeal.setContentId(request.getContentId()); + appeal.setReason(request.getReason().trim()); + appeal.setStatus("PENDING"); + return toDTO(repository.save(appeal)); + } + + @Transactional(readOnly = true) + public PageResult mine(int page, int size) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) throw new BusinessException("请先登录"); + int safePage = Math.max(1, page); + int safeSize = Math.min(Math.max(1, size), 100); + return toPage(repository.findByUserIdOrderByCreatedAtDesc( + userId, PageRequest.of(safePage - 1, safeSize)), safePage, safeSize); + } + + @Transactional(readOnly = true) + public PageResult pending(int page, int size) { + authorizationService.requireReviewerOrAdmin(); + int safePage = Math.max(1, page); + int safeSize = Math.min(Math.max(1, size), 100); + return toPage(repository.findByStatusOrderByCreatedAtAsc( + "PENDING", PageRequest.of(safePage - 1, safeSize)), safePage, safeSize); + } + + @Transactional + public ContentAppealDTO review(String id, ContentAppealReviewRequest request) { + authorizationService.requireReviewerOrAdmin(); + String status = request.getStatus().trim().toUpperCase(Locale.ROOT); + if (!Arrays.asList("APPROVED", "REJECTED").contains(status)) + throw new BusinessException("申诉状态必须是 APPROVED 或 REJECTED"); + ContentAppeal appeal = repository.findById(id) + .orElseThrow(() -> new BusinessException("申诉记录不存在")); + if (!"PENDING".equals(appeal.getStatus())) throw new BusinessException("该申诉已处理"); + if ("APPROVED".equals(status)) restore(appeal.getContentType(), appeal.getContentId()); + appeal.setStatus(status); + appeal.setReviewedBy(UserContext.getCurrentUserId()); + appeal.setReviewNote(trim(request.getNote())); + appeal.setReviewedAt(new Date()); + appeal = repository.save(appeal); + notificationService.createNotification(appeal.getUserId(), "CONTENT_APPEAL_RESULT", + "APPROVED".equals(status) ? "内容申诉通过" : "内容申诉未通过", + "APPROVED".equals(status) ? "你的内容申诉已通过,内容已恢复。" + : "你的内容申诉未通过" + (appeal.getReviewNote() == null ? "。" : ":" + appeal.getReviewNote()), + null, contentLink(appeal.getContentType(), appeal.getContentId()), appeal.getId(), + UserContext.getCurrentUserId()); + auditLogService.record("CONTENT_APPEAL_REVIEW", "CONTENT_APPEAL", appeal.getId(), + "status=PENDING", "status=" + status, + "contentType=" + appeal.getContentType() + ",contentId=" + appeal.getContentId()); + return toDTO(appeal); + } + + private ContentState findContent(String type, String id) { + switch (type) { + case "MOMENT": { + Moment item = momentRepository.findIncludingDeleted(id) + .orElseThrow(() -> new BusinessException("动态不存在")); + return new ContentState(item.getUserId(), Boolean.TRUE.equals(item.getDeleted()) + || "rejected".equalsIgnoreCase(item.getReviewStatus())); + } + case "ACTIVITY": { + Activity item = activityRepository.findIncludingDeleted(id) + .orElseThrow(() -> new BusinessException("活动不存在")); + return new ContentState(item.getCreatorId(), Boolean.TRUE.equals(item.getDeleted()) + || "rejected".equalsIgnoreCase(item.getReviewStatus())); + } + case "ANNOUNCEMENT": { + Announcement item = announcementRepository.findIncludingDeleted(id) + .orElseThrow(() -> new BusinessException("公告不存在")); + return new ContentState(item.getCreatorId(), Boolean.TRUE.equals(item.getDeleted()) + || "rejected".equalsIgnoreCase(item.getStatus())); + } + default: throw new BusinessException("不支持的内容类型"); + } + } + + private void restore(String type, String id) { + int updated; + if ("MOMENT".equals(type)) updated = momentRepository.restoreApproved(id); + else if ("ACTIVITY".equals(type)) updated = activityRepository.restoreApproved(id); + else updated = announcementRepository.restoreApproved(id); + if (updated != 1) throw new BusinessException("内容恢复失败或内容不存在"); + } + + private PageResult toPage(Page data, int page, int size) { + return PageResult.of(data.getContent().stream().map(this::toDTO).collect(Collectors.toList()), + data.getTotalElements(), page, size); + } + + private ContentAppealDTO toDTO(ContentAppeal item) { + ContentAppealDTO dto = new ContentAppealDTO(); + dto.setId(item.getId()); + dto.setUserId(item.getUserId()); + dto.setContentType(item.getContentType()); + dto.setContentId(item.getContentId()); + dto.setReason(item.getReason()); + dto.setStatus(item.getStatus()); + dto.setReviewedBy(item.getReviewedBy()); + dto.setReviewNote(item.getReviewNote()); + dto.setReviewedAt(item.getReviewedAt()); + dto.setCreatedAt(item.getCreatedAt()); + return dto; + } + + private String normalizeType(String value) { + String type = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!Arrays.asList("MOMENT", "ACTIVITY", "ANNOUNCEMENT").contains(type)) + throw new BusinessException("内容类型必须是 MOMENT、ACTIVITY 或 ANNOUNCEMENT"); + return type; + } + + private String trim(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim(); + } + + private String contentLink(String type, String id) { + if ("MOMENT".equals(type)) return "/moments/" + id; + if ("ACTIVITY".equals(type)) return "/activity/" + id; + return "/announcements/" + id; + } + + private static class ContentState { + private final String ownerId; + private final boolean rejectedOrHidden; + private ContentState(String ownerId, boolean rejectedOrHidden) { + this.ownerId = ownerId; + this.rejectedOrHidden = rejectedOrHidden; + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/ContentDraftService.java b/campus-server/src/main/java/com/campus/service/ContentDraftService.java new file mode 100644 index 0000000..e73c992 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ContentDraftService.java @@ -0,0 +1,86 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.ContentDraftVersion; +import com.campus.repository.ContentDraftVersionRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ContentDraftService { + private final ContentDraftVersionRepository repository; + private final ObjectMapper objectMapper; + + @Transactional + public Map save(String userId, Map payload) { + String draftKey = string(payload.get("draftId")); + if (draftKey == null) draftKey = UUID.randomUUID().toString(); + String contentType = string(payload.get("contentType")); + if (contentType == null) contentType = "MOMENT"; + Integer max = repository.maxVersion(userId, draftKey); + ContentDraftVersion draft = new ContentDraftVersion(); + draft.setUserId(userId); + draft.setDraftKey(draftKey); + draft.setContentType(contentType.toUpperCase()); + draft.setVersion(max == null ? 1 : max + 1); + try { + draft.setPayload(objectMapper.writeValueAsString(payload)); + } catch (JsonProcessingException ex) { + throw new BusinessException("草稿内容格式错误", ex); + } + repository.save(draft); + Map result = new LinkedHashMap<>(); + result.put("draftId", draftKey); + result.put("version", draft.getVersion()); + return result; + } + + @Transactional(readOnly = true) + public List> listLatest(String userId) { + Map latest = new LinkedHashMap<>(); + for (ContentDraftVersion item : repository.findByUserIdOrderByUpdatedAtDesc(userId)) { + latest.putIfAbsent(item.getDraftKey(), item); + } + return latest.values().stream().map(this::toMap).collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List> versions(String userId, String draftKey) { + return repository.findByUserIdAndDraftKeyOrderByVersionDesc(userId, draftKey) + .stream().map(this::toMap).collect(Collectors.toList()); + } + + @Transactional + public void delete(String userId, String draftKey) { + repository.deleteByUserIdAndDraftKey(userId, draftKey); + } + + @SuppressWarnings("unchecked") + private Map toMap(ContentDraftVersion item) { + try { + Map result = objectMapper.readValue(item.getPayload(), Map.class); + result.put("draftId", item.getDraftKey()); + result.put("version", item.getVersion()); + result.put("contentType", item.getContentType()); + result.put("updatedAt", item.getUpdatedAt()); + return result; + } catch (JsonProcessingException ex) { + throw new BusinessException("草稿数据损坏", ex); + } + } + + private String string(Object value) { + if (value == null || value.toString().trim().isEmpty()) return null; + return value.toString().trim(); + } +} diff --git a/campus-server/src/main/java/com/campus/service/ContentReviewService.java b/campus-server/src/main/java/com/campus/service/ContentReviewService.java new file mode 100644 index 0000000..434ea4e --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ContentReviewService.java @@ -0,0 +1,354 @@ +package com.campus.service; + +import com.campus.entity.ContentReview; +import com.campus.entity.Topic; +import com.campus.repository.ContentReviewRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.ActivityRepository; +import com.campus.repository.TopicRepository; +import com.campus.service.review.ImageReviewStrategy; +import com.campus.service.review.ReviewResult; +import com.campus.service.review.TextReviewStrategy; +import com.campus.service.review.VideoReviewStrategy; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContentReviewService { + + private final TextReviewStrategy textReviewStrategy; + private final ImageReviewStrategy imageReviewStrategy; + private final VideoReviewStrategy videoReviewStrategy; + private final ContentReviewRepository contentReviewRepository; + private final AnnouncementRepository announcementRepository; + private final MomentRepository momentRepository; + private final ActivityRepository activityRepository; + private final TopicRepository topicRepository; + private final ObjectMapper objectMapper; + private final PushService pushService; + private final NotificationService notificationService; + + /** 话题只拦截高/中危敏感词,LOW 仅记录(避免「垃圾/滚」等短文本误杀)。 */ + private static final Set TOPIC_BLOCKING_LEVELS = TextReviewStrategy.HIGH_AND_MEDIUM; + + @Async + public void reviewAnnouncement(String announcementId, String title, String content, String images) { + log.info("Start auto review for announcement: {}", announcementId); + + ReviewResult textResult = textReviewStrategy.review(title + " " + content); + ReviewResult imageResult = imageReviewStrategy.review(images); + + ReviewResult finalResult = combineResults(textResult, imageResult); + + saveReviewRecord("ANNOUNCEMENT", announcementId, finalResult); + } + + @Async + public void reviewMoment(String momentId, String content, String mediaUrls, String contentType) { + log.info("Start auto review for moment: {}", momentId); + + ReviewResult textResult = textReviewStrategy.review(content); + ReviewResult mediaResult; + + if ("video".equals(contentType)) { + mediaResult = videoReviewStrategy.review(mediaUrls); + } else { + mediaResult = imageReviewStrategy.review(mediaUrls); + } + + ReviewResult finalResult = combineResults(textResult, mediaResult); + + saveReviewRecord("MOMENT", momentId, finalResult); + } + + /** + * 同步审核方法(用于调试) + */ + public void reviewMomentSync(String momentId, String content, String mediaUrls) { + log.info("Start sync auto review for moment: {}", momentId); + + ReviewResult finalResult = previewMomentReview(content, mediaUrls); + + saveReviewRecord("MOMENT", momentId, finalResult); + log.info("Sync review completed for moment: {}, result: {}", momentId, finalResult.isPassed() ? "approved" : "rejected"); + } + + /** + * 只计算动态/话题讨论的审核结果、**不落库**。 + * + *

用于"先审后存":命中违禁词时直接让发布失败(400),不产生任何记录, + * 与话题发布({@link #previewTopicReview})同一口径。

+ */ + public ReviewResult previewMomentReview(String content, String mediaUrls) { + ReviewResult textResult = textReviewStrategy.review(content); + ReviewResult mediaResult = imageReviewStrategy.review(mediaUrls); + return combineResults(textResult, mediaResult); + } + + /** + * 话题讨论创建成功(审核已通过):只落审核流水,不发推送/站内通知。 + * 发布是同步判定、通过即上线,作者当场就能看到结果,再推一条"审核通过"属于噪声。 + */ + public void recordMomentApproval(String momentId, ReviewResult result) { + saveReviewRecord("MOMENT", momentId, result, false); + } + + @Async + public void reviewActivity(String activityId, String title, String description, String coverImage) { + log.info("Start auto review for activity: {}", activityId); + + ReviewResult textResult = textReviewStrategy.review(title + " " + description); + ReviewResult imageResult = imageReviewStrategy.review(coverImage); + + ReviewResult finalResult = combineResults(textResult, imageResult); + + saveReviewRecord("ACTIVITY", activityId, finalResult); + } + + /** + * 话题自动审核(同步,与动态同款链路):文本按高/中危拦截,封面图走图片策略。 + * 结果写入 content_review 并回写 topic.status / review_note。 + */ + public void reviewTopicSync(String topicId, String name, String description, String coverUrl) { + log.info("Start sync auto review for topic: {}", topicId); + + ReviewResult finalResult = previewTopicReview(name, description, coverUrl); + saveReviewRecord("TOPIC", topicId, finalResult); + + log.info("Sync review completed for topic: {}, result: {}", topicId, + finalResult.isPassed() ? "approved" : "rejected"); + } + + /** + * 只计算话题审核结果、不落库(存量 PENDING 补偿的 dry-run 使用)。 + */ + public ReviewResult previewTopicReview(String name, String description, String coverUrl) { + ReviewResult textResult = textReviewStrategy.review( + joinTopicText(name, description), TOPIC_BLOCKING_LEVELS); + ReviewResult coverResult = imageReviewStrategy.review(coverUrl); + return combineResults(textResult, coverResult); + } + + private String joinTopicText(String name, String description) { + StringBuilder text = new StringBuilder(); + if (name != null) { + text.append(name); + } + if (description != null && !description.trim().isEmpty()) { + if (text.length() > 0) { + text.append(' '); + } + text.append(description); + } + return text.toString(); + } + + /** + * 同步审核方法(用于调试) + */ + public void reviewActivitySync(String activityId, String title, String description, String coverImage) { + log.info("Start sync auto review for activity: {}", activityId); + + ReviewResult textResult = textReviewStrategy.review(title + " " + description); + ReviewResult imageResult = imageReviewStrategy.review(coverImage); + + ReviewResult finalResult = combineResults(textResult, imageResult); + + saveReviewRecord("ACTIVITY", activityId, finalResult); + log.info("Sync review completed for activity: {}, result: {}", activityId, finalResult.isPassed() ? "approved" : "rejected"); + } + + private ReviewResult combineResults(ReviewResult... results) { + ReviewResult finalResult = new ReviewResult(); + finalResult.setPassed(true); + int totalScore = 0; + int count = 0; + + for (ReviewResult result : results) { + if (!result.isPassed()) { + finalResult.setPassed(false); + finalResult.getViolationReasons().addAll(result.getViolationReasons()); + } + totalScore += result.getScore(); + count++; + } + + finalResult.setScore(count > 0 ? totalScore / count : 100); + return finalResult; + } + + /** + * 话题创建成功(审核已通过):只落审核流水,不发通知。 + * 发布是同步判定、通过即上线,作者不需要再收到"审核通过"通知。 + */ + public void recordTopicApproval(String topicId, ReviewResult result) { + saveReviewRecord("TOPIC", topicId, result, false); + } + + @Transactional + private void saveReviewRecord(String contentType, String contentId, ReviewResult result) { + saveReviewRecord(contentType, contentId, result, true); + } + + private void saveReviewRecord(String contentType, String contentId, ReviewResult result, boolean notify) { + ContentReview review = new ContentReview(); + review.setContentType(contentType); + review.setContentId(contentId); + review.setReviewType("AUTO"); + review.setStatus(result.isPassed() ? "approved" : "rejected"); + review.setScore(result.getScore()); + + String reason = !result.getViolationReasons().isEmpty() + ? String.join("; ", result.getViolationReasons()) + : null; + review.setReason(reason); + + try { + review.setResultDetails(objectMapper.writeValueAsString(result)); + } catch (JsonProcessingException e) { + log.error("Failed to serialize review result", e); + } + + contentReviewRepository.save(review); + + updateContentStatus(contentType, contentId, result.isPassed() ? "approved" : "rejected", reason, notify); + + log.info("Review record saved for {}: {}, status: {}", contentType, contentId, review.getStatus()); + } + + @Transactional + private void updateContentStatus(String contentType, String contentId, String status, String reason) { + updateContentStatus(contentType, contentId, status, reason, true); + } + + private void updateContentStatus(String contentType, String contentId, String status, String reason, boolean notify) { + switch (contentType) { + case "ANNOUNCEMENT": + announcementRepository.findById(contentId).ifPresent(announcement -> { + announcement.setStatus("approved".equals(status) + && announcement.getScheduledPublishAt() != null + && announcement.getScheduledPublishAt().after(new java.util.Date()) + ? "scheduled" : status); + announcement.setReviewReason(reason); + announcementRepository.save(announcement); + }); + break; + case "MOMENT": + momentRepository.findById(contentId).ifPresent(moment -> { + moment.setReviewStatus("approved".equals(status) + && moment.getScheduledPublishAt() != null + && moment.getScheduledPublishAt().after(new java.util.Date()) + ? "scheduled" : status); + moment.setReviewReason(reason); + momentRepository.save(moment); + + // 话题讨论是"先审后存 + 通过即上线",作者已知道结果,不再推通知(notify=false) + if (notify) { + // 推送:通知动态发布者审核结果 + pushService.pushMomentReview( + moment.getUserId(), + "approved".equals(status), + moment.getId(), + reason + ); + // 创建应用内通知 + boolean momentApproved = "approved".equals(status); + notificationService.createNotification( + moment.getUserId(), + momentApproved ? "MOMENT_REVIEW_APPROVED" : "MOMENT_REVIEW_REJECTED", + momentApproved ? "动态审核通过" : "动态审核未通过", + momentApproved ? "你的动态已通过审核" : "你的动态审核未通过" + (reason != null ? ":" + reason : ""), + null, + "/moment/" + moment.getId(), + moment.getId(), + null + ); + } + }); + break; + case "ACTIVITY": + activityRepository.findById(contentId).ifPresent(activity -> { + activity.setReviewStatus(status); + activity.setReviewReason(reason); + if (!"approved".equals(status)) { + activity.setStatus("INACTIVE"); + } + activityRepository.save(activity); + + // 推送:通知活动发布者审核结果 + pushService.pushActivityReview( + activity.getCreatorId(), + "approved".equals(status), + activity.getId(), + activity.getDisplayTitle(), + reason + ); + // 创建应用内通知 + boolean activityApproved = "approved".equals(status); + notificationService.createNotification( + activity.getCreatorId(), + activityApproved ? "ACTIVITY_REVIEW_APPROVED" : "ACTIVITY_REVIEW_REJECTED", + activityApproved ? "活动审核通过" : "活动审核未通过", + "你的活动「" + activity.getDisplayTitle() + "」" + + (activityApproved ? "已通过审核" : "审核未通过" + (reason != null ? ":" + reason : "")), + null, + "/activity/" + activity.getId(), + activity.getId(), + null + ); + }); + break; + case "TOPIC": + topicRepository.findById(contentId).ifPresent(topic -> { + boolean topicApproved = "approved".equals(status); + topic.setStatus(topicApproved ? "APPROVED" : "REJECTED"); + topic.setReviewNote(reason); + topic.setReviewedAt(new Date()); + // 自动审核没有人工审核人,留空;审核事实由 content_review.reviewType=AUTO 记录 + topic.setReviewedBy(null); + topicRepository.save(topic); + + // 创建时同步判定、通过即上线:不发通知;存量补偿/复审才通知 + if (notify) { + pushService.pushTopicReview( + topic.getCreatedBy(), + topicApproved, + topic.getId(), + topic.getName(), + reason + ); + notificationService.createNotification( + topic.getCreatedBy(), + topicApproved ? "TOPIC_REVIEW_APPROVED" : "TOPIC_REVIEW_REJECTED", + topicApproved ? "话题审核通过" : "话题审核未通过", + "你创建的话题「" + topic.getName() + "」" + + (topicApproved ? "已通过审核" : "未通过审核" + (reason != null ? ":" + reason : "")), + null, + "/topic/" + topic.getId(), + topic.getId(), + null + ); + } + }); + break; + default: + log.warn("Unknown content type: {}", contentType); + } + } + + public List getReviewHistory(String contentType, String contentId) { + return contentReviewRepository.findByContentTypeAndContentId(contentType, contentId); + } +} diff --git a/campus-server/src/main/java/com/campus/service/DictService.java b/campus-server/src/main/java/com/campus/service/DictService.java new file mode 100644 index 0000000..3a5bd02 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/DictService.java @@ -0,0 +1,221 @@ +package com.campus.service; + +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.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.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DictService { + + private final SysDictRepository sysDictRepository; + private final SysDictItemRepository sysDictItemRepository; + private final AuditLogService auditLogService; + + /** 缓存名称 */ + public static final String CACHE_NAME = "dict"; + + // ============== 公开读取接口(带缓存) ============== + + /** + * 根据字典编码获取启用的字典项(供前端下拉使用,带缓存) + */ + @Cacheable(value = CACHE_NAME, key = "#code", unless = "#result == null || #result.isEmpty()") + public List getEnabledItems(String code) { + SysDict dict = sysDictRepository.findByCode(code) + .orElseThrow(() -> new RuntimeException("字典不存在: " + code)); + if (Boolean.FALSE.equals(dict.getStatus())) { + return Collections.emptyList(); + } + List items = sysDictItemRepository + .findByDictIdAndStatusOrderBySortOrderAsc(dict.getId(), true); + return items.stream().map(this::toItemDTO).collect(Collectors.toList()); + } + + /** + * 根据字典编码和 value 获取 label + */ + public String getLabel(String code, String value) { + if (value == null) { + return null; + } + List items = getEnabledItems(code); + return items.stream() + .filter(i -> value.equals(i.getValue())) + .map(DictItemDTO::getLabel) + .findFirst() + .orElse(value); + } + + // ============== 管理接口 ============== + + public List listAllDicts() { + return sysDictRepository.findAll().stream() + .map(this::toDictDTO) + .collect(Collectors.toList()); + } + + public SysDictDTO getDictById(String id) { + SysDict dict = getDictEntity(id); + return toDictDTO(dict); + } + + public List listItemsByDictId(String dictId) { + return sysDictItemRepository.findByDictIdOrderBySortOrderAsc(dictId).stream() + .map(this::toDictItemDTO) + .collect(Collectors.toList()); + } + + @Transactional + @CacheEvict(value = CACHE_NAME, allEntries = true) + public SysDictDTO createDict(SysDictRequest request) { + sysDictRepository.findByCode(request.getCode()).ifPresent(d -> { + throw new RuntimeException("字典编码已存在: " + request.getCode()); + }); + SysDict dict = new SysDict(); + dict.setCode(request.getCode()); + dict.setName(request.getName()); + dict.setDescription(request.getDescription()); + dict.setStatus(request.getStatus() != null ? request.getStatus() : true); + dict = sysDictRepository.save(dict); + auditLogService.record("CREATE_DICT", "SYS_DICT", dict.getId(), null, + "code=" + dict.getCode() + ",status=" + dict.getStatus(), null); + return toDictDTO(dict); + } + + @Transactional + @CacheEvict(value = CACHE_NAME, allEntries = true) + public SysDictDTO updateDict(String id, SysDictRequest request) { + SysDict dict = getDictEntity(id); + String before = "name=" + dict.getName() + ",status=" + dict.getStatus(); + dict.setName(request.getName()); + dict.setDescription(request.getDescription()); + if (request.getStatus() != null) { + dict.setStatus(request.getStatus()); + } + dict = sysDictRepository.save(dict); + auditLogService.record("UPDATE_DICT", "SYS_DICT", dict.getId(), before, + "name=" + dict.getName() + ",status=" + dict.getStatus(), null); + return toDictDTO(dict); + } + + @Transactional + @CacheEvict(value = CACHE_NAME, allEntries = true) + public void deleteDict(String id) { + SysDict dict = getDictEntity(id); + dict.setDeleted(true); + sysDictRepository.save(dict); + auditLogService.record("DELETE_DICT", "SYS_DICT", dict.getId(), + "code=" + dict.getCode(), "deleted=true", null); + } + + @Transactional + @CacheEvict(value = CACHE_NAME, allEntries = true) + public SysDictItemDTO createItem(String dictId, SysDictItemRequest request) { + getDictEntity(dictId); + SysDictItem item = new SysDictItem(); + item.setDictId(dictId); + copyItemRequest(item, request); + item = sysDictItemRepository.save(item); + auditLogService.record("CREATE_DICT_ITEM", "SYS_DICT_ITEM", item.getId(), null, + "dictId=" + dictId + ",value=" + item.getValue(), null); + return toDictItemDTO(item); + } + + @Transactional + @CacheEvict(value = CACHE_NAME, allEntries = true) + public SysDictItemDTO updateItem(String itemId, SysDictItemRequest request) { + SysDictItem item = sysDictItemRepository.findById(itemId) + .orElseThrow(() -> new RuntimeException("字典项不存在")); + String before = "value=" + item.getValue() + ",label=" + item.getLabel() + ",status=" + item.getStatus(); + copyItemRequest(item, request); + item = sysDictItemRepository.save(item); + auditLogService.record("UPDATE_DICT_ITEM", "SYS_DICT_ITEM", item.getId(), before, + "value=" + item.getValue() + ",label=" + item.getLabel() + ",status=" + item.getStatus(), null); + return toDictItemDTO(item); + } + + @Transactional + @CacheEvict(value = CACHE_NAME, allEntries = true) + public void deleteItem(String itemId) { + SysDictItem item = sysDictItemRepository.findById(itemId) + .orElseThrow(() -> new RuntimeException("字典项不存在")); + item.setDeleted(true); + sysDictItemRepository.save(item); + auditLogService.record("DELETE_DICT_ITEM", "SYS_DICT_ITEM", item.getId(), + "value=" + item.getValue(), "deleted=true", null); + } + + // ============== 私有方法 ============== + + private SysDict getDictEntity(String id) { + return sysDictRepository.findById(id) + .orElseThrow(() -> new RuntimeException("字典不存在")); + } + + private void copyItemRequest(SysDictItem item, SysDictItemRequest req) { + item.setValue(req.getValue()); + item.setLabel(req.getLabel()); + item.setSortOrder(req.getSortOrder() != null ? req.getSortOrder() : 0); + item.setStatus(req.getStatus() != null ? req.getStatus() : true); + item.setColor(req.getColor()); + item.setIcon(req.getIcon()); + item.setExtra(req.getExtra()); + } + + private SysDictDTO toDictDTO(SysDict dict) { + SysDictDTO dto = new SysDictDTO(); + dto.setId(dict.getId()); + dto.setCode(dict.getCode()); + dto.setName(dict.getName()); + dto.setDescription(dict.getDescription()); + dto.setStatus(dict.getStatus()); + dto.setCreatedAt(dict.getCreatedAt()); + dto.setUpdatedAt(dict.getUpdatedAt()); + return dto; + } + + private SysDictItemDTO toDictItemDTO(SysDictItem item) { + SysDictItemDTO dto = new SysDictItemDTO(); + dto.setId(item.getId()); + dto.setDictId(item.getDictId()); + dto.setValue(item.getValue()); + dto.setLabel(item.getLabel()); + dto.setSortOrder(item.getSortOrder()); + dto.setStatus(item.getStatus()); + dto.setColor(item.getColor()); + dto.setIcon(item.getIcon()); + dto.setExtra(item.getExtra()); + dto.setCreatedAt(item.getCreatedAt()); + dto.setUpdatedAt(item.getUpdatedAt()); + return dto; + } + + private DictItemDTO toItemDTO(SysDictItem item) { + DictItemDTO dto = new DictItemDTO(); + dto.setValue(item.getValue()); + dto.setLabel(item.getLabel()); + dto.setColor(item.getColor()); + dto.setIcon(item.getIcon()); + dto.setExtra(item.getExtra()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/EasemobService.java b/campus-server/src/main/java/com/campus/service/EasemobService.java new file mode 100644 index 0000000..8059e85 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/EasemobService.java @@ -0,0 +1,597 @@ +package com.campus.service; + +import com.campus.config.EasemobConfig; +import com.easemob.im.server.EMService; +import com.easemob.im.server.model.EMTextMessage; +import com.easemob.im.server.model.EMKeyValue; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class EasemobService { + + /** 服务内部用于标识“操作目标实际是环信群主”的可恢复冲突。 */ + public static class GroupOwnerConflictException extends RuntimeException { + public GroupOwnerConflictException(String message, Throwable cause) { + super(message, cause); + } + } + + @Autowired(required = false) + private EMService emService; + + @Autowired(required = false) + private EasemobConfig easemobConfig; + + private RestTemplate restTemplate = new RestTemplate(); + + /** + * 将系统用户ID(UUID格式)转换为环信兼容的用户名 + * 环信用户名要求: ^[a-z][0-9a-z-]{1,32}$ + * UUID 去掉连字符后为 32 位十六进制字符,可能以数字开头;添加 "u" 前缀并截断至 32 位 + */ + private String toEasemobUsername(String userId) { + if (userId == null) return null; + String hex = userId.replace("-", ""); + return ("u" + hex).substring(0, 32); + } + + private boolean isServiceAvailable() { + if (emService == null) { + log.warn("环信EMService未初始化,请检查配置"); + return false; + } + return true; + } + + public boolean registerUser(String username, String password) { + if (!isServiceAvailable()) { + log.warn("环信服务不可用,跳过用户注册: {}", username); + return true; + } + + String easemobUsername = toEasemobUsername(username); + try { + emService.user().create(easemobUsername, password).block(); + log.info("环信用户注册成功: {} -> {}", username, easemobUsername); + return true; + } catch (Exception e) { + // 环信 username 唯一约束:用户已存在时 create 会抛 400,此时视为注册成功,避免刷 error 日志 + String msg = e.getMessage() == null ? "" : e.getMessage(); + if (msg.contains("unique") || msg.contains("exists") || msg.contains("already")) { + log.info("环信用户已存在,跳过注册: {} -> {}", username, easemobUsername); + return true; + } + log.error("环信用户注册失败: {}", username, e); + return false; + } + } + + /** 注销清理必须确认远端成功,禁止使用不可用时的降级成功值。 */ + public boolean deleteAccountResource(String type, String resourceId, String userId) { + if (!isServiceAvailable()) return false; + String path; + if ("IM_GROUP".equals(type)) path = "/chatgroups/" + resourceId; + else if ("IM_MEMBER".equals(type)) path = "/chatgroups/" + resourceId + "/users/" + toEasemobUsername(userId); + else path = "/users/" + toEasemobUsername(userId); + try { + return restTemplate.exchange(getRestApiUrl() + path, HttpMethod.DELETE, + new HttpEntity(createAuthHeaders()), Void.class).getStatusCode().is2xxSuccessful(); + } catch (HttpClientErrorException ex) { + return ex.getStatusCode() == HttpStatus.NOT_FOUND || ex.getStatusCode() == HttpStatus.GONE; + } + } + + public boolean deleteUser(String username) { + if (!isServiceAvailable()) { + log.warn("环信服务不可用,跳过用户删除: {}", username); + return true; + } + + String easemobUsername = toEasemobUsername(username); + try { + emService.user().delete(easemobUsername).block(); + log.info("环信用户删除成功: {}", username); + return true; + } catch (Exception e) { + log.error("环信用户删除失败: {}", username, e); + return false; + } + } + + public boolean sendTextMessage(String from, String to, String content) { + return sendTextMessage(from, to, content, null); + } + + public boolean sendTextMessage(String from, String to, String content, String messageId) { + if (!isServiceAvailable()) { + log.warn("环信服务不可用,跳过消息发送: from={}, to={}", from, to); + return true; + } + + try { + String easemobFrom = toEasemobUsername(from); + String easemobTo = toEasemobUsername(to); + Set targets = Collections.singleton(easemobTo); + EMTextMessage message = new EMTextMessage().text(content); + + Set extSet = null; + if (messageId != null) { + extSet = new HashSet<>(); + extSet.add(EMKeyValue.of("messageId", messageId)); + } + + emService.message() + .send(easemobFrom, "users", targets, message, extSet) + .block(); + + log.info("环信消息发送成功: from={}, to={}", from, to); + return true; + } catch (Exception e) { + log.error("环信消息发送失败: from={}, to={}", from, to, e); + return false; + } + } + + /** + * 发送私聊分享卡片。消息主体保留可读文本供旧客户端兜底,新客户端从扩展字段渲染卡片。 + * 与普通消息的降级策略不同:分享的核心结果就是实际投递,服务不可用时必须明确返回 false。 + */ + public boolean sendShareCardMessage(String from, String to, String displayText, + String messageId, String cardJson) { + return sendShareCardMessageInternal(from, "users", + to == null ? null : toEasemobUsername(to), displayText, messageId, cardJson); + } + + /** 发送群聊分享卡片;groupId 使用环信群 ID,不做本地用户 ID 转换。 */ + public boolean sendGroupShareCardMessage(String from, String groupId, String displayText, + String messageId, String cardJson) { + return sendShareCardMessageInternal(from, "chatgroups", groupId, displayText, messageId, cardJson); + } + + private boolean sendShareCardMessageInternal(String from, String targetType, String target, + String displayText, String messageId, String cardJson) { + if (!isServiceAvailable()) { + log.warn("环信服务不可用,分享卡片未发送: from={}, targetType={}, target={}", + from, targetType, target); + return false; + } + if (target == null || target.trim().isEmpty()) { + log.warn("环信分享卡片目标为空: from={}, targetType={}", from, targetType); + return false; + } + + try { + Set extSet = new LinkedHashSet<>(); + extSet.add(EMKeyValue.of("messageId", messageId)); + extSet.add(EMKeyValue.of("messageType", "SHARE_CARD")); + extSet.add(EMKeyValue.of("card", cardJson)); + + EMTextMessage message = new EMTextMessage().text(displayText); + emService.message() + .send(toEasemobUsername(from), targetType, Collections.singleton(target), message, extSet) + .block(); + log.info("环信分享卡片发送成功: from={}, targetType={}, target={}, messageId={}", + from, targetType, target, messageId); + return true; + } catch (Exception ex) { + log.error("环信分享卡片发送失败: from={}, targetType={}, target={}, messageId={}", + from, targetType, target, messageId, ex); + return false; + } + } + + public boolean sendImageMessage(String from, String to, String imageUrl) { + if (!isServiceAvailable()) { + log.warn("环信服务不可用,跳过图片消息发送"); + return true; + } + + log.warn("环信图片消息功能需要上传附件到环信服务器,请使用上传API"); + return false; + } + + public String getAppToken() { + if (!isServiceAvailable()) { + log.warn("环信服务不可用,跳过Token获取"); + return null; + } + + try { + Object token = emService.token().getAppToken().block(); + return token != null ? token.toString() : null; + } catch (Exception e) { + log.error("获取环信App Token失败: {}", e.getMessage()); + return null; + } + } + + public boolean isUserOnline(String username) { + log.warn("环信SDK新版本暂不支持查询用户在线状态"); + return false; + } + + public String getAppKey() { + if (easemobConfig != null && easemobConfig.getAppKey() != null + && !easemobConfig.getAppKey().contains("your-app-key")) { + return easemobConfig.getAppKey(); + } + log.warn("环信appKey未正确配置"); + return null; + } + + // ===================== 群聊相关 API (使用REST API) ===================== + + private String getRestApiUrl() { + String appKey = getAppKey(); + if (appKey == null) return null; + String[] parts = appKey.split("#"); + String baseUrl = easemobConfig.getBaseUrl() != null && !easemobConfig.getBaseUrl().isEmpty() + ? easemobConfig.getBaseUrl() + : "https://a1.easemob.com"; + return baseUrl + "/" + parts[0] + "/" + parts[1]; + } + + private String getAdminToken() { + try { + String url = getRestApiUrl() + "/token"; + Map params = new HashMap<>(); + params.put("grant_type", "client_credentials"); + params.put("client_id", easemobConfig.getClientId()); + params.put("client_secret", easemobConfig.getClientSecret()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return (String) response.getBody().get("access_token"); + } + } catch (Exception e) { + log.error("获取环信管理Token失败", e); + } + return null; + } + + private HttpHeaders createAuthHeaders() { + String token = getAdminToken(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (token != null) { + headers.set("Authorization", "Bearer " + token); + } + return headers; + } + + public String createGroup(String groupName, String description, String owner, List members) { + try { + String url = getRestApiUrl() + "/chatgroups"; + + String easemobOwner = toEasemobUsername(owner); + List easemobMembers = members != null + ? members.stream().map(this::toEasemobUsername).collect(Collectors.toList()) + : Collections.emptyList(); + + Map params = new HashMap<>(); + params.put("groupname", groupName); + params.put("desc", description != null ? description : ""); + params.put("owner", easemobOwner); + params.put("members", easemobMembers); + params.put("public", false); + params.put("approval", true); + + HttpEntity> entity = new HttpEntity<>(params, createAuthHeaders()); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Object dataObj = response.getBody().get("data"); + String groupId = null; + if (dataObj instanceof Map) { + Object idObj = ((Map) dataObj).get("groupid"); + if (idObj != null) { + groupId = idObj.toString(); + } + } else if (dataObj instanceof List && !((List) dataObj).isEmpty()) { + Object first = ((List) dataObj).get(0); + if (first instanceof Map) { + Object idObj = ((Map) first).get("groupid"); + if (idObj != null) { + groupId = idObj.toString(); + } + } + } else if (dataObj instanceof String) { + groupId = (String) dataObj; + } + log.info("环信群组创建成功: groupId={}, name={}", groupId, groupName); + return groupId; + } + log.error("环信群组创建失败: groupName={}, response={}", groupName, response.getBody()); + return null; + } catch (Exception e) { + log.error("环信群组创建失败: groupName={}", groupName, e); + return null; + } + } + + public Map getGroup(String groupId) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId; + HttpEntity entity = new HttpEntity<>(createAuthHeaders()); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); + + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody(); + } + } catch (Exception e) { + log.error("获取环信群组信息失败: groupId={}", groupId, e); + } + return null; + } + + public boolean deleteGroup(String groupId) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId; + HttpEntity entity = new HttpEntity<>(createAuthHeaders()); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, entity, Void.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("环信群组删除成功: groupId={}", groupId); + return true; + } + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.NOT_FOUND || e.getStatusCode() == HttpStatus.GONE) { + log.info("环信群组已不存在,按删除成功处理: groupId={}", groupId); + return true; + } + log.error("环信群组删除失败: groupId={}", groupId, e); + } catch (Exception e) { + log.error("环信群组删除失败: groupId={}", groupId, e); + } + return false; + } + + public boolean addGroupMembers(String groupId, List members) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId + "/users"; + + List easemobMembers = members != null + ? members.stream().map(this::toEasemobUsername).collect(Collectors.toList()) + : Collections.emptyList(); + + Map params = new HashMap<>(); + params.put("usernames", easemobMembers); + + HttpEntity> entity = new HttpEntity<>(params, createAuthHeaders()); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("环信添加群成员成功: groupId={}, count={}", groupId, members.size()); + return true; + } + } catch (org.springframework.web.client.HttpClientErrorException e) { + String body = e.getResponseBodyAsString(); + if (body != null && body.contains("already in group")) { + log.info("环信添加群成员: 用户已在群中, groupId={}", groupId); + return true; + } + log.error("环信添加群成员失败: groupId={}", groupId, e); + } catch (Exception e) { + log.error("环信添加群成员失败: groupId={}", groupId, e); + } + return false; + } + + public boolean removeGroupMember(String groupId, String member) { + try { + return removeGroupMemberDetectingOwnerConflict(groupId, member); + } catch (GroupOwnerConflictException e) { + return false; + } + } + + public boolean removeGroupMemberDetectingOwnerConflict(String groupId, String member) { + try { + String easemobMember = toEasemobUsername(member); + String url = getRestApiUrl() + "/chatgroups/" + groupId + "/users/" + easemobMember; + HttpEntity entity = new HttpEntity<>(createAuthHeaders()); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, entity, Void.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("环信移除群成员成功: groupId={}, member={}", groupId, member); + return true; + } + } catch (HttpClientErrorException e) { + if (isGroupOwnerConflict(e)) { + log.warn("环信拒绝移除群成员,目标用户是环信群主: groupId={}, member={}", groupId, member); + throw new GroupOwnerConflictException("目标用户是环信群主", e); + } + log.error("环信移除群成员失败: groupId={}, member={}", groupId, member, e); + } catch (Exception e) { + log.error("环信移除群成员失败: groupId={}, member={}", groupId, member, e); + } + return false; + } + + public boolean sendGroupMessage(String from, String groupId, String content) { + if (!isServiceAvailable()) { + log.warn("环信服务不可用,跳过群消息发送"); + return false; + } + if (groupId == null) { + log.warn("环信群消息groupId为空,跳过发送: from={}", from); + return false; + } + + try { + String easemobFrom = toEasemobUsername(from); + EMTextMessage message = new EMTextMessage().text(content); + emService.message() + .send(easemobFrom, "chatgroups", Collections.singleton(groupId), message, null) + .block(); + log.info("环信群消息发送成功: from={}, groupId={}", from, groupId); + return true; + } catch (Exception e) { + log.error("环信群消息发送失败: from={}, groupId={}", from, groupId, e); + return false; + } + } + + public List getGroupMembers(String groupId) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId + "/users"; + HttpEntity entity = new HttpEntity<>(createAuthHeaders()); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return (List) response.getBody().get("data"); + } + } catch (Exception e) { + log.error("获取环信群成员失败: groupId={}", groupId, e); + } + return Collections.emptyList(); + } + + public boolean transferGroupOwner(String groupId, String newOwner) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId; + + String easemobNewOwner = toEasemobUsername(newOwner); + + Map params = new HashMap<>(); + params.put("newowner", easemobNewOwner); + + HttpEntity> entity = new HttpEntity<>(params, createAuthHeaders()); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.PUT, entity, Void.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("环信群主转让成功: groupId={}, newOwner={}", groupId, newOwner); + return true; + } + } catch (HttpClientErrorException e) { + if (isAlreadyGroupOwner(e)) { + log.info("环信群主已是目标用户,按转让成功处理: groupId={}, newOwner={}", groupId, newOwner); + return true; + } + log.error("环信群主转让失败: groupId={}, newOwner={}", groupId, newOwner, e); + } catch (Exception e) { + log.error("环信群主转让失败: groupId={}, newOwner={}", groupId, newOwner, e); + } + return false; + } + + boolean isAlreadyGroupOwner(HttpClientErrorException e) { + if (e.getStatusCode() != HttpStatus.FORBIDDEN) { + return false; + } + String body = e.getResponseBodyAsString(); + return body != null && body.toLowerCase(Locale.ROOT) + .contains("new owner and old owner are the same"); + } + + public boolean addGroupAdmin(String groupId, String userId) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId + "/admin"; + Map body = Collections.singletonMap("newadmin", toEasemobUsername(userId)); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, + new HttpEntity<>(body, createAuthHeaders()), Void.class); + return response.getStatusCode().is2xxSuccessful(); + } catch (Exception e) { + log.error("环信设置群管理员失败: groupId={}, userId={}", groupId, userId, e); + return false; + } + } + + public boolean removeGroupAdmin(String groupId, String userId) { + try { + return removeGroupAdminDetectingOwnerConflict(groupId, userId); + } catch (GroupOwnerConflictException e) { + return false; + } + } + + public boolean removeGroupAdminDetectingOwnerConflict(String groupId, String userId) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId + "/admin/" + toEasemobUsername(userId); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, + new HttpEntity<>(createAuthHeaders()), Void.class); + return response.getStatusCode().is2xxSuccessful(); + } catch (HttpClientErrorException e) { + if (isGroupOwnerConflict(e)) { + log.warn("环信拒绝撤销群管理员,目标用户是环信群主: groupId={}, userId={}", groupId, userId); + throw new GroupOwnerConflictException("目标用户是环信群主", e); + } + log.error("环信撤销群管理员失败: groupId={}, userId={}", groupId, userId, e); + return false; + } catch (Exception e) { + log.error("环信撤销群管理员失败: groupId={}, userId={}", groupId, userId, e); + return false; + } + } + + boolean isGroupOwnerConflict(HttpClientErrorException e) { + if (e.getStatusCode() != HttpStatus.FORBIDDEN) { + return false; + } + String body = e.getResponseBodyAsString(); + if (body == null) { + return false; + } + String normalized = body.toLowerCase(Locale.ROOT); + return normalized.contains("forbidden operation on group owner") + || (normalized.contains("forbiddenopexception") && normalized.contains("group owner")); + } + + public boolean setGroupMemberMute(String groupId, String userId, boolean muted) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId + "/mute"; + HttpMethod method = HttpMethod.POST; + HttpEntity entity; + if (muted) { + Map body = new HashMap<>(); + body.put("usernames", Collections.singletonList(toEasemobUsername(userId))); + body.put("mute_duration", -1); + entity = new HttpEntity<>(body, createAuthHeaders()); + } else { + url += "/" + toEasemobUsername(userId); + method = HttpMethod.DELETE; + entity = new HttpEntity<>(createAuthHeaders()); + } + ResponseEntity response = restTemplate.exchange(url, method, entity, Void.class); + return response.getStatusCode().is2xxSuccessful(); + } catch (Exception e) { + log.error("环信设置群成员禁言失败: groupId={}, userId={}, muted={}", groupId, userId, muted, e); + return false; + } + } + + public boolean updateGroupInfo(String groupId, String newName, String newDescription) { + try { + String url = getRestApiUrl() + "/chatgroups/" + groupId; + + Map params = new HashMap<>(); + if (newName != null) params.put("groupname", newName); + if (newDescription != null) params.put("desc", newDescription); + + HttpEntity> entity = new HttpEntity<>(params, createAuthHeaders()); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.PUT, entity, Void.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("环信群资料更新成功: groupId={}", groupId); + return true; + } + } catch (Exception e) { + log.error("环信群资料更新失败: groupId={}", groupId, e); + } + return false; + } + +} diff --git a/campus-server/src/main/java/com/campus/service/FollowService.java b/campus-server/src/main/java/com/campus/service/FollowService.java new file mode 100644 index 0000000..cc9366f --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/FollowService.java @@ -0,0 +1,294 @@ +package com.campus.service; + +import com.campus.common.PageResult; +import com.campus.dto.request.NotificationRequest; +import com.campus.dto.response.FollowUserDTO; +import com.campus.entity.Follow; +import com.campus.entity.User; +import com.campus.repository.FollowRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FollowService { + private final ClickGuardService clickGuardService; + private final org.springframework.context.ApplicationEventPublisher eventPublisher; + private final InteractionLockService interactionLockService; + + private final FollowRepository followRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + private final PushService pushService; + + @Transactional + public Map toggleFollow(String targetUserId) { + String currentUserId = UserContext.getCurrentUserId(); + + if (currentUserId == null || currentUserId.isEmpty()) { + throw new com.campus.common.BusinessException("用户未登录"); + } + if (targetUserId == null || targetUserId.isEmpty()) { + throw new com.campus.common.BusinessException("目标用户ID不能为空"); + } + if (currentUserId.equals(targetUserId)) { + throw new com.campus.common.BusinessException("不能关注自己"); + } + + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_USER_FOLLOW, currentUserId, targetUserId); + interactionLockService.lockUsers(currentUserId, targetUserId); + Map result = new HashMap<>(); + + Optional existing = followRepository.findForInteraction(currentUserId, targetUserId); + + if (existing.isPresent()) { + followRepository.delete(existing.get()); + decrementFollowCount(currentUserId, targetUserId); + result.put("followed", false); + result.put("message", "已取消关注"); + } else { + followRepository.upsertActive(UUID.randomUUID().toString(), currentUserId, targetUserId); + incrementFollowCount(currentUserId, targetUserId); + result.put("followed", true); + result.put("message", "关注成功"); + + eventPublisher.publishEvent(new com.campus.service.event.InteractionCommittedAction(() -> { + User follower = userRepository.findById(currentUserId).orElse(null); + NotificationRequest nr = new NotificationRequest(); + nr.setType("FOLLOW"); + nr.setTitle("新的关注"); + nr.setContent((follower != null ? follower.getNickname() : "有人") + " 关注了你"); + nr.setAvatar(follower != null ? follower.getAvatar() : null); + nr.setRelatedId(currentUserId); + try { + notificationService.create(nr, targetUserId, currentUserId); + } catch (Exception e) { + log.warn("发送关注通知失败: {}", e.getMessage()); + } + + // 推送:通知被关注的用户 + if (follower != null) { + try { + pushService.pushFollowYou( + targetUserId, + follower.getNickname(), + follower.getAvatar(), + currentUserId + ); + } catch (Exception e) { + log.warn("发送关注推送失败: {}", e.getMessage()); + } + } + })); + } + + result.put("followersCount", followRepository.countByFollowingId(targetUserId)); + result.put("followingCount", followRepository.countByFollowerId(targetUserId)); + boolean isMutual = followRepository.existsByFollowerIdAndFollowingId(targetUserId, currentUserId); + result.put("isMutual", isMutual); + + return result; + } + + private void incrementFollowCount(String followerId, String followingId) { + User follower = userRepository.findById(followerId).orElse(null); + if (follower != null) { + follower.setFollowingCount((follower.getFollowingCount() == null ? 0 : follower.getFollowingCount()) + 1); + userRepository.save(follower); + } + User following = userRepository.findById(followingId).orElse(null); + if (following != null) { + following.setFollowersCount((following.getFollowersCount() == null ? 0 : following.getFollowersCount()) + 1); + userRepository.save(following); + } + } + + private void decrementFollowCount(String followerId, String followingId) { + User follower = userRepository.findById(followerId).orElse(null); + if (follower != null) { + int current = follower.getFollowingCount() == null ? 0 : follower.getFollowingCount(); + follower.setFollowingCount(Math.max(0, current - 1)); + userRepository.save(follower); + } + User following = userRepository.findById(followingId).orElse(null); + if (following != null) { + int current = following.getFollowersCount() == null ? 0 : following.getFollowersCount(); + following.setFollowersCount(Math.max(0, current - 1)); + userRepository.save(following); + } + } + + public PageResult listFollowers(String targetUserId, int page, int size) { + String currentUserId = UserContext.getCurrentUserId(); + if (targetUserId == null || targetUserId.isEmpty()) { + throw new com.campus.common.BusinessException("用户ID不能为空"); + } + Pageable pageable = PageRequest.of(page < 1 ? 0 : page - 1, size < 1 ? 20 : size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page followPage = followRepository.findByFollowingId(targetUserId, pageable); + + if (followPage.isEmpty()) { + return PageResult.empty(page, size); + } + + List followerIds = followPage.getContent().stream().map(Follow::getFollowerId).collect(Collectors.toList()); + List users = userRepository.findByIdIn(followerIds); + Map userMap = new HashMap<>(); + for (User u : users) { + userMap.put(u.getId(), u); + } + + Map followedBack = new HashMap<>(); + if (currentUserId != null && !currentUserId.isEmpty()) { + for (String uid : followerIds) { + followedBack.put(uid, followRepository.existsByFollowerIdAndFollowingId(uid, currentUserId)); + } + } + + Map createdAtMap = new HashMap<>(); + for (Follow f : followPage.getContent()) { + createdAtMap.put(f.getFollowerId(), f.getCreatedAt()); + } + + List dtoList = new ArrayList<>(followerIds.size()); + for (String uid : followerIds) { + User u = userMap.get(uid); + if (u == null) continue; + FollowUserDTO dto = toFollowUserDTO(u, currentUserId); + dto.setFollowedAt(createdAtMap.get(uid)); + dto.setIsMutual(followedBack.getOrDefault(uid, false)); + dtoList.add(dto); + } + + return PageResult.of(dtoList, followPage.getTotalElements(), page, size); + } + + public PageResult listFollowing(String targetUserId, int page, int size) { + String currentUserId = UserContext.getCurrentUserId(); + if (targetUserId == null || targetUserId.isEmpty()) { + throw new com.campus.common.BusinessException("用户ID不能为空"); + } + Pageable pageable = PageRequest.of(page < 1 ? 0 : page - 1, size < 1 ? 20 : size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page followPage = followRepository.findByFollowerId(targetUserId, pageable); + + if (followPage.isEmpty()) { + return PageResult.empty(page, size); + } + + List followingIds = followPage.getContent().stream().map(Follow::getFollowingId).collect(Collectors.toList()); + List users = userRepository.findByIdIn(followingIds); + Map userMap = new HashMap<>(); + for (User u : users) { + userMap.put(u.getId(), u); + } + + Map followedBack = new HashMap<>(); + if (currentUserId != null && !currentUserId.isEmpty()) { + for (String uid : followingIds) { + followedBack.put(uid, followRepository.existsByFollowerIdAndFollowingId(uid, currentUserId)); + } + } + + Map createdAtMap = new HashMap<>(); + for (Follow f : followPage.getContent()) { + createdAtMap.put(f.getFollowingId(), f.getCreatedAt()); + } + + List dtoList = new ArrayList<>(followingIds.size()); + for (String uid : followingIds) { + User u = userMap.get(uid); + if (u == null) continue; + FollowUserDTO dto = toFollowUserDTO(u, currentUserId); + dto.setFollowedAt(createdAtMap.get(uid)); + dto.setIsMutual(followedBack.getOrDefault(uid, false)); + dtoList.add(dto); + } + + return PageResult.of(dtoList, followPage.getTotalElements(), page, size); + } + + public Map getFollowStatus(String targetUserId) { + String currentUserId = UserContext.getCurrentUserId(); + if (targetUserId == null || targetUserId.isEmpty()) { + throw new com.campus.common.BusinessException("用户ID不能为空"); + } + + Map result = new HashMap<>(); + if (!userRepository.existsById(targetUserId)) { + throw new com.campus.common.BusinessException("用户不存在"); + } + + // 实时统计,避免 User 表缓存计数与 user_follow 表不一致 + result.put("followersCount", followRepository.countByFollowingId(targetUserId)); + result.put("followingCount", followRepository.countByFollowerId(targetUserId)); + + if (currentUserId != null && !currentUserId.isEmpty() && !currentUserId.equals(targetUserId)) { + result.put("isFollowed", followRepository.existsByFollowerIdAndFollowingId(currentUserId, targetUserId)); + result.put("isMutual", followRepository.existsByFollowerIdAndFollowingId(targetUserId, currentUserId)); + } else { + result.put("isFollowed", false); + result.put("isMutual", false); + } + return result; + } + + public Set getFollowedUserIds(String userId) { + if (userId == null || userId.isEmpty()) { + return Collections.emptySet(); + } + List follows = followRepository.findByFollowerId(userId); + Set ids = new HashSet<>(); + for (Follow f : follows) { + ids.add(f.getFollowingId()); + } + return ids; + } + + private FollowUserDTO toFollowUserDTO(User user, String currentUserId) { + FollowUserDTO dto = new FollowUserDTO(); + dto.setId(user.getId()); + dto.setNumber(user.getNumber()); + dto.setNickname(user.getNickname()); + dto.setAvatar(user.getAvatar()); + dto.setGender(user.getGender()); + dto.setAge(user.getBirthday() == null ? null : ageOf(user.getBirthday())); + dto.setUniversityName(user.getUniversityName()); + dto.setMajorName(user.getMajorName()); + dto.setGrade(user.getGrade()); + dto.setBio(user.getBio()); + dto.setFollowersCount(user.getFollowersCount() == null ? 0 : user.getFollowersCount()); + dto.setFollowingCount(user.getFollowingCount() == null ? 0 : user.getFollowingCount()); + + if (currentUserId != null && !currentUserId.isEmpty() && !currentUserId.equals(user.getId())) { + dto.setIsFollowed(followRepository.existsByFollowerIdAndFollowingId(currentUserId, user.getId())); + } else { + dto.setIsFollowed(false); + } + dto.setIsMutual(false); + dto.setXuexinCertified(user.getXuexinCertified()); + return dto; + } + + private int ageOf(Date birthday) { + Calendar now = Calendar.getInstance(); + Calendar born = Calendar.getInstance(); + born.setTime(birthday); + int age = now.get(Calendar.YEAR) - born.get(Calendar.YEAR); + if (now.get(Calendar.DAY_OF_YEAR) < born.get(Calendar.DAY_OF_YEAR)) { + age--; + } + return age; + } +} diff --git a/campus-server/src/main/java/com/campus/service/FriendService.java b/campus-server/src/main/java/com/campus/service/FriendService.java new file mode 100644 index 0000000..0c63fbe --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/FriendService.java @@ -0,0 +1,1047 @@ +package com.campus.service; + +import com.campus.common.PageResult; +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.request.NotificationRequest; +import com.campus.dto.response.FriendDTO; +import com.campus.dto.response.RecommendBatchDTO; +import com.campus.dto.response.UserProfileDTO; +import com.campus.entity.Friendship; +import com.campus.entity.Greeting; +import com.campus.entity.User; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.GreetingRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FriendService { + private final ClickGuardService clickGuardService; + private final AuthorizationService authorizationService; + private final org.springframework.context.ApplicationEventPublisher eventPublisher; + private final InteractionLockService interactionLockService; + + private final FriendshipRepository friendshipRepository; + private final UserRepository userRepository; + private final GreetingRepository greetingRepository; + private final NotificationService notificationService; + private final RecommendService recommendService; + private final DictService dictService; + private final FollowService followService; + private final StringRedisTemplate redisTemplate; + private final PushService pushService; + private final SseService sseService; + private final PrivacySettingsService privacySettingsService; + + /** 推荐批次状态:Redis Set key 前缀 + TTL */ + private static final String RECOMMEND_SHOWN_KEY = "recommend:shown:"; + private static final Duration RECOMMEND_TTL = Duration.ofHours(1); + /** 候选池大小为 limit 的倍数(保证至少能换 POOL_MULTIPLIER-1 批才触发重置) */ + private static final int POOL_MULTIPLIER = 5; + + // ===================== 关系查询 ===================== + + public List getMyFriends() { + String userId = UserContext.getCurrentUserId(); + return friendshipRepository.findByUserIdAndStatus(userId, "ACCEPTED"); + } + + public List getMyPendingRequests() { + String userId = UserContext.getCurrentUserId(); + return friendshipRepository.findByFriendIdAndStatus(userId, "PENDING"); + } + + /** + * 获取我收到的待处理好友申请(含对方用户信息) + */ + public List> getMyReceivedPendingWithUser() { + String userId = UserContext.getCurrentUserId(); + List list = friendshipRepository.findByFriendIdAndStatus(userId, "PENDING"); + return list.stream().map(f -> { + Map item = new LinkedHashMap<>(); + item.put("id", f.getId()); + item.put("userId", f.getUserId()); + item.put("friendId", f.getFriendId()); + item.put("status", f.getStatus()); + item.put("createdAt", f.getCreatedAt()); + userRepository.findById(f.getUserId()).ifPresent(u -> { + Map userMap = new LinkedHashMap<>(); + userMap.put("id", u.getId()); + userMap.put("nickname", u.getNickname()); + userMap.put("avatar", u.getAvatar()); + userMap.put("universityName", u.getUniversityName()); + userMap.put("majorName", u.getMajorName()); + userMap.put("grade", u.getGrade()); + userMap.put("bio", u.getBio()); + item.put("user", userMap); + }); + return item; + }).collect(Collectors.toList()); + } + + /** + * 获取我发出的待处理好友申请(含对方用户信息) + */ + public List> getMySentRequests() { + String userId = UserContext.getCurrentUserId(); + List list = friendshipRepository.findByUserIdAndStatus(userId, "PENDING"); + return list.stream().map(f -> { + Map item = new LinkedHashMap<>(); + item.put("id", f.getId()); + item.put("userId", f.getFriendId()); + item.put("friendId", f.getUserId()); + item.put("status", f.getStatus()); + item.put("createdAt", f.getCreatedAt()); + userRepository.findById(f.getFriendId()).ifPresent(u -> { + Map userMap = new LinkedHashMap<>(); + userMap.put("id", u.getId()); + userMap.put("nickname", u.getNickname()); + userMap.put("avatar", u.getAvatar()); + userMap.put("universityName", u.getUniversityName()); + userMap.put("majorName", u.getMajorName()); + userMap.put("grade", u.getGrade()); + userMap.put("bio", u.getBio()); + item.put("user", userMap); + }); + return item; + }).collect(Collectors.toList()); + } + + public Friendship getFriendship(String userId, String friendId) { + return friendshipRepository.findByUserIdAndFriendId(userId, friendId) + .orElseThrow(() -> new com.campus.common.BusinessException("好友关系不存在")); + } + + // ===================== 申请 / 同意 / 拒绝 ===================== + + @Transactional + public Friendship addFriend(AddFriendRequest request) { + String userId = InteractionLockService.currentUserId(); + String friendId = request.getFriendId(); + + if (userId.equals(friendId)) { + throw new com.campus.common.BusinessException("不能添加自己为好友"); + } + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_REQUEST, userId, friendId); + interactionLockService.lockUsers(userId, friendId); + return createFriendRequest(request, userId, friendId); + } + + /** 只允许在已持有双方用户锁的写事务内调用。 */ + private Friendship createFriendRequest(AddFriendRequest request, String userId, String friendId) { + User friend = userRepository.findById(friendId) + .orElseThrow(() -> new com.campus.common.BusinessException("用户不存在")); + + if (!privacySettingsService.allowsStrangerFriendRequest(friendId)) { + throw new com.campus.common.BusinessException("对方已关闭陌生人好友申请"); + } + + if (friendshipRepository.findForInteraction(userId, friendId).isPresent() + || friendshipRepository.findForInteraction(friendId, userId).isPresent()) { + throw new com.campus.common.BusinessException("已存在好友关系或待处理申请"); + } + + Friendship friendship = friendshipRepository.findIncludingDeleted(userId, friendId).orElseGet(Friendship::new); + friendship.setDeleted(false); + friendship.setUserId(userId); + friendship.setFriendId(friendId); + friendship.setStatus("PENDING"); + friendship = friendshipRepository.save(friendship); + + final String committedFriendshipId = friendship.getId(); + final String committedSenderId = friendship.getUserId(); + eventPublisher.publishEvent(new com.campus.service.event.InteractionCommittedAction(() -> { + User currentUser = UserContext.getCurrentUser(); + String content = (currentUser != null ? currentUser.getNickname() : "有人") + + " 想添加你为好友" + + (request.getMessage() != null && !request.getMessage().isEmpty() + ? ":附言:" + request.getMessage() + : ""); + + NotificationRequest notification = new NotificationRequest(); + notification.setType("FRIEND_REQUEST"); + notification.setTitle("新的好友申请"); + notification.setContent(content); + notification.setAvatar(currentUser != null ? currentUser.getAvatar() : null); + notification.setRelatedId(committedFriendshipId); + notificationService.create(notification, friend.getId(), userId); + + // 推送:通知接收者有新的好友申请 + if (currentUser != null) { + pushService.pushFriendRequest( + friend.getId(), + currentUser.getNickname(), + currentUser.getAvatar(), + userId + ); + } + + })); + + return friendship; + } + + /** + * 打招呼:向推荐/陌生人发送一条默认问候语。 + * 打招呼等价于发起好友申请,需等待对方同意后才能建立好友关系并聊天。 + */ + @Transactional + public void greetUser(GreetRequest request) { + String userId = InteractionLockService.currentUserId(); + if (userId == null) { + throw new com.campus.common.BusinessException("用户未登录"); + } + + String friendId = request.getFriendId(); + if (userId.equals(friendId)) { + throw new com.campus.common.BusinessException("不能对自己打招呼"); + } + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_REQUEST, userId, friendId); + interactionLockService.lockUsers(userId, friendId); + if (!userRepository.existsById(friendId)) { + throw new com.campus.common.BusinessException("用户不存在"); + } + if (greetingRepository.existsByUserIdAndTargetUserId(userId, friendId)) { + throw new com.campus.common.BusinessException("已经打过招呼了"); + } + + String content = (request.getMessage() != null && !request.getMessage().trim().isEmpty()) + ? request.getMessage().trim() + : "你好,很高兴认识你~"; + + Greeting greeting = greetingRepository.findIncludingDeleted(userId, friendId).orElseGet(Greeting::new); + greeting.setDeleted(false); + greeting.setUserId(userId); + greeting.setTargetUserId(friendId); + greeting.setMessage(content); + greetingRepository.save(greeting); + + // 打招呼即发起好友申请,需对方同意后才能聊天 + AddFriendRequest addRequest = new AddFriendRequest(); + addRequest.setFriendId(friendId); + addRequest.setMessage(content); + createFriendRequest(addRequest, userId, friendId); + } + + @Transactional + public Friendship acceptFriendRequest(String id) { + String actorId = InteractionLockService.currentUserId(); + java.util.List parties = friendshipRepository.findParties(id); + if (parties.isEmpty()) throw new com.campus.common.BusinessException("好友请求不存在"); + String requesterId = (String) parties.get(0)[0]; + String receiverId = (String) parties.get(0)[1]; + authorizationService.requireOwnerOrAdmin(receiverId, "无权处理该好友请求"); + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_RESPOND, actorId, requesterId); + interactionLockService.lockUsers(requesterId, receiverId); + Friendship friendship = friendshipRepository.findByIdForInteraction(id) + .orElseThrow(() -> new com.campus.common.BusinessException("好友请求不存在")); + + if (!"PENDING".equals(friendship.getStatus())) { + throw new com.campus.common.BusinessException("该好友请求已被处理"); + } + + String currentUserId = InteractionLockService.currentUserId(); + authorizationService.requireOwnerOrAdmin(friendship.getFriendId(), "无权处理该好友请求"); + + friendship.setStatus("ACCEPTED"); + friendship = friendshipRepository.save(friendship); + + if (!friendshipRepository.findForInteraction(friendship.getFriendId(), friendship.getUserId()).isPresent()) { + Friendship reverse = friendshipRepository.findIncludingDeleted(friendship.getFriendId(), friendship.getUserId()).orElseGet(Friendship::new); + reverse.setDeleted(false); + reverse.setUserId(friendship.getFriendId()); + reverse.setFriendId(friendship.getUserId()); + reverse.setStatus("ACCEPTED"); + friendshipRepository.save(reverse); + } + + final String committedFriendshipId = friendship.getId(); + final String committedSenderId = friendship.getUserId(); + eventPublisher.publishEvent(new com.campus.service.event.InteractionCommittedAction(() -> { + User acceptor = UserContext.getCurrentUser(); + NotificationRequest notification = new NotificationRequest(); + notification.setType("FRIEND_ACCEPTED"); + notification.setTitle("好友申请已通过"); + notification.setContent((acceptor != null ? acceptor.getNickname() : "对方") + + " 已同意添加你为好友"); + notification.setAvatar(acceptor != null ? acceptor.getAvatar() : null); + notification.setRelatedId(committedFriendshipId); + notificationService.create(notification, committedSenderId, currentUserId); + + // 推送:通知申请人,对方已同意 + if (acceptor != null) { + pushService.pushFriendAccepted( + committedSenderId, + acceptor.getNickname(), + acceptor.getAvatar(), + currentUserId + ); + } + + })); + + return friendship; + } + + @Transactional + public void rejectFriendRequest(String id) { + String actorId = InteractionLockService.currentUserId(); + java.util.List parties = friendshipRepository.findParties(id); + if (parties.isEmpty()) throw new com.campus.common.BusinessException("好友请求不存在"); + String requesterId = (String) parties.get(0)[0]; + String receiverId = (String) parties.get(0)[1]; + authorizationService.requireOwnerOrAdmin(receiverId, "无权处理该好友请求"); + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_RESPOND, actorId, requesterId); + interactionLockService.lockUsers(requesterId, receiverId); + Friendship friendship = friendshipRepository.findByIdForInteraction(id) + .orElseThrow(() -> new com.campus.common.BusinessException("好友请求不存在")); + + String currentUserId = InteractionLockService.currentUserId(); + authorizationService.requireOwnerOrAdmin(friendship.getFriendId(), "无权处理该好友请求"); + if (!"PENDING".equals(friendship.getStatus())) { + throw new com.campus.common.BusinessException("该好友请求已被处理"); + } + friendshipRepository.delete(friendship); + } + + /** + * 删除好友(仅作用于已是好友的 ACCEPTED 关系) + * + * @throws RuntimeException 各种校验失败 + */ + @Transactional + public void deleteFriend(String friendId) { + String userId = InteractionLockService.currentUserId(); + + if (friendId == null || friendId.isEmpty()) { + throw new com.campus.common.BusinessException("好友ID不能为空"); + } + if (userId.equals(friendId)) { + throw new com.campus.common.BusinessException("不能对自己执行该操作"); + } + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_CHANGE, userId, friendId); + interactionLockService.lockUsers(userId, friendId); + if (!userRepository.existsById(friendId)) { + throw new com.campus.common.BusinessException("好友不存在"); + } + + // 必须存在 ACCEPTED 关系才允许"删除好友" + Friendship direct = friendshipRepository.findForInteraction(userId, friendId).orElse(null); + Friendship reverse = friendshipRepository.findForInteraction(friendId, userId).orElse(null); + + boolean hasAccepted = + (direct != null && "ACCEPTED".equals(direct.getStatus())) + || (reverse != null && "ACCEPTED".equals(reverse.getStatus())); + if (!hasAccepted) { + if (direct != null && "PENDING".equals(direct.getStatus())) { + throw new com.campus.common.BusinessException("你已发送过好友申请,请到申请列表撤回"); + } + if (reverse != null && "PENDING".equals(reverse.getStatus())) { + throw new com.campus.common.BusinessException("对方已向你发送申请,请先同意或拒绝"); + } + throw new com.campus.common.BusinessException("该用户不是你的好友"); + } + + if (direct != null) { + friendshipRepository.delete(direct); + } + if (reverse != null) { + friendshipRepository.delete(reverse); + } + } + + /** + * 撤回自己发起的好友申请(PENDING 状态)。 + * 仅申请人本人可操作。 + */ + @Transactional + public void cancelFriendRequest(String friendId) { + String userId = InteractionLockService.currentUserId(); + + if (friendId == null || friendId.isEmpty()) { + throw new com.campus.common.BusinessException("好友ID不能为空"); + } + if (userId.equals(friendId)) { + throw new com.campus.common.BusinessException("不能对自己执行该操作"); + } + + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_CHANGE, userId, friendId); + interactionLockService.lockUsers(userId, friendId); + Friendship pending = friendshipRepository + .findForInteraction(userId, friendId) + .orElseThrow(() -> new com.campus.common.BusinessException("没有待处理的好友申请")); + + if (!"PENDING".equals(pending.getStatus())) { + throw new com.campus.common.BusinessException("申请已被处理,无法撤回"); + } + friendshipRepository.delete(pending); + } + + @Transactional + public Friendship acceptFriendRequestByUserId(String userId) { + String currentUserId = InteractionLockService.currentUserId(); + + if (userId == null || userId.isEmpty()) { + throw new com.campus.common.BusinessException("用户ID不能为空"); + } + if (currentUserId.equals(userId)) { + throw new com.campus.common.BusinessException("不能对自己执行该操作"); + } + + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_RESPOND, currentUserId, userId); + interactionLockService.lockUsers(currentUserId, userId); + Friendship friendship = friendshipRepository + .findForInteraction(userId, currentUserId) + .orElseThrow(() -> new com.campus.common.BusinessException("好友请求不存在")); + + if (!"PENDING".equals(friendship.getStatus())) { + throw new com.campus.common.BusinessException("该好友请求已被处理"); + } + + friendship.setStatus("ACCEPTED"); + friendship = friendshipRepository.save(friendship); + + if (!friendshipRepository.findForInteraction(currentUserId, userId).isPresent()) { + Friendship reverse = friendshipRepository.findIncludingDeleted(currentUserId, userId).orElseGet(Friendship::new); + reverse.setDeleted(false); + reverse.setUserId(currentUserId); + reverse.setFriendId(userId); + reverse.setStatus("ACCEPTED"); + friendshipRepository.save(reverse); + } + + final String committedFriendshipId = friendship.getId(); + final String committedSenderId = friendship.getUserId(); + eventPublisher.publishEvent(new com.campus.service.event.InteractionCommittedAction(() -> { + User acceptor = UserContext.getCurrentUser(); + NotificationRequest notification = new NotificationRequest(); + notification.setType("FRIEND_ACCEPTED"); + notification.setTitle("好友申请已通过"); + notification.setContent((acceptor != null ? acceptor.getNickname() : "对方") + + " 已同意添加你为好友"); + notification.setAvatar(acceptor != null ? acceptor.getAvatar() : null); + notification.setRelatedId(committedFriendshipId); + notificationService.create(notification, userId, currentUserId); + + if (acceptor != null) { + pushService.pushFriendAccepted( + userId, + acceptor.getNickname(), + acceptor.getAvatar(), + currentUserId + ); + } + + })); + + return friendship; + } + + /** + * 拉黑好友:将 ACCEPTED 关系改为 BLOCKED 状态。 + * 拉黑后对方不能再发送消息,也不能再申请好友。 + */ + @Transactional + public void blockFriend(String friendId) { + String userId = InteractionLockService.currentUserId(); + + if (friendId == null || friendId.isEmpty()) { + throw new com.campus.common.BusinessException("好友ID不能为空"); + } + if (userId.equals(friendId)) { + throw new com.campus.common.BusinessException("不能对自己执行该操作"); + } + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_CHANGE, userId, friendId); + interactionLockService.lockUsers(userId, friendId); + if (!userRepository.existsById(friendId)) { + throw new com.campus.common.BusinessException("用户不存在"); + } + + // 查找已有关系:可能是 ACCEPTED、或 PENDING、或已是 BLOCKED + Friendship direct = friendshipRepository.findForInteraction(userId, friendId).orElse(null); + Friendship reverse = friendshipRepository.findForInteraction(friendId, userId).orElse(null); + + boolean hasRelation = direct != null || reverse != null; + if (!hasRelation) { + throw new com.campus.common.BusinessException("不存在与该用户的好友关系"); + } + + // 已拉黑则提示 + if ((direct != null && "BLOCKED".equals(direct.getStatus())) + || (reverse != null && "BLOCKED".equals(reverse.getStatus()))) { + throw new com.campus.common.BusinessException("该用户已在黑名单中"); + } + + // 更新或创建 BLOCKED 关系 + if (direct != null) { + direct.setStatus("BLOCKED"); + friendshipRepository.save(direct); + } else { + Friendship block = friendshipRepository.findIncludingDeleted(userId, friendId).orElseGet(Friendship::new); + block.setDeleted(false); + block.setUserId(userId); + block.setFriendId(friendId); + block.setStatus("BLOCKED"); + friendshipRepository.save(block); + } + + // 如果存在反向 ACCEPTED 关系,也删除(断开双向连接) + if (reverse != null && "ACCEPTED".equals(reverse.getStatus())) { + friendshipRepository.delete(reverse); + } + } + + /** + * 获取当前用户主动拉黑的用户。黑名单关系固定由当前用户指向对方, + * 因此不会把对方对当前用户的关系误展示出来。 + */ + public List listBlacklist() { + String userId = InteractionLockService.currentUserId(); + List blocked = friendshipRepository.findByUserIdAndStatus(userId, "BLOCKED"); + if (blocked.isEmpty()) { + return Collections.emptyList(); + } + List ids = blocked.stream().map(Friendship::getFriendId).collect(Collectors.toList()); + Map users = userRepository.findByIdIn(ids).stream() + .collect(Collectors.toMap(User::getId, u -> u)); + return ids.stream().map(users::get).filter(u -> u != null).map(u -> { + FriendDTO dto = new FriendDTO(); + fillCommonFields(dto, u); + dto.setRelationStatus("BLOCKED"); + return dto; + }).collect(Collectors.toList()); + } + + /** 移出黑名单,不自动创建或恢复好友关系。 */ + @Transactional + public void unblockFriend(String friendId) { + String userId = InteractionLockService.currentUserId(); + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_FRIEND_CHANGE, userId, friendId); + interactionLockService.lockUsers(userId, friendId); + Friendship blocked = friendshipRepository.findForInteraction(userId, friendId) + .orElseThrow(() -> new com.campus.common.BusinessException("该用户不在黑名单中")); + if (!"BLOCKED".equals(blocked.getStatus())) { + throw new com.campus.common.BusinessException("该用户不在黑名单中"); + } + friendshipRepository.delete(blocked); + } + + // ===================== 搜索 / 筛选 / 推荐 ===================== + + public PageResult search(FriendSearchRequest request) { + String userId = InteractionLockService.currentUserId(); + Pageable pageable = toPageable(request.getPage(), request.getSize()); + String keyword = request.getKeyword() == null ? "" : request.getKeyword().trim(); + Page page = keyword.isEmpty() + ? userRepository.findBySchoolExcludeFriends(currentUser().getUniversityName(), + userId, collectExcludeIds(userId), pageable) + : userRepository.searchByKeyword(keyword, userId, pageable); + PageResult result = toFriendPage(page, false); + fillGreetedFlags(result.getItems(), userId); + return result; + } + + public PageResult filter(FriendFilterRequest request) { + String userId = InteractionLockService.currentUserId(); + User me = currentUser(); + Pageable pageable = toPageable(request.getPage(), request.getSize()); + + String school = request.getUniversityName(); + String gender = request.getGender(); + String major = request.getMajorName(); + String education = request.getEducation(); + String grade = request.getGrade(); + String hometown = request.getHometown(); + String location = request.getLocation(); + List interests = null; + + // 快捷筛选:基于当前登录用户信息在后端生成条件,前端无需回传用户自身资料 + if (request.getQuickFilters() != null) { + for (String qf : request.getQuickFilters()) { + if (qf == null) continue; + switch (qf.trim()) { + case "sameSchool": + school = me.getUniversityName(); + break; + case "sameGrade": + grade = me.getGrade(); + break; + case "oppositeGender": + gender = oppositeGender(me.getGender()); + break; + case "commonLocation": + hometown = me.getHometown(); + break; + case "commonInterests": + interests = me.getInterests(); + break; + default: + break; + } + } + } + + List excludeIds = collectExcludeIds(userId); + Page page = (interests != null && !interests.isEmpty()) + ? userRepository.filterUsersByInterests(school, gender, major, education, grade, hometown, location, interests, userId, excludeIds, pageable) + : userRepository.filterUsers(school, gender, major, education, grade, hometown, location, userId, excludeIds, pageable); + PageResult result = toFriendPage(page, true); + fillGreetedFlags(result.getItems(), userId); + return result; + } + + private String oppositeGender(String g) { + if (g == null) return null; + switch (g) { + case "male": return "female"; + case "female": return "male"; + case "M": return "F"; + case "F": return "M"; + default: return null; + } + } + + /** + * 智能推荐(带"换一批"状态) + * + * @param limit 本次返回数量 + * @param batchToken 上一次响应中返回的 batchToken;首次传 null/空,会创建新批次 + * @return 批次结果 + * + * 严谨性保证: + * 1. 同批次内不会重复(Redis Set 去重) + * 2. 已发申请 / 已是好友的人永远不出现在推荐中(动态过滤) + * 3. 候选池耗尽时自动重置(清空 shown Set,重新从头) + * 4. 1 小时不活动自动过期(防止永久状态) + * 5. 池大小为 limit * 5,至少能换 4 批才触发重置(保证「换一批」体感流畅) + */ + public RecommendBatchDTO recommendBatch(int limit, String batchToken) { + String userId = InteractionLockService.currentUserId(); + String key = RECOMMEND_SHOWN_KEY + userId; + + User me = currentUser(); + Set relatedIds = collectRelatedUserIds(userId); + + // 1. 取候选池(按匹配度排序取前 N) + int poolSize = Math.max(limit * POOL_MULTIPLIER, 50); + List pool = buildRankedPool(me, userId, relatedIds, poolSize); + int total = pool.size(); + + // 2. 读取本批次已展示的 ID(仅在带 token 且 Redis 中有状态时) + Set shown = new HashSet<>(); + boolean hasExistingBatch = batchToken != null && !batchToken.isEmpty(); + if (hasExistingBatch) { + Set members = redisTemplate.opsForSet().members(key); + if (members != null) { + shown.addAll(members); + } + } + + // 3. 过滤掉已展示的 + List remaining = new ArrayList<>(pool.size()); + for (User u : pool) { + if (!shown.contains(u.getId())) { + remaining.add(u); + } + } + + // 4. 池耗尽 → 重置(清空 shown + 重新从完整池开始) + boolean wasReset = false; + if (remaining.isEmpty()) { + redisTemplate.delete(key); + shown.clear(); + remaining = new ArrayList<>(pool); + wasReset = true; + } + + // 5. 取本批次 + int batchSize = Math.min(limit, remaining.size()); + List batch = new ArrayList<>(remaining.subList(0, batchSize)); + + // 6. 持久化已展示 ID(Set 自动去重,SADD 原子) + if (!batch.isEmpty()) { + String[] ids = batch.stream().map(User::getId).toArray(String[]::new); + redisTemplate.opsForSet().add(key, ids); + redisTemplate.expire(key, RECOMMEND_TTL); + } + + // 7. 构造成 DTO + List dtos = new ArrayList<>(batchSize); + for (User u : batch) { + FriendDTO dto = recommendService.score(me, u); + fillCommonFields(dto, u); + dto.setRelationStatus(relatedIds.contains(u.getId()) ? "ACCEPTED" : "NONE"); + dtos.add(dto); + } + // 二次校验:分值高的优先 + dtos.sort((a, b) -> Integer.compare( + b.getMatchScore() == null ? 0 : b.getMatchScore(), + a.getMatchScore() == null ? 0 : a.getMatchScore())); + fillGreetedFlags(dtos, userId); + + // 8. 计算 hasMore + boolean hasMore; + if (wasReset) { + // 重置后已把 limit 个塞进 Set,剩余 = poolSize - batchSize + hasMore = (total - batchSize) > 0; + } else { + // 未重置,剩余 = remaining - batchSize + hasMore = (remaining.size() - batchSize) > 0; + } + + RecommendBatchDTO result = new RecommendBatchDTO(); + result.setList(dtos); + result.setBatchToken(UUID.randomUUID().toString()); + result.setHasMore(hasMore); + result.setTotal(total); + return result; + } + + /** + * 取按匹配度排序的候选池(同校 + 随机补充,不含自己/已是好友/已发申请的人) + */ + private List buildRankedPool(User me, String userId, Set relatedIds, int poolSize) { + Pageable pageable = PageRequest.of(0, poolSize); + List candidates = new ArrayList<>(); + // 优先同校 + candidates.addAll(userRepository.findCandidatesBySchool(me.getUniversityName(), userId, pageable)); + + // 不足时从全校随机补齐 + if (candidates.size() < poolSize) { + int need = poolSize - candidates.size(); + Pageable p2 = PageRequest.of(0, need * 3); + for (User u : userRepository.findRandomUsers(userId, p2)) { + if (candidates.size() >= poolSize) break; + if (!containsId(candidates, u.getId())) { + candidates.add(u); + } + } + } + + // 计算分值并过滤 + List ranked = new ArrayList<>(candidates.size()); + for (User u : candidates) { + if (u.getId().equals(userId)) continue; + if (relatedIds.contains(u.getId())) continue; + ranked.add(u); + } + + // 按匹配度降序(用 score() 算) + ranked.sort((a, b) -> Integer.compare( + recommendService.score(me, b).getMatchScore() == null ? 0 : recommendService.score(me, b).getMatchScore(), + recommendService.score(me, a).getMatchScore() == null ? 0 : recommendService.score(me, a).getMatchScore())); + return ranked; + } + + /** + * 兼容旧签名:仅返回 list + */ + public List recommend(int limit) { + return recommendBatch(limit, null).getList(); + } + + // ===================== 资料 / 共同好友 ===================== + + public UserProfileDTO getUserProfile(String targetUserId) { + String currentUserId = InteractionLockService.currentUserId(); + User target = userRepository.findById(targetUserId) + .orElseThrow(() -> new com.campus.common.BusinessException("用户不存在")); + + if (!targetUserId.equals(currentUserId) + && !privacySettingsService.isProfileVisible(targetUserId) + && !isAcceptedRelation(currentUserId, targetUserId)) { + throw new com.campus.common.BusinessException("该用户已关闭陌生人资料查看"); + } + + UserProfileDTO dto = new UserProfileDTO(); + dto.setId(target.getId()); + dto.setNumber(target.getNumber()); + String displayNickname = target.getNickname(); + if (displayNickname == null || displayNickname.trim().isEmpty()) { + if (targetUserId.equals(currentUserId) && target.getPhone() != null) { + displayNickname = target.getPhone(); + } else if (target.getNumber() != null && !target.getNumber().trim().isEmpty()) { + displayNickname = target.getNumber(); + } else if (target.getPhone() != null && target.getPhone().length() >= 4) { + displayNickname = "用户" + target.getPhone().substring(target.getPhone().length() - 4); + } else { + displayNickname = "青核用户"; + } + } + dto.setNickname(displayNickname); + dto.setAvatar(target.getAvatar()); + dto.setGender(target.getGender()); + dto.setBirthday(target.getBirthday()); + dto.setAge(target.getBirthday() == null ? null : ageOf(target.getBirthday())); + dto.setUniversityName(target.getUniversityName()); + dto.setCollegeName(target.getCollegeName()); + dto.setMajorName(target.getMajorName()); + dto.setSecondMajorName(target.getSecondMajorName()); + dto.setUniversityId(target.getUniversityId()); + dto.setCollegeId(target.getCollegeId()); + dto.setMajorId(target.getMajorId()); + dto.setSecondMajorId(target.getSecondMajorId()); + dto.setGrade(target.getGrade()); + dto.setEducation(target.getEducation()); + dto.setEducationType(target.getEducationType()); + dto.setHighSchool(target.getHighSchool()); + dto.setHometown(target.getHometown()); + dto.setHometownProvince(target.getHometownProvince()); + dto.setHometownCity(target.getHometownCity()); + dto.setHometownDistrict(target.getHometownDistrict()); + dto.setLocation(target.getLocation()); + dto.setBio(target.getBio()); + dto.setInterests(translateInterests(target.getInterests())); + dto.setRelationStatus(resolveRelationStatus(currentUserId, targetUserId)); + + Map status = followService.getFollowStatus(targetUserId); + dto.setFollowersCount(((Number) status.getOrDefault("followersCount", 0)).intValue()); + dto.setFollowingCount(((Number) status.getOrDefault("followingCount", 0)).intValue()); + if (currentUserId != null && !currentUserId.isEmpty() && !currentUserId.equals(targetUserId)) { + dto.setIsFollowed((Boolean) status.getOrDefault("isFollowed", false)); + dto.setIsMutualFollow((Boolean) status.getOrDefault("isMutual", false)); + } else { + dto.setIsFollowed(false); + dto.setIsMutualFollow(false); + } + + dto.setXuexinCertified(target.getXuexinCertified()); + dto.setIsOnline(privacySettingsService.isOnlineStatusVisible(targetUserId) + && sseService.isUserOnline(targetUserId)); + + List mutualIds = getMutualFriendIds(currentUserId, targetUserId); + dto.setMutualFriendCount(mutualIds.size()); + if (!mutualIds.isEmpty()) { + List previewIds = mutualIds.subList(0, Math.min(4, mutualIds.size())); + List preview = userRepository.findByIdIn(previewIds); + dto.setMutualFriends(preview.stream().map(u -> { + FriendDTO f = new FriendDTO(); + fillCommonFields(f, u); + f.setRelationStatus("ACCEPTED"); + return f; + }).collect(Collectors.toList())); + } else { + dto.setMutualFriends(Collections.emptyList()); + } + return dto; + } + + public List getMutualFriends(String targetUserId) { + String currentUserId = InteractionLockService.currentUserId(); + List ids = getMutualFriendIds(currentUserId, targetUserId); + if (ids.isEmpty()) { + return Collections.emptyList(); + } + List users = userRepository.findByIdIn(ids); + return users.stream().map(u -> { + FriendDTO f = new FriendDTO(); + fillCommonFields(f, u); + f.setRelationStatus("ACCEPTED"); + return f; + }).collect(Collectors.toList()); + } + + /** + * 好友列表(带用户信息卡片) + */ + public List listMyFriendCards() { + List list = getMyFriends(); + if (list.isEmpty()) { + return Collections.emptyList(); + } + List ids = list.stream().map(Friendship::getFriendId).collect(Collectors.toList()); + List users = userRepository.findByIdIn(ids); + return users.stream().map(u -> { + FriendDTO f = new FriendDTO(); + fillCommonFields(f, u); + f.setRelationStatus("ACCEPTED"); + return f; + }).collect(Collectors.toList()); + } + + // ===================== 工具方法 ===================== + + private List getMutualFriendIds(String userA, String userB) { + Set a = new HashSet<>(); + friendshipRepository.findByUserIdAndStatus(userA, "ACCEPTED") + .forEach(f -> a.add(f.getFriendId())); + Set b = new HashSet<>(); + friendshipRepository.findByUserIdAndStatus(userB, "ACCEPTED") + .forEach(f -> b.add(f.getFriendId())); + a.retainAll(b); + return new ArrayList<>(a); + } + + private String resolveRelationStatus(String currentUserId, String targetUserId) { + if (friendshipRepository.existsByUserIdAndFriendId(currentUserId, targetUserId)) { + Friendship f = friendshipRepository.findByUserIdAndFriendId(currentUserId, targetUserId).get(); + return f.getStatus(); + } + if (friendshipRepository.existsByUserIdAndFriendId(targetUserId, currentUserId)) { + Friendship f = friendshipRepository.findByUserIdAndFriendId(targetUserId, currentUserId).get(); + return "RECEIVED".equals(f.getStatus()) ? "RECEIVED" : f.getStatus(); + } + return "NONE"; + } + + private boolean isAcceptedRelation(String userA, String userB) { + if (userA == null || userB == null) { + return false; + } + return friendshipRepository.findByUserIdAndFriendId(userA, userB) + .map(f -> "ACCEPTED".equals(f.getStatus())) + .orElseGet(() -> friendshipRepository.findByUserIdAndFriendId(userB, userA) + .map(f -> "ACCEPTED".equals(f.getStatus())) + .orElse(false)); + } + + private Set collectRelatedUserIds(String userId) { + Set ids = new HashSet<>(); + friendshipRepository.findByUserIdAndStatus(userId, "ACCEPTED") + .forEach(f -> ids.add(f.getFriendId())); + friendshipRepository.findByFriendIdAndStatus(userId, "ACCEPTED") + .forEach(f -> ids.add(f.getUserId())); + friendshipRepository.findByUserIdAndStatus(userId, "PENDING") + .forEach(f -> ids.add(f.getFriendId())); + friendshipRepository.findByFriendIdAndStatus(userId, "PENDING") + .forEach(f -> ids.add(f.getUserId())); + return ids; + } + + /** + * 收集需要从推荐/搜索结果中排除的用户 ID 列表。 + * 空集合时返回一个哨兵值,避免 Hibernate 生成空的 NOT IN () 导致 SQL 报错。 + */ + private List collectExcludeIds(String userId) { + List ids = new ArrayList<>(collectRelatedUserIds(userId)); + if (ids.isEmpty()) { + ids.add(""); + } + return ids; + } + + /** + * 批量标记当前用户是否已向列表中各用户打过招呼。 + */ + private void fillGreetedFlags(List dtos, String userId) { + if (dtos == null || dtos.isEmpty() || userId == null) { + return; + } + List targetIds = dtos.stream() + .map(FriendDTO::getId) + .filter(id -> id != null) + .collect(Collectors.toList()); + if (targetIds.isEmpty()) { + return; + } + Set greetedIds = greetingRepository + .findByUserIdAndTargetUserIdIn(userId, targetIds) + .stream() + .map(Greeting::getTargetUserId) + .collect(Collectors.toSet()); + dtos.forEach(dto -> dto.setGreeted(greetedIds.contains(dto.getId()))); + } + + private PageResult toFriendPage(Page page, boolean withScore) { + String currentUserId = InteractionLockService.currentUserId(); + User me = withScore ? currentUser() : null; + Page mapped = page.map(u -> { + FriendDTO f = new FriendDTO(); + fillCommonFields(f, u); + f.setRelationStatus(resolveRelationStatus(currentUserId, u.getId())); + if (withScore) { + FriendDTO scored = recommendService.score(me, u); + f.setMatchScore(scored.getMatchScore()); + f.setMatchReasons(scored.getMatchReasons()); + } + return f; + }); + return PageResult.from(mapped); + } + + private void fillCommonFields(FriendDTO dto, User u) { + dto.setId(u.getId()); + dto.setNumber(u.getNumber()); + dto.setNickname(u.getNickname()); + dto.setAvatar(u.getAvatar()); + dto.setGender(u.getGender()); + dto.setAge(u.getBirthday() == null ? null : ageOf(u.getBirthday())); + dto.setUniversityName(u.getUniversityName()); + dto.setMajorName(u.getMajorName()); + dto.setGrade(u.getGrade()); + dto.setInterests(translateInterests(u.getInterests())); + dto.setBio(u.getBio()); + dto.setHometown(u.getHometown()); + dto.setLocation(u.getLocation()); + dto.setEducation(u.getEducation()); + dto.setXuexinCertified(u.getXuexinCertified()); + } + + /** + * 将英文兴趣值转为中文标签 + */ + private List translateInterests(List interests) { + if (interests == null || interests.isEmpty()) { + return interests; + } + return interests.stream() + .map(v -> dictService.getLabel("interest_tags", v)) + .collect(Collectors.toList()); + } + + private Pageable toPageable(Integer page, Integer size) { + int p = page == null || page < 1 ? 0 : page - 1; + int s = size == null || size < 1 ? 20 : size; + return PageRequest.of(p, s); + } + + private User currentUser() { + return userRepository.findById(InteractionLockService.currentUserId()) + .orElseThrow(() -> new com.campus.common.BusinessException("当前用户不存在")); + } + + private int ageOf(Date birthday) { + Calendar now = Calendar.getInstance(); + Calendar born = Calendar.getInstance(); + born.setTime(birthday); + int age = now.get(Calendar.YEAR) - born.get(Calendar.YEAR); + if (now.get(Calendar.DAY_OF_YEAR) < born.get(Calendar.DAY_OF_YEAR)) { + age--; + } + return age; + } + + private boolean containsId(List list, String id) { + for (User u : list) { + if (u.getId().equals(id)) { + return true; + } + } + return false; + } +} diff --git a/campus-server/src/main/java/com/campus/service/GiftService.java b/campus-server/src/main/java/com/campus/service/GiftService.java new file mode 100644 index 0000000..b005671 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/GiftService.java @@ -0,0 +1,342 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.SendGiftRequest; +import com.campus.dto.response.GiftDTO; +import com.campus.dto.response.GiftRecordDTO; +import com.campus.entity.ChatConversation; +import com.campus.entity.ChatMessage; +import com.campus.entity.Gift; +import com.campus.entity.GiftRecord; +import com.campus.entity.User; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.ChatMessageRepository; +import com.campus.repository.GiftRecordRepository; +import com.campus.repository.GiftRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.UserRepository; +import com.campus.util.PageUtils; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 礼物服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GiftService { + + private final GiftRepository giftRepository; + private final GiftRecordRepository giftRecordRepository; + private final ChatMessageRepository chatMessageRepository; + private final ChatConversationRepository chatConversationRepository; + private final MomentRepository momentRepository; + private final UserRepository userRepository; + private final PointsService pointsService; + private final PushService pushService; + private final NotificationService notificationService; + private final TransactionTemplate transactionTemplate; + + /** + * 获取礼物列表 + */ + public List getGiftList() { + return giftRepository.findByEnabledTrueOrderBySortAsc().stream() + .map(this::toGiftDTO) + .collect(Collectors.toList()); + } + + /** + * 送礼物 + *

+ * 核心数据写入在事务内完成,推送和通知在事务提交后执行, + * 避免推送服务超时阻塞主事务导致前端请求超时。 + */ + public GiftRecordDTO sendGift(SendGiftRequest request) { + GiftRecordDTO dto = transactionTemplate.execute(status -> doSendGift(request)); + // 推送和通知在事务提交后执行,避免阻塞主流程 + if (dto != null) { + sendGiftNotification(dto, request); + } + return dto; + } + + /** + * 送礼物核心事务:积分扣减、礼物记录、聊天记录 + */ + private GiftRecordDTO doSendGift(SendGiftRequest request) { + String senderId = UserContext.getCurrentUserId(); + String receiverId = request.getReceiverId(); + + // 不能给自己送礼物 + if (senderId.equals(receiverId)) { + throw new BusinessException("不能给自己送礼物"); + } + + // 获取礼物信息 + Gift gift = giftRepository.findById(request.getGiftId()) + .orElseThrow(() -> new BusinessException("礼物不存在")); + + if (!gift.getEnabled()) { + throw new BusinessException("该礼物已下架"); + } + + int count = request.getCount() != null ? request.getCount() : 1; + int totalPoints = gift.getPoints() * count; + + // 获取接收者信息 + User receiver = userRepository.findById(receiverId) + .orElseThrow(() -> new BusinessException("接收者不存在")); + + // 扣除发送者积分 + pointsService.spendPoints(senderId, "SEND_GIFT", totalPoints, + PointsService.RELATED_GIFT, gift.getId(), + String.format("送给%s的%s x%d", receiver.getNickname(), gift.getName(), count)); + + // 记录礼物 + GiftRecord record = new GiftRecord(); + record.setSenderId(senderId); + record.setReceiverId(receiverId); + record.setGiftId(gift.getId()); + record.setGiftName(gift.getName()); + record.setGiftIcon(gift.getIconUrl()); + record.setPoints(gift.getPoints()); + record.setCount(count); + + // 校验并设置动态ID(可选) + String momentId = request.getMomentId(); + if (momentId != null && momentId.trim().isEmpty()) { + momentId = null; + } + if (momentId != null && momentId.length() > 64) { + throw new BusinessException("动态ID格式不正确"); + } + record.setMomentId(momentId); + + // 校验并设置留言 + String message = request.getMessage(); + if (message != null && message.length() > 200) { + message = message.substring(0, 200); + } + record.setMessage(message); + + record = giftRecordRepository.save(record); + + // 只有聊天对话框送礼物才插入聊天记录(没有momentId的情况) + // 青春号/动态送礼物不插入聊天记录 + if (momentId == null) { + String conversationId = buildPrivateConversationId(senderId, receiverId); + ChatMessage chatMessage = new ChatMessage(); + chatMessage.setConversationId(conversationId); + chatMessage.setSenderId(senderId); + chatMessage.setContent(String.format("{\"id\":\"%s\",\"name\":\"%s\",\"icon\":\"%s\",\"points\":%d}", + gift.getId(), gift.getName(), gift.getIconUrl(), gift.getPoints())); + chatMessage.setMessageType("GIFT"); + chatMessage.setIsRead(false); + chatMessageRepository.save(chatMessage); + + // 更新会话最后消息 + updateConversationLastMessage(senderId, conversationId, "PRIVATE", "[礼物]", "GIFT", null); + updateConversationLastMessage(receiverId, conversationId, "PRIVATE", "[礼物]", "GIFT", null); + } + + // 获取发送者信息 + User sender = userRepository.findById(senderId).orElse(null); + String senderNickname = sender != null ? sender.getNickname() : "用户"; + + // 给接收者加积分(礼物收益的80%,平台抽20%) + int receivedPoints = (int) (totalPoints * 0.8); + if (receivedPoints > 0) { + pointsService.earnPointsWithAmount(receiverId, "RECEIVE_GIFT", receivedPoints, + PointsService.RELATED_GIFT, record.getId(), senderId, + String.format("收到%s送的%s x%d", senderNickname, gift.getName(), count)); + } + + return toGiftRecordDTO(record); + } + + /** + * 发送礼物推送通知(事务提交后执行,异常不影响主流程) + */ + private void sendGiftNotification(GiftRecordDTO dto, SendGiftRequest request) { + String senderId = dto.getSenderId(); + if (senderId == null) { + return; + } + User sender = userRepository.findById(senderId).orElse(null); + if (sender == null) { + return; + } + String momentId = request.getMomentId(); + if (momentId != null && momentId.trim().isEmpty()) { + momentId = null; + } + + // 聊天框送礼(无 momentId):已在聊天记录中生成礼物消息, + // 不再单独创建「收到礼物」通知/推送,避免重复提示。 + if (momentId == null) { + return; + } + + try { + pushService.pushGiftReceived( + dto.getReceiverId(), + sender.getNickname(), + sender.getAvatar(), + senderId, + dto.getGiftName(), + dto.getCount(), + momentId + ); + } catch (Exception e) { + log.warn("礼物推送失败,不影响礼物发送: receiverId={}, error={}", dto.getReceiverId(), e.getMessage()); + } + + try { + notificationService.createNotification( + dto.getReceiverId(), + "GIFT_RECEIVED", + "收到新礼物", + sender.getNickname() + " 给你送了" + dto.getGiftName() + " x" + dto.getCount(), + sender.getAvatar(), + momentId != null ? "/moment/" + momentId : "/profile", + momentId != null ? momentId : senderId, + senderId + ); + } catch (Exception e) { + log.warn("礼物通知创建失败,不影响礼物发送: receiverId={}, error={}", dto.getReceiverId(), e.getMessage()); + } + } + + /** + * 获取我收到的礼物记录 + */ + public PageResult getReceivedGifts(int page, int size) { + String userId = UserContext.getCurrentUserId(); + Page pageData = giftRecordRepository.findByReceiverIdOrderByCreatedAtDesc( + userId, PageUtils.request(page, size)); + return PageResult.of( + pageData.getContent().stream().map(this::toGiftRecordDTO).collect(Collectors.toList()), + pageData.getTotalElements(), page, size); + } + + /** + * 获取我送出的礼物记录 + */ + public PageResult getSentGifts(int page, int size) { + String userId = UserContext.getCurrentUserId(); + Page pageData = giftRecordRepository.findBySenderIdOrderByCreatedAtDesc( + userId, PageUtils.request(page, size)); + return PageResult.of( + pageData.getContent().stream().map(this::toGiftRecordDTO).collect(Collectors.toList()), + pageData.getTotalElements(), page, size); + } + + /** + * 获取动态收到的礼物列表 + */ + public List getGiftsByMoment(String momentId) { + return giftRecordRepository.findByMomentIdOrderByCreatedAtDesc(momentId).stream() + .map(this::toGiftRecordDTO) + .collect(Collectors.toList()); + } + + /** + * 获取动态收到的礼物总积分 + */ + public Integer getMomentGiftPoints(String momentId) { + return giftRecordRepository.sumPointsByMoment(momentId); + } + + /** + * 获取用户礼物统计 + */ + public Map getUserGiftStats(String userId) { + // 先校验用户是否存在 + if (!userRepository.existsById(userId)) { + throw new RuntimeException("用户不存在"); + } + int received = giftRecordRepository.sumPointsReceived(userId); + int sent = giftRecordRepository.sumPointsSent(userId); + Map stats = new java.util.HashMap<>(); + stats.put("receivedPoints", received); + stats.put("sentPoints", sent); + stats.put("netPoints", received - sent); + return stats; + } + + private GiftDTO toGiftDTO(Gift gift) { + GiftDTO dto = new GiftDTO(); + dto.setId(gift.getId()); + dto.setName(gift.getName()); + dto.setIconUrl(gift.getIconUrl()); + dto.setPoints(gift.getPoints()); + dto.setValue(gift.getValue()); + return dto; + } + + private GiftRecordDTO toGiftRecordDTO(GiftRecord record) { + GiftRecordDTO dto = new GiftRecordDTO(); + dto.setId(record.getId()); + dto.setSenderId(record.getSenderId()); + dto.setReceiverId(record.getReceiverId()); + dto.setGiftId(record.getGiftId()); + dto.setGiftName(record.getGiftName()); + dto.setGiftIcon(record.getGiftIcon()); + dto.setPoints(record.getPoints()); + dto.setCount(record.getCount()); + dto.setMomentId(record.getMomentId()); + dto.setMessage(record.getMessage()); + dto.setCreatedAt(record.getCreatedAt()); + + // 填充用户信息 + userRepository.findById(record.getSenderId()).ifPresent(user -> { + dto.setSenderNickname(user.getNickname()); + dto.setSenderAvatar(user.getAvatar()); + }); + userRepository.findById(record.getReceiverId()).ifPresent(user -> { + dto.setReceiverNickname(user.getNickname()); + dto.setReceiverAvatar(user.getAvatar()); + }); + + return dto; + } + + private String buildPrivateConversationId(String userId1, String userId2) { + if (userId1.compareTo(userId2) < 0) { + return userId1 + "_" + userId2; + } + return userId2 + "_" + userId1; + } + + private void updateConversationLastMessage(String userId, String peerId, String conversationType, + String content, String messageType, Integer duration) { + ChatConversation conversation = chatConversationRepository + .findByUserIdAndPeerId(userId, peerId) + .orElseGet(() -> { + ChatConversation newConv = new ChatConversation(); + newConv.setUserId(userId); + newConv.setPeerId(peerId); + newConv.setConversationType(conversationType); + return newConv; + }); + + conversation.setLastMessage(content); + conversation.setLastMessageType(messageType); + conversation.setLastMessageDuration(duration); + conversation.setLastMessageTime(new java.util.Date()); + conversation.setUnreadCount(conversation.getUnreadCount() != null ? conversation.getUnreadCount() + 1 : 1); + chatConversationRepository.save(conversation); + } +} diff --git a/campus-server/src/main/java/com/campus/service/GroupAnnouncementNotificationListener.java b/campus-server/src/main/java/com/campus/service/GroupAnnouncementNotificationListener.java new file mode 100644 index 0000000..1cc9154 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/GroupAnnouncementNotificationListener.java @@ -0,0 +1,27 @@ +package com.campus.service; + +import com.campus.service.event.GroupAnnouncementPublishedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GroupAnnouncementNotificationListener { + + private final ChatService chatService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void notifyGroup(GroupAnnouncementPublishedEvent event) { + try { + chatService.sendGroupAnnouncementNotification( + event.getGroupId(), event.getPublisherId(), event.getContent()); + } catch (Exception e) { + log.warn("群公告已保存,但群聊通知发送失败: groupId={}, publisherId={}, error={}", + event.getGroupId(), event.getPublisherId(), e.getMessage()); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/GroupAnnouncementService.java b/campus-server/src/main/java/com/campus/service/GroupAnnouncementService.java new file mode 100644 index 0000000..1684dd5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/GroupAnnouncementService.java @@ -0,0 +1,129 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.UpdateGroupAnnouncementRequest; +import com.campus.dto.response.GroupAnnouncementDTO; +import com.campus.entity.GroupAnnouncement; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import com.campus.service.event.GroupAnnouncementPublishedEvent; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.Date; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GroupAnnouncementService { + + private final GroupRepository groupRepository; + private final GroupMemberRepository groupMemberRepository; + private final GroupAnnouncementRepository groupAnnouncementRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + public GroupAnnouncementDTO getAnnouncement(String groupId) { + groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + requireActiveMember(groupId, requireCurrentUserId()); + return groupAnnouncementRepository.findByGroupId(groupId) + .map(this::toDTO) + .orElse(null); + } + + @Transactional + public GroupAnnouncementDTO updateAnnouncement(String groupId, UpdateGroupAnnouncementRequest request) { + String currentUserId = requireCurrentUserId(); + groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + GroupMember operator = requireManager(groupId, currentUserId); + if ("ADMIN".equals(operator.getRole()) && Boolean.TRUE.equals(operator.getIsMuted())) { + throw new BusinessException("你已被群禁言"); + } + + String content = request.getContent() == null ? null : request.getContent().trim(); + if (!StringUtils.hasText(content)) { + throw new BusinessException("群公告不能为空"); + } + if (content.length() > 5000) { + throw new BusinessException("群公告不能超过5000个字符"); + } + + User publisher = userRepository.findById(currentUserId).orElse(null); + GroupAnnouncement announcement = groupAnnouncementRepository.findByGroupId(groupId) + .orElseGet(GroupAnnouncement::new); + announcement.setGroupId(groupId); + announcement.setContent(content); + announcement.setPublisherId(currentUserId); + announcement.setPublisherNickname(resolvePublisherNickname(publisher, operator)); + announcement.setPublisherAvatar(publisher == null ? null : publisher.getAvatar()); + Date now = new Date(); + announcement.setPublishedAt(now); + announcement.setUpdatedAt(now); + announcement = groupAnnouncementRepository.save(announcement); + + eventPublisher.publishEvent(new GroupAnnouncementPublishedEvent( + groupId, currentUserId, content)); + return toDTO(announcement); + } + + private String requireCurrentUserId() { + String userId = UserContext.getCurrentUserId(); + if (!StringUtils.hasText(userId)) { + throw new BusinessException("用户未登录"); + } + return userId; + } + + private GroupMember requireActiveMember(String groupId, String userId) { + GroupMember member = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .orElseThrow(() -> new BusinessException("不在群内")); + if ("PENDING".equals(member.getRole())) { + throw new BusinessException("入群申请尚未通过"); + } + return member; + } + + private GroupMember requireManager(String groupId, String userId) { + GroupMember member = requireActiveMember(groupId, userId); + if (!"OWNER".equals(member.getRole()) && !"ADMIN".equals(member.getRole())) { + throw new BusinessException("只有群主或管理员可以操作"); + } + return member; + } + + private String resolvePublisherNickname(User publisher, GroupMember operator) { + if (publisher != null && StringUtils.hasText(publisher.getNickname())) { + return publisher.getNickname().trim(); + } + if (StringUtils.hasText(operator.getNickname())) { + return operator.getNickname().trim(); + } + return "群管理员"; + } + + private GroupAnnouncementDTO toDTO(GroupAnnouncement announcement) { + GroupAnnouncementDTO dto = new GroupAnnouncementDTO(); + dto.setGroupId(announcement.getGroupId()); + dto.setContent(announcement.getContent()); + dto.setPublishedAt(announcement.getPublishedAt()); + dto.setUpdatedAt(announcement.getUpdatedAt()); + + GroupAnnouncementDTO.PublisherDTO publisher = new GroupAnnouncementDTO.PublisherDTO(); + publisher.setId(announcement.getPublisherId()); + publisher.setNickname(announcement.getPublisherNickname()); + publisher.setAvatar(announcement.getPublisherAvatar()); + dto.setPublisher(publisher); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/GroupInvitationService.java b/campus-server/src/main/java/com/campus/service/GroupInvitationService.java new file mode 100644 index 0000000..d2f348d --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/GroupInvitationService.java @@ -0,0 +1,534 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.common.PageResult; +import com.campus.dto.response.GroupInvitationDTO; +import com.campus.dto.response.GroupInviteResult; +import com.campus.entity.Group; +import com.campus.entity.GroupInvitation; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.GroupInvitationRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import com.campus.util.PageUtils; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 群聊邀请:已建群后把用户拉进群,必须等被邀请人「允许」才真正写入 {@link GroupMember}。 + * + *

与建群带人的边界:{@code GroupService.createGroup} 里传 memberIds 仍是直接入群, + * 不走本 Service、也不需要对方同意(产品明确的"建群时拉人不需同意")。 + * + *

与 GroupMember.role='PENDING' 的边界:那个 PENDING 是"用户主动申请入群等群主审批", + * 由 {@code GroupService.approveJoinRequest} 消费。本 Service 从不写 PENDING 角色, + * 只在「允许」后写 MEMBER,避免两套语义互相污染、也避免软删行占死 uk_gm_group_user。 + * + *

为什么"允许"是唯一动作:产品决策为「只给允许按钮,不点就是搁置」,所以 + * 被邀请人不操作即停在 PENDING,到达 expireAt 后由懒判定刷成 EXPIRED。因此本类没有 reject 方法。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GroupInvitationService { + + /** 一次最多邀请多少人(防止一次请求打爆通知与推送)。 */ + private static final int MAX_BATCH = 50; + + private final GroupInvitationRepository invitationRepository; + private final GroupRepository groupRepository; + private final GroupMemberRepository groupMemberRepository; + private final UserRepository userRepository; + private final EasemobService easemobService; + private final NotificationService notificationService; + private final PushService pushService; + private final ClickGuardService clickGuardService; + /** 复用群内身份校验与环信群 ID 校验,避免两处各写一套口径。 */ + private final GroupService groupService; + + /** 邀请有效期(天),默认 7 天;可用 app.group.invite-expire-days 覆盖。 */ + @Value("${app.group.invite-expire-days:7}") + private int inviteExpireDays; + + // ===================== 发起邀请 ===================== + + /** + * 群主或管理员批量邀请用户入群。逐人独立成败,返回部分成功结果。 + */ + @Transactional + public GroupInviteResult invite(String groupId, List userIds, String message) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + // 与 GroupService.addMembers 同口径:群主或管理员才能拉人 + groupService.requireManager(groupId, currentUserId); + + if (group.isActivityGroup()) { + throw new BusinessException("活动群不支持邀请入群"); + } + + if (userIds == null || userIds.isEmpty()) { + throw new BusinessException("请选择要邀请的用户"); + } + + Set uniqueIds = new LinkedHashSet<>(); + for (String userId : userIds) { + if (userId == null || userId.trim().isEmpty() || !uniqueIds.add(userId)) { + throw new BusinessException("用户ID不能为空或重复"); + } + } + if (uniqueIds.size() > MAX_BATCH) { + throw new BusinessException("一次最多邀请 " + MAX_BATCH + " 人"); + } + + if (group.getMemberCount() >= group.getMaxMembers()) { + throw new BusinessException("群成员已满"); + } + + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_GROUP_INVITE_SEND, currentUserId, groupId); + + String normalizedMessage = normalizeMessage(message); + User inviter = userRepository.findById(currentUserId).orElse(null); + String inviterNickname = inviter != null ? inviter.getNickname() : null; + String inviterAvatar = inviter != null ? inviter.getAvatar() : null; + + Date now = new Date(); + Date expireAt = new Date(now.getTime() + expireDaysMillis()); + + GroupInviteResult result = new GroupInviteResult(); + + for (String userId : uniqueIds) { + InviteCandidate candidate = buildInvitationOrCollectFailure( + groupId, currentUserId, userId, normalizedMessage, expireAt, result); + if (candidate == null) { + continue; + } + invitationRepository.save(candidate.invitation); + + result.addInvited(toDTO(candidate.invitation, group, inviter, candidate.invitee)); + notifyInvitee(candidate.invitation, group, currentUserId, inviterNickname, inviterAvatar); + } + + // 唯一键兜底:并发下两个管理员同时邀请同一人时,只有一条能插入成功。 + // flush 让冲突在本方法内暴露(否则会在事务提交时才炸,变成 500 且文案不可控)。 + try { + invitationRepository.flush(); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + log.warn("群邀请写入冲突(并发重复邀请): groupId={}, inviterId={}", groupId, currentUserId, e); + throw new BusinessException("部分用户已有待处理邀请,请刷新后重试", e); + } + + return result; + } + + /** + * 校验单个被邀请人;不通过时把原因写进 failed 并返回 null。 + * 通过时把「邀请实体 + 被邀请人」一起带出,避免调用方为补 DTO 再查一次用户。 + */ + private InviteCandidate buildInvitationOrCollectFailure(String groupId, String inviterId, String userId, + String message, Date expireAt, + GroupInviteResult result) { + if (userId.equals(inviterId)) { + result.addFailure(userId, "不能邀请自己"); + return null; + } + + User invitee = userRepository.findById(userId).orElse(null); + if (invitee == null) { + result.addFailure(userId, "用户不存在: " + userId); + return null; + } + if (Boolean.TRUE.equals(invitee.getDeleted()) || !"ACTIVE".equals(invitee.getAccountStatus())) { + result.addFailure(userId, "用户账号不可用: " + userId); + return null; + } + + GroupMember existingMember = groupMemberRepository + .findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId).orElse(null); + if (existingMember != null) { + if ("PENDING".equals(existingMember.getRole())) { + // 他自己已经申请过入群,群主直接审批即可,再发邀请会产生两条并行待办 + result.addFailure(userId, "该用户已提交入群申请,请直接审批"); + } else { + result.addFailure(userId, "用户已在群内: " + userId); + } + return null; + } + + boolean alreadyInvited = invitationRepository + .findByGroupIdAndInviteeIdAndPendingKey(groupId, userId, GroupInvitation.PENDING_KEY) + .isPresent(); + if (alreadyInvited) { + result.addFailure(userId, "已向该用户发送过邀请"); + return null; + } + + GroupInvitation invitation = new GroupInvitation(); + invitation.setGroupId(groupId); + invitation.setInviterId(inviterId); + invitation.setInviteeId(userId); + invitation.setStatus(GroupInvitation.STATUS_PENDING); + invitation.setPendingKey(GroupInvitation.PENDING_KEY); + invitation.setMessage(message); + invitation.setExpireAt(expireAt); + return new InviteCandidate(invitation, invitee); + } + + /** 单个被邀请人的校验结果载体。 */ + private static final class InviteCandidate { + private final GroupInvitation invitation; + private final User invitee; + + private InviteCandidate(GroupInvitation invitation, User invitee) { + this.invitation = invitation; + this.invitee = invitee; + } + } + + private void notifyInvitee(GroupInvitation invitation, Group group, String inviterId, + String inviterNickname, String inviterAvatar) { + String displayName = inviterNickname == null || inviterNickname.trim().isEmpty() ? "有人" : inviterNickname; + String content = displayName + " 邀请你加入群聊「" + group.getName() + "」"; + + // 先推送再建站内通知:createNotification 不再自动推送(见 NotificationService 注释) + pushService.pushGroupInvite(invitation.getInviteeId(), inviterNickname, inviterAvatar, + group.getId(), group.getName(), invitation.getId()); + notificationService.createNotification( + invitation.getInviteeId(), + "GROUP_INVITE", + "群聊邀请", + content, + inviterAvatar, + "/group-invite/" + invitation.getId(), + invitation.getId(), + inviterId + ); + } + + // ===================== 查询 ===================== + + /** + * 我收到的群邀请。默认只返回 PENDING;status=ALL 返回全部历史。 + * + *

必须用读写事务:方法内要执行「懒过期」批量 UPDATE, + * 类级 readOnly 会让 MySQL 连接进入只读模式而拒绝更新。 + */ + @Transactional + public PageResult listMine(String status, Integer page, Integer size) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + invitationRepository.expireOverdueByInvitee(currentUserId, new Date()); + + int normalizedPage = PageUtils.normalizePage(page); + int normalizedSize = PageUtils.normalizeSize(size); + Pageable pageable = PageUtils.request(normalizedPage, normalizedSize); + + Page invitations; + String normalizedStatus = normalizeStatus(status); + if (normalizedStatus == null) { + // 默认 & 显式 PENDING:都只看待处理 + invitations = invitationRepository.findByInviteeIdAndStatusOrderByCreatedAtDesc( + currentUserId, GroupInvitation.STATUS_PENDING, pageable); + } else if ("ALL".equals(normalizedStatus)) { + invitations = invitationRepository.findByInviteeIdOrderByCreatedAtDesc(currentUserId, pageable); + } else { + invitations = invitationRepository.findByInviteeIdAndStatusOrderByCreatedAtDesc( + currentUserId, normalizedStatus, pageable); + } + + return PageResult.of(toDTOs(invitations.getContent()), invitations.getTotalElements(), + normalizedPage, normalizedSize); + } + + /** + * 群主/管理员查看本群发出的邀请(用于确认"谁还没点允许")。 + */ + @Transactional + public PageResult listByGroup(String groupId, String status, Integer page, Integer size) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + groupRepository.findById(groupId).orElseThrow(() -> new BusinessException("群组不存在")); + groupService.requireManager(groupId, currentUserId); + + invitationRepository.expireOverdueByGroup(groupId, new Date()); + + int normalizedPage = PageUtils.normalizePage(page); + int normalizedSize = PageUtils.normalizeSize(size); + Pageable pageable = PageUtils.request(normalizedPage, normalizedSize); + + String normalizedStatus = normalizeStatus(status); + Page invitations = invitationRepository.findByGroupIdAndStatusOrderByCreatedAtDesc( + groupId, + normalizedStatus == null || "ALL".equals(normalizedStatus) + ? GroupInvitation.STATUS_PENDING : normalizedStatus, + pageable); + + return PageResult.of(toDTOs(invitations.getContent()), invitations.getTotalElements(), + normalizedPage, normalizedSize); + } + + // ===================== 被邀请人:允许入群 ===================== + + /** + * 被邀请人点「允许」→ 真正加入群聊。 + * + *

顺序严格遵循「先远端成功、再改本地」:环信加群失败时邀请保持 PENDING,可稍后重试, + * 不会出现"本地是成员、环信里不在群"的错位。 + */ + @Transactional + public GroupMember accept(String invitationId) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + GroupInvitation invitation = invitationRepository.findById(invitationId) + .orElseThrow(() -> new BusinessException("邀请不存在")); + + if (!currentUserId.equals(invitation.getInviteeId())) { + throw new ForbiddenException("无权处理该邀请"); + } + + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_GROUP_INVITE_RESPOND, + currentUserId, invitationId); + + Date now = new Date(); + + if (!invitation.isPending()) { + // 已 ACCEPTED 且确实在群内 → 幂等返回,避免前端重复点击/刷新报错 + if (GroupInvitation.STATUS_ACCEPTED.equals(invitation.getStatus())) { + GroupMember already = groupMemberRepository + .findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(invitation.getGroupId(), currentUserId) + .orElse(null); + if (already != null && !"PENDING".equals(already.getRole())) { + return already; + } + } + throw new BusinessException("该邀请已处理"); + } + + if (invitation.isOverdue(now)) { + invitation.settleTo(GroupInvitation.STATUS_EXPIRED, now); + invitationRepository.save(invitation); + throw new BusinessException("邀请已过期"); + } + + Group group = groupRepository.findById(invitation.getGroupId()) + .orElseThrow(() -> new BusinessException("群组不存在")); + + GroupMember existingMember = groupMemberRepository + .findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(group.getId(), currentUserId).orElse(null); + if (existingMember != null && !"PENDING".equals(existingMember.getRole())) { + // 期间被别人批准/自己申请进了群:把邀请收口成 ACCEPTED,幂等返回 + invitation.settleTo(GroupInvitation.STATUS_ACCEPTED, now); + invitationRepository.save(invitation); + return existingMember; + } + + if (group.getMemberCount() >= group.getMaxMembers()) { + throw new BusinessException("群成员已满"); + } + + String easemobGroupId = groupService.requireEasemobGroupId(group); + if (!easemobService.addGroupMembers(easemobGroupId, Collections.singletonList(currentUserId))) { + // 邀请保持 PENDING:用户可重试,不会丢 + throw new BusinessException("环信加群失败,请稍后重试"); + } + + // 他可能已有一条 PENDING 申请行,复用它以免撞 uk_gm_group_user 唯一键 + GroupMember member = existingMember != null ? existingMember : new GroupMember(); + if (member.getId() == null) { + member.setGroupId(group.getId()); + member.setUserId(currentUserId); + } + member.setRole("MEMBER"); + member.setJoinTime(now); + member = groupMemberRepository.save(member); + + // 计数与明细同事务(AGENTS.md §4.17) + group.setMemberCount(group.getMemberCount() + 1); + groupRepository.save(group); + + invitation.settleTo(GroupInvitation.STATUS_ACCEPTED, now); + invitationRepository.save(invitation); + + notifyInviterAccepted(invitation, group, currentUserId); + + return member; + } + + private void notifyInviterAccepted(GroupInvitation invitation, Group group, String inviteeId) { + User invitee = userRepository.findById(inviteeId).orElse(null); + String inviteeNickname = invitee != null ? invitee.getNickname() : "有人"; + String content = inviteeNickname + " 已同意加入群聊「" + group.getName() + "」"; + + Map extra = new HashMap<>(); + extra.put("type", "GROUP_INVITE_ACCEPTED"); + extra.put("relatedId", group.getId()); + extra.put("senderId", inviteeId); + if (invitee != null) { + extra.put("senderNickname", invitee.getNickname()); + extra.put("senderAvatar", invitee.getAvatar()); + } + pushService.pushToUser(invitation.getInviterId(), "邀请已通过", content, extra); + + notificationService.createNotification( + invitation.getInviterId(), + "GROUP_INVITE_ACCEPTED", + "邀请已通过", + content, + invitee != null ? invitee.getAvatar() : null, + "/group/" + group.getId(), + group.getId(), + inviteeId + ); + } + + // ===================== 邀请人:撤回 ===================== + + /** + * 撤回邀请:邀请人本人或该群群主。被邀请人侧没有「拒绝」动作,撤回用于"发错了"。 + */ + @Transactional + public void cancel(String invitationId) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + GroupInvitation invitation = invitationRepository.findById(invitationId) + .orElseThrow(() -> new BusinessException("邀请不存在")); + + if (!invitation.isPending()) { + throw new BusinessException("该邀请已处理"); + } + + boolean isInviter = currentUserId.equals(invitation.getInviterId()); + boolean isGroupOwner = groupRepository.findById(invitation.getGroupId()) + .map(g -> currentUserId.equals(g.getOwnerId())) + .orElse(false); + if (!isInviter && !isGroupOwner) { + throw new ForbiddenException("无权撤回该邀请"); + } + + invitation.settleTo(GroupInvitation.STATUS_CANCELLED, new Date()); + invitationRepository.save(invitation); + } + + // ===================== 内部工具 ===================== + + private long expireDaysMillis() { + int days = inviteExpireDays > 0 ? inviteExpireDays : 7; + return days * 24L * 60L * 60L * 1000L; + } + + private String normalizeMessage(String message) { + if (message == null) { + return null; + } + String trimmed = message.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 归一化查询状态:null/空/PENDING → null(代表"只看待处理"); + * ALL → "ALL";其他按大写原样返回(ACCEPTED/CANCELLED/EXPIRED)。 + */ + private String normalizeStatus(String status) { + if (status == null || status.trim().isEmpty()) { + return null; + } + String normalized = status.trim().toUpperCase(Locale.ROOT); + if (GroupInvitation.STATUS_PENDING.equals(normalized)) { + return null; + } + return normalized; + } + + private List toDTOs(List invitations) { + if (invitations == null || invitations.isEmpty()) { + return Collections.emptyList(); + } + + Set groupIds = invitations.stream() + .map(GroupInvitation::getGroupId).collect(Collectors.toSet()); + Set userIds = new LinkedHashSet<>(); + invitations.forEach(inv -> { + userIds.add(inv.getInviterId()); + userIds.add(inv.getInviteeId()); + }); + + // 批量装配,避免每条邀请都查一次用户/群(N+1) + Map groupMap = groupRepository.findAllById(groupIds).stream() + .collect(Collectors.toMap(Group::getId, g -> g)); + Map userMap = userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, u -> u)); + + List result = new ArrayList<>(invitations.size()); + for (GroupInvitation invitation : invitations) { + result.add(toDTO(invitation, + groupMap.get(invitation.getGroupId()), + userMap.get(invitation.getInviterId()), + userMap.get(invitation.getInviteeId()))); + } + return result; + } + + private GroupInvitationDTO toDTO(GroupInvitation invitation, Group group, User inviter, User invitee) { + GroupInvitationDTO dto = new GroupInvitationDTO(); + dto.setId(invitation.getId()); + dto.setGroupId(invitation.getGroupId()); + if (group != null) { + dto.setGroupName(group.getName()); + dto.setGroupAvatar(group.getAvatar()); + dto.setGroupMemberCount(group.getMemberCount()); + } + dto.setInviterId(invitation.getInviterId()); + if (inviter != null) { + dto.setInviterNickname(inviter.getNickname()); + dto.setInviterAvatar(inviter.getAvatar()); + } + dto.setInviteeId(invitation.getInviteeId()); + if (invitee != null) { + dto.setInviteeNickname(invitee.getNickname()); + dto.setInviteeAvatar(invitee.getAvatar()); + } + dto.setStatus(invitation.getStatus()); + dto.setMessage(invitation.getMessage()); + dto.setExpireAt(invitation.getExpireAt()); + dto.setRespondedAt(invitation.getRespondedAt()); + dto.setCreatedAt(invitation.getCreatedAt()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/GroupService.java b/campus-server/src/main/java/com/campus/service/GroupService.java new file mode 100644 index 0000000..10ac660 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/GroupService.java @@ -0,0 +1,805 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.entity.Activity; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.ActivityParticipantRepository; +import com.campus.repository.ActivityRepository; +import com.campus.repository.GroupInvitationRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GroupService { + + private final GroupRepository groupRepository; + private final GroupMemberRepository groupMemberRepository; + private final GroupAnnouncementRepository groupAnnouncementRepository; + private final GroupInvitationRepository groupInvitationRepository; + private final ActivityRepository activityRepository; + private final ActivityParticipantRepository participantRepository; + private final EasemobService easemobService; + private final NotificationService notificationService; + private final PushService pushService; + private final UserRepository userRepository; + + // ===================== 创建群组 ===================== + + @Transactional + public Group createGroup(String name, String description, String avatar, Integer maxMembers, + List memberIds) { + String ownerId = UserContext.getCurrentUserId(); + if (ownerId == null) { + throw new BusinessException("用户未登录"); + } + + int capacity = maxMembers != null ? maxMembers : 200; + + // 建群时拉人是「直接入群、不需要对方同意」,但数据仍必须合法: + // 剔除创建者自己、去重、校验账号可用、校验容量,否则会撞 uk_gm_group_user 变成 500。 + Set initialMemberIds = normalizeInitialMemberIds(ownerId, memberIds, capacity); + + Group group = new Group(); + group.setName(name); + group.setDescription(description); + group.setAvatar(avatar); + group.setOwnerId(ownerId); + group.setMaxMembers(capacity); + group.setMemberCount(1); + group.setIsPublic(false); + group.setIsApprovalRequired(true); + + String easemobGroupId = easemobService.createGroup(name, description != null ? description : "", + ownerId, new ArrayList<>(initialMemberIds)); + if (easemobGroupId == null) { + throw new BusinessException("环信创建群聊失败,请稍后重试"); + } + group.setEasemobGroupId(easemobGroupId); + group = groupRepository.save(group); + + GroupMember ownerMember = new GroupMember(); + ownerMember.setGroupId(group.getId()); + ownerMember.setUserId(ownerId); + ownerMember.setRole("OWNER"); + ownerMember.setJoinTime(new Date()); + groupMemberRepository.save(ownerMember); + + if (!initialMemberIds.isEmpty()) { + Date now = new Date(); + List members = new ArrayList<>(initialMemberIds.size()); + for (String memberId : initialMemberIds) { + GroupMember member = new GroupMember(); + member.setGroupId(group.getId()); + member.setUserId(memberId); + member.setRole("MEMBER"); + member.setJoinTime(now); + members.add(member); + } + groupMemberRepository.saveAll(members); + group.setMemberCount(group.getMemberCount() + members.size()); + groupRepository.save(group); + } + + return group; + } + + /** + * 规范化建群时的初始成员列表(这部分人直接入群,不需要对方同意)。 + * + *

剔除规则:创建者自己、重复 ID、空白 ID —— 这三种以前会写出重复成员行并撞唯一键, + * 表现为 500「服务器内部错误」。 + *

拒绝规则:用户不存在 / 账号不可用 / 超出容量,抛 400 明确文案。 + */ + private Set normalizeInitialMemberIds(String ownerId, List memberIds, int capacity) { + Set result = new LinkedHashSet<>(); + if (memberIds == null) { + return result; + } + for (String memberId : memberIds) { + if (memberId == null || memberId.trim().isEmpty() || memberId.equals(ownerId)) { + continue; + } + if (!result.add(memberId)) { + continue; + } + User user = userRepository.findById(memberId) + .orElseThrow(() -> new BusinessException("用户不存在: " + memberId)); + if (Boolean.TRUE.equals(user.getDeleted()) || !"ACTIVE".equals(user.getAccountStatus())) { + throw new BusinessException("用户账号不可用: " + memberId); + } + } + if (result.size() + 1 > capacity) { + throw new BusinessException("群成员数量超过上限"); + } + return result; + } + + // ===================== 加入群组 ===================== + + @Transactional + public GroupMember joinGroup(String groupId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + // 活动群不开放自助加入:必须是活动创建者或已报名用户(详见 joinActivityGroup) + if (isActivityGroup(group)) { + return joinActivityGroup(group, userId); + } + + if (groupMemberRepository.existsByGroupIdAndUserId(groupId, userId)) { + throw new BusinessException("已在群内"); + } + + if (group.getMemberCount() >= group.getMaxMembers()) { + throw new BusinessException("群成员已满"); + } + + // 私有群不允许自助加入。原实现只依赖 isApprovalRequired,私有群一旦被设成免审批, + // 任何人都能凭群 ID 直接进群;这里补上 isPublic 判定。 + if (!Boolean.TRUE.equals(group.getIsPublic()) + && !Boolean.TRUE.equals(group.getIsApprovalRequired())) { + throw new BusinessException("该群不支持自助加入"); + } + + if (Boolean.TRUE.equals(group.getIsApprovalRequired())) { + GroupMember member = new GroupMember(); + member.setGroupId(groupId); + member.setUserId(userId); + member.setRole("PENDING"); + member.setJoinTime(new Date()); + member = groupMemberRepository.save(member); + + // 通知群主:有人申请加入群组 + User applicant = userRepository.findById(userId).orElse(null); + Map pushExtra1 = new HashMap<>(); + pushExtra1.put("type", "GROUP_JOIN_REQUEST"); + pushExtra1.put("relatedId", groupId); + pushExtra1.put("senderId", userId); + if (applicant != null) { + pushExtra1.put("senderNickname", applicant.getNickname()); + pushExtra1.put("senderAvatar", applicant.getAvatar()); + } + pushService.pushToUser(group.getOwnerId(), "入群申请", + (applicant != null ? applicant.getNickname() : "有人") + " 申请加入群聊「" + group.getName() + "」", + pushExtra1); + notificationService.createNotification( + group.getOwnerId(), + "GROUP_JOIN_REQUEST", + "入群申请", + (applicant != null ? applicant.getNickname() : "有人") + " 申请加入群聊「" + group.getName() + "」", + applicant != null ? applicant.getAvatar() : null, + "/group/" + groupId, + groupId, + userId + ); + + return member; + } + + return addMemberAndNotifyOwner(group, userId); + } + + /** 是否为活动群:显式标记优先,标记缺失时用 activity.group_id 反查兜底(只会让判定更严格)。 */ + private boolean isActivityGroup(Group group) { + if (group.isActivityGroup()) { + return true; + } + return activityRepository.findFirstByGroupId(group.getId()).isPresent(); + } + + /** + * 活动群入群校验:只有活动创建者或已报名(CONFIRMED)的用户能进群。 + * 已报名用户重复调用时返回已有成员记录(幂等),不重复请求环信。 + */ + private GroupMember joinActivityGroup(Group group, String userId) { + Activity activity = resolveJoinableActivity(group); + if (activity == null) { + throw new BusinessException("活动群聊已关闭"); + } + + // 资格校验放在「已在群内」之前:历史上被放行进来的未报名用户,同样不能再从本接口进入 + boolean allowed = userId.equals(activity.getCreatorId()) + || isParticipantConfirmed(activity.getId(), userId); + if (!allowed) { + if (isParticipantPending(activity.getId(), userId)) { + throw new BusinessException("报名审核通过后才能加入活动群聊"); + } + throw new BusinessException("请先报名活动,报名成功后才能加入活动群聊"); + } + + GroupMember existing = groupMemberRepository + .findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(group.getId(), userId) + .orElse(null); + if (existing != null) { + return existing; + } + + if (group.getMemberCount() >= group.getMaxMembers()) { + throw new BusinessException("群成员已满"); + } + return addMemberAndNotifyOwner(group, userId); + } + + /** 解析活动群关联的活动;关联缺失、活动已删除或已取消时返回 null(视为群聊已关闭)。 */ + private Activity resolveJoinableActivity(Group group) { + String activityId = group.getActivityId(); + Activity activity = null; + if (activityId != null && !activityId.trim().isEmpty()) { + // 必须用 findIncludingDeleted:活动是软删,普通查询查不到会把活动群误判成可自由加入 + activity = activityRepository.findIncludingDeleted(activityId).orElse(null); + } + if (activity == null) { + activity = activityRepository.findFirstByGroupId(group.getId()).orElse(null); + } + if (activity == null) { + log.warn("活动群缺少可用的关联活动,拒绝入群: groupId={}, activityId={}", group.getId(), activityId); + return null; + } + if (Boolean.TRUE.equals(activity.getDeleted()) + || "CANCELLED".equalsIgnoreCase(activity.getStatus())) { + return null; + } + return activity; + } + + private boolean isParticipantConfirmed(String activityId, String userId) { + return participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(activityId, userId) + .map(p -> "CONFIRMED".equalsIgnoreCase(p.getStatus())) + .orElse(false); + } + + private boolean isParticipantPending(String activityId, String userId) { + return participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(activityId, userId) + .map(p -> "PENDING".equalsIgnoreCase(p.getStatus())) + .orElse(false); + } + + /** 先远端加群成功,再写本地成员与计数;环信失败时不留下成员记录。 */ + private GroupMember addMemberAndNotifyOwner(Group group, String userId) { + if (!easemobService.addGroupMembers(requireEasemobGroupId(group), Collections.singletonList(userId))) { + throw new BusinessException("环信加群失败,请稍后重试"); + } + + GroupMember member = new GroupMember(); + member.setGroupId(group.getId()); + member.setUserId(userId); + member.setRole("MEMBER"); + member.setJoinTime(new Date()); + member = groupMemberRepository.save(member); + + group.setMemberCount(group.getMemberCount() + 1); + groupRepository.save(group); + + // 通知群主:有新成员加入群组 + User newMember = userRepository.findById(userId).orElse(null); + Map pushExtra2 = new HashMap<>(); + pushExtra2.put("type", "GROUP_MEMBER_JOINED"); + pushExtra2.put("relatedId", group.getId()); + pushExtra2.put("senderId", userId); + if (newMember != null) { + pushExtra2.put("senderNickname", newMember.getNickname()); + pushExtra2.put("senderAvatar", newMember.getAvatar()); + } + pushService.pushToUser(group.getOwnerId(), "新成员加入", + (newMember != null ? newMember.getNickname() : "有新成员") + " 加入了群聊「" + group.getName() + "」", + pushExtra2); + notificationService.createNotification( + group.getOwnerId(), + "GROUP_MEMBER_JOINED", + "新成员加入", + (newMember != null ? newMember.getNickname() : "有新成员") + " 加入了群聊「" + group.getName() + "」", + newMember != null ? newMember.getAvatar() : null, + "/group/" + group.getId(), + group.getId(), + userId + ); + + return member; + } + + // ===================== 退出群组 ===================== + + @Transactional + public void leaveGroup(String groupId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + GroupMember member = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .orElseThrow(() -> new BusinessException("不在群内")); + + if ("OWNER".equals(member.getRole())) { + throw new BusinessException("群主不能退出,请先转让群主"); + } + + if ("PENDING".equals(member.getRole())) { + groupMemberRepository.delete(member); + return; + } + + if (!removeEasemobMemberWithOwnerRecovery(group, userId)) { + throw new BusinessException("环信退群失败,请稍后重试"); + } + + groupMemberRepository.delete(member); + group.setMemberCount(group.getMemberCount() - 1); + groupRepository.save(group); + } + + // ===================== 群成员管理 ===================== + + @Transactional + public void approveJoinRequest(String groupId, String userId) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + requireManager(groupId, currentUserId); + + GroupMember member = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .orElseThrow(() -> new BusinessException("申请不存在")); + + if (!"PENDING".equals(member.getRole())) { + throw new BusinessException("申请已处理"); + } + + if (!easemobService.addGroupMembers(requireEasemobGroupId(group), Collections.singletonList(userId))) { + throw new BusinessException("环信加群失败,请稍后重试"); + } + + member.setRole("MEMBER"); + groupMemberRepository.save(member); + + group.setMemberCount(group.getMemberCount() + 1); + groupRepository.save(group); + + // 通知申请人:入群申请已通过 + Map pushExtra3 = new HashMap<>(); + pushExtra3.put("type", "GROUP_JOIN_APPROVED"); + pushExtra3.put("relatedId", groupId); + pushService.pushToUser(userId, "入群申请已通过", + "你的入群申请已通过,现在可以进入群聊「" + group.getName() + "」了", + pushExtra3); + notificationService.createNotification( + userId, + "GROUP_JOIN_APPROVED", + "入群申请已通过", + "你的入群申请已通过,现在可以进入群聊「" + group.getName() + "」了", + group.getAvatar(), + "/group/" + groupId, + groupId, + currentUserId + ); + } + + @Transactional + public void rejectJoinRequest(String groupId, String userId) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + requireManager(groupId, currentUserId); + + GroupMember member = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .orElseThrow(() -> new BusinessException("申请不存在")); + + if (!"PENDING".equals(member.getRole())) { + throw new BusinessException("申请已处理"); + } + + groupMemberRepository.delete(member); + } + + // 说明:原来「已建群直接把用户塞进群」的 addMembers 已下线(2026-10-27)。 + // 已建群拉人改为「发群邀请、等对方允许」,实现见 GroupInvitationService.invite。 + // 建群时带人(createGroup 的 memberIds)仍然直接入群,不经过邀请。 + + @Transactional + public void removeMember(String groupId, String userId) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + GroupMember currentMember = requireManager(groupId, currentUserId); + + GroupMember targetMember = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .orElseThrow(() -> new BusinessException("用户不在群内")); + + if ("OWNER".equals(targetMember.getRole())) { + throw new BusinessException("不能移除群主"); + } + if ("PENDING".equals(targetMember.getRole())) { + throw new BusinessException("请使用拒绝申请接口处理待审批用户"); + } + if ("ADMIN".equals(targetMember.getRole()) && !"OWNER".equals(currentMember.getRole())) { + throw new BusinessException("管理员不能移除其他管理员"); + } + + if (!removeEasemobMemberWithOwnerRecovery(group, userId)) { + throw new BusinessException("环信移除成员失败,请稍后重试"); + } + + groupMemberRepository.delete(targetMember); + group.setMemberCount(group.getMemberCount() - 1); + groupRepository.save(group); + + // 被移出的人若有该群待处理邀请,一并作废,避免"刚被移除又被邀请"的错位状态 + groupInvitationRepository.cancelPendingFor(groupId, userId, new Date()); + } + + @Transactional + public void setMemberMute(String groupId, String userId, boolean isMuted) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + GroupMember currentMember = requireManager(groupId, currentUserId); + GroupMember target = requireActiveMember(groupId, userId); + if ("OWNER".equals(target.getRole())) { + throw new BusinessException("不能禁言群主"); + } + if ("ADMIN".equals(target.getRole()) && !"OWNER".equals(currentMember.getRole())) { + throw new BusinessException("只有群主可以禁言管理员"); + } + if (!"MEMBER".equals(target.getRole()) && !"ADMIN".equals(target.getRole())) { + throw new BusinessException("只能禁言普通成员或管理员"); + } + if (Boolean.TRUE.equals(target.getIsMuted()) == isMuted) return; + if (!easemobService.setGroupMemberMute(requireEasemobGroupId(group), userId, isMuted)) { + throw new BusinessException("环信禁言设置失败,请稍后重试"); + } + target.setIsMuted(isMuted); + groupMemberRepository.save(target); + } + + @Transactional + public GroupMember updateMyGroupNickname(String groupId, String nickname) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new BusinessException("用户未登录"); + } + groupRepository.findById(groupId).orElseThrow(() -> new BusinessException("群组不存在")); + GroupMember member = requireActiveMember(groupId, userId); + String normalized = nickname == null ? null : nickname.trim(); + if (normalized != null && normalized.isEmpty()) { + normalized = null; + } + if (normalized != null && normalized.length() > 50) { + throw new BusinessException("本群昵称不能超过50个字符"); + } + member.setNickname(normalized); + return groupMemberRepository.save(member); + } + + // ===================== 群组查询 ===================== + + public List getMyGroups() { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new BusinessException("用户未登录"); + } + + return groupRepository.findGroupsByMemberId(userId); + } + + public PageResult searchGroups(String keyword, Integer page, Integer size) { + Pageable pageable = PageRequest.of(page != null && page > 0 ? page - 1 : 0, + size != null && size > 0 ? size : 20, Sort.by(Sort.Direction.DESC, "createdAt")); + + Page pageResult; + if (keyword != null && !keyword.isEmpty()) { + pageResult = groupRepository.findByNameContaining(keyword, pageable); + } else { + pageResult = groupRepository.findAll(pageable); + } + + return PageResult.from(pageResult); + } + + public Group getGroupInfo(String groupId) { + return groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + } + + public List getGroupMembers(String groupId) { + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + String userId = UserContext.getCurrentUserId(); + requireActiveMember(groupId, userId); + + List members = groupMemberRepository.findByGroupIdOrderByJoinTimeAsc(groupId) + .stream().filter(m -> !"PENDING".equals(m.getRole())).collect(Collectors.toList()); + + // 批量加载用户信息(昵称、头像),填充到群成员对象 + List memberUserIds = members.stream() + .map(GroupMember::getUserId) + .distinct() + .collect(Collectors.toList()); + if (!memberUserIds.isEmpty()) { + Map userMap = userRepository.findAllById(memberUserIds) + .stream() + .collect(Collectors.toMap(User::getId, u -> u)); + for (GroupMember member : members) { + User user = userMap.get(member.getUserId()); + if (user != null) { + // 群昵称为空时使用用户昵称 + if (member.getNickname() == null || member.getNickname().isEmpty()) { + member.setNickname(user.getNickname()); + } + member.setAvatar(user.getAvatar()); + } + } + } + + return members; + } + + public List getJoinRequests(String groupId) { + groupRepository.findById(groupId).orElseThrow(() -> new BusinessException("群组不存在")); + requireManager(groupId, UserContext.getCurrentUserId()); + return groupMemberRepository.findByGroupIdAndRoleOrderByJoinTimeAsc(groupId, "PENDING"); + } + + // ===================== 群主管理 ===================== + + @Transactional + public void transferOwner(String groupId, String newOwnerId) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + if (!currentUserId.equals(group.getOwnerId())) { + throw new BusinessException("只有群主可以转让"); + } + + if (currentUserId.equals(newOwnerId)) { + throw new BusinessException("不能转让给自己"); + } + GroupMember oldOwner = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, currentUserId) + .orElseThrow(() -> new BusinessException("群主信息不存在")); + GroupMember newOwner = requireActiveMember(groupId, newOwnerId); + if (Boolean.TRUE.equals(newOwner.getIsMuted())) { + throw new BusinessException("请先解除新群主的禁言"); + } + if (!easemobService.transferGroupOwner(requireEasemobGroupId(group), newOwnerId)) { + throw new BusinessException("环信转让群主失败,请稍后重试"); + } + + oldOwner.setRole("MEMBER"); + groupMemberRepository.save(oldOwner); + newOwner.setRole("OWNER"); + groupMemberRepository.save(newOwner); + + group.setOwnerId(newOwnerId); + groupRepository.save(group); + } + + @Transactional + public void appointAdmin(String groupId, String userId) { + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + requireOwner(group); + GroupMember target = requireActiveMember(groupId, userId); + if ("OWNER".equals(target.getRole())) throw new BusinessException("群主无需设为管理员"); + if ("ADMIN".equals(target.getRole())) return; + if (!easemobService.addGroupAdmin(requireEasemobGroupId(group), userId)) { + throw new BusinessException("环信设置管理员失败,请稍后重试"); + } + target.setRole("ADMIN"); + groupMemberRepository.save(target); + } + + @Transactional + public void revokeAdmin(String groupId, String userId) { + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + requireOwner(group); + GroupMember target = requireActiveMember(groupId, userId); + if (!"ADMIN".equals(target.getRole())) throw new BusinessException("该成员不是管理员"); + try { + if (!easemobService.removeGroupAdminDetectingOwnerConflict(requireEasemobGroupId(group), userId)) { + throw new BusinessException("环信撤销管理员失败,请稍后重试"); + } + } catch (EasemobService.GroupOwnerConflictException e) { + recoverEasemobOwner(group, userId); + // 环信群主转让后,原群主会自动降为普通成员,无需再次撤销管理员。 + } + target.setRole("MEMBER"); + groupMemberRepository.save(target); + } + + // ===================== 群组资料修改 ===================== + + @Transactional + public Group updateGroupInfo(String groupId, String name, String description, String avatar) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + requireManager(groupId, currentUserId); + + String newName = name != null && !name.isEmpty() ? name : null; + if (newName != null || description != null) { + if (!easemobService.updateGroupInfo(requireEasemobGroupId(group), newName, description)) { + throw new BusinessException("环信修改群资料失败,请稍后重试"); + } + } + if (newName != null) { + group.setName(name); + } + + if (description != null) { + group.setDescription(description); + } + + if (avatar != null) { + group.setAvatar(avatar); + } + + return groupRepository.save(group); + } + + // ===================== 删除群组 ===================== + + @Transactional + public void deleteGroup(String groupId) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException("群组不存在")); + + if (!currentUserId.equals(group.getOwnerId())) { + throw new BusinessException("只有群主可以删除群"); + } + + if (!easemobService.deleteGroup(requireEasemobGroupId(group))) { + throw new BusinessException("环信解散群聊失败,请稍后重试"); + } + + groupAnnouncementRepository.deleteByGroupId(groupId); + groupMemberRepository.deleteByGroupId(groupId); + // 群邀请独立成表,必须显式清理:群是软删,外键 ON DELETE CASCADE 不会触发 + groupInvitationRepository.deleteAllByGroupId(groupId); + groupRepository.delete(group); + } + + // ===================== 检查成员角色 ===================== + + public String getMemberRole(String groupId, String userId) { + return groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .map(GroupMember::getRole) + .orElse("NONE"); + } + + public boolean isMember(String groupId, String userId) { + return groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .map(m -> !"PENDING".equals(m.getRole())).orElse(false); + } + + private GroupMember requireActiveMember(String groupId, String userId) { + GroupMember member = groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(groupId, userId) + .orElseThrow(() -> new BusinessException("不在群内")); + if ("PENDING".equals(member.getRole())) throw new BusinessException("入群申请尚未通过"); + return member; + } + + /** + * 校验当前用户是群主或管理员。 + * 设为 public 供 {@code GroupInvitationService} 复用,保证两处权限口径完全一致。 + */ + public GroupMember requireManager(String groupId, String userId) { + GroupMember member = requireActiveMember(groupId, userId); + if (!"OWNER".equals(member.getRole()) && !"ADMIN".equals(member.getRole())) { + throw new BusinessException("只有群主或管理员可以操作"); + } + return member; + } + + private void requireOwner(Group group) { + if (!group.getOwnerId().equals(UserContext.getCurrentUserId())) { + throw new BusinessException("只有群主可以操作"); + } + } + + private boolean removeEasemobMemberWithOwnerRecovery(Group group, String userId) { + String easemobGroupId = requireEasemobGroupId(group); + try { + return easemobService.removeGroupMemberDetectingOwnerConflict(easemobGroupId, userId); + } catch (EasemobService.GroupOwnerConflictException e) { + recoverEasemobOwner(group, userId); + try { + return easemobService.removeGroupMemberDetectingOwnerConflict(easemobGroupId, userId); + } catch (EasemobService.GroupOwnerConflictException retryFailure) { + throw new BusinessException("群聊群主数据修复后仍未生效,请稍后重试", retryFailure); + } + } + } + + private void recoverEasemobOwner(Group group, String unexpectedOwnerId) { + if (group.getOwnerId().equals(unexpectedOwnerId)) { + throw new BusinessException("不能对群主执行该操作"); + } + + String easemobGroupId = requireEasemobGroupId(group); + log.warn("检测到群主数据不一致,开始恢复环信群主: groupId={}, easemobGroupId={}, localOwnerId={}, easemobOwnerId={}", + group.getId(), easemobGroupId, group.getOwnerId(), unexpectedOwnerId); + if (!easemobService.transferGroupOwner(easemobGroupId, group.getOwnerId())) { + throw new BusinessException("群聊群主数据不一致,自动修复失败,请稍后重试"); + } + log.info("环信群主数据已恢复: groupId={}, ownerId={}", group.getId(), group.getOwnerId()); + } + + /** + * 取环信群 ID,未就绪时抛业务异常。 + * 设为 public 供 {@code GroupInvitationService} 复用。 + */ + public String requireEasemobGroupId(Group group) { + if (group.getEasemobGroupId() == null || group.getEasemobGroupId().trim().isEmpty()) { + throw new BusinessException("群聊服务尚未就绪,请稍后重试"); + } + return group.getEasemobGroupId(); + } +} diff --git a/campus-server/src/main/java/com/campus/service/HomeService.java b/campus-server/src/main/java/com/campus/service/HomeService.java new file mode 100644 index 0000000..fdab49f --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/HomeService.java @@ -0,0 +1,41 @@ +package com.campus.service; + +import com.campus.dto.response.HomeOverviewDTO; +import com.campus.dto.response.RecommendBatchDTO; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** 首页首屏数据聚合服务。 */ +@Service +@RequiredArgsConstructor +public class HomeService { + + private final UserRepository userRepository; + private final FriendService friendService; + private final ActivityService activityService; + private final MomentService momentService; + private final PointsService pointsService; + private final NotificationService notificationService; + + public HomeOverviewDTO overview() { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new RuntimeException("用户未登录"); + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("当前用户不存在")); + + HomeOverviewDTO dto = new HomeOverviewDTO(); + dto.setProfile(friendService.getUserProfile(userId)); + dto.setPoints(pointsService.getAccountInfo(userId)); + dto.setUnread(notificationService.getUnreadDetail(userId)); + RecommendBatchDTO batch = friendService.recommendBatch(6, null); + dto.setRecommendedFriends(batch == null ? java.util.Collections.emptyList() : batch.getList()); + dto.setActivities(activityService.list(null, user.getUniversityName(), 1, 5, userId)); + dto.setMoments(momentService.list(null, 1, 5)); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/InteractionCommittedListener.java b/campus-server/src/main/java/com/campus/service/InteractionCommittedListener.java new file mode 100644 index 0000000..7d997fa --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/InteractionCommittedListener.java @@ -0,0 +1,24 @@ +package com.campus.service; + +import com.campus.service.event.InteractionCommittedAction; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class InteractionCommittedListener { + private final InteractionSideEffectService sideEffectService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCommitted(InteractionCommittedAction event) { + try { + sideEffectService.execute(event.getAction()); + } catch (Exception ex) { + log.warn("互动已提交,辅助动作失败", ex); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/InteractionLockService.java b/campus-server/src/main/java/com/campus/service/InteractionLockService.java new file mode 100644 index 0000000..164498c --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/InteractionLockService.java @@ -0,0 +1,32 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.util.TreeSet; +import java.util.Arrays; + +/** 关系写操作使用固定顺序的数据库行锁;Redis 故障时仍能保证并发安全。 */ +@Service +@RequiredArgsConstructor +public class InteractionLockService { + private final UserRepository userRepository; + + public static String currentUserId() { + String id = UserContext.getCurrentUserId(); + if (id == null || id.trim().isEmpty()) throw new BusinessException("用户未登录"); + return id; + } + + @Transactional(propagation = Propagation.MANDATORY) + public void lockUsers(String first, String second) { + if (first == null || second == null) throw new BusinessException("用户ID不能为空"); + for (String id : new TreeSet<>(Arrays.asList(first, second))) { + userRepository.findByIdForUpdate(id).orElseThrow(() -> new BusinessException("用户不存在")); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/InteractionSideEffectService.java b/campus-server/src/main/java/com/campus/service/InteractionSideEffectService.java new file mode 100644 index 0000000..e430723 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/InteractionSideEffectService.java @@ -0,0 +1,14 @@ +package com.campus.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 独立事务失败只能影响积分/通知,不能撤销已经提交的互动。 */ +@Service +public class InteractionSideEffectService { + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(Runnable action) { + action.run(); + } +} diff --git a/campus-server/src/main/java/com/campus/service/LoginSecurityService.java b/campus-server/src/main/java/com/campus/service/LoginSecurityService.java new file mode 100644 index 0000000..44c322f --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/LoginSecurityService.java @@ -0,0 +1,67 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ServiceUnavailableException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class LoginSecurityService { + private static final int MAX_FAILURES = 5; + private static final long FAILURE_WINDOW_MINUTES = 15; + private static final long LOCK_MINUTES = 15; + private static final String FAILED_PREFIX = "login:failed:"; + private static final String LOCKED_PREFIX = "login:locked:"; + + private final RedisTemplate redisTemplate; + private final RedisRetryExecutor retryExecutor; + + public void checkNotLocked(String phone) { + try { + Boolean locked = retryExecutor.execute("login-lock-read", + () -> redisTemplate.hasKey(LOCKED_PREFIX + phone)); + if (Boolean.TRUE.equals(locked)) { + throw new BusinessException("密码错误次数过多,请 15 分钟后再试或使用验证码登录"); + } + } catch (DataAccessException ex) { + throw new ServiceUnavailableException("登录安全服务暂时不可用,请稍后重试", ex); + } + } + + /** @return 本次失败后是否已触发锁定。 */ + public boolean recordFailure(String phone) { + try { + return retryExecutor.execute("login-failure-write", () -> { + String failedKey = FAILED_PREFIX + phone; + Long count = redisTemplate.opsForValue().increment(failedKey); + if (count != null && count == 1L) { + redisTemplate.expire(failedKey, FAILURE_WINDOW_MINUTES, TimeUnit.MINUTES); + } + if (count != null && count >= MAX_FAILURES) { + redisTemplate.opsForValue().set(LOCKED_PREFIX + phone, true, LOCK_MINUTES, TimeUnit.MINUTES); + redisTemplate.delete(failedKey); + return true; + } + return false; + }); + } catch (DataAccessException ex) { + throw new ServiceUnavailableException("登录安全服务暂时不可用,请稍后重试", ex); + } + } + + public void clearFailures(String phone) { + try { + retryExecutor.execute("login-failure-clear", () -> { + redisTemplate.delete(FAILED_PREFIX + phone); + redisTemplate.delete(LOCKED_PREFIX + phone); + }); + } catch (DataAccessException ex) { + throw new ServiceUnavailableException("登录安全服务暂时不可用,请稍后重试", ex); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/MatchingService.java b/campus-server/src/main/java/com/campus/service/MatchingService.java new file mode 100644 index 0000000..c1ca6d4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/MatchingService.java @@ -0,0 +1,350 @@ +package com.campus.service; + +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.entity.Friendship; +import com.campus.entity.MatchAction; +import com.campus.entity.MatchPair; +import com.campus.entity.MatchPreference; +import com.campus.entity.User; +import com.campus.dto.request.NotificationRequest; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.MatchActionRepository; +import com.campus.repository.MatchPairRepository; +import com.campus.repository.MatchPreferenceRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** 心动匹配业务,与好友申请关系分开存储。 */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MatchingService { + + private final UserRepository userRepository; + private final MatchPreferenceRepository preferenceRepository; + private final MatchActionRepository actionRepository; + private final MatchPairRepository pairRepository; + private final FriendshipRepository friendshipRepository; + private final RecommendService recommendService; + private final NotificationService notificationService; + private final PrivacySettingsService privacySettingsService; + + public MatchPreferenceDTO getPreference() { + String userId = currentUserId(); + return toPreferenceDTO(preferenceRepository.findByUserId(userId).orElseGet(() -> defaultPreference(userId))); + } + + @Transactional + public MatchPreferenceDTO updatePreference(MatchPreferenceRequest request) { + String userId = currentUserId(); + MatchPreference preference = preferenceRepository.findByUserId(userId) + .orElseGet(() -> defaultPreference(userId)); + + if (request.getPreferredGender() != null) { + String gender = request.getPreferredGender().trim().toUpperCase(); + if (!("ANY".equals(gender) || "M".equals(gender) || "F".equals(gender))) { + throw new RuntimeException("preferredGender 必须是 ANY、M 或 F"); + } + preference.setPreferredGender(gender); + } + if (request.getMinAge() != null) { + preference.setMinAge(request.getMinAge()); + } + if (request.getMaxAge() != null) { + preference.setMaxAge(request.getMaxAge()); + } + if (preference.getMinAge() == null || preference.getMaxAge() == null + || preference.getMinAge() < 18 || preference.getMaxAge() > 100 + || preference.getMinAge() > preference.getMaxAge()) { + throw new RuntimeException("年龄范围必须是 18-100 岁,且最小年龄不能大于最大年龄"); + } + if (request.getSameUniversityOnly() != null) { + preference.setSameUniversityOnly(request.getSameUniversityOnly()); + } + return toPreferenceDTO(preferenceRepository.save(preference)); + } + + /** 获取未操作过的候选人,按匹配度降序返回。 */ + public List candidates(Integer limit) { + String userId = currentUserId(); + User me = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("当前用户不存在")); + MatchPreference preference = preferenceRepository.findByUserId(userId) + .orElseGet(() -> defaultPreference(userId)); + + int requested = limit == null || limit < 1 ? 20 : Math.min(limit, 50); + List actions = actionRepository.findByUserId(userId); + Set actionedIds = actions.stream().map(MatchAction::getTargetUserId).collect(Collectors.toSet()); + Set blockedIds = blockedIds(userId); + + List pool = userRepository.findMatchCandidates(userId, PageRequest.of(0, 500)); + List filtered = pool.stream() + .filter(candidate -> !actionedIds.contains(candidate.getId())) + .filter(candidate -> !blockedIds.contains(candidate.getId())) + .filter(candidate -> matchesPreference(me, candidate, preference)) + .filter(candidate -> privacySettingsService.isProfileVisible(candidate.getId())) + .collect(Collectors.toList()); + + filtered.sort(Comparator.comparingInt((User u) -> score(me, u)).reversed()); + List result = new ArrayList<>(); + for (User user : filtered.subList(0, Math.min(requested, filtered.size()))) { + result.add(toFriendDTO(me, user)); + } + return result; + } + + @Transactional + public MatchActionDTO act(MatchActionRequest request) { + String userId = currentUserId(); + String targetId = request.getTargetUserId(); + if (userId.equals(targetId)) { + throw new RuntimeException("不能对自己执行匹配操作"); + } + User me = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("当前用户不存在")); + User target = userRepository.findById(targetId).orElseThrow(() -> new RuntimeException("匹配对象不存在")); + if (isBlocked(userId, targetId)) { + throw new RuntimeException("无法对黑名单用户执行匹配操作"); + } + + String actionName = request.getAction().trim().toUpperCase(); + if (!("LIKE".equals(actionName) || "SUPER_LIKE".equals(actionName) || "PASS".equals(actionName))) { + throw new RuntimeException("action 必须是 LIKE、SUPER_LIKE 或 PASS"); + } + + MatchAction action = actionRepository.findByUserIdAndTargetUserId(userId, targetId) + .orElseGet(() -> { + MatchAction created = new MatchAction(); + created.setUserId(userId); + created.setTargetUserId(targetId); + return created; + }); + action.setAction(actionName); + action = actionRepository.save(action); + + MatchActionDTO result = new MatchActionDTO(); + result.setActionId(action.getId()); + result.setTargetUserId(targetId); + result.setAction(actionName); + result.setMatched(false); + + if ("PASS".equals(actionName)) { + return result; + } + + MatchAction reciprocal = actionRepository.findByUserIdAndTargetUserId(targetId, userId).orElse(null); + if (reciprocal == null || !isPositive(reciprocal.getAction())) { + return result; + } + + String low = userId.compareTo(targetId) < 0 ? userId : targetId; + String high = userId.compareTo(targetId) < 0 ? targetId : userId; + MatchPair pair = pairRepository.findByUserLowIdAndUserHighId(low, high).orElse(null); + boolean created = pair == null; + if (created) { + pair = new MatchPair(); + pair.setUserLowId(low); + pair.setUserHighId(high); + pair.setStatus("ACTIVE"); + pair = pairRepository.save(pair); + notifyMatch(me, target, pair); + } + result.setMatched(true); + result.setPairId(pair.getId()); + return result; + } + + public List listPairs() { + String userId = currentUserId(); + List pairs = pairRepository.findByUserLowIdOrUserHighIdOrderByCreatedAtDesc(userId, userId).stream() + .filter(pair -> "ACTIVE".equals(pair.getStatus())).collect(Collectors.toList()); + if (pairs.isEmpty()) { + return Collections.emptyList(); + } + Set ids = new HashSet<>(); + pairs.forEach(pair -> ids.add(userId.equals(pair.getUserLowId()) ? pair.getUserHighId() : pair.getUserLowId())); + Map users = userRepository.findByIdIn(ids).stream() + .collect(Collectors.toMap(User::getId, u -> u)); + return pairs.stream().map(pair -> { + String otherId = userId.equals(pair.getUserLowId()) ? pair.getUserHighId() : pair.getUserLowId(); + User other = users.get(otherId); + MatchPairDTO dto = new MatchPairDTO(); + dto.setPairId(pair.getId()); + dto.setUserId(otherId); + if (other != null) { + dto.setNickname(other.getNickname()); + dto.setAvatar(other.getAvatar()); + dto.setUniversityName(other.getUniversityName()); + dto.setMajorName(other.getMajorName()); + } + dto.setStatus(pair.getStatus()); + dto.setMatchedAt(pair.getCreatedAt()); + return dto; + }).collect(Collectors.toList()); + } + + @Transactional + public void unmatch(String pairId) { + String userId = currentUserId(); + MatchPair pair = pairRepository.findById(pairId) + .orElseThrow(() -> new RuntimeException("配对不存在")); + if (!userId.equals(pair.getUserLowId()) && !userId.equals(pair.getUserHighId())) { + throw new RuntimeException("无权取消该配对"); + } + if (!"ACTIVE".equals(pair.getStatus())) { + return; + } + pair.setStatus("UNMATCHED"); + pairRepository.save(pair); + String otherId = userId.equals(pair.getUserLowId()) ? pair.getUserHighId() : pair.getUserLowId(); + actionRepository.findByUserIdAndTargetUserId(userId, otherId).ifPresent(action -> { + action.setAction("PASS"); + actionRepository.save(action); + }); + } + + private void notifyMatch(User me, User target, MatchPair pair) { + NotificationRequest toTarget = notification("匹配成功", "你和 " + safeName(me) + " 互相喜欢,匹配成功", me, pair.getId()); + notificationService.create(toTarget, target.getId(), me.getId()); + NotificationRequest toMe = notification("匹配成功", "你和 " + safeName(target) + " 互相喜欢,匹配成功", target, pair.getId()); + notificationService.create(toMe, me.getId(), target.getId()); + } + + private NotificationRequest notification(String title, String content, User sender, String pairId) { + NotificationRequest request = new NotificationRequest(); + request.setType("MATCH_SUCCESS"); + request.setTitle(title); + request.setContent(content); + request.setAvatar(sender.getAvatar()); + request.setRelatedId(pairId); + request.setSenderId(sender.getId()); + return request; + } + + private FriendDTO toFriendDTO(User me, User other) { + FriendDTO dto = new FriendDTO(); + dto.setId(other.getId()); + dto.setNumber(other.getNumber()); + dto.setNickname(other.getNickname()); + dto.setAvatar(other.getAvatar()); + dto.setGender(other.getGender()); + dto.setAge(other.getBirthday() == null ? null : ageOf(other.getBirthday())); + dto.setUniversityName(other.getUniversityName()); + dto.setMajorName(other.getMajorName()); + dto.setGrade(other.getGrade()); + dto.setInterests(other.getInterests()); + dto.setBio(other.getBio()); + dto.setHometown(other.getHometown()); + dto.setLocation(other.getLocation()); + dto.setEducation(other.getEducation()); + dto.setXuexinCertified(other.getXuexinCertified()); + dto.setRelationStatus("NONE"); + dto.setMatchAction(null); + FriendDTO score = recommendService.score(me, other); + dto.setMatchScore(score.getMatchScore()); + dto.setMatchReasons(score.getMatchReasons()); + return dto; + } + + private boolean matchesPreference(User me, User candidate, MatchPreference preference) { + if (Boolean.TRUE.equals(preference.getSameUniversityOnly()) + && !safeEquals(me.getUniversityName(), candidate.getUniversityName())) { + return false; + } + String gender = preference.getPreferredGender(); + if (gender != null && !"ANY".equalsIgnoreCase(gender) + && !gender.equalsIgnoreCase(candidate.getGender())) { + return false; + } + Integer age = candidate.getBirthday() == null ? null : ageOf(candidate.getBirthday()); + return age != null && age >= preference.getMinAge() && age <= preference.getMaxAge(); + } + + private Set blockedIds(String userId) { + Set ids = new HashSet<>(); + friendshipRepository.findByUserIdAndStatus(userId, "BLOCKED") + .forEach(friendship -> ids.add(friendship.getFriendId())); + friendshipRepository.findByFriendIdAndStatus(userId, "BLOCKED") + .forEach(friendship -> ids.add(friendship.getUserId())); + return ids; + } + + private boolean isBlocked(String userId, String targetId) { + return blockedIds(userId).contains(targetId); + } + + private int score(User me, User other) { + Integer score = recommendService.score(me, other).getMatchScore(); + return score == null ? 0 : score; + } + + private boolean isPositive(String action) { + return "LIKE".equals(action) || "SUPER_LIKE".equals(action); + } + + private MatchPreference defaultPreference(String userId) { + MatchPreference preference = new MatchPreference(); + preference.setUserId(userId); + preference.setPreferredGender("ANY"); + preference.setMinAge(18); + preference.setMaxAge(100); + preference.setSameUniversityOnly(false); + return preference; + } + + private MatchPreferenceDTO toPreferenceDTO(MatchPreference preference) { + MatchPreferenceDTO dto = new MatchPreferenceDTO(); + dto.setUserId(preference.getUserId()); + dto.setPreferredGender(preference.getPreferredGender()); + dto.setMinAge(preference.getMinAge()); + dto.setMaxAge(preference.getMaxAge()); + dto.setSameUniversityOnly(preference.getSameUniversityOnly()); + return dto; + } + + private String safeName(User user) { + return user.getNickname() == null || user.getNickname().trim().isEmpty() ? "对方" : user.getNickname(); + } + + private boolean safeEquals(String a, String b) { + return a != null && a.equals(b); + } + + private int ageOf(Date birthday) { + Calendar now = Calendar.getInstance(); + Calendar born = Calendar.getInstance(); + born.setTime(birthday); + int age = now.get(Calendar.YEAR) - born.get(Calendar.YEAR); + if (now.get(Calendar.DAY_OF_YEAR) < born.get(Calendar.DAY_OF_YEAR)) { + age--; + } + return age; + } + + private String currentUserId() { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new RuntimeException("用户未登录"); + } + return userId; + } +} diff --git a/campus-server/src/main/java/com/campus/service/ModerationService.java b/campus-server/src/main/java/com/campus/service/ModerationService.java new file mode 100644 index 0000000..3ca7ea8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ModerationService.java @@ -0,0 +1,175 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.BatchContentReviewRequest; +import com.campus.dto.response.ContentReviewDTO; +import com.campus.entity.Activity; +import com.campus.entity.Announcement; +import com.campus.entity.ContentReview; +import com.campus.entity.Moment; +import com.campus.repository.ActivityRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.ContentReviewRepository; +import com.campus.repository.MomentRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ModerationService { + private final ContentReviewRepository contentReviewRepository; + private final MomentRepository momentRepository; + private final ActivityRepository activityRepository; + private final AnnouncementRepository announcementRepository; + private final AuthorizationService authorizationService; + private final NotificationService notificationService; + private final AuditLogService auditLogService; + + @Transactional(readOnly = true) + public PageResult workbench(String status, int page, int size) { + authorizationService.requireReviewerOrAdmin(); + int safePage = Math.max(1, page); + int safeSize = Math.min(Math.max(1, size), 100); + String normalized = status == null || status.trim().isEmpty() + ? "pending" : normalizeStatus(status); + Page data = contentReviewRepository.findByStatusOrderByCreatedAtAsc( + normalized, PageRequest.of(safePage - 1, safeSize)); + return page(data, safePage, safeSize); + } + + @Transactional(readOnly = true) + public PageResult history(int page, int size) { + authorizationService.requireReviewerOrAdmin(); + int safePage = Math.max(1, page); + int safeSize = Math.min(Math.max(1, size), 100); + return page(contentReviewRepository.findAllByOrderByCreatedAtDesc( + PageRequest.of(safePage - 1, safeSize)), safePage, safeSize); + } + + @Transactional + public List batchReview(BatchContentReviewRequest request) { + authorizationService.requireReviewerOrAdmin(); + if (request.getItems().size() > 100) throw new BusinessException("单次最多审核 100 条内容"); + List result = new ArrayList<>(); + for (BatchContentReviewRequest.Item item : request.getItems()) { + result.add(review(item.getContentType(), item.getContentId(), item.isApproved(), item.getReason())); + } + return result; + } + + @Transactional + public ContentReviewDTO review(String contentType, String contentId, boolean approved, String reason) { + authorizationService.requireReviewerOrAdmin(); + String type = normalizeType(contentType); + String status = approved ? "approved" : "rejected"; + String ownerId = updateContent(type, contentId, approved, trim(reason)); + ContentReview review = new ContentReview(); + review.setContentType(type); + review.setContentId(contentId); + review.setStatus(status); + review.setReason(trim(reason)); + review.setReviewType("MANUAL"); + review.setReviewedBy(UserContext.getCurrentUserId()); + review.setReviewedAt(new Date()); + review = contentReviewRepository.save(review); + notificationService.createNotification(ownerId, + type + (approved ? "_REVIEW_APPROVED" : "_REVIEW_REJECTED"), + approved ? "内容审核通过" : "内容审核未通过", + approved ? "你提交的内容已通过人工审核。" + : "你提交的内容未通过人工审核" + (trim(reason) == null ? "。" : ":" + trim(reason)), + null, contentLink(type, contentId), contentId, UserContext.getCurrentUserId()); + auditLogService.record("CONTENT_REVIEW", type, contentId, null, + "status=" + status, trim(reason)); + return toDTO(review); + } + + private String updateContent(String type, String id, boolean approved, String reason) { + switch (type) { + case "MOMENT": { + Moment item = momentRepository.findIncludingDeleted(id) + .orElseThrow(() -> new BusinessException("动态不存在")); + item.setDeleted(false); + item.setReviewStatus(approved ? "approved" : "rejected"); + item.setReviewReason(reason); + momentRepository.save(item); + return item.getUserId(); + } + case "ACTIVITY": { + Activity item = activityRepository.findIncludingDeleted(id) + .orElseThrow(() -> new BusinessException("活动不存在")); + item.setDeleted(false); + item.setReviewStatus(approved ? "approved" : "rejected"); + item.setReviewReason(reason); + if (!approved) item.setStatus("INACTIVE"); + else if ("INACTIVE".equals(item.getStatus())) item.setStatus("ACTIVE"); + activityRepository.save(item); + return item.getCreatorId(); + } + case "ANNOUNCEMENT": { + Announcement item = announcementRepository.findIncludingDeleted(id) + .orElseThrow(() -> new BusinessException("公告不存在")); + item.setDeleted(false); + item.setStatus(approved ? "approved" : "rejected"); + item.setReviewReason(reason); + announcementRepository.save(item); + return item.getCreatorId(); + } + default: throw new BusinessException("不支持的内容类型"); + } + } + + private PageResult page(Page data, int page, int size) { + return PageResult.of(data.getContent().stream().map(this::toDTO).collect(Collectors.toList()), + data.getTotalElements(), page, size); + } + + private ContentReviewDTO toDTO(ContentReview item) { + ContentReviewDTO dto = new ContentReviewDTO(); + dto.setId(item.getId()); + dto.setContentType(item.getContentType()); + dto.setContentId(item.getContentId()); + dto.setStatus(item.getStatus()); + dto.setReason(item.getReason()); + dto.setScore(item.getScore()); + dto.setReviewedBy(item.getReviewedBy()); + dto.setReviewedAt(item.getReviewedAt()); + dto.setReviewType(item.getReviewType()); + dto.setCreatedAt(item.getCreatedAt()); + return dto; + } + + private String normalizeType(String value) { + String type = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!java.util.Arrays.asList("MOMENT", "ACTIVITY", "ANNOUNCEMENT").contains(type)) + throw new BusinessException("内容类型必须是 MOMENT、ACTIVITY 或 ANNOUNCEMENT"); + return type; + } + + private String normalizeStatus(String value) { + String status = value.trim().toLowerCase(Locale.ROOT); + if (!java.util.Arrays.asList("pending", "approved", "rejected").contains(status)) + throw new BusinessException("审核状态必须是 pending、approved 或 rejected"); + return status; + } + + private String trim(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim(); + } + + private String contentLink(String type, String id) { + if ("MOMENT".equals(type)) return "/moments/" + id; + if ("ACTIVITY".equals(type)) return "/activity/" + id; + return "/announcements/" + id; + } +} diff --git a/campus-server/src/main/java/com/campus/service/MomentCityBackfillService.java b/campus-server/src/main/java/com/campus/service/MomentCityBackfillService.java new file mode 100644 index 0000000..a9a8413 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/MomentCityBackfillService.java @@ -0,0 +1,40 @@ +package com.campus.service; + +import com.campus.entity.Moment; +import com.campus.entity.User; +import com.campus.repository.MomentRepository; +import com.campus.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MomentCityBackfillService { + private final MomentRepository momentRepository; + private final UserRepository userRepository; + private final AuthorizationService authorizationService; + + @Transactional + public int backfill() { + authorizationService.requireAdmin(); + int updated = 0; + for (Moment moment : momentRepository.findByCityIsNull()) { + User user = userRepository.findById(moment.getUserId()).orElse(null); + if (user == null) continue; + String city = first(user.getHometownCity(), user.getLocation()); + if (city == null) continue; + moment.setCity(city); + moment.setProvince(first(user.getHometownProvince(), null)); + momentRepository.save(moment); + updated++; + } + return updated; + } + + private String first(String preferred, String fallback) { + if (preferred != null && !preferred.trim().isEmpty()) return preferred.trim(); + if (fallback != null && !fallback.trim().isEmpty()) return fallback.trim(); + return null; + } +} diff --git a/campus-server/src/main/java/com/campus/service/MomentLikeCommentService.java b/campus-server/src/main/java/com/campus/service/MomentLikeCommentService.java new file mode 100644 index 0000000..2da688e --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/MomentLikeCommentService.java @@ -0,0 +1,644 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.CommentRequest; +import com.campus.dto.response.MomentCommentDTO; +import com.campus.dto.response.MomentLikeUserDTO; +import com.campus.dto.response.InteractionLikeStateDTO; +import com.campus.entity.Moment; +import com.campus.entity.MomentComment; +import com.campus.entity.MomentCommentLike; +import com.campus.entity.MomentLike; +import com.campus.entity.User; +import com.campus.repository.MomentCommentLikeRepository; +import com.campus.repository.MomentCommentRepository; +import com.campus.repository.MomentLikeRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.NotificationRepository; +import com.campus.repository.UserRepository; +import com.campus.util.PageUtils; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 动态点赞和评论服务 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MomentLikeCommentService { + @Value("${app.interaction-rate-limit.window-ms:5000}") + private long interactionRateWindowMs; + @Value("${app.interaction-rate-limit.max-requests:20}") + private long interactionRateMaxRequests; + private final ClickGuardService clickGuardService; + private final AuthorizationService authorizationService; + private final org.springframework.context.ApplicationEventPublisher eventPublisher; + + private final MomentRepository momentRepository; + private final MomentCommentRepository momentCommentRepository; + private final MomentLikeRepository momentLikeRepository; + private final MomentCommentLikeRepository momentCommentLikeRepository; + private final UserRepository userRepository; + private final NotificationRepository notificationRepository; + private final PointsService pointsService; + private final PushService pushService; + private final NotificationService notificationService; + private final UserRestrictionService userRestrictionService; + + // 每页显示的回复数 + private static final int REPLY_PAGE_SIZE = 3; + + // ==================== 点赞功能 ==================== + + /** + * 点赞/取消点赞 + */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public boolean toggleLike(String momentId) { + return changeLike(momentId, null).getLiked(); + } + + /** 兼容 toggle 接口的事务内状态快照。 */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public InteractionLikeStateDTO toggleLikeState(String momentId) { + return changeLike(momentId, null); + } + + /** 将动态点赞设置为明确状态;重复设置同一状态是幂等操作。 */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public InteractionLikeStateDTO setLike(String momentId, boolean desiredLiked) { + return changeLike(momentId, desiredLiked); + } + + private InteractionLikeStateDTO changeLike(String momentId, Boolean desiredLiked) { + String userId = InteractionLockService.currentUserId(); + clickGuardService.ensureWithinRate(ClickGuardService.SCOPE_MOMENT_LIKE, userId, + interactionRateWindowMs, interactionRateMaxRequests); + + // 检查动态是否存在 + Moment moment = momentRepository.findByIdForInteraction(momentId) + .orElseThrow(() -> new BusinessException("动态不存在")); + + Optional existingLike = momentLikeRepository.findByMomentIdAndUserId(momentId, userId); + boolean currentlyLiked = existingLike.isPresent(); + boolean nextLiked = desiredLiked == null ? !currentlyLiked : desiredLiked; + + if (nextLiked && !"approved".equals(moment.getReviewStatus())) { + throw new BusinessException("动态未发布,无法点赞"); + } + + if (currentlyLiked == nextLiked) { + return likeState(nextLiked, moment.getLikeCount()); + } + + if (!nextLiked) { + // 取消点赞 + momentLikeRepository.delete(existingLike.get()); + // 更新动态点赞数 + if (moment.getLikeCount() > 0) { + moment.setLikeCount(moment.getLikeCount() - 1); + momentRepository.save(moment); + } + return likeState(false, moment.getLikeCount()); + } else { + // 未过审动态不允许新增点赞(取消点赞仍放行,避免历史点赞无法收回) + if (!"approved".equals(moment.getReviewStatus())) { + throw new BusinessException("动态未发布,无法点赞"); + } + // 点赞 + momentLikeRepository.upsertActive(UUID.randomUUID().toString(), momentId, userId); + + // 更新动态点赞数 + moment.setLikeCount(moment.getLikeCount() + 1); + momentRepository.save(moment); + + eventPublisher.publishEvent(new com.campus.service.event.InteractionCommittedAction(() -> { + // 触发积分:点赞动态 + 推送通知 + pointsService.onLikeMoment(userId, momentId, moment.getUserId()); + + // 如果点赞者不是动态发布者,推送通知 + if (!userId.equals(moment.getUserId())) { + // 检查是否已发送过点赞通知(取消点赞后再点赞不重复通知) + boolean alreadyNotified = notificationRepository.existsByUserIdAndTypeAndSenderIdAndRelatedId( + moment.getUserId(), "MOMENT_LIKE", userId, momentId); + if (!alreadyNotified) { + Optional likerOpt = userRepository.findById(userId); + if (likerOpt.isPresent()) { + User liker = likerOpt.get(); + pushService.pushMomentLike( + moment.getUserId(), + liker.getNickname(), + liker.getAvatar(), + userId, + momentId + ); + notificationService.createNotification( + moment.getUserId(), + "MOMENT_LIKE", + "新的点赞", + liker.getNickname() + " 点赞了你的动态", + liker.getAvatar(), + "/moment/" + momentId, + momentId, + userId + ); + } + } + } + + })); + + return likeState(true, moment.getLikeCount()); + } + } + + private InteractionLikeStateDTO likeState(boolean liked, long likeCount) { + InteractionLikeStateDTO state = new InteractionLikeStateDTO(); + state.setLiked(liked); + state.setLikeCount(likeCount); + return state; + } + + /** + * 获取点赞数(所有人可查看) + */ + public long getLikeCount(String momentId) { + return momentRepository.findById(momentId) + .map(Moment::getLikeCount) + .orElse(0); + } + + /** + * 获取点赞用户列表(发布者或点赞者可查看) + */ + public PageResult getLikeUsers(String momentId, int page, int size) { + String currentUserId = UserContext.getCurrentUserId(); + + // 获取动态 + Moment moment = momentRepository.findById(momentId) + .orElseThrow(() -> new BusinessException("动态不存在")); + + // 权限检查:发布者或已点赞用户可以查看点赞列表 + boolean isCreator = moment.getUserId().equals(currentUserId); + boolean hasLiked = momentLikeRepository.existsByMomentIdAndUserId(momentId, currentUserId); + + if (!isCreator && !hasLiked) { + authorizationService.requireOwnerOrAdmin(moment.getUserId(), "只有发布者或已点赞用户可以查看点赞列表"); + } + + Pageable pageable = PageUtils.request(page, size); + Page likePage = momentLikeRepository.findByMomentId(momentId, pageable); + + // 获取用户信息 + List likeUserIds = likePage.getContent().stream() + .map(like -> String.valueOf(like.getUserId())) + .collect(Collectors.toList()); + + Map userMap = getUserMap(likeUserIds); + + List result = likePage.getContent().stream() + .map(like -> { + MomentLikeUserDTO dto = new MomentLikeUserDTO(); + dto.setId(like.getId()); + dto.setUserId(String.valueOf(like.getUserId())); + dto.setLikedAt(like.getCreatedAt()); + + User user = userMap.get(String.valueOf(like.getUserId())); + if (user != null) { + dto.setUserName(user.getNickname()); + dto.setUserAvatar(user.getAvatar()); + } + if (Boolean.TRUE.equals(moment.getAnonymous()) && java.util.Objects.equals(moment.getUserId(), like.getUserId())) { + dto.setUserId(null); + dto.setUserName("匿名楼主"); + dto.setUserAvatar(null); + } + return dto; + }) + .collect(Collectors.toList()); + + return PageResult.of(result, likePage.getTotalElements(), page, size); + } + + /** + * 检查当前用户是否已点赞 + */ + public boolean isLiked(String momentId) { + String userId = UserContext.getCurrentUserId(); + return momentLikeRepository.existsByMomentIdAndUserId(momentId, userId); + } + + // ==================== 评论功能 ==================== + + /** + * 添加评论 + */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public MomentCommentDTO addComment(String momentId, CommentRequest request) { + String userId = InteractionLockService.currentUserId(); + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_MOMENT_COMMENT, userId, momentId); + + // 检查动态是否存在 + Moment moment = momentRepository.findByIdForInteraction(momentId) + .orElseThrow(() -> new BusinessException("动态不存在")); + + userRestrictionService.ensureAllowed(userId, "COMMENT"); + + // 检查动态是否已审核通过 + if (!"approved".equals(moment.getReviewStatus())) { + throw new BusinessException("动态未发布,无法评论"); + } + + // 如果是回复,检查父评论是否存在 + if (request.getParentId() != null) { + MomentComment parentComment = momentCommentRepository.findById(request.getParentId()) + .orElseThrow(() -> new BusinessException("父评论不存在")); + + if (!"ACTIVE".equals(parentComment.getStatus())) { + throw new BusinessException("父评论不存在"); + } + + // 父评论必须属于同一个动态 + if (!parentComment.getMomentId().equals(momentId)) { + throw new BusinessException("父评论不属于此动态"); + } + } + + MomentComment comment = new MomentComment(); + comment.setMomentId(momentId); + comment.setUserId(userId); + comment.setParentId(request.getParentId()); + comment.setContent(request.getContent()); + comment.setStatus("ACTIVE"); + + comment = momentCommentRepository.save(comment); + + // 更新动态评论数 + moment.setCommentCount(moment.getCommentCount() + 1); + momentRepository.save(moment); + + final String committedCommentId = comment.getId(); + eventPublisher.publishEvent(new com.campus.service.event.InteractionCommittedAction(() -> { + // 触发积分:评论动态 + pointsService.onCommentMoment(userId, momentId, moment.getUserId(), committedCommentId); + + // 推送通知 + Optional commenterOpt = userRepository.findById(userId); + if (commenterOpt.isPresent()) { + User commenter = commenterOpt.get(); + boolean anonymousAuthor = Boolean.TRUE.equals(moment.getAnonymous()) && userId.equals(moment.getUserId()); + String displayName = anonymousAuthor ? "匿名楼主" : commenter.getNickname(); + String displayAvatar = anonymousAuthor ? null : commenter.getAvatar(); + String displaySenderId = anonymousAuthor ? null : userId; + + if (request.getParentId() != null) { + // 回复评论:给原评论作者推送 + MomentComment parentComment = momentCommentRepository.findById(request.getParentId()).orElse(null); + if (parentComment != null && !userId.equals(String.valueOf(parentComment.getUserId()))) { + pushService.pushCommentReply( + String.valueOf(parentComment.getUserId()), + displayName, + displayAvatar, + displaySenderId, + momentId, + committedCommentId, + request.getContent() + ); + notificationService.createNotification( + String.valueOf(parentComment.getUserId()), + "COMMENT_REPLY", + "新的回复", + displayName + " 回复了你的评论:" + request.getContent(), + displayAvatar, + "/moment/" + momentId, + momentId, + displaySenderId + ); + } + } else { + // 评论动态:给动态发布者推送 + if (!userId.equals(moment.getUserId())) { + pushService.pushMomentComment( + moment.getUserId(), + displayName, + displayAvatar, + displaySenderId, + momentId, + committedCommentId, + request.getContent() + ); + notificationService.createNotification( + moment.getUserId(), + "MOMENT_COMMENT", + "新的评论", + displayName + " 评论了你的动态:" + request.getContent(), + displayAvatar, + "/moment/" + momentId, + momentId, + displaySenderId + ); + } + } + } + + })); + + return toCommentDTO(comment, userId, comment.getParentId() != null); + } + + /** + * 删除评论 + */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public void deleteComment(String momentId, String commentId) { + String userId = InteractionLockService.currentUserId(); + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_COMMENT_DELETE, userId, commentId); + + Moment lockedMoment = momentRepository.findByIdForInteraction(momentId) + .orElseThrow(() -> new BusinessException("动态不存在")); + MomentComment comment = momentCommentRepository.findByIdForInteraction(commentId) + .orElseThrow(() -> new BusinessException("评论不存在")); + + // 权限检查:评论者或动态发布者可以删除 + if (!comment.getUserId().equals(userId)) { + authorizationService.requireOwnerOrAdmin(lockedMoment.getUserId(), "无权删除此评论"); + } + + // 检查评论是否属于该动态 + if (!comment.getMomentId().equals(momentId)) { + throw new BusinessException("评论不属于此动态"); + } + + if (!"ACTIVE".equals(comment.getStatus())) return; + + // 软删除 + comment.setStatus("INACTIVE"); + momentCommentRepository.save(comment); + + // 更新动态评论数 + Moment moment = lockedMoment; + if (moment.getCommentCount() > 0) { + moment.setCommentCount(moment.getCommentCount() - 1); + momentRepository.save(moment); + } + } + + /** + * 获取评论列表(分页,包含回复) + */ + public PageResult getComments(String momentId, int page, int size) { + String currentUserId = UserContext.getCurrentUserId(); + + // 检查动态是否存在 + if (!momentRepository.existsById(momentId)) { + throw new BusinessException("动态不存在"); + } + + Pageable pageable = PageUtils.request(page, size); + + // 查询一级评论 + Page commentPage = momentCommentRepository + .findByMomentIdAndParentIdIsNullAndStatusOrderByCreatedAtDesc(momentId, "ACTIVE", pageable); + + if (commentPage.isEmpty()) { + return PageResult.empty(page, size); + } + + // 获取一级评论的ID列表 + List parentIds = commentPage.getContent().stream() + .map(MomentComment::getId) + .collect(Collectors.toList()); + + // 批量查询所有回复 + List allReplies = momentCommentRepository + .findRepliesByParentIds(parentIds, "ACTIVE"); + + // 按父评论ID分组 + Map> repliesMap = allReplies.stream() + .collect(Collectors.groupingBy(MomentComment::getParentId)); + + // 获取所有涉及的用户ID + Set commentUserIds = new HashSet<>(); + commentPage.getContent().forEach(c -> commentUserIds.add(String.valueOf(c.getUserId()))); + allReplies.forEach(r -> commentUserIds.add(String.valueOf(r.getUserId()))); + + Map userMap = getUserMap(new ArrayList<>(commentUserIds)); + + // 构建评论列表 + List result = commentPage.getContent().stream() + .map(comment -> { + MomentCommentDTO dto = toCommentDTO(comment, currentUserId, false); + + // 添加回复 + List replies = repliesMap.getOrDefault(comment.getId(), Collections.emptyList()); + List replyDTOs = replies.stream() + .limit(REPLY_PAGE_SIZE) + .map(reply -> toCommentDTO(reply, currentUserId, true)) + .collect(Collectors.toList()); + + dto.setReplies(replyDTOs); + dto.setReplyCount(replies.size()); + + return dto; + }) + .collect(Collectors.toList()); + + return PageResult.of(result, commentPage.getTotalElements(), page, size); + } + + /** + * 获取评论数 + */ + public long getCommentCount(String momentId) { + return momentCommentRepository.countByMomentIdAndStatus(momentId, "ACTIVE"); + } + + // ==================== 评论点赞功能 ==================== + + /** + * 评论点赞/取消点赞 + */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public boolean toggleCommentLike(String commentId) { + return changeCommentLike(commentId, null).getLiked(); + } + + /** 兼容 toggle 接口的事务内状态快照。 */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public InteractionLikeStateDTO toggleCommentLikeState(String commentId) { + return changeCommentLike(commentId, null); + } + + /** 将评论点赞设置为明确状态;重复设置同一状态是幂等操作。 */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public InteractionLikeStateDTO setCommentLike(String commentId, boolean desiredLiked) { + return changeCommentLike(commentId, desiredLiked); + } + + private InteractionLikeStateDTO changeCommentLike(String commentId, Boolean desiredLiked) { + String userId = InteractionLockService.currentUserId(); + clickGuardService.ensureWithinRate(ClickGuardService.SCOPE_COMMENT_LIKE, userId, + interactionRateWindowMs, interactionRateMaxRequests); + + // 检查评论是否存在 + MomentComment comment = momentCommentRepository.findByIdForInteraction(commentId) + .orElseThrow(() -> new BusinessException("评论不存在")); + + Optional existingLike = momentCommentLikeRepository + .findByCommentIdAndUserId(commentId, userId); + + boolean currentlyLiked = existingLike.isPresent(); + boolean nextLiked = desiredLiked == null ? !currentlyLiked : desiredLiked; + if (nextLiked && !"ACTIVE".equals(comment.getStatus())) { + throw new BusinessException("评论不存在"); + } + if (currentlyLiked == nextLiked) { + return likeState(nextLiked, comment.getLikeCount()); + } + + if (!nextLiked) { + // 取消点赞 + momentCommentLikeRepository.delete(existingLike.get()); + // 更新评论点赞数 + if (comment.getLikeCount() > 0) { + comment.setLikeCount(comment.getLikeCount() - 1); + momentCommentRepository.save(comment); + } + return likeState(false, comment.getLikeCount()); + } else { + // 点赞 + if (!"ACTIVE".equals(comment.getStatus())) throw new BusinessException("评论不存在"); + momentCommentLikeRepository.upsertActive(UUID.randomUUID().toString(), commentId, userId); + // 更新评论点赞数 + comment.setLikeCount(comment.getLikeCount() + 1); + momentCommentRepository.save(comment); + + eventPublisher.publishEvent(new com.campus.service.event.InteractionCommittedAction(() -> { + // 如果点赞者不是评论作者,推送通知 + if (!userId.equals(String.valueOf(comment.getUserId()))) { + // 检查是否已发送过评论点赞通知(取消点赞后再点赞不重复通知) + boolean alreadyNotified = notificationRepository.existsByUserIdAndTypeAndSenderIdAndRelatedId( + String.valueOf(comment.getUserId()), "COMMENT_LIKE", userId, comment.getMomentId()); + if (!alreadyNotified) { + Optional likerOpt = userRepository.findById(userId); + if (likerOpt.isPresent()) { + User liker = likerOpt.get(); + Moment source = momentRepository.findById(comment.getMomentId()).orElse(null); + boolean anonymousAuthor = source != null && Boolean.TRUE.equals(source.getAnonymous()) && userId.equals(source.getUserId()); + pushService.pushCommentLike( + String.valueOf(comment.getUserId()), + (anonymousAuthor ? "匿名楼主" : liker.getNickname()), + (anonymousAuthor ? null : liker.getAvatar()), + anonymousAuthor ? null : userId, + comment.getMomentId(), + commentId + ); + notificationService.createNotification( + String.valueOf(comment.getUserId()), + "COMMENT_LIKE", + "新的点赞", + (anonymousAuthor ? "匿名楼主" : liker.getNickname()) + " 点赞了你的评论", + (anonymousAuthor ? null : liker.getAvatar()), + "/moment/" + comment.getMomentId(), + comment.getMomentId(), + anonymousAuthor ? null : userId + ); + } + } + } + + })); + + return likeState(true, comment.getLikeCount()); + } + } + + /** + * 获取评论点赞数 + */ + public long getCommentLikeCount(String commentId) { + return momentCommentLikeRepository.countByCommentId(commentId); + } + + /** + * 检查当前用户是否已点赞评论 + */ + public boolean isCommentLiked(String commentId) { + String userId = UserContext.getCurrentUserId(); + return momentCommentLikeRepository.existsByCommentIdAndUserId(commentId, userId); + } + + // ==================== 私有方法 ==================== + + private MomentCommentDTO toCommentDTO(MomentComment comment, String currentUserId, boolean isReply) { + MomentCommentDTO dto = new MomentCommentDTO(); + dto.setId(comment.getId()); + dto.setMomentId(comment.getMomentId()); + Moment source = momentRepository.findById(comment.getMomentId()).orElse(null); + boolean anonymousAuthor = source != null && Boolean.TRUE.equals(source.getAnonymous()) + && java.util.Objects.equals(source.getUserId(), comment.getUserId()); + dto.setIsAnonymousAuthor(anonymousAuthor); + dto.setOwnedByCurrentUser(currentUserId != null && currentUserId.equals(comment.getUserId())); + dto.setUserId(anonymousAuthor ? null : comment.getUserId()); + dto.setParentId(comment.getParentId() != null ? comment.getParentId() : null); + dto.setContent(comment.getContent()); + dto.setLikeCount(comment.getLikeCount()); + dto.setCreatedAt(comment.getCreatedAt()); + dto.setIsLiked(momentCommentLikeRepository.existsByCommentIdAndUserId(comment.getId(), currentUserId)); + + // 获取用户信息 + Optional user = userRepository.findById(String.valueOf(comment.getUserId())); + if (user.isPresent()) { + dto.setUserName(user.get().getNickname()); + dto.setUserAvatar(user.get().getAvatar()); + } + + if (anonymousAuthor) { + dto.setUserName("匿名楼主"); + dto.setUserAvatar(null); + } + + // 如果是回复,获取被回复用户的昵称 + if (isReply && comment.getParentId() != null) { + Optional parentComment = momentCommentRepository.findById(comment.getParentId()); + if (parentComment.isPresent()) { + Optional parentUser = userRepository.findById(String.valueOf(parentComment.get().getUserId())); + if (parentUser.isPresent()) { + dto.setParentNickname(source != null && Boolean.TRUE.equals(source.getAnonymous()) + && java.util.Objects.equals(source.getUserId(), parentComment.get().getUserId()) + ? "匿名楼主" : parentUser.get().getNickname()); + } + } + } + + return dto; + } + + private Map getUserMap(List userIds) { + if (userIds.isEmpty()) { + return Collections.emptyMap(); + } + return userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, u -> u)); + } +} diff --git a/campus-server/src/main/java/com/campus/service/MomentService.java b/campus-server/src/main/java/com/campus/service/MomentService.java new file mode 100644 index 0000000..030fbbb --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/MomentService.java @@ -0,0 +1,1451 @@ +package com.campus.service; + +import com.campus.common.BusinessException; + +import com.campus.common.PageResult; +import com.campus.dto.request.MomentRequest; +import com.campus.dto.response.MomentDTO; +import com.campus.entity.Moment; +import com.campus.entity.MomentLike; +import com.campus.entity.MomentFavorite; +import com.campus.entity.MomentViewRecord; +import com.campus.entity.University; +import com.campus.entity.User; +import com.campus.entity.VideoPlayRecord; +import com.campus.repository.MomentLikeRepository; +import com.campus.repository.MomentFavoriteRepository; +import com.campus.repository.MomentViewRecordRepository; +import com.campus.repository.MomentCommentRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.UniversityRepository; +import com.campus.repository.UserRepository; +import com.campus.repository.VideoLikeRepository; +import com.campus.repository.VideoPlayRecordRepository; +import com.campus.service.review.ReviewResult; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.ArrayList; +import java.util.Date; +import java.util.Locale; +import java.util.Comparator; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MomentService { + + private final ClickGuardService clickGuardService; + private final MomentRepository momentRepository; + private final MomentLikeRepository momentLikeRepository; + private final MomentFavoriteRepository momentFavoriteRepository; + private final MomentViewRecordRepository momentViewRecordRepository; + private final MomentCommentRepository momentCommentRepository; + private final VideoPlayService videoPlayService; + private static final long VIEW_INTERVAL_MILLIS = 30 * 60 * 1000L; + /** 审核通过:只有该状态进入公开可见/可互动口径。 */ + private static final String REVIEW_APPROVED = "approved"; + /** 话题讨论正文长度上限(比动态更克制,讨论是轻量内容)。 */ + private static final int DISCUSSION_MAX_LENGTH = 2000; + /** 话题讨论图片张数上限兜底值(决策 2026-11-01:9 张)。 */ + private static final int DEFAULT_DISCUSSION_MAX_IMAGES = 9; + /** 讨论图片允许的扩展名(OSS 上传白名单的子集,额外放开 webp)。 */ + private static final Set DISCUSSION_IMAGE_EXTENSIONS = + new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "gif", "webp")); + /** 讨论视频 ID 格式(阿里云 VOD 的 videoId 是字母数字串)。 */ + private static final String VOD_VIDEO_ID_PATTERN = "^[0-9A-Za-z]{16,64}$"; + /** 讨论媒体 URL 的默认允许域名(与 OSS / VOD 的 CDN 域名一致)。 */ + private static final String DEFAULT_MEDIA_HOSTS = "oss.yoyoeco.com,video.yoyoeco.com"; + + /** + * 讨论图片张数上限。 + * + *

用字段默认值而不是只靠 {@code @Value}:单测不启动 Spring 时这里仍是 9, + * 校验逻辑才测得到真实文案。(字段非 final,Mockito 不会覆盖。)

+ */ + @Value("${app.topic.discussion.max-images:9}") + private int discussionMaxImages = DEFAULT_DISCUSSION_MAX_IMAGES; + + /** 讨论媒体允许的域名(逗号分隔,支持子域)。 */ + @Value("${app.topic.discussion.allowed-media-hosts:oss.yoyoeco.com,video.yoyoeco.com}") + private String discussionAllowedMediaHosts = DEFAULT_MEDIA_HOSTS; + + /** 视频转码超时(分钟):超过仍未拿到播放地址,{@code videoStatus} 判 FAILED。 */ + @Value("${app.topic.discussion.video-timeout-minutes:30}") + private long discussionVideoTimeoutMinutes = 30; + private final ContentReviewService contentReviewService; + private final PointsService pointsService; + private final UserRepository userRepository; + private final UniversityRepository universityRepository; + private final VideoLikeRepository videoLikeRepository; + private final VideoPlayRecordRepository videoPlayRecordRepository; + private final PrivacySettingsService privacySettingsService; + private final FollowService followService; + private final AuthorizationService authorizationService; + private final NotificationService notificationService; + private final UserRestrictionService userRestrictionService; + private final com.campus.repository.UserBlockedMomentRepository blockedMomentRepository; + /** 视频域:发布讨论后补拉播放地址(VOD 转码回调可能早于落库到达)。 */ + private final VodService vodService; + + private String cleanMediaUrl(String url) { + if (url == null) { + return null; + } + url = url.trim().replaceAll("^`|`$", ""); + return cleanVodUrlSignature(url); + } + + private List cleanMediaUrls(List urls) { + if (urls == null) { + return null; + } + return urls.stream() + .map(this::cleanMediaUrl) + .filter(url -> url != null && !url.isEmpty()) + .collect(Collectors.toList()); + } + + private String cleanVodUrlSignature(String url) { + if (url == null || url.isEmpty()) { + return url; + } + try { + java.net.URL u = new java.net.URL(url); + String query = u.getQuery(); + String path = u.getPath(); + if (query == null || query.isEmpty()) { + return url; + } + + if (path != null && path.toLowerCase().endsWith(".m3u8")) { + return url; + } + + StringBuilder cleanedQuery = new StringBuilder(); + String[] params = query.split("&"); + for (String param : params) { + String[] keyValue = param.split("=", 2); + String key = keyValue[0].toLowerCase(); + if (!key.equals("sign") && !key.equals("expire")) { + if (cleanedQuery.length() > 0) { + cleanedQuery.append("&"); + } + cleanedQuery.append(param); + } + } + String newUrl; + if (cleanedQuery.length() > 0) { + newUrl = u.getProtocol() + "://" + u.getAuthority() + u.getPath() + "?" + cleanedQuery.toString(); + } else { + newUrl = u.getProtocol() + "://" + u.getAuthority() + u.getPath(); + } + return newUrl; + } catch (java.net.MalformedURLException e) { + return url; + } + } + + /** + * 公开动态列表(推荐/校园/关注等 feed)。 + * + *

只返回 {@code reviewStatus=approved} 的动态:自动审核驳回、待审、定时未到 + * ({@code scheduled})的内容都不进公开流。作者自己的全部状态请走 {@link #listMyMoments}。

+ */ + public PageResult list(String scope, int page, int pageSize) { + return list(scope, null, page, pageSize); + } + + public PageResult list(String scope, String sort, int page, int pageSize) { + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + if (sort != null && !sort.trim().isEmpty() && !"recommend".equalsIgnoreCase(sort)) { + throw new com.campus.common.BusinessException("排序方式无效"); + } + if ("recommend".equalsIgnoreCase(sort) && (scope == null || "CAMPUS".equalsIgnoreCase(scope))) { + return listRecommended(safePage, safeSize); + } + Pageable pageable = PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + String normalizedScope = trimToNull(scope); + Page momentPage = normalizedScope != null + ? momentRepository.findFeedMomentsByScope(REVIEW_APPROVED, normalizedScope, pageable) + : momentRepository.findFeedMoments(REVIEW_APPROVED, pageable); + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + private PageResult listRecommended(int page, int pageSize) { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + return list("CAMPUS", null, page, pageSize); + } + Date now = new Date(); + Date candidateSince = new Date(now.getTime() - 30L * 24 * 60 * 60 * 1000); + List candidates = momentRepository.findRecommendationCandidates(candidateSince, PageRequest.of(0, 500)); + Set blockedAuthors = privacySettingsService.blockedUserIds(userId); + Set blockedMoments = blockedMomentRepository.findByUserId(userId).stream() + .map(com.campus.entity.UserBlockedMoment::getMomentId).collect(Collectors.toSet()); + candidates = candidates.stream() + .filter(m -> !blockedAuthors.contains(m.getUserId()) && !blockedMoments.contains(m.getId())) + .collect(Collectors.toList()); + if (candidates.isEmpty()) return PageResult.empty(page, pageSize); + + List userInterests = userRepository.findById(userId).map(User::getInterests) + .orElse(Collections.emptyList()); + Set interests = userInterests == null ? Collections.emptySet() + : userInterests.stream().filter(Objects::nonNull).map(this::normalizeInterest) + .filter(v -> !v.isEmpty()).collect(Collectors.toSet()); + Set followed = followService.getFollowedUserIds(userId); + if (followed == null) followed = Collections.emptySet(); + Date behaviorSince = new Date(now.getTime() - 90L * 24 * 60 * 60 * 1000); + Set engaged = new HashSet<>(momentLikeRepository.findRecentMomentIdsByUser(userId, behaviorSince)); + engaged.addAll(momentFavoriteRepository.findRecentMomentIdsByUser(userId, behaviorSince)); + engaged.addAll(momentCommentRepository.findRecentMomentIdsByUser(userId, behaviorSince)); + return rankRecommendationCandidates(candidates, interests, followed, engaged, now, page, pageSize); + } + + private PageResult rankRecommendationCandidates(List candidates, Set interests, + Set followed, Set engaged, + Date now, int page, int pageSize) { + List ranked = candidates.stream().sorted(Comparator + .comparingDouble((Moment m) -> recommendationScore(m, interests, followed, engaged, now)).reversed() + .thenComparing(Moment::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(Moment::getId, Comparator.nullsLast(Comparator.reverseOrder()))) + .collect(Collectors.toList()); + int from = Math.min((page - 1) * pageSize, ranked.size()); + int to = Math.min(from + pageSize, ranked.size()); + return PageResult.of(batchToDTOList(ranked.subList(from, to)), ranked.size(), page, pageSize); + } + + private double recommendationScore(Moment moment, Set interests, Set followed, + Set engaged, Date now) { + Set contentTerms = new HashSet<>(); + if (moment.getCategory() != null) contentTerms.add(normalizeInterest(moment.getCategory())); + contentTerms.addAll(parseList(moment.getTags()).stream().map(this::normalizeInterest).collect(Collectors.toSet())); + long interestMatches = contentTerms.stream().filter(interests::contains).count(); + long daysOld = Math.max(0, (now.getTime() - moment.getCreatedAt().getTime()) / (24L * 60 * 60 * 1000)); + double freshness = Math.exp(-daysOld / 10.0); + return interestMatches * 8.0 + (followed.contains(moment.getUserId()) ? 5.0 : 0.0) + + (engaged.contains(moment.getId()) ? -4.0 : 0.0) + freshness; + } + + private String normalizeInterest(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + /** + * 动态域:按单个标签查询已审核动态。 + * + *

这是话题详情页「最热/最新」的数据入口,替代原先由话题服务内部读取动态的做法。 + * 只做标签字符串的精确匹配,不解析话题别名(别名是话题域的概念); + * 也按动态域自己的口径过滤(已审核、未删除、未拉黑/屏蔽)。

+ */ + public PageResult listByTag(String tag, int page, int pageSize) { + String normalized = tag == null ? "" : tag.trim(); + while (normalized.startsWith("#")) { + normalized = normalized.substring(1).trim(); + } + if (normalized.isEmpty()) { + throw new com.campus.common.BusinessException("标签不能为空"); + } + if (normalized.contains(",")) { + throw new com.campus.common.BusinessException("一次只能按一个标签查询"); + } + if (normalized.length() > 100) { + throw new com.campus.common.BusinessException("标签长度不能超过100个字符"); + } + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + Pageable pageable = PageRequest.of(safePage - 1, safeSize); + Page momentPage = momentRepository.findApprovedByExactTag(normalized, pageable); + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + // ==================== 话题讨论(content_source=TOPIC,只属于话题板块) ==================== + // + // 概念边界(别混用): + // * moment.tags = 动态的分类标签,与话题无关; + // * content_source = 内容来源,决定"进动态流还是进话题页"; + // * topic_id = 讨论所属话题,话题页/合并迁移只认它。 + // 讨论**不写 tags、不写 category**,所以任何"按标签查话题"的语法在这里都不成立。 + + /** + * 发布话题讨论。 + * + *

审核口径与话题发布一致:**先审后存**,命中违禁词直接 400,且不产生任何记录 + * (避免用户"发了但看不见")。通过即 {@code approved} 上线,只落审核流水、不发通知。

+ * + *

媒体形态三选一(互斥):纯文本 / 图文(1-9 张)/ 视频(1 条)。 + * 视频只接受 {@code vodVideoId},播放地址与封面由 VOD 转码回调回填——发布**不等转码**。

+ * + * @param topicId 已解析并校验过的话题 ID(别名解析与上线校验由 TopicDiscussionService 负责) + * @param topicName 话题名,仅用于日志排查,不落库 + * @param vodVideoId 已上传到 VOD 的视频 ID;与 {@code mediaUrls} 互斥 + */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public MomentDTO createTopicDiscussion(String topicId, String topicName, String content, + List mediaUrls, String vodVideoId, Boolean anonymous) { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new com.campus.common.BusinessException("用户未登录"); + } + if (topicId == null || topicId.trim().isEmpty()) { + throw new com.campus.common.BusinessException("话题不存在或尚未上线"); + } + String text = content == null ? "" : content.trim(); + if (text.length() > DISCUSSION_MAX_LENGTH) { + throw new com.campus.common.BusinessException("讨论内容需要1到2000个字符"); + } + userRestrictionService.ensureAllowed(userId, "PUBLISH"); + + String videoId = trimToNull(vodVideoId); + List cleanedMediaUrls = cleanDiscussionMediaUrls(mediaUrls); + boolean hasMedia = !cleanedMediaUrls.isEmpty(); + + if (videoId != null && hasMedia) { + throw new com.campus.common.BusinessException("图片和视频不能同时发布"); + } + if (text.isEmpty() && !hasMedia && videoId == null) { + throw new com.campus.common.BusinessException("讨论内容不能为空"); + } + if (videoId != null) { + validateDiscussionVideo(videoId); + } else if (hasMedia) { + validateDiscussionImages(cleanedMediaUrls); + } + + String media = hasMedia ? String.join(",", cleanedMediaUrls) : null; + + ReviewResult review = contentReviewService.previewMomentReview(text, media); + if (!review.isPassed()) { + throw new com.campus.common.BusinessException(buildRejectMessage(review)); + } + + Moment moment = new Moment(); + moment.setUserId(userId); + moment.setContent(text); + moment.setMediaUrls(media); + // 视频只落 ID:playUrl / cover / duration 由 VOD 转码回调回填 + moment.setVodVideoId(videoId); + // 讨论不占用动态的分类体系 + moment.setTags(null); + moment.setCategory(null); + // 讨论不是动态,scope 只为满足 NOT NULL;所有按 scope 的动态查询都已排除 TOPIC + moment.setScope("CAMPUS"); + moment.setAnonymous(Boolean.TRUE.equals(anonymous)); + moment.setIsYouthVideo(false); + moment.setReviewStatus(REVIEW_APPROVED); + moment.setContentSource(Moment.SOURCE_TOPIC); + moment.setTopicId(topicId); + moment = momentRepository.save(moment); + + contentReviewService.recordMomentApproval(moment.getId(), review); + // 转码可能早于落库完成(回调先到就会丢 playUrl),发布后立刻补拉一次 + if (videoId != null) { + vodService.asyncPullPlayInfo(videoId); + } + log.info("发布话题讨论: topic={}, momentId={}, hasMedia={}, vodVideoId={}", + topicName, moment.getId(), hasMedia, videoId); + // 不计"发布动态"积分/任务:见 CommerceService 的 publish-moment 口径 + return toDTO(moment); + } + + /** + * 编辑话题讨论:整体替换正文与媒体(互斥规则同发布),并重新走同步审核。 + * + *

为什么需要这个入口:动态的编辑接口 {@code PUT /api/campus/moments} 要求 + * {@code MomentRequest.category} 非空,而讨论本来就不写分类——前端要么传假分类 + * (会污染"讨论不占分类体系"的不变量),要么改不了。所以话题域必须有自己的编辑入口。

+ * + *

约定:**不允许改所属话题**,要换话题请删了重发(与既有文档口径一致)。

+ */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public MomentDTO updateTopicDiscussion(String id, String content, List mediaUrls, + String vodVideoId, Boolean anonymous) { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new com.campus.common.BusinessException("用户未登录"); + } + Moment moment = requireDiscussion(id); + authorizationService.requireOwnerOrAdmin(moment.getUserId()); + + String text = content == null ? "" : content.trim(); + if (text.length() > DISCUSSION_MAX_LENGTH) { + throw new com.campus.common.BusinessException("讨论内容需要1到2000个字符"); + } + String videoId = trimToNull(vodVideoId); + List cleanedMediaUrls = cleanDiscussionMediaUrls(mediaUrls); + boolean hasMedia = !cleanedMediaUrls.isEmpty(); + + if (videoId != null && hasMedia) { + throw new com.campus.common.BusinessException("图片和视频不能同时发布"); + } + if (text.isEmpty() && !hasMedia && videoId == null) { + throw new com.campus.common.BusinessException("讨论内容不能为空"); + } + if (videoId != null) { + // 编辑时允许保留自己原来的 videoId + validateDiscussionVideo(videoId, moment.getId()); + } else if (hasMedia) { + validateDiscussionImages(cleanedMediaUrls); + } + + String media = hasMedia ? String.join(",", cleanedMediaUrls) : null; + ReviewResult review = contentReviewService.previewMomentReview(text, media); + if (!review.isPassed()) { + throw new com.campus.common.BusinessException(buildRejectMessage(review)); + } + + moment.setContent(text); + moment.setMediaUrls(media); + // 讨论不占用动态分类体系:每次编辑都强制回 null。除了守住不变量, + // 还能自愈历史上被 PUT /api/campus/moments 写进来的假分类/假标签。 + moment.setTags(null); + moment.setCategory(null); + if (videoId == null) { + // 换成图片/纯文本:清掉旧视频的全部派生字段,避免残留播放地址 + moment.setVodVideoId(null); + moment.setPlayUrl(null); + moment.setVideoCoverUrl(null); + moment.setVideoDuration(null); + moment.setAuditStatus(null); + } else if (!videoId.equals(moment.getVodVideoId())) { + // 换了视频:旧播放地址作废,等新视频的回调 + moment.setVodVideoId(videoId); + moment.setPlayUrl(null); + moment.setVideoCoverUrl(null); + moment.setVideoDuration(null); + moment.setAuditStatus(null); + } + moment.setAnonymous(Boolean.TRUE.equals(anonymous)); + moment.setReviewStatus(REVIEW_APPROVED); + moment.setReviewReason(null); + moment = momentRepository.save(moment); + + contentReviewService.recordMomentApproval(moment.getId(), review); + if (videoId != null && (moment.getPlayUrl() == null || moment.getPlayUrl().isEmpty())) { + vodService.asyncPullPlayInfo(videoId); + } + return toDTO(moment); + } + + /** 删除话题讨论(软删;不清理 OSS/VOD 资源,理由见实现方案 §9.5)。 */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public void deleteTopicDiscussion(String id) { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new com.campus.common.BusinessException("用户未登录"); + } + Moment moment = requireDiscussion(id); + authorizationService.requireOwnerOrAdmin(moment.getUserId()); + moment.setDeleted(true); + momentRepository.save(moment); + } + + /** 取一条讨论:必须存在且 {@code contentSource=TOPIC},否则按"讨论不存在"处理。 */ + private Moment requireDiscussion(String id) { + if (id == null || id.trim().isEmpty()) { + throw new com.campus.common.BusinessException("讨论不存在"); + } + Moment moment = momentRepository.findById(id).orElse(null); + if (moment == null || !Moment.SOURCE_TOPIC.equals(moment.getContentSource())) { + throw new com.campus.common.BusinessException("讨论不存在"); + } + return moment; + } + + /** 清洗讨论图片 URL:trim / 去反引号 / 去签名参数 / 去重(保序)。 */ + private List cleanDiscussionMediaUrls(List mediaUrls) { + List cleaned = cleanMediaUrls(mediaUrls); + if (cleaned == null || cleaned.isEmpty()) { + return Collections.emptyList(); + } + return new ArrayList<>(new LinkedHashSet<>(cleaned)); + } + + /** + * 图片校验:张数、协议、域名白名单、扩展名、URL 长度。 + * + *

只做**结构**校验,不做图片内容安全——{@code ImageReviewStrategy} 当前恒通过 + * (见实现方案 §4.6),别误以为这里拦住了违规图。

+ */ + private void validateDiscussionImages(List urls) { + if (urls.size() > discussionMaxImages) { + throw new com.campus.common.BusinessException("图片最多只能上传" + discussionMaxImages + "张"); + } + for (String url : urls) { + if (url.length() > 500) { + throw new com.campus.common.BusinessException("图片地址不合法"); + } + java.net.URI uri; + try { + uri = new java.net.URI(url); + } catch (java.net.URISyntaxException e) { + throw new com.campus.common.BusinessException("图片地址不合法"); + } + String scheme = uri.getScheme(); + if (scheme == null + || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) { + throw new com.campus.common.BusinessException("图片地址不合法"); + } + if (!isAllowedMediaHost(uri.getHost())) { + throw new com.campus.common.BusinessException("图片地址不合法"); + } + String extension = extensionOf(uri.getPath()); + if (extension == null || !DISCUSSION_IMAGE_EXTENSIONS.contains(extension)) { + throw new com.campus.common.BusinessException("不支持的图片格式"); + } + } + } + + /** 视频校验:只接受格式合法的、且未被其他内容占用的 videoId。 */ + private void validateDiscussionVideo(String videoId) { + validateDiscussionVideo(videoId, null); + } + + /** + * @param selfId 编辑场景下自己的记录 ID(允许复用自己原来的 videoId,传 null 表示发布新讨论) + */ + private void validateDiscussionVideo(String videoId, String selfId) { + if (!videoId.matches(VOD_VIDEO_ID_PATTERN)) { + throw new com.campus.common.BusinessException("视频信息无效"); + } + if (selfId == null || !videoId.equals(momentRepository.findById(selfId) + .map(Moment::getVodVideoId).orElse(null))) { + if (momentRepository.existsByVodVideoId(videoId)) { + throw new com.campus.common.BusinessException("该视频已被其他内容使用"); + } + } + } + + private boolean isAllowedMediaHost(String host) { + if (host == null || host.trim().isEmpty() || discussionAllowedMediaHosts == null) { + return false; + } + String normalized = host.trim().toLowerCase(Locale.ROOT); + for (String allowed : discussionAllowedMediaHosts.split(",")) { + String candidate = allowed.trim().toLowerCase(Locale.ROOT); + if (candidate.isEmpty()) { + continue; + } + if (normalized.equals(candidate) || normalized.endsWith("." + candidate)) { + return true; + } + } + return false; + } + + /** + * 从 URL 路径取扩展名(小写)。没有扩展名时返回 {@code null}。 + */ + private String extensionOf(String path) { + if (path == null) { + return null; + } + int slash = path.lastIndexOf('/'); + String name = slash >= 0 ? path.substring(slash + 1) : path; + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return null; + } + return name.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + /** + * 派生视频就绪状态(不落库):{@code READY/PROCESSING/FAILED/REJECTED},非视频内容返回 {@code null}。 + */ + private String resolveVideoStatus(Moment moment) { + String videoId = moment.getVodVideoId(); + if (videoId == null || videoId.trim().isEmpty()) { + return null; + } + if ("REJECTED".equalsIgnoreCase(moment.getAuditStatus())) { + return "REJECTED"; + } + String playUrl = moment.getPlayUrl(); + if (playUrl != null && !playUrl.trim().isEmpty()) { + return "READY"; + } + Date created = moment.getCreatedAt(); + if (created == null) { + return "PROCESSING"; + } + long timeoutMillis = Math.max(discussionVideoTimeoutMinutes, 1) * 60_000L; + return System.currentTimeMillis() - created.getTime() > timeoutMillis ? "FAILED" : "PROCESSING"; + } + + /** + * 话题详情页的讨论列表。 + * + * @param sort {@code latest}(默认,按发布时间倒序)/ {@code hot}(与动态热榜同一热度公式) + */ + public PageResult listTopicDiscussions(String topicId, String sort, int page, int pageSize) { + if (topicId == null || topicId.trim().isEmpty()) { + throw new com.campus.common.BusinessException("话题不存在或尚未上线"); + } + String selectedSort = sort == null ? "latest" : sort.trim().toLowerCase(Locale.ROOT); + if (!"latest".equals(selectedSort) && !"hot".equals(selectedSort)) { + throw new com.campus.common.BusinessException("排序方式无效"); + } + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + Page momentPage; + if ("hot".equals(selectedSort)) { + momentPage = momentRepository.findApprovedByTopicIdOrderByHot(topicId, + PageRequest.of(safePage - 1, safeSize)); + } else { + momentPage = momentRepository.findApprovedByTopicId(topicId, + PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt"))); + } + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + /** 我的讨论:本人发过的讨论(含未过审,本人可见),可按话题筛选。 */ + public PageResult listMyDiscussions(String topicId, int page, int pageSize) { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new com.campus.common.BusinessException("用户未登录"); + } + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + Pageable pageable = PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + Page momentPage = momentRepository.findMyDiscussions(userId, trimToNull(topicId), pageable); + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + /** 话题合并时把讨论迁到目标话题(由 TopicDiscussionService 监听合并事件调用)。 */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public int migrateTopicDiscussions(String sourceId, String targetId) { + if (sourceId == null || targetId == null || sourceId.equals(targetId)) { + return 0; + } + return momentRepository.reassignTopic(sourceId, targetId); + } + + /** 审核原因 → 发布失败文案(与话题发布同一套:统一「包含违禁词」,多条用分号连接)。 */ + private String buildRejectMessage(ReviewResult review) { + List reasons = review.getViolationReasons().stream() + .map(reason -> reason.replace("包含敏感词:", "包含违禁词:")) + .distinct() + .collect(Collectors.toList()); + return reasons.isEmpty() ? "包含违禁词" : String.join(";", reasons); + } + + /** + * 他人主页动态:只返回已过审内容;查看自己的主页时返回全部状态(含未过审)。 + * + *

两个分支都排除话题讨论:讨论只属于话题页面,不挂在个人主页。

+ */ + public PageResult listByUserId(String userId, int page, int pageSize) { + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + Pageable pageable = PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + boolean self = userId != null && userId.equals(UserContext.getCurrentUserId()); + Page momentPage = self + ? momentRepository.findFeedMomentsByUser(userId, pageable) + : momentRepository.findFeedMomentsByUserAndReviewStatus(userId, REVIEW_APPROVED, pageable); + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + public PageResult listMyMoments(int page, int pageSize) { + String userId = UserContext.getCurrentUserId(); + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + Pageable pageable = PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + Page momentPage = momentRepository.findFeedMomentsByUser(userId, pageable); + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + public PageResult listYouthVideos(String userId, String sortBy, int page, int pageSize) { + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + Pageable pageable = PageRequest.of(safePage - 1, safeSize); + Page momentPage; + + if (userId != null) { + momentPage = userId.equals(UserContext.getCurrentUserId()) + ? momentRepository.findByUserIdAndIsYouthVideoTrue(userId, pageable) + : momentRepository.findPublicYouthVideosByUser(userId, pageable); + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + String currentUserId = UserContext.getCurrentUserId(); + boolean hasFollowing = "following".equals(sortBy); + boolean isDefault = (sortBy == null || "new".equals(sortBy)) && currentUserId != null && !currentUserId.isEmpty(); + + if ("likes".equals(sortBy)) { + momentPage = momentRepository.findYouthVideosUnwatchedOrderByLikeCount(currentUserId, pageable); + } else if ("views".equals(sortBy)) { + momentPage = momentRepository.findYouthVideosUnwatchedOrderByViewCount(currentUserId, pageable); + } else if (hasFollowing) { + List followedIds = new ArrayList<>(followService.getFollowedUserIds(currentUserId)); + momentPage = followedIds.isEmpty() + ? Page.empty(pageable) + : momentRepository.findYouthVideosFromFollowedUsersUnwatched(currentUserId, followedIds, pageable); + } else { + momentPage = momentRepository.findYouthVideosUnwatchedOrderByCreatedAt(currentUserId, pageable); + } + + List content = new ArrayList<>(momentPage.getContent()); + + // 默认排序下,已关注用户发布的视频优先 + if (isDefault && content.size() > 1) { + Set followedIds = followService.getFollowedUserIds(currentUserId); + if (followedIds != null && !followedIds.isEmpty()) { + content.sort((a, b) -> { + boolean aFollowed = followedIds.contains(a.getUserId()); + boolean bFollowed = followedIds.contains(b.getUserId()); + if (aFollowed && !bFollowed) return -1; + if (!aFollowed && bFollowed) return 1; + return 0; + }); + } + } + + return PageResult.of(batchToDTOList(content), momentPage.getTotalElements(), safePage, safeSize); + } + + public PageResult listMyYouthVideos(int page, int pageSize) { + String userId = UserContext.getCurrentUserId(); + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + Pageable pageable = PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + Page momentPage = userId.equals(UserContext.getCurrentUserId()) + ? momentRepository.findByUserIdAndIsYouthVideoTrue(userId, pageable) + : momentRepository.findPublicYouthVideosByUser(userId, pageable); + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + /** 供话题聚合服务复用同一套动态 DTO 批量转换。 */ + public List toTopicDTOList(List moments) { + return batchToDTOList(moments); + } + + /** 动态热榜,按点赞、评论、分享和浏览综合热度排序。 */ + public PageResult listHot(String category, int page, int pageSize) { + int safePage = page < 1 ? 1 : page; + int safeSize = pageSize < 1 ? 20 : Math.min(pageSize, 100); + Date now = new Date(); + Date since = new Date(now.getTime() - 7L * 24 * 60 * 60 * 1000); + List candidates = momentRepository.findHotCandidates(category, PageRequest.of(0, 2000)); + Set blockedAuthors = privacySettingsService.blockedUserIds(UserContext.getCurrentUserId()); + Set blockedMoments = UserContext.getCurrentUserId() == null ? Collections.emptySet() + : blockedMomentRepository.findByUserId(UserContext.getCurrentUserId()).stream() + .map(com.campus.entity.UserBlockedMoment::getMomentId).collect(Collectors.toSet()); + candidates = candidates.stream().filter(m -> !blockedAuthors.contains(m.getUserId()) + && !blockedMoments.contains(m.getId())).collect(Collectors.toList()); + if (candidates.isEmpty()) return PageResult.empty(safePage, safeSize); + List ids = candidates.stream().map(Moment::getId).collect(Collectors.toList()); + Map likes = scoreMap(momentLikeRepository.sumRecentHeatByMomentIds(ids, since, now)); + Map comments = scoreMap(momentCommentRepository.sumRecentHeatByMomentIds(ids, since, now)); + candidates = candidates.stream().filter(m -> recentHeat(m, likes, comments) > 0) + .collect(Collectors.toList()); + if (candidates.isEmpty()) return PageResult.empty(safePage, safeSize); + List ordered = candidates.stream().sorted(Comparator + .comparingDouble((Moment m) -> recentHeat(m, likes, comments)).reversed() + .thenComparing(Moment::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(Moment::getId, Comparator.nullsLast(Comparator.reverseOrder()))) + .collect(Collectors.toList()); + int from = Math.min((safePage - 1) * safeSize, ordered.size()); + int to = Math.min(from + safeSize, ordered.size()); + return PageResult.of(batchToDTOList(ordered.subList(from, to)), ordered.size(), safePage, safeSize); + } + + private Map scoreMap(List rows) { + Map result = new HashMap<>(); + for (Object[] row : rows) result.put(String.valueOf(row[0]), ((Number) row[1]).doubleValue()); + return result; + } + + private double recentHeat(Moment moment, Map likes, Map comments) { + return likes.getOrDefault(moment.getId(), 0.0) + + 2.0 * comments.getOrDefault(moment.getId(), 0.0); + } + + /** 同城动态分页;city 为空时自动使用当前用户学校、所在地或家乡城市。 */ + public PageResult listCity(String city, String province, int page, int pageSize) { + String safeCity = normalizeCity(city); + String safeProvince = normalizeProvince(province); + if (safeCity == null) { + String[] currentLocation = resolveCurrentUserLocation(); + if (currentLocation != null) { + safeProvince = safeProvince != null ? safeProvince : normalizeProvince(currentLocation[0]); + safeCity = normalizeCity(currentLocation[1]); + } + } + if (safeCity == null) { + return PageResult.empty(Math.max(page, 1), Math.max(Math.min(pageSize, 100), 1)); + } + + int safePage = Math.max(page, 1); + int safeSize = Math.max(Math.min(pageSize, 100), 1); + Pageable pageable = PageRequest.of(safePage - 1, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + Page momentPage = safeProvince == null + ? momentRepository.findApprovedByCity(safeCity, pageable) + : momentRepository.findApprovedByCityAndProvince(safeCity, safeProvince, pageable); + return PageResult.of(batchToDTOList(momentPage.getContent()), momentPage.getTotalElements(), safePage, safeSize); + } + + private String[] resolveCurrentUserLocation() { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + return null; + } + User user = userRepository.findById(userId).orElse(null); + University university = user == null || user.getUniversityId() == null ? null + : universityRepository.findById(user.getUniversityId()).orElse(null); + return resolveUserLocation(user, university); + } + + private String[] resolveUserLocation(User user, University university) { + if (user == null && university == null) { + return null; + } + String province = university != null ? trimToNull(university.getProvince()) : null; + String city = university != null ? trimToNull(university.getCity()) : null; + if (user != null) { + city = firstNonBlank(city, extractCity(user.getLocation()), trimToNull(user.getHometownCity())); + province = firstNonBlank(province, trimToNull(user.getHometownProvince())); + } + return city == null ? null : new String[]{province, city}; + } + + private void applyResolvedLocation(Moment moment, MomentRequest request, String userId) { + User user = userId == null ? null : userRepository.findById(userId).orElse(null); + University university = user == null || user.getUniversityId() == null ? null + : universityRepository.findById(user.getUniversityId()).orElse(null); + String[] resolved = resolveUserLocation(user, university); + + String province = trimToNull(request.getProvince()); + String city = trimToNull(request.getCity()); + if (province == null && resolved != null) province = resolved[0]; + if (city == null && resolved != null) city = resolved[1]; + moment.setProvince(normalizeProvince(province)); + moment.setCity(normalizeCity(city)); + } + + private String normalizeCity(String value) { + String normalized = trimToNull(value); + if (normalized == null) return null; + return normalized.endsWith("市") ? normalized.substring(0, normalized.length() - 1) : normalized; + } + + private String normalizeProvince(String value) { + String normalized = trimToNull(value); + if (normalized == null) return null; + return normalized.endsWith("省") ? normalized.substring(0, normalized.length() - 1) : normalized; + } + private String extractCity(String value) { + String normalized = trimToNull(value); + if (normalized == null) { + return null; + } + String[] parts = normalized.split("[/,,>\\s]+"); + for (String part : parts) { + String candidate = trimToNull(part); + if (candidate == null) continue; + if (candidate.endsWith("市") || candidate.endsWith("自治州") || candidate.endsWith("地区")) { + return candidate; + } + } + return parts.length > 1 && trimToNull(parts[1]) != null ? trimToNull(parts[1]) : normalized; + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return null; + } + + private String trimToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + @Transactional + public MomentDTO getById(String id) { + Moment moment = momentRepository.findById(id) + .orElseThrow(() -> new com.campus.common.BusinessException("动态不存在")); + String currentUserId = UserContext.getCurrentUserId(); + if (!REVIEW_APPROVED.equals(moment.getReviewStatus()) && !canViewUnapproved(moment, currentUserId)) { + // 未过审内容对非作者一律按"不存在"处理,避免被直接拿 ID 访问 + throw new com.campus.common.BusinessException("动态不存在"); + } + if (privacySettingsService.isBlocked(currentUserId, moment.getUserId())) { + throw new com.campus.common.BusinessException("该动态不可见"); + } + moment = moment.hasVideoContent() + ? videoPlayService.recordVideoOpen(id) + : recordTextView(id); + return toDTO(moment); + } + + /** 未过审(pending/rejected/scheduled)动态只对作者本人、审核员与管理员可见。 */ + private boolean canViewUnapproved(Moment moment, String currentUserId) { + if (currentUserId != null && currentUserId.equals(moment.getUserId())) { + return true; + } + return authorizationService.isReviewerOrAdmin(UserContext.getCurrentUser()); + } + + /** 自动审核把状态改成 scheduled(未来定时发布)时,创建与编辑后都要再落一次状态。 */ + private Moment settleScheduled(Moment moment) { + Moment latest = momentRepository.findById(moment.getId()).orElse(moment); + if (latest.getScheduledPublishAt() != null && latest.getScheduledPublishAt().after(new Date()) + && REVIEW_APPROVED.equalsIgnoreCase(latest.getReviewStatus())) { + latest.setReviewStatus("scheduled"); + latest = momentRepository.save(latest); + } + return latest; + } + + private Moment recordTextView(String id) { + Moment moment = momentRepository.findByIdForView(id) + .orElseThrow(() -> new RuntimeException("动态不存在")); + String userId = UserContext.getCurrentUserId(); + if (userId == null) return moment; + + Date now = new Date(); + MomentViewRecord record = momentViewRecordRepository.findByMomentIdAndUserId(id, userId) + .orElse(null); + if (record != null && now.getTime() - record.getLastViewTime().getTime() < VIEW_INTERVAL_MILLIS) { + return moment; + } + if (record == null) { + record = new MomentViewRecord(); + record.setMomentId(id); + record.setUserId(userId); + } + record.setLastViewTime(now); + momentViewRecordRepository.save(record); + moment.setViewCount(moment.getViewCount() + 1); + return moment; + } + + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public MomentDTO create(MomentRequest request) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) throw new BusinessException("用户未登录"); + userRestrictionService.ensureAllowed(userId, "PUBLISH"); + Moment moment = new Moment(); + moment.setUserId(userId); + moment.setContent(request.getContent()); + List cleanedMediaUrls = cleanMediaUrls(request.getMediaUrls()); + moment.setMediaUrls(cleanedMediaUrls != null + ? String.join(",", cleanedMediaUrls) : null); + moment.setTags(request.getTags() != null + ? String.join(",", request.getTags()) : null); + moment.setCategory(request.getCategory()); + moment.setScope(request.getScope()); + applyResolvedLocation(moment, request, userId); + moment.setExpiresAt(request.getExpiresAt()); + moment.setScheduledPublishAt(request.getScheduledPublishAt()); + moment.setReviewStatus("pending"); + moment.setIsYouthVideo(request.getIsYouthVideo() != null ? request.getIsYouthVideo() : false); + moment.setAnonymous(request.getAnonymous() != null ? request.getAnonymous() : false); + + // 视频原始URL(上传后由VOD处理转码) + if (request.getVideoOriginalUrl() != null) { + moment.setVideoOriginalUrl(request.getVideoOriginalUrl()); + } + + // VOD 视频ID(VOD直传的媒资ID,回调会自动更新播放地址) + if (request.getVodVideoId() != null) { + moment.setVodVideoId(request.getVodVideoId()); + } + + moment = momentRepository.save(moment); + + // 同步审核 + contentReviewService.reviewMomentSync( + moment.getId(), + moment.getContent(), + moment.getMediaUrls() + ); + + // 重新查询获取审核后的状态(自动审核为同步执行,返回前已落定 approved/rejected) + moment = settleScheduled(moment); + + // 触发积分:发布动态 + if ("approved".equals(moment.getReviewStatus())) { + pointsService.onPublishMoment(userId, moment.getId(), moment.getIsYouthVideo()); + } + + return toDTO(moment); + } + + /** + * 编辑动态:内容变更后必须重新走自动审核。 + * + *

原先只改字段不动 {@code reviewStatus},导致「先发合规内容过审、再编辑成违规内容」 + * 仍然保持 approved 并公开可见。现在统一重置为 pending 后同步复审。

+ */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public MomentDTO update(String id, MomentRequest request) { + Moment moment = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(moment.getUserId()); + if (request.getAnonymous() != null && request.getAnonymous() != Boolean.TRUE.equals(moment.getAnonymous())) { + throw new BusinessException("发布后不支持修改匿名状态"); + } + // 讨论必须走话题域自己的编辑入口:MomentRequest 的 category / scope 都是 @NotBlank, + // 放行只能靠编造一个分类,而下面的 setCategory 会把假分类写进讨论, + // 破坏「讨论不占动态分类体系」(MomentTopicDiscussionTest 守着这条不变量)。 + if (Moment.SOURCE_TOPIC.equals(moment.getContentSource())) { + throw new com.campus.common.BusinessException("话题讨论请在话题页编辑"); + } + moment.setContent(request.getContent()); + List cleanedMediaUrls = cleanMediaUrls(request.getMediaUrls()); + moment.setMediaUrls(cleanedMediaUrls != null + ? String.join(",", cleanedMediaUrls) : null); + moment.setTags(request.getTags() != null + ? String.join(",", request.getTags()) : null); + moment.setCategory(request.getCategory()); + moment.setScope(request.getScope()); + if (request.getProvince() != null || request.getCity() != null) { + applyResolvedLocation(moment, request, UserContext.getCurrentUserId()); + } + moment.setExpiresAt(request.getExpiresAt()); + moment.setScheduledPublishAt(request.getScheduledPublishAt()); + moment.setReviewStatus("pending"); + moment.setReviewReason(null); + moment = momentRepository.save(moment); + + contentReviewService.reviewMomentSync( + moment.getId(), + moment.getContent(), + moment.getMediaUrls() + ); + moment = settleScheduled(moment); + return toDTO(moment); + } + + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public void delete(String id) { + Moment moment = getByIdEntity(id); + authorizationService.requireOwnerOrAdmin(moment.getUserId()); + moment.setDeleted(true); + momentRepository.save(moment); + } + + /** 审核员或管理员人工复核动态。 */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public MomentDTO reviewMoment(String id, boolean approved, String reason) { + authorizationService.requireReviewerOrAdmin(); + Moment moment = getByIdEntity(id); + moment.setReviewStatus(approved ? "approved" : "rejected"); + moment.setReviewReason(reason == null || reason.trim().isEmpty() ? null : reason.trim()); + moment = momentRepository.save(moment); + notificationService.createNotification( + moment.getUserId(), + approved ? "MOMENT_REVIEW_APPROVED" : "MOMENT_REVIEW_REJECTED", + approved ? "动态审核通过" : "动态审核未通过", + approved ? "你发布的动态已通过审核。" + : "你发布的动态未通过审核" + (moment.getReviewReason() == null ? "。" : ":" + moment.getReviewReason()), + null, "/moments/" + moment.getId(), moment.getId(), UserContext.getCurrentUserId()); + return toDTO(moment); + } + + @Transactional + public void incrementLike(String id) { + Moment moment = momentRepository.findByIdForInteraction(id) + .orElseThrow(() -> new com.campus.common.BusinessException("动态不存在")); + moment.setLikeCount(moment.getLikeCount() + 1); + momentRepository.save(moment); + } + + @Transactional + public void incrementComment(String id) { + Moment moment = momentRepository.findByIdForInteraction(id) + .orElseThrow(() -> new com.campus.common.BusinessException("动态不存在")); + moment.setCommentCount(moment.getCommentCount() + 1); + momentRepository.save(moment); + } + + @Transactional + public void incrementShare(String id) { + momentRepository.incrementShareCount(id); + } + + private Moment getByIdEntity(String id) { + return momentRepository.findById(id) + .orElseThrow(() -> new com.campus.common.BusinessException("动态不存在")); + } + + /** + * 批量转换 Moment → MomentDTO,消除 N+1 查询问题 + * 原来每条动态都触发 3 次独立查询(用户、点赞状态、播放进度), + * 现在无论多少条动态,固定 3 次查询搞定。 + */ + private List batchToDTOList(List moments) { + if (moments == null || moments.isEmpty()) { + return Collections.emptyList(); + } + + String currentUserId = UserContext.getCurrentUserId(); + Set blockedIds = privacySettingsService.blockedUserIds(currentUserId); + Set blockedMomentIds = currentUserId == null ? Collections.emptySet() + : blockedMomentRepository.findByUserId(currentUserId).stream() + .map(com.campus.entity.UserBlockedMoment::getMomentId).collect(Collectors.toSet()); + moments = moments.stream() + .filter(moment -> !blockedIds.contains(moment.getUserId())) + .filter(moment -> !blockedMomentIds.contains(moment.getId())) + .collect(Collectors.toList()); + if (moments.isEmpty()) { + return Collections.emptyList(); + } + + // 1. 收集所有需要查询的 ID + Set userIds = new HashSet<>(); + Set momentIds = new HashSet<>(); + for (Moment m : moments) { + if (!Boolean.TRUE.equals(m.getAnonymous())) { + userIds.add(m.getUserId()); + } + momentIds.add(m.getId()); + } + boolean hasCurrentUser = currentUserId != null && !currentUserId.isEmpty(); + + // 2. 批量查询(固定 3 次 DB 查询) + Map userMap = userIds.isEmpty() ? Collections.emptyMap() + : userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, u -> u, (a, b) -> a)); + + Set likedMomentIds = hasCurrentUser + ? momentLikeRepository.findByMomentIdInAndUserId(momentIds, currentUserId) + .stream().map(MomentLike::getMomentId).collect(Collectors.toSet()) + : Collections.emptySet(); + + Set favoritedMomentIds = hasCurrentUser + ? momentFavoriteRepository.findByMomentIdInAndUserId(momentIds, currentUserId) + .stream().map(MomentFavorite::getMomentId).collect(Collectors.toSet()) + : Collections.emptySet(); + + Map favoriteCounts = momentFavoriteRepository.countByMomentIds(momentIds).stream() + .collect(Collectors.toMap(row -> (String) row[0], row -> (Long) row[1])); + + Map progressMap = hasCurrentUser + ? videoPlayRecordRepository.findByVideoIdInAndUserId(momentIds, currentUserId) + .stream().collect(Collectors.toMap( + VideoPlayRecord::getVideoId, + VideoPlayRecord::getPlayPosition, + (a, b) -> a)) + : Collections.emptyMap(); + + Set followedUserIds = hasCurrentUser && !userIds.isEmpty() + ? followService.getFollowedUserIds(currentUserId) + : Collections.emptySet(); + + // 3. 组装 DTO 列表 + return moments.stream() + .map(m -> mapToDTO(m, userMap, likedMomentIds, favoritedMomentIds, favoriteCounts, + progressMap, followedUserIds, currentUserId)) + .collect(Collectors.toList()); + } + + @Transactional + public void blockMoment(String id) { + String userId = UserContext.getCurrentUserId(); + if (!momentRepository.existsById(id)) throw new com.campus.common.BusinessException("动态不存在"); + if (!blockedMomentRepository.findByUserIdAndMomentId(userId, id).isPresent()) { + com.campus.entity.UserBlockedMoment item = new com.campus.entity.UserBlockedMoment(); + item.setUserId(userId); + item.setMomentId(id); + blockedMomentRepository.save(item); + } + } + + @Transactional + public void unblockMoment(String id) { + String userId = UserContext.getCurrentUserId(); + blockedMomentRepository.findByUserIdAndMomentId(userId, id).ifPresent(blockedMomentRepository::delete); + } + + /** + * 单条 Moment → DTO 映射(使用预加载的批量数据) + */ + private MomentDTO mapToDTO(Moment moment, Map userMap, + Set likedMomentIds, Set favoritedMomentIds, + Map favoriteCounts, + Map progressMap, + Set followedUserIds, String currentUserId) { + MomentDTO dto = new MomentDTO(); + dto.setId(moment.getId()); + dto.setUserId(Boolean.TRUE.equals(moment.getAnonymous()) ? null : moment.getUserId()); + dto.setOwnedByCurrentUser(moment.getUserId() != null && moment.getUserId().equals(UserContext.getCurrentUserId())); + dto.setIsFollowed(false); + dto.setContentSource(moment.getContentSource() == null ? Moment.SOURCE_FEED : moment.getContentSource()); + dto.setTopicId(moment.getTopicId()); + dto.setContent(moment.getContent()); + dto.setMediaUrls(parseList(moment.getMediaUrls())); + dto.setTags(parseList(moment.getTags())); + dto.setCategory(moment.getCategory()); + dto.setScope(moment.getScope()); + dto.setProvince(moment.getProvince()); + dto.setCity(moment.getCity()); + dto.setExpiresAt(moment.getExpiresAt()); + dto.setScheduledPublishAt(moment.getScheduledPublishAt()); + dto.setViewCount(moment.getViewCount()); + dto.setLikeCount(moment.getLikeCount()); + dto.setFavoriteCount(favoriteCounts.getOrDefault(moment.getId(), 0L)); + dto.setCommentCount(moment.getCommentCount()); + dto.setReviewStatus(moment.getReviewStatus()); + dto.setReviewReason(moment.getReviewReason()); + dto.setIsYouthVideo(moment.getIsYouthVideo()); + dto.setAnonymous(moment.getAnonymous()); + dto.setCreatedAt(moment.getCreatedAt()); + dto.setUpdatedAt(moment.getUpdatedAt()); + + dto.setVideoOriginalUrl(moment.getVideoOriginalUrl()); + dto.setVideoCoverUrl(moment.getVideoCoverUrl()); + dto.setVodVideoId(moment.getVodVideoId()); + dto.setPlayUrl(moment.getPlayUrl()); + dto.setAuditStatus(moment.getAuditStatus()); + dto.setVideoDuration(moment.getVideoDuration()); + dto.setVideoFileSize(moment.getVideoFileSize()); + dto.setVideoWidth(moment.getVideoWidth()); + dto.setVideoHeight(moment.getVideoHeight()); + dto.setVideoStatus(resolveVideoStatus(moment)); + dto.setShareCount(moment.getShareCount()); + + // 从批量预加载的 Map 中获取用户信息 + if (!Boolean.TRUE.equals(moment.getAnonymous())) { + User user = userMap.get(moment.getUserId()); + if (user != null) { + dto.setUserName(user.getNickname()); + dto.setUserAvatar(user.getAvatar()); + } + } else { + dto.setUserName("匿名用户"); + } + + // 从批量预加载的 Set 中获取点赞状态 + dto.setIsLiked(likedMomentIds.contains(moment.getId())); + dto.setIsFavorited(favoritedMomentIds.contains(moment.getId())); + + // 从批量预加载的 Set 中获取关注状态(匿名用户和自己不显示关注状态) + if (!Boolean.TRUE.equals(moment.getAnonymous()) && !moment.getUserId().equals(currentUserId)) { + dto.setIsFollowed(followedUserIds.contains(moment.getUserId())); + } + + // 从批量预加载的 Map 中获取播放进度 + Integer progress = progressMap.get(moment.getId()); + if (progress != null) { + dto.setPlayPosition(progress); + } + + return dto; + } + + /** + * 单条 Moment → DTO 转换(用于详情页等非批量场景) + */ + private MomentDTO toDTO(Moment moment) { + MomentDTO dto = new MomentDTO(); + dto.setId(moment.getId()); + dto.setUserId(Boolean.TRUE.equals(moment.getAnonymous()) ? null : moment.getUserId()); + dto.setOwnedByCurrentUser(moment.getUserId() != null && moment.getUserId().equals(UserContext.getCurrentUserId())); + dto.setIsFollowed(false); + dto.setContentSource(moment.getContentSource() == null ? Moment.SOURCE_FEED : moment.getContentSource()); + dto.setTopicId(moment.getTopicId()); + dto.setContent(moment.getContent()); + dto.setMediaUrls(parseList(moment.getMediaUrls())); + dto.setTags(parseList(moment.getTags())); + dto.setCategory(moment.getCategory()); + dto.setScope(moment.getScope()); + dto.setProvince(moment.getProvince()); + dto.setCity(moment.getCity()); + dto.setExpiresAt(moment.getExpiresAt()); + dto.setScheduledPublishAt(moment.getScheduledPublishAt()); + dto.setViewCount(moment.getViewCount()); + dto.setLikeCount(moment.getLikeCount()); + dto.setFavoriteCount(momentFavoriteRepository.countByMomentIdAndDeletedFalse(moment.getId())); + dto.setCommentCount(moment.getCommentCount()); + dto.setReviewStatus(moment.getReviewStatus()); + dto.setReviewReason(moment.getReviewReason()); + dto.setIsYouthVideo(moment.getIsYouthVideo()); + dto.setAnonymous(moment.getAnonymous()); + dto.setCreatedAt(moment.getCreatedAt()); + dto.setUpdatedAt(moment.getUpdatedAt()); + + // 视频相关字段 + dto.setVideoOriginalUrl(moment.getVideoOriginalUrl()); + dto.setVideoCoverUrl(moment.getVideoCoverUrl()); + dto.setVodVideoId(moment.getVodVideoId()); + dto.setPlayUrl(moment.getPlayUrl()); + dto.setAuditStatus(moment.getAuditStatus()); + dto.setVideoDuration(moment.getVideoDuration()); + dto.setVideoFileSize(moment.getVideoFileSize()); + dto.setVideoWidth(moment.getVideoWidth()); + dto.setVideoHeight(moment.getVideoHeight()); + dto.setVideoStatus(resolveVideoStatus(moment)); + dto.setShareCount(moment.getShareCount()); + + // 设置用户信息 + if (!Boolean.TRUE.equals(moment.getAnonymous())) { + User user = userRepository.findById(moment.getUserId()).orElse(null); + if (user != null) { + dto.setUserName(user.getNickname()); + dto.setUserAvatar(user.getAvatar()); + } + } else { + dto.setUserName("匿名用户"); + } + + // 检查当前用户是否已点赞 + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId != null) { + dto.setIsLiked(momentLikeRepository.existsByMomentIdAndUserId(moment.getId(), currentUserId)); + dto.setIsFavorited(momentFavoriteRepository.existsByMomentIdAndUserId(moment.getId(), currentUserId)); + + // 非匿名、非自己时填充关注状态 + if (!Boolean.TRUE.equals(moment.getAnonymous()) && !moment.getUserId().equals(currentUserId)) { + dto.setIsFollowed(followService.getFollowedUserIds(currentUserId).contains(moment.getUserId())); + } + + // 获取播放进度 + videoPlayRecordRepository.findByVideoIdAndUserId(moment.getId(), currentUserId) + .ifPresent(record -> dto.setPlayPosition(record.getPlayPosition())); + } + + return dto; + } + + /** 收藏/取消收藏动态。 */ + @Transactional + @CacheEvict(value = "moments", allEntries = true) + public boolean toggleFavorite(String momentId) { + String userId = InteractionLockService.currentUserId(); + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_MOMENT_FAVORITE, userId, momentId); + momentRepository.findByIdForInteraction(momentId) + .orElseThrow(() -> new com.campus.common.BusinessException("动态不存在")); + getByIdEntity(momentId); + MomentFavorite existing = momentFavoriteRepository.findByMomentIdAndUserId(momentId, userId).orElse(null); + if (existing != null) { + momentFavoriteRepository.delete(existing); + return false; + } + momentFavoriteRepository.upsertActive(java.util.UUID.randomUUID().toString(), momentId, userId); + return true; + } + + public long getFavoriteCount(String momentId) { + return momentFavoriteRepository.countByMomentIdAndDeletedFalse(momentId); + } + + public boolean isFavorited(String momentId) { + String userId = UserContext.getCurrentUserId(); + return userId != null && momentFavoriteRepository.existsByMomentIdAndUserId(momentId, userId); + } + + /** 查询当前用户收藏的动态。已被删除的动态不会出现在结果中。 */ + public PageResult listMyFavorites(int page, int pageSize) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + int safePage = page < 1 ? 1 : page; + int safeSize = pageSize < 1 ? 20 : Math.min(pageSize, 100); + Page favoritePage = momentFavoriteRepository.findActiveByUserIdOrderByCreatedAtDesc( + userId, PageRequest.of(safePage - 1, safeSize)); + List ids = favoritePage.getContent().stream() + .map(MomentFavorite::getMomentId).collect(Collectors.toList()); + Map momentMap = momentRepository.findAllById(ids).stream() + .collect(Collectors.toMap(Moment::getId, m -> m)); + List moments = ids.stream().map(momentMap::get) + .filter(m -> m != null && REVIEW_APPROVED.equals(m.getReviewStatus())) + .collect(Collectors.toList()); + return PageResult.of(batchToDTOList(moments), favoritePage.getTotalElements(), safePage, safeSize); + } + + private List parseList(String str) { + if (str == null || str.isEmpty()) { + return Collections.emptyList(); + } + return Arrays.stream(str.split(",")) + .map(s -> s.trim().replaceAll("^`|`$", "")) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + public List listApproved(String userId) { + Page momentPage = userId != null + ? momentRepository.findFeedMomentsByUser(userId, PageRequest.of(0, 100)) + : momentRepository.findFeedMoments(REVIEW_APPROVED, PageRequest.of(0, 100)); + List filtered = momentPage.getContent().stream() + .filter(m -> "approved".equals(m.getReviewStatus())) + .collect(Collectors.toList()); + return batchToDTOList(filtered); + } + + @Transactional + public void incrementView(String id) { + if (momentRepository.incrementViewCount(id) == 0) { + throw new RuntimeException("动态不存在"); + } + } + + /** + * 更新动态的视频URL(后台上传完成后异步回调) + */ + @Transactional + public void updateVideoUrl(String id, String videoUrl) { + Moment moment = momentRepository.findById(id) + .orElseThrow(() -> new RuntimeException("动态不存在")); + authorizationService.requireOwnerOrAdmin(moment.getUserId()); + moment.setVideoOriginalUrl(videoUrl); + momentRepository.save(moment); + } + + /** + * 更新动态的 VOD 视频ID(后台上传完成后异步回调) + * VOD 转码/审核完成后通过回调自动更新播放地址 + */ + @Transactional + public void updateVodVideoId(String id, String vodVideoId) { + Moment moment = momentRepository.findById(id) + .orElseThrow(() -> new RuntimeException("动态不存在")); + authorizationService.requireOwnerOrAdmin(moment.getUserId()); + moment.setVodVideoId(vodVideoId); + momentRepository.save(moment); + } + + @Transactional + public void updateMediaUrls(String id, List mediaUrls) { + Moment moment = momentRepository.findById(id) + .orElseThrow(() -> new RuntimeException("动态不存在")); + authorizationService.requireOwnerOrAdmin(moment.getUserId()); + List cleanedMediaUrls = cleanMediaUrls(mediaUrls); + moment.setMediaUrls(cleanedMediaUrls != null ? String.join(",", cleanedMediaUrls) : null); + momentRepository.save(moment); + } +} diff --git a/campus-server/src/main/java/com/campus/service/MomentShareCountService.java b/campus-server/src/main/java/com/campus/service/MomentShareCountService.java new file mode 100644 index 0000000..c2fd88d --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/MomentShareCountService.java @@ -0,0 +1,37 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.repository.MomentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 动态分享计数及其可选请求去重。 */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MomentShareCountService { + + private final MomentRepository momentRepository; + private final ShareRequestIdempotencyService requestIdempotencyService; + + @Transactional + public void incrementShare(String momentId, String requestId) { + String normalizedRequestId = requestId == null ? null : requestId.trim(); + if (normalizedRequestId == null || normalizedRequestId.isEmpty()) { + if (momentRepository.incrementShareCount(momentId) == 0) { + throw new BusinessException("动态不存在"); + } + return; + } + String senderId = InteractionLockService.currentUserId(); + String requestHash = ShareRequestIdempotencyService.sha256("MOMENT_COUNT\n" + momentId); + ShareRequestIdempotencyService.Claim claim = requestIdempotencyService.claim(senderId, + ShareRequestIdempotencyService.TYPE_MOMENT_COUNT, normalizedRequestId, requestHash); + if (claim.isCompleted()) return; + if (momentRepository.incrementShareCount(momentId) == 0) { + throw new BusinessException("动态不存在"); + } + requestIdempotencyService.complete(claim.getRecordId(), "{}"); + } +} diff --git a/campus-server/src/main/java/com/campus/service/NotificationService.java b/campus-server/src/main/java/com/campus/service/NotificationService.java new file mode 100644 index 0000000..6f9c0d1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/NotificationService.java @@ -0,0 +1,309 @@ +package com.campus.service; + +import com.campus.common.PageResult; +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.entity.Notification; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.NotificationRepository; +import com.campus.repository.NotificationPreferenceRepository; +import com.campus.repository.NotificationTemplateRepository; +import com.campus.repository.UserRepository; +import com.campus.entity.NotificationPreference; +import com.campus.entity.NotificationTemplate; +import com.campus.entity.User; +import com.campus.common.BusinessException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; +import java.util.List; +import java.time.LocalTime; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final ChatConversationRepository chatConversationRepository; + private final AuthorizationService authorizationService; + private final PushService pushService; + private final SseService sseService; + private final ObjectMapper objectMapper; + private final NotificationPreferenceRepository preferenceRepository; + private final NotificationTemplateRepository templateRepository; + private final UserRepository userRepository; + + public PageResult list(String userId, String type, Boolean isRead, int page, int pageSize) { + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + Pageable pageable = PageRequest.of(safePage - 1, safeSize); + Page notificationPage; + if (type != null) { + notificationPage = notificationRepository.findByUserIdAndType(userId, type, pageable); + } else if (isRead != null) { + notificationPage = notificationRepository.findByUserIdAndIsRead(userId, isRead, pageable); + } else { + notificationPage = notificationRepository.findByUserId(userId, pageable); + } + return PageResult.of(notificationPage.map(this::toDTO).getContent(), + notificationPage.getTotalElements(), safePage, safeSize); + } + + public long getUnreadCount(String userId) { + return notificationRepository.countByUserIdAndIsReadFalse(userId); + } + + /** 获取未读数明细(通知和聊天分开返回) */ + public UnreadDetailDTO getUnreadDetail(String userId) { + long notificationUnread = notificationRepository.countByUserIdAndIsReadFalse(userId); + long chatUnread = chatConversationRepository.sumUnreadCountByUserId(userId); + return new UnreadDetailDTO(notificationUnread, chatUnread, notificationUnread + chatUnread); + } + + /** 获取全部未读总数(通知 + 聊天会话) */ + public UnreadCountDTO getTotalUnreadCount(String userId) { + long notificationUnread = notificationRepository.countByUserIdAndIsReadFalse(userId); + long chatUnread = chatConversationRepository.sumUnreadCountByUserId(userId); + return new UnreadCountDTO(notificationUnread + chatUnread); + } + + @Transactional + public NotificationDTO create(NotificationRequest request, String userId, String senderId) { + Notification notification = new Notification(); + notification.setUserId(userId); + notification.setType(NotificationTypes.requireValid(request.getType())); + notification.setTitle(request.getTitle()); + notification.setContent(request.getContent()); + notification.setAvatar(request.getAvatar()); + notification.setLink(request.getLink()); + notification.setRelatedId(request.getRelatedId()); + notification.setSenderId(senderId); + notification = notificationRepository.save(notification); + + // 同步推送消息到 App + if (allowsRealtime(userId, notification.getType())) { + try { + pushService.pushToUser(userId, request.getTitle(), request.getContent(), null); + } catch (Exception e) { + log.error("[通知] 推送失败,但不影响通知创建 -> userId={}, title={}", userId, request.getTitle(), e); + } + pushSseEvent(userId, notification); + } + + return toDTO(notification); + } + + /** + * 业务事件触发时创建通知(供其他 Service 调用) + */ + @Transactional + public void createNotification(String userId, String type, String title, String content, + String avatar, String link, String relatedId, String senderId) { + Notification notification = new Notification(); + notification.setUserId(userId); + notification.setType(NotificationTypes.requireValid(type)); + notification.setTitle(title); + notification.setContent(content); + notification.setAvatar(avatar); + notification.setLink(link); + notification.setRelatedId(relatedId); + notification.setSenderId(senderId); + notificationRepository.save(notification); + log.info("[通知] 创建通知 -> userId={}, type={}, title={}", userId, type, title); + + // 注意:此处不再无条件调用 pushToUser,避免与调用方已有的显式推送产生重复推送。 + // 调用方应在调用 createNotification 之前自行调用对应的 pushService.pushXxx() 方法。 + + // SSE 实时推送通知给在线用户 + if (allowsRealtime(userId, notification.getType())) pushSseEvent(userId, notification); + } + + @Transactional + public void markAsRead(String id, String userId) { + Notification notification = notificationRepository.findById(id) + .orElseThrow(() -> new RuntimeException("通知不存在")); + if (!notification.getUserId().equals(userId)) { + throw new RuntimeException("无权操作他人通知"); + } + notification.setIsRead(true); + notificationRepository.save(notification); + } + + @Transactional + public void markAllAsRead(String userId) { + notificationRepository.markAllAsReadByUserId(userId); + log.info("[通知] 全部已读 -> userId={}", userId); + } + + @Transactional + public void markBatchAsRead(String userId, List ids) { + for (Notification notification : notificationRepository.findAllById(ids)) { + if (!userId.equals(notification.getUserId())) throw new com.campus.common.ForbiddenException("无权操作他人通知"); + notification.setIsRead(true); + notificationRepository.save(notification); + } + } + + @Transactional + public void deleteBatch(String userId, List ids) { + for (Notification notification : notificationRepository.findAllById(ids)) { + if (!userId.equals(notification.getUserId())) throw new com.campus.common.ForbiddenException("无权删除他人通知"); + notificationRepository.delete(notification); + } + } + + @Transactional + public NotificationPreference savePreference(String userId, NotificationPreference input) { + validateTime(input.getQuietStart()); + validateTime(input.getQuietEnd()); + NotificationPreference pref = preferenceRepository.findByUserId(userId).orElseGet(NotificationPreference::new); + pref.setUserId(userId); + pref.setPushEnabled(value(input.getPushEnabled(), true)); + pref.setSystemEnabled(value(input.getSystemEnabled(), true)); + pref.setSocialEnabled(value(input.getSocialEnabled(), true)); + pref.setActivityEnabled(value(input.getActivityEnabled(), true)); + pref.setChatEnabled(value(input.getChatEnabled(), true)); + pref.setQuietStart(input.getQuietStart()); + pref.setQuietEnd(input.getQuietEnd()); + return preferenceRepository.save(pref); + } + + @Transactional(readOnly = true) + public NotificationPreference getPreference(String userId) { + return preferenceRepository.findByUserId(userId).orElseGet(() -> { + NotificationPreference pref = new NotificationPreference(); + pref.setUserId(userId); + return pref; + }); + } + + @Transactional + public int broadcast(String title, String content, String link) { + authorizationService.requireAdmin(); + int count = 0; + for (User user : userRepository.findAll()) { + if (Boolean.TRUE.equals(user.getDeleted()) || "DISABLED".equals(user.getAccountStatus())) continue; + createNotification(user.getId(), "ADMIN_BROADCAST", title, content, null, link, null, + com.campus.util.UserContext.getCurrentUserId()); + count++; + } + return count; + } + + @Transactional(readOnly = true) + public List listTemplates() { + authorizationService.requireAdmin(); + return templateRepository.findAll(); + } + + @Transactional + public NotificationTemplate saveTemplate(NotificationTemplate input) { + authorizationService.requireAdmin(); + NotificationTemplate target = input.getId() == null ? new NotificationTemplate() + : templateRepository.findById(input.getId()).orElseThrow(() -> new BusinessException("通知模板不存在")); + target.setCode(input.getCode()); + target.setName(input.getName()); + target.setTitle(input.getTitle()); + target.setContent(input.getContent()); + target.setEnabled(value(input.getEnabled(), true)); + return templateRepository.save(target); + } + + @Transactional + public void delete(String id, String userId) { + Notification notification = notificationRepository.findById(id) + .orElseThrow(() -> new RuntimeException("通知不存在")); + if (!notification.getUserId().equals(userId) && !authorizationService.isCurrentAdmin()) { + throw new com.campus.common.ForbiddenException("无权删除他人的通知"); + } + notificationRepository.delete(notification); + } + + public NotificationDTO toDTO(Notification notification) { + NotificationDTO dto = new NotificationDTO(); + dto.setId(notification.getId()); + dto.setUserId(notification.getUserId()); + dto.setType(notification.getType()); + dto.setTitle(notification.getTitle()); + dto.setContent(notification.getContent()); + dto.setIsRead(notification.getIsRead()); + dto.setAvatar(notification.getAvatar()); + dto.setLink(notification.getLink()); + dto.setRelatedId(notification.getRelatedId()); + dto.setSenderId(notification.getSenderId()); + dto.setCreatedAt(notification.getCreatedAt()); + return dto; + } + + /** + * 通过 SSE 向在线用户实时推送通知事件 + *

+ * 推送内容包含新通知 JSON 和最新未读数,前端收到后可直接更新 UI。 + */ + private void pushSseEvent(String userId, Notification notification) { + try { + NotificationDTO dto = toDTO(notification); + long unreadCount = notificationRepository.countByUserIdAndIsReadFalse(userId); + long chatUnread = chatConversationRepository.sumUnreadCountByUserId(userId); + + Map eventData = new HashMap<>(); + eventData.put("notification", dto); + eventData.put("notificationUnread", unreadCount); + eventData.put("chatUnread", chatUnread); + eventData.put("totalUnread", unreadCount + chatUnread); + + String json = objectMapper.writeValueAsString(eventData); + sseService.sendToUser(userId, json, "notification"); + log.debug("[SSE] 推送通知 -> userId={}, type={}, unread={}", userId, dto.getType(), unreadCount); + } catch (Exception e) { + log.warn("[SSE] 推送通知序列化失败 -> userId={}, error={}", userId, e.getMessage()); + } + } + + private boolean allowsRealtime(String userId, String type) { + NotificationPreference pref = preferenceRepository.findByUserId(userId).orElse(null); + if (pref == null || !Boolean.TRUE.equals(pref.getPushEnabled())) return pref == null; + if (inQuietHours(pref)) return false; + if (type.contains("CHAT")) return Boolean.TRUE.equals(pref.getChatEnabled()); + if (type.startsWith("ACTIVITY")) return Boolean.TRUE.equals(pref.getActivityEnabled()); + if (type.contains("LIKE") || type.contains("COMMENT") || type.contains("FOLLOW") + || type.contains("FRIEND") || type.contains("MATCH")) return Boolean.TRUE.equals(pref.getSocialEnabled()); + return Boolean.TRUE.equals(pref.getSystemEnabled()); + } + + private boolean inQuietHours(NotificationPreference pref) { + if (pref.getQuietStart() == null || pref.getQuietEnd() == null) return false; + try { + LocalTime now = LocalTime.now(); + LocalTime start = LocalTime.parse(pref.getQuietStart()); + LocalTime end = LocalTime.parse(pref.getQuietEnd()); + return start.equals(end) || (start.isBefore(end) + ? !now.isBefore(start) && now.isBefore(end) + : !now.isBefore(start) || now.isBefore(end)); + } catch (Exception ex) { + return false; + } + } + + private void validateTime(String value) { + if (value == null || value.trim().isEmpty()) return; + try { LocalTime.parse(value); } + catch (Exception ex) { throw new BusinessException("免打扰时间格式必须为 HH:mm"); } + } + + private Boolean value(Boolean input, boolean fallback) { + return input == null ? fallback : input; + } +} diff --git a/campus-server/src/main/java/com/campus/service/NotificationTypes.java b/campus-server/src/main/java/com/campus/service/NotificationTypes.java new file mode 100644 index 0000000..01c255a --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/NotificationTypes.java @@ -0,0 +1,31 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +public final class NotificationTypes { + private static final Set TYPES = new HashSet<>(Arrays.asList( + "SYSTEM", "NOTICE", "ANNOUNCEMENT", "APPLICATION", "CHAT_MESSAGE", "GROUP_CHAT_MESSAGE", + "ACTIVITY_JOIN", "ACTIVITY_JOIN_REQUEST", "ACTIVITY_JOIN_APPROVED", "ACTIVITY_JOIN_REJECTED", + "ACTIVITY_MEMBER_REMOVED", "ACTIVITY_QUIT", "ACTIVITY_APPLY_CANCELLED", + "ACTIVITY_REVIEW_APPROVED", "ACTIVITY_REVIEW_REJECTED", + "MOMENT_REVIEW_APPROVED", "MOMENT_REVIEW_REJECTED", "POST_REVIEW_APPROVED", "POST_REVIEW_REJECTED", + "TOPIC_REVIEW_APPROVED", "TOPIC_REVIEW_REJECTED", + "ANNOUNCEMENT_REVIEW_APPROVED", "ANNOUNCEMENT_REVIEW_REJECTED", + "MOMENT_LIKE", "POST_LIKE", "MOMENT_COMMENT", "COMMENT_REPLY", "COMMENT_LIKE", + "FOLLOW", "FOLLOW_YOU", "FRIEND_REQUEST", "FRIEND_ACCEPTED", "MATCH_SUCCESS", "GIFT_RECEIVED", + "GROUP_JOIN_REQUEST", "GROUP_JOIN_APPROVED", "GROUP_MEMBER_JOINED", + "GROUP_INVITE", "GROUP_INVITE_ACCEPTED", "REPORT_RESULT", + "CONTENT_APPEAL_RESULT", "ADMIN_BROADCAST")); + + private NotificationTypes() {} + + public static String requireValid(String value) { + String type = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!TYPES.contains(type)) throw new BusinessException("不支持的通知类型: " + type); + return type; + } +} diff --git a/campus-server/src/main/java/com/campus/service/OssStsService.java b/campus-server/src/main/java/com/campus/service/OssStsService.java new file mode 100644 index 0000000..3c06915 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/OssStsService.java @@ -0,0 +1,40 @@ +package com.campus.service; + +import com.campus.config.OssProperties; +import com.campus.config.StsProperties; +import com.campus.dto.response.StsTokenResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * OSS 上传凭证服务 + * 直接返回永久 AK/SK,前端直传 OSS + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OssStsService { + + private final OssProperties ossProperties; + private final StsProperties stsProperties; + + /** + * 获取 OSS 上传凭证 + * 直接使用永久 AK/SK,不通过 STS AssumeRole + */ + public StsTokenResponse getStsToken() { + StsTokenResponse result = new StsTokenResponse(); + result.setAccessKeyId(ossProperties.getAccessKeyId()); + result.setAccessKeySecret(ossProperties.getAccessKeySecret()); + result.setSecurityToken(""); + result.setExpiration(""); + result.setBucketName(ossProperties.getBucketName()); + result.setEndpoint(ossProperties.getEndpoint().replace("http://", "https://")); + result.setUploadPrefix(stsProperties.getUploadPrefix()); + result.setDomain(ossProperties.getDomain()); + + log.info("OSS上传凭证已返回 (直接AK/SK模式)"); + return result; + } +} diff --git a/campus-server/src/main/java/com/campus/service/PointsService.java b/campus-server/src/main/java/com/campus/service/PointsService.java new file mode 100644 index 0000000..6f64396 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/PointsService.java @@ -0,0 +1,572 @@ +package com.campus.service; + +import com.campus.common.PageResult; +import com.campus.dto.response.PointRuleDTO; +import com.campus.dto.response.PointTransactionDTO; +import com.campus.dto.response.PointsAccountDTO; +import com.campus.entity.PointRule; +import com.campus.entity.PointTransaction; +import com.campus.entity.User; +import com.campus.entity.UserPoints; +import com.campus.repository.PointRuleRepository; +import com.campus.repository.PointTransactionRepository; +import com.campus.repository.UserPointsRepository; +import com.campus.repository.UserRepository; +import com.campus.util.PageUtils; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 积分服务 - 核心服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PointsService { + + private final PointRuleRepository pointRuleRepository; + private final PointTransactionRepository pointTransactionRepository; + private final UserPointsRepository userPointsRepository; + private final UserRepository userRepository; + + // ========== 规则常量 ========== + // 获得积分规则 + public static final String RULE_PUBLISH_MOMENT = "PUBLISH_MOMENT"; // 发布动态 + public static final String RULE_PUBLISH_VIDEO = "PUBLISH_VIDEO"; // 发布视频(青春号) + public static final String RULE_CREATE_ACTIVITY = "CREATE_ACTIVITY"; // 创建组局活动 + public static final String RULE_JOIN_ACTIVITY = "JOIN_ACTIVITY"; // 参加活动 + public static final String RULE_LIKE_MOMENT = "LIKE_MOMENT"; // 点赞动态 + public static final String RULE_LIKE_COMMENT = "LIKE_COMMENT"; // 点赞评论 + public static final String RULE_COMMENT_MOMENT = "COMMENT_MOMENT"; // 评论动态 + public static final String RULE_BE_LIKED = "BE_LIKED"; // 被点赞 + public static final String RULE_BE_COMMENTED = "BE_COMMENTED"; // 被评论 + public static final String RULE_BE_SHARED = "BE_SHARED"; // 被转发 + public static final String RULE_DAILY_CHECK_IN = "DAILY_CHECK_IN"; // 每日签到 + public static final String RULE_FIRST_LOGIN = "FIRST_LOGIN"; // 首次登录 + public static final String RULE_COMPLETE_PROFILE = "COMPLETE_PROFILE"; // 完善资料 + public static final String RULE_REAL_NAME_AUTH = "REAL_NAME_AUTH"; // 实名认证 + public static final String RULE_RECEIVE_GIFT = "RECEIVE_GIFT"; // 收到礼物 + + // 消耗积分规则 + public static final String RULE_SEND_GIFT = "SEND_GIFT"; // 送出礼物 + public static final String RULE_REDEEM_PRIZE = "REDEEM_PRIZE"; // 兑换奖品 + public static final String RULE_REPORT_CONTENT = "REPORT_CONTENT"; // 举报内容(冻结) + + // 扣除积分规则 + public static final String RULE_PUNISH_CONTENT = "PUNISH_CONTENT"; // 内容违规扣分 + public static final String RULE_PUNISH_SPAM = "PUNISH_SPAM"; // 刷屏扣分 + + // 关联类型常量 + public static final String RELATED_MOMENT = "MOMENT"; // 动态 + public static final String RELATED_COMMENT = "COMMENT"; // 评论 + public static final String RELATED_ACTIVITY = "ACTIVITY"; // 活动 + public static final String RELATED_GIFT = "GIFT"; // 礼物 + public static final String RELATED_CHECK_IN = "CHECK_IN"; // 签到 + + // ========== 积分账户操作 ========== + + /** + * 获取用户积分账户信息 + */ + public PointsAccountDTO getAccountInfo(String userId) { + // 先校验用户是否存在,避免为不存在的用户自动创建积分账户 + if (!userRepository.existsById(userId)) { + throw new RuntimeException("用户不存在"); + } + UserPoints account = getOrCreateAccount(userId); + + PointsAccountDTO dto = new PointsAccountDTO(); + dto.setUserId(userId); + dto.setPoints(account.getPoints()); + dto.setTotalEarned(account.getTotalEarned()); + dto.setTotalSpent(account.getTotalSpent()); + dto.setLevel(account.getLevel()); + dto.setExperience(account.getExperience()); + dto.setExperienceToNextLevel(100 - (account.getExperience() % 100)); + dto.setTotalCheckInDays(account.getTotalCheckInDays()); + dto.setConsecutiveCheckInDays(account.getConsecutiveCheckInDays()); + dto.setCheckedInToday(isCheckedInToday(account)); + dto.setTodayPoints(getTodayPoints(userId)); + dto.setRank(getUserRank(userId)); + + return dto; + } + + /** + * 获得积分 + */ + @Transactional + public PointTransaction earnPoints(String userId, String ruleCode, String relatedType, + String relatedId, String relatedUserId, String remark) { + // 检查规则是否存在 + PointRule rule = pointRuleRepository.findByCode(ruleCode) + .orElseThrow(() -> new RuntimeException("积分规则不存在: " + ruleCode)); + + if (!rule.getEnabled()) { + log.debug("积分规则已禁用: {}", ruleCode); + return null; + } + + // 检查每日上限 + if (rule.getDailyLimit() > 0) { + int todayPoints = getTodayEarnedPoints(userId, ruleCode); + if (todayPoints >= rule.getDailyLimit()) { + log.debug("已达到每日积分上限: {}, userId={}", ruleCode, userId); + return null; + } + } + + // 检查永久上限 + if (rule.getTotalLimit() > 0) { + int totalPoints = getTotalEarnedPoints(userId, ruleCode); + if (totalPoints >= rule.getTotalLimit()) { + log.debug("已达到总积分上限: {}, userId={}", ruleCode, userId); + return null; + } + } + + // 更新账户 + UserPoints account = getOrCreateAccountForUpdate(userId); + int balanceBefore = account.getPoints(); + + account.setPoints(account.getPoints() + rule.getPoints()); + account.setTotalEarned(account.getTotalEarned() + rule.getPoints()); + account.setExperience(account.getExperience() + rule.getPoints()); + account.updateLevel(); + userPointsRepository.save(account); + + // 记录流水 + PointTransaction transaction = new PointTransaction(); + transaction.setUserId(userId); + transaction.setRuleCode(ruleCode); + transaction.setRuleName(rule.getName()); + transaction.setType("EARN"); + transaction.setPoints(rule.getPoints()); + transaction.setBalanceBefore(balanceBefore); + transaction.setBalanceAfter(account.getPoints()); + transaction.setRelatedType(relatedType); + transaction.setRelatedId(relatedId); + transaction.setRelatedUserId(relatedUserId); + transaction.setRemark(remark); + return pointTransactionRepository.save(transaction); + } + + /** + * 消耗积分 + */ + @Transactional + public PointTransaction spendPoints(String userId, String ruleCode, Integer points, + String relatedType, String relatedId, String remark) { + // 检查规则是否存在 + PointRule rule = pointRuleRepository.findByCode(ruleCode) + .orElseThrow(() -> new RuntimeException("积分规则不存在: " + ruleCode)); + + if (!rule.getEnabled()) { + throw new RuntimeException("该积分功能已禁用"); + } + + // 检查余额 + UserPoints account = getOrCreateAccountForUpdate(userId); + if (account.getPoints() < points) { + throw new RuntimeException("积分余额不足"); + } + + int balanceBefore = account.getPoints(); + + // 更新账户 + account.setPoints(account.getPoints() - points); + account.setTotalSpent(account.getTotalSpent() + points); + userPointsRepository.save(account); + + // 记录流水 + PointTransaction transaction = new PointTransaction(); + transaction.setUserId(userId); + transaction.setRuleCode(ruleCode); + transaction.setRuleName(rule.getName()); + transaction.setType("SPEND"); + transaction.setPoints(points); + transaction.setBalanceBefore(balanceBefore); + transaction.setBalanceAfter(account.getPoints()); + transaction.setRelatedType(relatedType); + transaction.setRelatedId(relatedId); + transaction.setRemark(remark); + return pointTransactionRepository.save(transaction); + } + + /** + * 扣除积分(惩罚) + */ + @Transactional + public PointTransaction deductPoints(String userId, String ruleCode, Integer points, + String relatedType, String relatedId, String remark) { + PointRule rule = pointRuleRepository.findByCode(ruleCode).orElse(null); + String ruleName = (rule != null) ? rule.getName() : ruleCode; + + UserPoints account = getOrCreateAccountForUpdate(userId); + int balanceBefore = account.getPoints(); + + // 积分不能为负数 + int newPoints = Math.max(0, account.getPoints() - points); + account.setPoints(newPoints); + userPointsRepository.save(account); + + // 记录流水 + PointTransaction transaction = new PointTransaction(); + transaction.setUserId(userId); + transaction.setRuleCode(ruleCode); + transaction.setRuleName(ruleName); + transaction.setType("SPEND"); + transaction.setPoints(points); + transaction.setBalanceBefore(balanceBefore); + transaction.setBalanceAfter(newPoints); + transaction.setRelatedType(relatedType); + transaction.setRelatedId(relatedId); + transaction.setRemark(remark); + return pointTransactionRepository.save(transaction); + } + + /** + * 获得积分(自定义数量,用于礼物收益等动态积分场景) + */ + @Transactional + public PointTransaction earnPointsWithAmount(String userId, String ruleCode, Integer points, + String relatedType, String relatedId, String relatedUserId, String remark) { + PointRule rule = pointRuleRepository.findByCode(ruleCode) + .orElseThrow(() -> new RuntimeException("积分规则不存在: " + ruleCode)); + + if (!rule.getEnabled()) { + log.debug("积分规则已禁用: {}", ruleCode); + return null; + } + + UserPoints account = getOrCreateAccountForUpdate(userId); + int balanceBefore = account.getPoints(); + + account.setPoints(account.getPoints() + points); + account.setTotalEarned(account.getTotalEarned() + points); + account.setExperience(account.getExperience() + points); + account.updateLevel(); + userPointsRepository.save(account); + + PointTransaction transaction = new PointTransaction(); + transaction.setUserId(userId); + transaction.setRuleCode(ruleCode); + transaction.setRuleName(rule.getName()); + transaction.setType("EARN"); + transaction.setPoints(points); + transaction.setBalanceBefore(balanceBefore); + transaction.setBalanceAfter(account.getPoints()); + transaction.setRelatedType(relatedType); + transaction.setRelatedId(relatedId); + transaction.setRelatedUserId(relatedUserId); + transaction.setRemark(remark); + return pointTransactionRepository.save(transaction); + } + + // ========== 签到功能 ========== + + /** + * 每日签到 + */ + @Transactional + public PointTransaction dailyCheckIn(String userId) { + UserPoints account = getOrCreateAccountForUpdate(userId); + Date today = truncateDate(new Date()); + + // 检查今日是否已签到 + if (isCheckedInToday(account)) { + throw new RuntimeException("今日已签到"); + } + + // 检查是否连续签到 + Date yesterday = truncateDate(new Date(today.getTime() - 86400000)); + if (account.getLastCheckInDate() != null && + truncateDate(account.getLastCheckInDate()).equals(yesterday)) { + // 连续签到 + account.setConsecutiveCheckInDays(account.getConsecutiveCheckInDays() + 1); + } else { + // 重新开始签到 + account.setConsecutiveCheckInDays(1); + } + + account.setTotalCheckInDays(account.getTotalCheckInDays() + 1); + account.setLastCheckInDate(today); + + // 计算签到积分(连续签到加成) + int basePoints = 5; + int bonusPoints = 0; + if (account.getConsecutiveCheckInDays() >= 7) { + bonusPoints = 5; // 连续7天签到额外+5 + } else if (account.getConsecutiveCheckInDays() >= 3) { + bonusPoints = 2; // 连续3天签到额外+2 + } + int totalPoints = basePoints + bonusPoints; + + // 更新积分 + int balanceBefore = account.getPoints(); + account.setPoints(account.getPoints() + totalPoints); + account.setTotalEarned(account.getTotalEarned() + totalPoints); + account.setExperience(account.getExperience() + totalPoints); + account.updateLevel(); + userPointsRepository.save(account); + + // 记录流水 + String remark = String.format("签到获得%d积分(连续%d天签到)", totalPoints, account.getConsecutiveCheckInDays()); + PointTransaction transaction = new PointTransaction(); + transaction.setUserId(String.valueOf(account.getUserId())); + transaction.setRuleCode(RULE_DAILY_CHECK_IN); + transaction.setRuleName("每日签到"); + transaction.setType("EARN"); + transaction.setPoints(totalPoints); + transaction.setBalanceBefore(balanceBefore); + transaction.setBalanceAfter(account.getPoints()); + transaction.setRelatedType(RELATED_CHECK_IN); + transaction.setRemark(remark); + return pointTransactionRepository.save(transaction); + } + + // ========== 积分流水查询 ========== + + /** + * 获取积分变动记录 + */ + public PageResult getTransactions(String userId, int page, int size) { + Pageable pageable = PageUtils.request(page, size); + Page pageData = pointTransactionRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + + List list = pageData.getContent().stream() + .map(this::toTransactionDTO) + .collect(Collectors.toList()); + + return PageResult.of(list, pageData.getTotalElements(), page, size); + } + + /** + * 获取积分规则列表 + */ + public List getActiveRules() { + return pointRuleRepository.findByEnabledTrueOrderBySortAsc().stream() + .map(this::toRuleDTO) + .collect(Collectors.toList()); + } + + // ========== 排行榜 ========== + + /** + * 获取积分排行榜 + */ + public List getPointsRanking(int top) { + Pageable pageable = PageRequest.of(0, top); + List list = userPointsRepository.findTopByPoints(pageable); + + return list.stream().map(account -> { + PointsAccountDTO dto = new PointsAccountDTO(); + dto.setUserId(String.valueOf(account.getUserId())); + dto.setPoints(account.getPoints()); + dto.setLevel(account.getLevel()); + return dto; + }).collect(Collectors.toList()); + } + + // ========== 业务场景积分触发 ========== + + /** + * 发布动态 + */ + public void onPublishMoment(String userId, String momentId, boolean isVideo) { + String ruleCode = isVideo ? RULE_PUBLISH_VIDEO : RULE_PUBLISH_MOMENT; + earnPoints(userId, ruleCode, RELATED_MOMENT, momentId, null, "发布动态"); + } + + /** + * 创建组局活动 + */ + public void onCreateActivity(String userId, String activityId) { + earnPoints(userId, RULE_CREATE_ACTIVITY, RELATED_ACTIVITY, activityId, null, "创建组局活动"); + } + + /** + * 参加活动 + */ + public void onJoinActivity(String userId, String activityId, String creatorId) { + // 给参与者加积分 + earnPoints(userId, RULE_JOIN_ACTIVITY, RELATED_ACTIVITY, activityId, creatorId, "参加活动"); + // 给活动创建者加积分 + if (!userId.equals(creatorId)) { + earnPoints(creatorId, RULE_JOIN_ACTIVITY, RELATED_ACTIVITY, activityId, userId, "有人参加你的活动"); + } + } + + /** + * 点赞动态 + */ + public void onLikeMoment(String userId, String momentId, String momentCreatorId) { + // 点赞者获得积分 + earnPoints(userId, RULE_LIKE_MOMENT, RELATED_MOMENT, momentId, momentCreatorId, "点赞动态"); + // 被点赞者获得积分 + if (!userId.equals(momentCreatorId)) { + earnPoints(momentCreatorId, RULE_BE_LIKED, RELATED_MOMENT, momentId, userId, "动态被点赞"); + } + } + + /** + * 评论动态 + */ + public void onCommentMoment(String userId, String momentId, String momentCreatorId, String commentId) { + // 评论者获得积分 + earnPoints(userId, RULE_COMMENT_MOMENT, RELATED_COMMENT, commentId, momentCreatorId, "评论动态"); + // 被评论者获得积分 + if (!userId.equals(momentCreatorId)) { + earnPoints(momentCreatorId, RULE_BE_COMMENTED, RELATED_MOMENT, momentId, userId, "动态被评论"); + } + } + + /** + * 收到礼物 + */ + public void onReceiveGift(String receiverId, String senderId, Integer giftPoints, String giftId) { + earnPoints(receiverId, RULE_RECEIVE_GIFT, RELATED_GIFT, giftId, senderId, "收到礼物打赏"); + } + + /** + * 送出礼物(消耗积分) + */ + public PointTransaction onSendGift(String userId, Integer points, String giftId) { + return spendPoints(userId, RULE_SEND_GIFT, points, RELATED_GIFT, giftId, "送出礼物"); + } + + // ========== 私有方法 ========== + + private UserPoints getOrCreateAccount(String userId) { + return userPointsRepository.findByUserId(userId) + .orElseGet(() -> { + UserPoints account = new UserPoints(); + account.setUserId(userId); + account.setPoints(0); + account.setTotalEarned(0); + account.setTotalSpent(0); + account.setTotalCheckInDays(0); + account.setConsecutiveCheckInDays(0); + account.setLevel(1); + account.setExperience(0); + return userPointsRepository.save(account); + }); + } + + /** 写操作使用行锁,防止并发奖励或兑换造成积分丢失更新。 */ + private UserPoints getOrCreateAccountForUpdate(String userId) { + return userPointsRepository.findByUserIdForUpdate(userId) + .orElseGet(() -> { + UserPoints account = new UserPoints(); + account.setUserId(userId); + account.setPoints(0); + account.setTotalEarned(0); + account.setTotalSpent(0); + account.setTotalCheckInDays(0); + account.setConsecutiveCheckInDays(0); + account.setLevel(1); + account.setExperience(0); + return userPointsRepository.save(account); + }); + } + + private boolean isCheckedInToday(UserPoints account) { + if (account.getLastCheckInDate() == null) { + return false; + } + Date today = truncateDate(new Date()); + return truncateDate(account.getLastCheckInDate()).equals(today); + } + + private Date truncateDate(Date date) { + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + return cal.getTime(); + } + + private int getTodayPoints(String userId) { + Date today = truncateDate(new Date()); + Calendar cal = Calendar.getInstance(); + cal.setTime(today); + cal.add(Calendar.DAY_OF_MONTH, 1); + Date tomorrow = cal.getTime(); + + List todayTransactions = pointTransactionRepository.findTodayByUserId(userId, today, tomorrow); + return todayTransactions.stream() + .filter(t -> "EARN".equals(t.getType())) + .mapToInt(PointTransaction::getPoints) + .sum(); + } + + private int getTodayEarnedPoints(String userId, String ruleCode) { + Date today = truncateDate(new Date()); + Calendar cal = Calendar.getInstance(); + cal.setTime(today); + cal.add(Calendar.DAY_OF_MONTH, 1); + Date tomorrow = cal.getTime(); + + Integer points = pointTransactionRepository.sumTodayPointsByRule(userId, ruleCode, today, tomorrow); + return points != null ? points : 0; + } + + private int getTotalEarnedPoints(String userId, String ruleCode) { + Integer points = pointTransactionRepository.sumTotalPointsByRule(userId, ruleCode); + return points != null ? points : 0; + } + + private int getUserRank(String userId) { + UserPoints account = userPointsRepository.findByUserId(userId).orElse(null); + if (account == null) { + return 0; + } + + List all = userPointsRepository.findTopByPoints(PageRequest.of(0, Integer.MAX_VALUE)); + for (int i = 0; i < all.size(); i++) { + if (all.get(i).getUserId().equals(userId)) { + return i + 1; + } + } + return 0; + } + + private PointTransactionDTO toTransactionDTO(PointTransaction t) { + 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; + } + + private PointRuleDTO toRuleDTO(PointRule rule) { + PointRuleDTO dto = new PointRuleDTO(); + dto.setCode(rule.getCode()); + dto.setName(rule.getName()); + dto.setDescription(rule.getDescription()); + dto.setType(rule.getType()); + dto.setPoints(rule.getPoints()); + dto.setDailyLimit(rule.getDailyLimit()); + dto.setTotalLimit(rule.getTotalLimit()); + dto.setEnabled(rule.getEnabled()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/PrivacySettingsService.java b/campus-server/src/main/java/com/campus/service/PrivacySettingsService.java new file mode 100644 index 0000000..479b7a0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/PrivacySettingsService.java @@ -0,0 +1,136 @@ +package com.campus.service; + +import com.campus.dto.request.PrivacySettingsRequest; +import com.campus.dto.response.PrivacySettingsDTO; +import com.campus.entity.UserPrivacySetting; +import com.campus.repository.UserPrivacySettingRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PrivacySettingsService { + + private final UserPrivacySettingRepository repository; + private final com.campus.repository.FriendshipRepository friendshipRepository; + + public PrivacySettingsDTO getCurrent() { + String userId = currentUserId(); + return toDTO(repository.findByUserId(userId).orElseGet(() -> defaults(userId))); + } + + @Transactional + public PrivacySettingsDTO update(PrivacySettingsRequest request) { + String userId = currentUserId(); + UserPrivacySetting setting = repository.findByUserId(userId).orElseGet(() -> defaults(userId)); + if (request.getProfileVisible() != null) { + setting.setProfileVisible(request.getProfileVisible()); + } + if (request.getLocationVisible() != null) { + setting.setLocationVisible(request.getLocationVisible()); + } + if (request.getOnlineStatusVisible() != null) { + setting.setOnlineStatusVisible(request.getOnlineStatusVisible()); + } + if (request.getAllowStrangerFriendRequest() != null) { + setting.setAllowStrangerFriendRequest(request.getAllowStrangerFriendRequest()); + } + if (request.getAllowStrangerMessage() != null) { + setting.setAllowStrangerMessage(request.getAllowStrangerMessage()); + } + return toDTO(repository.save(setting)); + } + + public boolean isLocationVisible(String userId) { + return repository.findByUserId(userId) + .map(UserPrivacySetting::getLocationVisible) + .map(Boolean.TRUE::equals) + .orElse(true); + } + + public boolean allowsStrangerFriendRequest(String userId) { + return repository.findByUserId(userId) + .map(UserPrivacySetting::getAllowStrangerFriendRequest) + .map(Boolean.TRUE::equals) + .orElse(true); + } + + public boolean allowsStrangerMessage(String userId) { + return repository.findByUserId(userId) + .map(UserPrivacySetting::getAllowStrangerMessage) + .map(Boolean.TRUE::equals) + .orElse(true); + } + + public boolean isProfileVisible(String userId) { + return repository.findByUserId(userId) + .map(UserPrivacySetting::getProfileVisible) + .map(Boolean.TRUE::equals) + .orElse(true); + } + + public boolean isOnlineStatusVisible(String userId) { + return repository.findByUserId(userId) + .map(UserPrivacySetting::getOnlineStatusVisible) + .map(Boolean.TRUE::equals) + .orElse(true); + } + + /** 当前用户与目标用户任一方向拉黑时,内容和推荐均不可见。 */ + public boolean isBlocked(String currentUserId, String targetUserId) { + if (currentUserId == null || targetUserId == null || currentUserId.equals(targetUserId)) { + return false; + } + return friendshipRepository.findByUserIdAndFriendId(currentUserId, targetUserId) + .map(f -> "BLOCKED".equals(f.getStatus())) + .orElseGet(() -> friendshipRepository.findByUserIdAndFriendId(targetUserId, currentUserId) + .map(f -> "BLOCKED".equals(f.getStatus())) + .orElse(false)); + } + + public Set blockedUserIds(String currentUserId) { + Set ids = new HashSet<>(); + if (currentUserId == null || currentUserId.trim().isEmpty()) return ids; + friendshipRepository.findByUserIdAndStatus(currentUserId, "BLOCKED") + .forEach(f -> ids.add(f.getFriendId())); + friendshipRepository.findByFriendIdAndStatus(currentUserId, "BLOCKED") + .forEach(f -> ids.add(f.getUserId())); + return ids; + } + + private UserPrivacySetting defaults(String userId) { + UserPrivacySetting setting = new UserPrivacySetting(); + setting.setUserId(userId); + setting.setProfileVisible(true); + setting.setLocationVisible(true); + setting.setOnlineStatusVisible(true); + setting.setAllowStrangerFriendRequest(true); + setting.setAllowStrangerMessage(true); + return setting; + } + + private PrivacySettingsDTO toDTO(UserPrivacySetting setting) { + PrivacySettingsDTO dto = new PrivacySettingsDTO(); + dto.setUserId(setting.getUserId()); + dto.setProfileVisible(Boolean.TRUE.equals(setting.getProfileVisible())); + dto.setLocationVisible(Boolean.TRUE.equals(setting.getLocationVisible())); + dto.setOnlineStatusVisible(Boolean.TRUE.equals(setting.getOnlineStatusVisible())); + dto.setAllowStrangerFriendRequest(Boolean.TRUE.equals(setting.getAllowStrangerFriendRequest())); + dto.setAllowStrangerMessage(Boolean.TRUE.equals(setting.getAllowStrangerMessage())); + return dto; + } + + private String currentUserId() { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new RuntimeException("用户未登录"); + } + return userId; + } +} diff --git a/campus-server/src/main/java/com/campus/service/ProfileService.java b/campus-server/src/main/java/com/campus/service/ProfileService.java new file mode 100644 index 0000000..36aaba5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ProfileService.java @@ -0,0 +1,83 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProfileService { + private final UserRepository userRepository; + private final UserService userService; + private final ClickGuardService clickGuardService; + + /** 仅更新用户可编辑资料,认证身份信息不接受客户端覆盖。 */ + @Transactional + public User updateProfile(String operatorId, String userId, User updateData) { + if (operatorId == null || operatorId.trim().isEmpty()) { + throw new BusinessException("用户未登录"); + } + clickGuardService.ensureNotTooFrequent("profile-update", operatorId, userId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException("用户不存在")); + User before = new User(); + org.springframework.beans.BeanUtils.copyProperties(user, before); + applyProfileUpdates(user, updateData); + if (!user.equals(before)) { + return userService.saveUser(user); + } + return user; + } + + /** + * 将 updateData 中的非空字段应用到 user 上 + */ + private void applyProfileUpdates(User user, User updateData) { + if (updateData.getAvatar() != null) { + user.setAvatar(updateData.getAvatar()); + } + if (updateData.getBio() != null) { + user.setBio(updateData.getBio()); + } + if (updateData.getInterests() != null) { + user.setInterests(updateData.getInterests()); + } + if (updateData.getSecondMajorName() != null) { + user.setSecondMajorName(updateData.getSecondMajorName()); + } + if (updateData.getSecondMajorId() != null) { + user.setSecondMajorId(updateData.getSecondMajorId()); + } + if (updateData.getHometown() != null) { + user.setHometown(updateData.getHometown()); + } + if (updateData.getHometownProvince() != null) { + user.setHometownProvince(updateData.getHometownProvince()); + } + if (updateData.getHometownCity() != null) { + user.setHometownCity(updateData.getHometownCity()); + } + if (updateData.getHometownDistrict() != null) { + user.setHometownDistrict(updateData.getHometownDistrict()); + } + if (updateData.getHighSchool() != null) { + user.setHighSchool(updateData.getHighSchool()); + } + // 老乡筛选依赖 hometown 字段,前端只传省/市/区,这里自动用市(退而用省)填充 + if (user.getHometown() == null || user.getHometown().trim().isEmpty()) { + if (user.getHometownCity() != null && !user.getHometownCity().trim().isEmpty()) { + user.setHometown(user.getHometownCity()); + } else if (user.getHometownProvince() != null && !user.getHometownProvince().trim().isEmpty()) { + user.setHometown(user.getHometownProvince()); + } + } + // 注:xuexinCertified / xuexinCertifiedAt / xuexinReportId / xuexinEnrollmentStatus + // 这四个学信网字段**不允许**通过通用 updateProfile 修改,只能通过 + // /api/profile/xuexin/submit 专用接口设置,防止绕过 OCR 直接把自己改成「已通过」。 + } + +} diff --git a/campus-server/src/main/java/com/campus/service/PushService.java b/campus-server/src/main/java/com/campus/service/PushService.java new file mode 100644 index 0000000..3155d5e --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/PushService.java @@ -0,0 +1,172 @@ +package com.campus.service; + +import java.util.Map; + +public interface PushService { + + /** + * 推送给指定用户(通过 Account / 用户ID 推送) + * + * @param userId 接收推送的用户ID + * @param title 通知标题 + * @param content 通知内容 + * @param extraData 附带的扩展数据(用于跳转页面、传参数) + */ + void pushToUser(String userId, String title, String content, Map extraData); + + /** + * 按标签批量推送(如全校公告) + * + * @param tag 标签 + * @param title 通知标题 + * @param content 通知内容 + * @param extraData 附带的扩展数据 + */ + void pushToTag(String tag, String title, String content, Map extraData); + + /** + * 全量广播推送(慎用,打扰用户) + */ + void pushBroadcast(String title, String content, Map extraData); + + /** + * 动态被点赞 + */ + void pushMomentLike(String momentPublisherId, String likerNickname, String likerAvatar, + String likerId, String momentId); + + /** + * 动态被评论 + */ + void pushMomentComment(String momentPublisherId, String commenterNickname, String commenterAvatar, + String commenterId, String momentId, String commentId, String commentText); + + /** + * 评论被回复 + */ + void pushCommentReply(String commentAuthorId, String replierNickname, String replierAvatar, + String replierId, String momentId, String commentId, String replyText); + + /** + * 评论被点赞 + */ + void pushCommentLike(String commentAuthorId, String likerNickname, String likerAvatar, + String likerId, String momentId, String commentId); + + /** + * 活动有人报名 + */ + void pushActivityJoin(String activityCreatorId, String participantNickname, String participantAvatar, + String participantId, String activityId, String activityTitle); + + /** + * 活动有人提交报名申请(待发起人审核)。 + * 与 pushActivityJoin 区分:报名申请尚未通过审核,推送文案不能写成「报名参加了」。 + */ + void pushActivityApply(String activityCreatorId, String participantNickname, String participantAvatar, + String participantId, String activityId, String activityTitle); + + /** + * 报名申请人主动撤回了申请 + */ + void pushActivityApplyCancelled(String activityCreatorId, String participantNickname, String participantAvatar, + String participantId, String activityId, String activityTitle); + + /** + * 活动有人退出 + */ + void pushActivityQuit(String activityCreatorId, String quitterNickname, String quitterAvatar, + String quitterId, String activityId, String activityTitle); + + /** + * 活动审核结果 + */ + void pushActivityReview(String activityCreatorId, boolean approved, String activityId, String activityTitle, String reason); + + /** + * 收到好友申请 + */ + void pushFriendRequest(String receiverId, String senderNickname, String senderAvatar, String senderId); + + /** + * 好友申请被同意 + */ + void pushFriendAccepted(String receiverId, String accepterNickname, String accepterAvatar, String accepterId); + + /** + * 有人关注我 + */ + void pushFollowYou(String targetUserId, String followerNickname, String followerAvatar, String followerId); + + /** + * 收到礼物 + */ + void pushGiftReceived(String receiverId, String senderNickname, String senderAvatar, + String senderId, String giftName, Integer giftCount, String momentId); + + /** + * 校园帖子被点赞 + */ + void pushPostLike(String postAuthorId, String likerNickname, String likerAvatar, + String likerId, String postId); + + /** + * 帖子审核结果 + */ + void pushPostReview(String postAuthorId, boolean approved, String postId, String reason); + + /** + * 动态审核结果 + */ + void pushMomentReview(String momentAuthorId, boolean approved, String momentId, String reason); + + /** + * 话题审核结果(自动审核,文案风格与动态一致) + */ + void pushTopicReview(String topicAuthorId, boolean approved, String topicId, String topicName, String reason); + + /** + * 系统公告 + */ + void pushAnnouncement(String title, String content, String announcementId); + + /** + * 私聊消息推送 + * @param receiverId 接收者用户ID + * @param senderId 发送者用户ID + * @param senderNickname 发送者昵称 + * @param content 消息内容 + */ + void pushChatMessage(String receiverId, String senderId, String senderNickname, String content); + + /** + * 群聊消息推送 + * @param receiverId 接收者用户ID(群成员) + * @param senderId 发送者用户ID + * @param senderNickname 发送者昵称 + * @param groupId 群组ID + * @param content 消息内容 + */ + void pushGroupChatMessage(String receiverId, String senderId, String senderNickname, String groupId, String content); + + /** + * 收到群聊邀请(群主/管理员把已建群的用户拉进群,需要对方点「允许」) + * + * @param inviteeId 被邀请人用户ID + * @param inviterNickname 邀请人昵称 + * @param inviterAvatar 邀请人头像 + * @param groupId 群组ID + * @param groupName 群名称 + * @param invitationId 邀请ID(前端点通知后按它调「允许」接口) + */ + void pushGroupInvite(String inviteeId, String inviterNickname, String inviterAvatar, + String groupId, String groupName, String invitationId); + + /** + * 绑定 Account - 用户登录时调用(用于推送绑定用户ID) + * @param userId 用户ID + * @param deviceToken 设备Token(Android/iOS deviceToken) + * @param platform 平台:ANDROID / IOS + */ + void bindAccount(String userId, String deviceToken, String platform); +} diff --git a/campus-server/src/main/java/com/campus/service/QrCodeDecoder.java b/campus-server/src/main/java/com/campus/service/QrCodeDecoder.java new file mode 100644 index 0000000..5974aec --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/QrCodeDecoder.java @@ -0,0 +1,303 @@ +package com.campus.service; + +import com.google.zxing.*; +import com.google.zxing.client.j2se.BufferedImageLuminanceSource; +import com.google.zxing.common.GlobalHistogramBinarizer; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.multi.qrcode.QRCodeMultiReader; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.util.EnumMap; +import java.util.Map; + +/** + * 二维码解码工具(ZXing)- 「先定位 QR 位置,再解码」版本 + * + * 核心原则: + * QR 码自身有三个「1:1:3:1:1」黑白块比例的 Finder Pattern(定位方块)。 + * ZXing 的 QRCodeReader / QRCodeMultiReader 内部就是靠这个比例来**真正识别 + * 二维码位置**的,它不关心码在图的左上/右下/中心哪里 —— 我们不用「猜位置」。 + * + * 唯一需要做的是:把手机拍的大照片(3000×4000+ 像素)**先降采样**,减少 + * 杂字/UI 元素干扰 Finder Pattern 的比例匹配,让 ZXing 找定位块的成功率暴涨。 + * + * 流程: + * 1) 先把整张图缩到 maxSide=1600px(像素数减 85%,Finder 比例不变) + * 2) 用 QRCodeMultiReader 在整张降采样图里**真正定位**所有 QR 码 → 解码 + * 3) 4 旋转 × 2 二值化 × 2 正/反色 增强(每张降采样图 16 种组合) + * 4) 仍失败 → 滑窗裁剪 + 600px 最小边放大 兜底 + * + */ +@Slf4j +@Component +public class QrCodeDecoder { + + /** Step1 整图定位时的最大边(像素)。QR 定位靠比例,降采样不丢信息 */ + private static final int MAX_SIDE_WHOLE_IMAGE = 1600; + /** Step2 滑窗兜底时的解码最小边(像素),小 QR 需要放大才能识别定位点 */ + private static final int MIN_DECODE_SIZE_SLIDE = 600; + /** 旋转角度组合 */ + private static final int[] ROTATIONS = {0, 90, 180, 270}; + + private static final Map HINTS; + + static { + HINTS = new EnumMap<>(DecodeHintType.class); + // 只扫 QR 码,减少 CPU 和误判 + HINTS.put(DecodeHintType.POSSIBLE_FORMATS, + java.util.Collections.singletonList(BarcodeFormat.QR_CODE)); + HINTS.put(DecodeHintType.CHARACTER_SET, "UTF-8"); + HINTS.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); + } + + public String decode(MultipartFile file) { + if (file == null || file.isEmpty()) return null; + try (InputStream in = file.getInputStream()) { + BufferedImage image = ImageIO.read(in); + if (image == null) { + log.warn("QR解码失败:ImageIO无法解析图片 fileName={}", file.getOriginalFilename()); + return null; + } + return decode(image); + } catch (IOException e) { + log.warn("QR解码读取文件失败: {}", e.getMessage()); + return null; + } + } + + public String decode(BufferedImage image) { + if (image == null) return null; + int w = image.getWidth(); + int h = image.getHeight(); + + // ========================================================= + // Step 1:整图降采样 → 用 ZXing 的 FinderPattern 真正定位 QR + // (这是用户要求的「先识别二维码位置,再扫」——猜位置是错的) + // ========================================================= + BufferedImage scaled = fitMaxSide(image, MAX_SIDE_WHOLE_IMAGE); + log.debug("Step1:整图定位 QR。原图 {}x{} → 降采样 {}x{}", w, h, scaled.getWidth(), scaled.getHeight()); + + String t = decodeWholeImageWithAugment(scaled); + if (t != null) { + log.debug("Step1 整图定位成功: {}", shorten(t)); + return t; + } + + // ========================================================= + // Step 2:滑窗兜底(整图被杂字干扰时用) + // ========================================================= + log.debug("Step1 失败,转 Step2 滑窗兜底"); + t = decodeWithSlidingWindow(scaled); + if (t != null) { + log.debug("Step2 滑窗解码成功: {}", shorten(t)); + return t; + } + + log.debug("QR 解码失败(所有策略):原图 {}x{} → 降采样 {}x{}", w, h, scaled.getWidth(), scaled.getHeight()); + return null; + } + + // ==================== Step1:整图定位 ==================== + + /** + * 在整张图上做「4 旋转 × 2 二值化 × 2 反色」= 16 种组合, + * 用 QRCodeMultiReader 内部的 FinderPattern 1:1:3:1:1 比例匹配来定位 QR。 + */ + private String decodeWholeImageWithAugment(BufferedImage img) { + for (int degrees : ROTATIONS) { + BufferedImage rotated = degrees == 0 ? img : rotate(img, degrees); + + // 正常 + String t = tryBinarizers(rotated, false, /*allowMulti=*/true); + if (t != null) return t; + // 反色 + t = tryBinarizers(rotated, true, /*allowMulti=*/true); + if (t != null) return t; + } + return null; + } + + // ==================== Step2:滑窗兜底 ==================== + + private String decodeWithSlidingWindow(BufferedImage img) { + int w = img.getWidth(); + int h = img.getHeight(); + int win = Math.min(w, h) / 3; // 窗口大小:较小边的 1/3 + int step = win / 2; // 步长:窗口一半(50% overlap,不漏扫) + + // 优先扫左下角和右下角(学信网报告码一般在底部,提升命中速度) + int[][] preferred = new int[][]{ + // 左下三块 + {0, h - win, win, win}, + {win / 2, h - win, win, win}, + {win, h - win, win, win}, + // 右下三块 + {w - win, h - win, win, win}, + {w - win - win / 2, h - win, win, win}, + }; + + for (int[] box : preferred) { + String t = decodeWindow(img, box[0], box[1], box[2], box[3]); + if (t != null) return t; + } + // 全图滑窗 + for (int y = 0; y < h - win / 2; y += step) { + for (int x = 0; x < w - win / 2; x += step) { + String t = decodeWindow(img, x, y, win, win); + if (t != null) return t; + } + } + return null; + } + + private String decodeWindow(BufferedImage img, int x, int y, int w, int h) { + x = Math.max(0, x); + y = Math.max(0, y); + w = Math.min(w, img.getWidth() - x); + h = Math.min(h, img.getHeight() - y); + if (w < 80 || h < 80) return null; + BufferedImage sub; + try { + sub = img.getSubimage(x, y, w, h); + } catch (Exception e) { + return null; + } + BufferedImage scaled = ensureMinSize(sub, MIN_DECODE_SIZE_SLIDE); + return decodeWholeImageWithAugment(scaled); + } + + // ==================== 解码底层 ==================== + + private String tryBinarizers(BufferedImage img, boolean invert, boolean allowMulti) { + LuminanceSource baseSrc = new BufferedImageLuminanceSource(img); + LuminanceSource src = baseSrc; + if (invert) { + try { + src = baseSrc.invert(); + } catch (Exception ignored) {} + } + + // Hybrid(光照不均好) + try { + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(src)); + Result r = allowMulti + ? safeMultiDecode(bitmap) + : new MultiFormatReader().decode(bitmap, HINTS); + if (r != null && r.getText() != null && !r.getText().trim().isEmpty()) { + return r.getText().trim(); + } + } catch (ReaderException ignored) {} + + // GlobalHistogram(高对比/手机照好) + try { + BinaryBitmap bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(src)); + Result r = allowMulti + ? safeMultiDecode(bitmap) + : new MultiFormatReader().decode(bitmap, HINTS); + if (r != null && r.getText() != null && !r.getText().trim().isEmpty()) { + return r.getText().trim(); + } + } catch (ReaderException ignored) {} + + return null; + } + + /** + * QRCodeMultiReader:内部靠 FinderPattern(1:1:3:1:1)真正定位 QR 位置 + * 允许一图有多个 QR,返回第一个非空结果。 + */ + private Result safeMultiDecode(BinaryBitmap bitmap) { + try { + Result[] results = new QRCodeMultiReader().decodeMultiple(bitmap, HINTS); + if (results != null) { + for (Result r : results) { + if (r != null && r.getText() != null && !r.getText().trim().isEmpty()) { + return r; + } + } + } + } catch (ReaderException ignored) {} + return null; + } + + // ==================== 图像辅助 ==================== + + /** 缩放,保持比例,最大边不超过 maxSide(只缩小不放大) */ + private BufferedImage fitMaxSide(BufferedImage src, int maxSide) { + int w = src.getWidth(); + int h = src.getHeight(); + int longer = Math.max(w, h); + if (longer <= maxSide) return src; + double scale = (double) maxSide / longer; + return scaleImage(src, scale); + } + + /** 缩放到 minSide,保持比例(只放大不缩小) */ + private BufferedImage ensureMinSize(BufferedImage src, int minSide) { + int w = src.getWidth(); + int h = src.getHeight(); + int side = Math.min(w, h); + if (side >= minSide) return src; + double scale = (double) minSide / side; + return scaleImage(src, scale); + } + + private BufferedImage scaleImage(BufferedImage src, double scale) { + int w = src.getWidth(); + int h = src.getHeight(); + int nw = Math.max(40, (int) Math.round(w * scale)); + int nh = Math.max(40, (int) Math.round(h * scale)); + BufferedImage dst = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB); + Graphics2D g = dst.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g.drawImage(src, 0, 0, nw, nh, null); + } finally { + g.dispose(); + } + return dst; + } + + /** 旋转 0/90/180/270 度 */ + private BufferedImage rotate(BufferedImage src, int degrees) { + int w = src.getWidth(); + int h = src.getHeight(); + int nw = (degrees == 90 || degrees == 270) ? h : w; + int nh = (degrees == 90 || degrees == 270) ? w : h; + AffineTransform at = AffineTransform.getRotateInstance( + Math.toRadians(degrees), nw / 2.0, nh / 2.0); + // 90/270 需要平移回正(旋转中心按新图算) + if (degrees == 90) at.translate(-(nh - nw) / 2.0, -(nw - nh) / 2.0); + if (degrees == 270) at.translate( -(nh - nw) / 2.0, -(nw - nh) / 2.0); + + BufferedImage dst = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB); + Graphics2D g = dst.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setColor(Color.WHITE); + g.fillRect(0, 0, nw, nh); + g.setTransform(at); + g.drawImage(src, 0, 0, null); + } finally { + g.dispose(); + } + return dst; + } + + private static String shorten(String s) { + if (s == null) return null; + return s.length() <= 48 ? s : s.substring(0, 48) + "..."; + } +} diff --git a/campus-server/src/main/java/com/campus/service/QrScanService.java b/campus-server/src/main/java/com/campus/service/QrScanService.java new file mode 100644 index 0000000..81c7514 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/QrScanService.java @@ -0,0 +1,90 @@ +package com.campus.service; + +import com.campus.dto.response.ActivityDTO; +import com.campus.dto.response.QrScanResultDTO; +import com.campus.dto.response.UserProfileDTO; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 将二维码文本解析为青核业务路由。 */ +@Service +@RequiredArgsConstructor +public class QrScanService { + + private static final String ID = "([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"; + private static final Pattern USER_PREFIX = Pattern.compile("^YOUTHOME_USER:" + ID + "$", Pattern.CASE_INSENSITIVE); + private static final Pattern ACTIVITY_PREFIX = Pattern.compile("^YOUTHOME_ACTIVITY:" + ID + "$", Pattern.CASE_INSENSITIVE); + private static final Pattern USER_ROUTE = Pattern.compile("(?:youthome://|https?://[^/]+/)(?:user|users)/" + ID + "(?:[/?#].*)?$", Pattern.CASE_INSENSITIVE); + private static final Pattern ACTIVITY_ROUTE = Pattern.compile("(?:youthome://|https?://[^/]+/)(?:activity|activities)/" + ID + "(?:[/?#].*)?$", Pattern.CASE_INSENSITIVE); + + private final QrCodeDecoder qrCodeDecoder; + private final UserRepository userRepository; + private final FriendService friendService; + private final ActivityService activityService; + + public QrScanResultDTO decodeAndResolve(org.springframework.web.multipart.MultipartFile file) { + String text = qrCodeDecoder.decode(file); + if (!StringUtils.hasText(text)) { + throw new RuntimeException("未识别到二维码,请重新拍摄清晰图片"); + } + return resolve(text); + } + + public QrScanResultDTO resolve(String rawText) { + if (!StringUtils.hasText(rawText)) { + throw new RuntimeException("二维码内容不能为空"); + } + String text = rawText.trim(); + String userId = match(USER_PREFIX, text); + if (userId == null) userId = match(USER_ROUTE, text); + if (userId != null) return resolveUser(text, userId); + + String activityId = match(ACTIVITY_PREFIX, text); + if (activityId == null) activityId = match(ACTIVITY_ROUTE, text); + if (activityId != null) return resolveActivity(text, activityId); + + throw new RuntimeException("不支持的青核二维码类型"); + } + + private QrScanResultDTO resolveUser(String text, String userId) { + if (!userRepository.existsById(userId)) { + throw new RuntimeException("二维码对应的用户不存在"); + } + UserProfileDTO profile = friendService.getUserProfile(userId); + QrScanResultDTO result = base("USER", userId, text, "/users/profile?userId=" + userId); + result.setUser(profile); + return result; + } + + private QrScanResultDTO resolveActivity(String text, String activityId) { + String currentUserId = UserContext.getCurrentUserId(); + User current = currentUserId == null ? null : userRepository.findById(currentUserId).orElse(null); + String school = current == null ? null : current.getUniversityName(); + ActivityDTO activity = activityService.getById(activityId, currentUserId, school); + QrScanResultDTO result = base("ACTIVITY", activityId, text, "/campus/activities/detail?id=" + activityId); + result.setActivity(activity); + return result; + } + + private QrScanResultDTO base(String type, String targetId, String text, String route) { + QrScanResultDTO result = new QrScanResultDTO(); + result.setType(type); + result.setTargetId(targetId); + result.setOriginalText(text); + result.setRoute(route); + return result; + } + + private String match(Pattern pattern, String text) { + Matcher matcher = pattern.matcher(text); + return matcher.matches() ? matcher.group(1) : null; + } + +} diff --git a/campus-server/src/main/java/com/campus/service/RadarService.java b/campus-server/src/main/java/com/campus/service/RadarService.java new file mode 100644 index 0000000..99c7e9d --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/RadarService.java @@ -0,0 +1,385 @@ +package com.campus.service; + +import com.campus.dto.response.RadarDTO; +import com.campus.entity.Activity; +import com.campus.entity.Friendship; +import com.campus.entity.Greeting; +import com.campus.entity.User; +import com.campus.repository.ActivityRepository; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.GreetingRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 校园雷达服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RadarService { + + @org.springframework.beans.factory.annotation.Value("${app.activity-location.radar-coordinates-verified:false}") + private boolean radarCoordinatesVerified; + + private final UserRepository userRepository; + private final ActivityRepository activityRepository; + private final FriendshipRepository friendshipRepository; + private final GreetingRepository greetingRepository; + private final PrivacySettingsService privacySettingsService; + + /** + * 更新用户位置 + */ + @Transactional + public void updateLocation(Double latitude, Double longitude) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + throw new RuntimeException("用户未登录"); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("用户不存在")); + + user.setLatitude(latitude); + user.setLongitude(longitude); + userRepository.save(user); + + log.info("更新用户 {} 位置: {}, {}", userId, latitude, longitude); + } + + /** + * 扫描附近用户和活动 + * + * @param mode 模式:nearby-附近, school-校内 + * @param radius 半径(米) + * @return 扫描结果 + */ + public RadarDTO scan(String mode, Integer radius) { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new RuntimeException("用户未登录"); + } + + User currentUser = userRepository.findById(currentUserId) + .orElseThrow(() -> new RuntimeException("用户不存在")); + + if (currentUser.getLatitude() == null || currentUser.getLongitude() == null) { + throw new RuntimeException("请先开启位置共享"); + } + + RadarDTO result = new RadarDTO(); + + // 获取附近用户 + List nearbyUsers = findNearbyUsers( + currentUser, + mode, + radius != null ? radius : 500 + ); + result.setNearbyUsers(nearbyUsers); + + // 获取附近活动 + List nearbyActivities = findNearbyActivities( + currentUser, + mode, + radius != null ? radius : 500 + ); + result.setNearbyActivities(nearbyActivities); + + return result; + } + + /** + * 查找附近用户 + */ + private List findNearbyUsers(User currentUser, String mode, int radius) { + List nearbyUsers; + + if ("school".equals(mode)) { + // 校内模式:同校用户 + nearbyUsers = userRepository.findNearbyUsersBySchool( + currentUser.getUniversityName(), + currentUser.getId(), + currentUser.getLatitude(), + currentUser.getLongitude(), + radius + ); + } else { + // 附近模式:所有附近用户 + nearbyUsers = userRepository.findNearbyUsers( + currentUser.getId(), + currentUser.getLatitude(), + currentUser.getLongitude(), + radius + ); + } + + // 查询当前用户的好友ID集合(双向查询) + Set friendIds = new HashSet<>(); + List sentFriends = friendshipRepository.findByUserIdAndStatus(currentUser.getId(), "ACCEPTED"); + List receivedFriends = friendshipRepository.findByFriendIdAndStatus(currentUser.getId(), "ACCEPTED"); + for (Friendship f : sentFriends) { + friendIds.add(f.getFriendId()); + } + for (Friendship f : receivedFriends) { + friendIds.add(f.getUserId()); + } + + // 查询当前用户已打招呼的目标用户ID集合 + Set greetedIds = new HashSet<>(); + List nearbyUserIds = nearbyUsers.stream().map(User::getId).collect(Collectors.toList()); + if (!nearbyUserIds.isEmpty()) { + List greetings = greetingRepository.findByUserIdAndTargetUserIdIn(currentUser.getId(), nearbyUserIds); + for (Greeting g : greetings) { + greetedIds.add(g.getTargetUserId()); + } + } + + return nearbyUsers.stream() + .filter(user -> !privacySettingsService.isBlocked(currentUser.getId(), user.getId())) + .filter(user -> privacySettingsService.isLocationVisible(user.getId())) + .map(user -> convertToRadarUserDTO(user, currentUser, friendIds.contains(user.getId()), greetedIds.contains(user.getId()))) + .collect(Collectors.toList()); + } + + /** + * 查找附近活动 + */ + private List findNearbyActivities(User currentUser, String mode, int radius) { + List activities; + + if (radarCoordinatesVerified) { + activities = "school".equals(mode) + ? activityRepository.findNearbyActivitiesAtVenueBySchool(currentUser.getUniversityName(), currentUser.getId(), currentUser.getLatitude(), currentUser.getLongitude(), radius) + : activityRepository.findNearbyActivitiesAtVenue(currentUser.getId(), currentUser.getLatitude(), currentUser.getLongitude(), radius); + } else if ("school".equals(mode)) { + // 校内模式:同校用户创建的活动 + activities = activityRepository.findNearbyActivitiesBySchool( + currentUser.getUniversityName(), + currentUser.getId(), + currentUser.getLatitude(), + currentUser.getLongitude(), + radius + ); + } else { + // 附近模式:附近用户创建的活动 + activities = activityRepository.findNearbyActivities( + currentUser.getId(), + currentUser.getLatitude(), + currentUser.getLongitude(), + radius + ); + } + + return activities.stream() + .map(activity -> convertToRadarActivityDTO(activity, currentUser)) + .collect(Collectors.toList()); + } + + /** + * 转换用户为雷达用户DTO + */ + private RadarDTO.RadarUserDTO convertToRadarUserDTO(User user, User currentUser, boolean isFriend, boolean greeted) { + RadarDTO.RadarUserDTO dto = new RadarDTO.RadarUserDTO(); + dto.setId(user.getId()); + dto.setNickname(user.getNickname()); + dto.setAvatar(user.getAvatar()); + dto.setSchool(user.getUniversityName()); + dto.setGrade(user.getGrade()); + dto.setMajor(user.getMajorName()); + dto.setInterests(user.getInterests() != null ? user.getInterests() : new ArrayList<>()); + + // 计算距离 + double distance = calculateDistance( + currentUser.getLatitude(), currentUser.getLongitude(), + user.getLatitude(), user.getLongitude() + ); + dto.setDistance((int) distance); + + // 计算方向 + int direction = calculateDirection( + currentUser.getLatitude(), currentUser.getLongitude(), + user.getLatitude(), user.getLongitude() + ); + dto.setDirection(direction); + + // 判断是否在线(5分钟内活跃视为在线) + boolean isOnline = isUserOnline(user); + dto.setIsOnline(isOnline); + + // 最后活跃时间 + dto.setLastActive(formatLastActive(user.getUpdatedAt())); + + // 是否已是好友 + dto.setIsFriend(isFriend); + + // 是否已打过招呼 + dto.setGreeted(greeted); + + return dto; + } + + /** + * 转换活动为雷达活动DTO + */ + private RadarDTO.RadarActivityDTO convertToRadarActivityDTO(Activity activity, User currentUser) { + RadarDTO.RadarActivityDTO dto = new RadarDTO.RadarActivityDTO(); + dto.setId(activity.getId()); + dto.setTitle(activity.getTitle()); + dto.setType(activity.getType()); + dto.setCurrentCount(activity.getCurrentCount() != null ? activity.getCurrentCount() : 0); + dto.setMaxCount(activity.getMaxCount()); + dto.setLocation(activity.getLocation()); + + // 核实坐标系后使用举办地点;关闭时保留原版本行为以便灰度与回退。 + Double latitude = activity.getLatitude(), longitude = activity.getLongitude(); + if (!radarCoordinatesVerified) { + User creator = userRepository.findById(activity.getCreatorId()).orElse(null); + latitude = creator == null ? null : creator.getLatitude(); + longitude = creator == null ? null : creator.getLongitude(); + } else { + dto.setLatitude(latitude); + dto.setLongitude(longitude); + dto.setCoordinateSystem("GCJ02"); + } + if (latitude != null && longitude != null) { + dto.setDistance((int) calculateDistance(currentUser.getLatitude(), currentUser.getLongitude(), latitude, longitude)); + dto.setDirection(calculateDirection(currentUser.getLatitude(), currentUser.getLongitude(), latitude, longitude)); + } + + // 格式化时间 + dto.setTime(formatActivityTime(activity.getEventTime())); + + return dto; + } + + /** + * 计算两点之间的距离(Haversine公式) + * + * @return 距离(米) + */ + private double calculateDistance(double lat1, double lon1, double lat2, double lon2) { + final int R = 6371000; // 地球半径(米) + + double lat1Rad = Math.toRadians(lat1); + double lat2Rad = Math.toRadians(lat2); + double deltaLatRad = Math.toRadians(lat2 - lat1); + double deltaLonRad = Math.toRadians(lon2 - lon1); + + double a = Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) + + Math.cos(lat1Rad) * Math.cos(lat2Rad) + * Math.sin(deltaLonRad / 2) * Math.sin(deltaLonRad / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; + } + + /** + * 计算方向角度(方位角) + * + * @return 方向角度(0-360,0=北,90=东,180=南,270=西) + */ + private int calculateDirection(double lat1, double lon1, double lat2, double lon2) { + double lat1Rad = Math.toRadians(lat1); + double lat2Rad = Math.toRadians(lat2); + double deltaLonRad = Math.toRadians(lon2 - lon1); + + double y = Math.sin(deltaLonRad) * Math.cos(lat2Rad); + double x = Math.cos(lat1Rad) * Math.sin(lat2Rad) + - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(deltaLonRad); + + double bearing = Math.atan2(y, x); + bearing = Math.toDegrees(bearing); + + // 转换为 0-360 范围 + return (int) ((bearing + 360) % 360); + } + + /** + * 判断用户是否在线(5分钟内活跃) + */ + private boolean isUserOnline(User user) { + if (user.getUpdatedAt() == null) { + return false; + } + + LocalDateTime lastActive = LocalDateTime.ofInstant( + user.getUpdatedAt().toInstant(), + ZoneId.systemDefault() + ); + LocalDateTime now = LocalDateTime.now(); + + return Duration.between(lastActive, now).toMinutes() <= 5; + } + + /** + * 格式化最后活跃时间 + */ + private String formatLastActive(Date date) { + if (date == null) { + return "未知"; + } + + LocalDateTime lastActive = LocalDateTime.ofInstant( + date.toInstant(), + ZoneId.systemDefault() + ); + LocalDateTime now = LocalDateTime.now(); + + long minutes = Duration.between(lastActive, now).toMinutes(); + + if (minutes < 1) { + return "刚刚"; + } else if (minutes < 60) { + return minutes + "分钟前"; + } else if (minutes < 1440) { + return (minutes / 60) + "小时前"; + } else { + return (minutes / 1440) + "天前"; + } + } + + /** + * 格式化活动时间 + */ + private String formatActivityTime(Date date) { + if (date == null) { + return "待定"; + } + + LocalDateTime eventTime = LocalDateTime.ofInstant( + date.toInstant(), + ZoneId.systemDefault() + ); + LocalDateTime now = LocalDateTime.now(); + + // 如果是今天 + if (eventTime.toLocalDate().equals(now.toLocalDate())) { + return String.format("今天 %02d:%02d", eventTime.getHour(), eventTime.getMinute()); + } + + // 如果是明天 + if (eventTime.toLocalDate().equals(now.toLocalDate().plusDays(1))) { + return String.format("明天 %02d:%02d", eventTime.getHour(), eventTime.getMinute()); + } + + // 其他日期 + return String.format("%d月%d日 %02d:%02d", + eventTime.getMonthValue(), + eventTime.getDayOfMonth(), + eventTime.getHour(), + eventTime.getMinute()); + } +} diff --git a/campus-server/src/main/java/com/campus/service/RecommendService.java b/campus-server/src/main/java/com/campus/service/RecommendService.java new file mode 100644 index 0000000..7bf97d6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/RecommendService.java @@ -0,0 +1,120 @@ +package com.campus.service; + +import com.campus.dto.response.FriendDTO; +import com.campus.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 好友推荐算法(基于多维度权重) + */ +@Service +@RequiredArgsConstructor +public class RecommendService { + + /** 权重常量(合计满分 110,归一化到 100) */ + private static final int W_SAME_SCHOOL = 30; + private static final int W_SAME_MAJOR = 20; + private static final int W_SAME_HOMETOWN = 25; + private static final int W_SAME_AGE = 10; + private static final int W_COMMON_INTEREST_PER = 5; + private static final int W_COMMON_INTEREST_MAX = 20; + private static final int W_OPPOSITE_GENDER = 5; + private static final int TOTAL_WEIGHT = 110; + + /** + * 计算匹配度 + * + * @param me 当前用户 + * @param other 候选用户 + * @return DTO(仅含 matchScore / matchReasons) + */ + public FriendDTO score(User me, User other) { + FriendDTO dto = new FriendDTO(); + List reasons = new ArrayList<>(); + int score = 0; + + // 同校 + if (me.getUniversityName() != null && me.getUniversityName().equals(other.getUniversityName())) { + score += W_SAME_SCHOOL; + reasons.add("同校:" + other.getUniversityName()); + } + // 同专业 + if (me.getMajorName() != null && me.getMajorName().equalsIgnoreCase(other.getMajorName())) { + score += W_SAME_MAJOR; + reasons.add("同专业:" + other.getMajorName()); + } + // 同老家 + if (isNotBlank(me.getHometown()) && me.getHometown().equals(other.getHometown())) { + score += W_SAME_HOMETOWN; + reasons.add("同老家:" + other.getHometown()); + } + // 同龄(±2岁) + if (me.getBirthday() != null && other.getBirthday() != null) { + int diff = Math.abs(ageOf(me.getBirthday()) - ageOf(other.getBirthday())); + if (diff <= 2) { + score += W_SAME_AGE; + reasons.add("年龄相仿"); + } + } + // 共同兴趣 + if (me.getInterests() != null && other.getInterests() != null) { + Set mine = new HashSet<>(me.getInterests()); + int common = 0; + for (String i : other.getInterests()) { + if (mine.contains(i)) { + common++; + } + } + if (common > 0) { + int add = Math.min(common * W_COMMON_INTEREST_PER, W_COMMON_INTEREST_MAX); + score += add; + reasons.add("共同兴趣 " + common + " 个"); + } + } + // 异性(且双方均非保密) + if (isOppositeGender(me.getGender(), other.getGender())) { + score += W_OPPOSITE_GENDER; + reasons.add("异性"); + } + + // 归一化 + int normalized = (int) Math.round(score * 100.0 / TOTAL_WEIGHT); + if (normalized > 100) { + normalized = 100; + } + dto.setMatchScore(normalized); + dto.setMatchReasons(reasons); + return dto; + } + + private boolean isOppositeGender(String a, String b) { + if (a == null || b == null) { + return false; + } + return ("M".equalsIgnoreCase(a) && "F".equalsIgnoreCase(b)) + || ("F".equalsIgnoreCase(a) && "M".equalsIgnoreCase(b)); + } + + private int ageOf(Date birthday) { + Calendar now = Calendar.getInstance(); + Calendar born = Calendar.getInstance(); + born.setTime(birthday); + int age = now.get(Calendar.YEAR) - born.get(Calendar.YEAR); + if (now.get(Calendar.DAY_OF_YEAR) < born.get(Calendar.DAY_OF_YEAR)) { + age--; + } + return age; + } + + private boolean isNotBlank(String s) { + return s != null && !s.isEmpty() && !s.trim().isEmpty(); + } +} diff --git a/campus-server/src/main/java/com/campus/service/RedisRetryExecutor.java b/campus-server/src/main/java/com/campus/service/RedisRetryExecutor.java new file mode 100644 index 0000000..85d2eb8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/RedisRetryExecutor.java @@ -0,0 +1,49 @@ +package com.campus.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Component; + +import java.util.function.Supplier; + +/** + * 对可安全重放的 Redis 操作做一次短重试。 + * 主要处理云 Redis 空闲连接被服务端回收后,连接池首次借出旧连接的场景。 + */ +@Slf4j +@Component +public class RedisRetryExecutor { + private static final long RETRY_DELAY_MS = 100L; + + public T execute(String operation, Supplier supplier) { + try { + return supplier.get(); + } catch (DataAccessException first) { + log.warn("Redis 操作首次失败,准备重试: operation={}, cause={}", + operation, rootMessage(first)); + pauseBriefly(); + return supplier.get(); + } + } + + public void execute(String operation, Runnable runnable) { + execute(operation, () -> { + runnable.run(); + return null; + }); + } + + private void pauseBriefly() { + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + + private String rootMessage(Throwable error) { + Throwable current = error; + while (current.getCause() != null) current = current.getCause(); + return current.getMessage(); + } +} diff --git a/campus-server/src/main/java/com/campus/service/ReportService.java b/campus-server/src/main/java/com/campus/service/ReportService.java new file mode 100644 index 0000000..5f4cca1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ReportService.java @@ -0,0 +1,206 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.ReportRequest; +import com.campus.dto.response.ReportDTO; +import com.campus.entity.Report; +import com.campus.repository.ActivityRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.ReportRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Date; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private static final List TARGET_TYPES = Arrays.asList( + "MOMENT", "ACTIVITY", "ANNOUNCEMENT", "USER", "TOPIC"); + + private final ReportRepository reportRepository; + private final MomentRepository momentRepository; + private final ActivityRepository activityRepository; + private final AnnouncementRepository announcementRepository; + private final UserRepository userRepository; + private final com.campus.repository.TopicRepository topicRepository; + private final NotificationService notificationService; + private final AuthorizationService authorizationService; + private final AuditLogService auditLogService; + + @Transactional + public ReportDTO create(ReportRequest request) { + String reporterId = UserContext.getCurrentUserId(); + if (reporterId == null) { + throw new BusinessException("请先登录"); + } + + String targetType = request.getTargetType().trim().toUpperCase(Locale.ROOT); + String targetId = request.getTargetId().trim(); + if (!TARGET_TYPES.contains(targetType)) { + throw new BusinessException("不支持的举报对象类型"); + } + if (reporterId.equals(targetId) && "USER".equals(targetType)) { + throw new BusinessException("不能举报自己"); + } + if (!targetExists(targetType, targetId)) { + throw new BusinessException("举报对象不存在"); + } + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetIdAndStatusIn( + reporterId, targetType, targetId, Arrays.asList("PENDING", "ACCEPTED"))) { + throw new BusinessException("你已经举报过该对象"); + } + + Report report = new Report(); + report.setReporterId(reporterId); + report.setTargetType(targetType); + report.setTargetId(targetId); + report.setReason(request.getReason().trim()); + report.setDescription(request.getDescription()); + report.setStatus("PENDING"); + return toDTO(reportRepository.save(report)); + } + + @Transactional(readOnly = true) + public List listMine() { + String reporterId = UserContext.getCurrentUserId(); + if (reporterId == null) { + throw new BusinessException("请先登录"); + } + return reportRepository.findByReporterIdOrderByCreatedAtDesc(reporterId) + .stream().map(this::toDTO).collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public PageResult listPending(int page, int size) { + requireReviewer(); + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), 100); + Pageable pageable = PageRequest.of(safePage - 1, safeSize); + Page reports = reportRepository.findByStatusOrderByCreatedAtAsc("PENDING", pageable); + return PageResult.from(reports.map(this::toDTO)); + } + + @Transactional + public ReportDTO review(String id, String status, String action, String note) { + requireReviewer(); + String normalizedStatus = status == null ? "" : status.trim().toUpperCase(Locale.ROOT); + if (!"ACCEPTED".equals(normalizedStatus) && !"REJECTED".equals(normalizedStatus)) { + throw new BusinessException("审核状态必须是 ACCEPTED 或 REJECTED"); + } + String normalizedAction = action == null || action.trim().isEmpty() + ? "NONE" : action.trim().toUpperCase(Locale.ROOT); + if (!"NONE".equals(normalizedAction) && !"HIDE_CONTENT".equals(normalizedAction)) { + throw new BusinessException("处理动作必须是 NONE 或 HIDE_CONTENT"); + } + + Report report = reportRepository.findById(id) + .orElseThrow(() -> new BusinessException("举报记录不存在")); + if (!"PENDING".equals(report.getStatus())) { + throw new BusinessException("该举报已处理"); + } + if (!"ACCEPTED".equals(normalizedStatus)) { + normalizedAction = "NONE"; + } + if ("HIDE_CONTENT".equals(normalizedAction) && "USER".equals(report.getTargetType())) { + throw new BusinessException("举报用户暂不支持自动封禁,请选择 NONE"); + } + String reviewerId = UserContext.getCurrentUserId(); + report.setStatus(normalizedStatus); + report.setAction(normalizedAction); + report.setReviewedBy(reviewerId); + report.setReviewNote(note); + report.setReviewedAt(new Date()); + if ("HIDE_CONTENT".equals(normalizedAction)) { + hideContent(report.getTargetType(), report.getTargetId()); + } + report = reportRepository.save(report); + auditLogService.record("REVIEW_REPORT", "REPORT", report.getId(), "status=PENDING", + "status=" + normalizedStatus + ",action=" + normalizedAction, note); + + String title = "ACCEPTED".equals(normalizedStatus) ? "举报已受理" : "举报未通过"; + String content = "ACCEPTED".equals(normalizedStatus) + ? "你提交的举报已受理,平台将根据规则处理。" + : "你提交的举报未通过审核" + (note == null || note.trim().isEmpty() ? "。" : ":" + note.trim()); + notificationService.createNotification( + report.getReporterId(), "REPORT_RESULT", title, content, + null, "/reports/" + report.getId(), report.getId(), reviewerId); + return toDTO(report); + } + + private void hideContent(String targetType, String targetId) { + switch (targetType) { + case "MOMENT": + momentRepository.findById(targetId).ifPresent(item -> { + item.setDeleted(true); + momentRepository.save(item); + }); + break; + case "ACTIVITY": + activityRepository.findById(targetId).ifPresent(item -> { + item.setDeleted(true); + activityRepository.save(item); + }); + break; + case "ANNOUNCEMENT": + announcementRepository.findById(targetId).ifPresent(item -> { + item.setDeleted(true); + announcementRepository.save(item); + }); + break; + default: + throw new BusinessException("该举报对象不支持内容下架"); + } + } + + private void requireReviewer() { + authorizationService.requireReviewerOrAdmin(); + } + + private boolean targetExists(String targetType, String targetId) { + switch (targetType) { + case "MOMENT": + return momentRepository.existsById(targetId); + case "ACTIVITY": + return activityRepository.existsById(targetId); + case "ANNOUNCEMENT": + return announcementRepository.existsById(targetId); + case "USER": + return userRepository.existsById(targetId); + case "TOPIC": + return topicRepository.existsById(targetId); + default: + return false; + } + } + + private ReportDTO toDTO(Report report) { + ReportDTO dto = new ReportDTO(); + dto.setId(report.getId()); + dto.setReporterId(report.getReporterId()); + dto.setTargetType(report.getTargetType()); + dto.setTargetId(report.getTargetId()); + dto.setReason(report.getReason()); + dto.setDescription(report.getDescription()); + dto.setStatus(report.getStatus()); + dto.setAction(report.getAction()); + dto.setReviewedBy(report.getReviewedBy()); + dto.setReviewNote(report.getReviewNote()); + dto.setCreatedAt(report.getCreatedAt()); + dto.setReviewedAt(report.getReviewedAt()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/ScheduledPublishingService.java b/campus-server/src/main/java/com/campus/service/ScheduledPublishingService.java new file mode 100644 index 0000000..f3e40ae --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ScheduledPublishingService.java @@ -0,0 +1,33 @@ +package com.campus.service; + +import com.campus.entity.Announcement; +import com.campus.entity.Moment; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.MomentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; + +@Service +@RequiredArgsConstructor +public class ScheduledPublishingService { + private final MomentRepository momentRepository; + private final AnnouncementRepository announcementRepository; + + @Scheduled(cron = "0 * * * * ?") + @Transactional + public void publishDueContent() { + Date now = new Date(); + for (Moment moment : momentRepository.findByReviewStatusAndScheduledPublishAtBefore("scheduled", now)) { + moment.setReviewStatus("approved"); + momentRepository.save(moment); + } + for (Announcement announcement : announcementRepository.findByStatusAndScheduledPublishAtBefore("scheduled", now)) { + announcement.setStatus("approved"); + announcementRepository.save(announcement); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/SensitiveWordService.java b/campus-server/src/main/java/com/campus/service/SensitiveWordService.java new file mode 100644 index 0000000..494ea7d --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/SensitiveWordService.java @@ -0,0 +1,92 @@ +package com.campus.service; + +import com.campus.entity.SensitiveWord; +import com.campus.repository.SensitiveWordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.PostConstruct; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SensitiveWordService { + + private final SensitiveWordRepository sensitiveWordRepository; + private final RedisTemplate redisTemplate; + private final AuditLogService auditLogService; + private final RedisRetryExecutor redisRetryExecutor; + + private static final String SENSITIVE_WORDS_CACHE_KEY = "sensitive:words"; + + @PostConstruct + public void init() { + refreshCache(); + } + + public Page getAllWords(Pageable pageable) { + return sensitiveWordRepository.findAll(pageable); + } + + @Transactional + public SensitiveWord addWord(SensitiveWord word) { + SensitiveWord saved = sensitiveWordRepository.save(word); + auditLogService.record("CREATE_SENSITIVE_WORD", "SENSITIVE_WORD", saved.getId(), null, + "enabled=" + saved.getEnabled() + ",level=" + saved.getLevel(), null); + refreshCache(); + return saved; + } + + @Transactional + public void deleteWord(String id) { + SensitiveWord existing = sensitiveWordRepository.findById(id) + .orElseThrow(() -> new com.campus.common.BusinessException("敏感词不存在")); + sensitiveWordRepository.delete(existing); + auditLogService.record("DELETE_SENSITIVE_WORD", "SENSITIVE_WORD", id, + "enabled=" + existing.getEnabled() + ",level=" + existing.getLevel(), "deleted=true", null); + refreshCache(); + } + + @Transactional + public SensitiveWord updateWord(String id, SensitiveWord word) { + SensitiveWord existing = sensitiveWordRepository.findById(id) + .orElseThrow(() -> new com.campus.common.BusinessException("敏感词不存在")); + String before = "enabled=" + existing.getEnabled() + ",level=" + existing.getLevel(); + word.setId(id); + SensitiveWord updated = sensitiveWordRepository.save(word); + auditLogService.record("UPDATE_SENSITIVE_WORD", "SENSITIVE_WORD", id, before, + "enabled=" + updated.getEnabled() + ",level=" + updated.getLevel(), null); + refreshCache(); + return updated; + } + + public List getEnabledWords() { + try { + @SuppressWarnings("unchecked") + List cached = redisRetryExecutor.execute("sensitive-words-read", + () -> (List) redisTemplate.opsForValue().get(SENSITIVE_WORDS_CACHE_KEY)); + if (cached != null) return cached; + } catch (RuntimeException ex) { + log.warn("敏感词缓存读取失败,降级查询数据库: {}", ex.getMessage()); + } + return refreshCache(); + } + + private List refreshCache() { + List words = sensitiveWordRepository.findByEnabledTrue(); + try { + redisRetryExecutor.execute("sensitive-words-write", + () -> redisTemplate.opsForValue().set(SENSITIVE_WORDS_CACHE_KEY, words, 1, TimeUnit.HOURS)); + } catch (RuntimeException ex) { + log.warn("敏感词缓存写入失败,继续使用数据库结果: {}", ex.getMessage()); + } + return words; + } +} diff --git a/campus-server/src/main/java/com/campus/service/ShareRequestIdempotencyService.java b/campus-server/src/main/java/com/campus/service/ShareRequestIdempotencyService.java new file mode 100644 index 0000000..2c13e51 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ShareRequestIdempotencyService.java @@ -0,0 +1,153 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ServiceUnavailableException; +import com.campus.common.TooManyRequestsException; +import com.campus.common.ForbiddenException; +import com.campus.entity.ShareRequestRecord; +import com.campus.repository.ShareRequestRecordRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.UUID; + +/** 分享写操作的持久化请求去重。失败或结果未知时不自动重放远端投递。 */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ShareRequestIdempotencyService { + + // 好友和群聊共用命名空间,同一请求标识不能被两种发送接口复用。 + public static final String TYPE_FRIENDS = "SHARE_SEND"; + public static final String TYPE_GROUP = "SHARE_SEND"; + public static final String TYPE_MOMENT_COUNT = "MOMENT_COUNT"; + public static final String REQUEST_IN_PROGRESS_MESSAGE = "分享请求正在处理中,请勿重复提交"; + public static final String REQUEST_KEY_CONFLICT_MESSAGE = "分享请求标识已被其他请求使用"; + + private final ShareRequestRecordRepository repository; + private final ObjectMapper objectMapper; + + @Transactional + public Claim claim(String senderId, String requestType, String requestId, String requestHash) { + String normalizedId = requestId == null ? null : requestId.trim(); + if (normalizedId == null || !normalizedId.matches("[A-Za-z0-9._:-]{1,64}")) { + throw new BusinessException("分享请求标识格式不正确"); + } + int inserted = repository.insertIfAbsent(UUID.randomUUID().toString(), senderId, + requestType, normalizedId, requestHash); + ShareRequestRecord record = repository.findForUpdate(senderId, requestType, normalizedId) + .orElseThrow(() -> new IllegalStateException("分享请求记录创建失败")); + if (!requestHash.equals(record.getRequestHash())) { + throw new BusinessException(REQUEST_KEY_CONFLICT_MESSAGE); + } + + if ("COMPLETED".equals(record.getStatus())) { + return new Claim(false, true, record.getId(), record.getResponseJson()); + } + if ("FAILED_BUSINESS".equals(record.getStatus())) { + throw new BusinessException(record.getErrorMessage()); + } + if ("FAILED_UNAVAILABLE".equals(record.getStatus())) { + throw new ServiceUnavailableException(record.getErrorMessage()); + } + if ("FAILED_FORBIDDEN".equals(record.getStatus())) { + throw new ForbiddenException(record.getErrorMessage()); + } + if ("PROCESSING".equals(record.getStatus()) && inserted == 1) { + return new Claim(true, false, record.getId(), null); + } + if ("PROCESSING".equals(record.getStatus())) { + throw new TooManyRequestsException(REQUEST_IN_PROGRESS_MESSAGE); + } + throw new BusinessException("分享请求状态不可用,请发起新的分享"); + } + + @Transactional + public void complete(String recordId, Object response) { + ShareRequestRecord record = lockById(recordId); + try { + record.setResponseJson(objectMapper.writeValueAsString(response)); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("分享结果序列化失败", ex); + } + record.setStatus("COMPLETED"); + record.setErrorType(null); + record.setErrorMessage(null); + repository.save(record); + } + + /** 每个接收目标结束后单独保存已知结果;未知状态仍保持 PROCESSING,禁止自动重新发送。 */ + @Transactional + public void checkpoint(String recordId, Object response) { + ShareRequestRecord record = lockById(recordId); + try { + record.setResponseJson(objectMapper.writeValueAsString(response)); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("分享结果序列化失败", ex); + } + repository.save(record); + } + + @Transactional + public void fail(String recordId, String errorType, String message) { + ShareRequestRecord record = lockById(recordId); + record.setStatus("FORBIDDEN".equals(errorType) ? "FAILED_FORBIDDEN" + : "BUSINESS".equals(errorType) ? "FAILED_BUSINESS" : "FAILED_UNAVAILABLE"); + record.setErrorType(errorType); + record.setErrorMessage(message == null ? "分享服务暂不可用,请稍后重试" : message); + repository.save(record); + } + + /** 完成记录保留 90 天;处理中记录不自动删除,避免未知远端结果被重复投递。 */ + @Scheduled(cron = "0 40 3 * * ?", zone = "Asia/Shanghai") + @Transactional + public void cleanupExpiredRecords() { + int deleted = repository.deleteExpiredCompletedRecords(); + if (deleted > 0) log.info("已清理过期分享幂等记录: count={}", deleted); + } + + public static String sha256(String value) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(64); + for (byte b : bytes) hex.append(String.format("%02x", b)); + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("无法生成分享请求摘要", ex); + } + } + + private ShareRequestRecord lockById(String recordId) { + return repository.findByIdForUpdate(recordId) + .orElseThrow(() -> new IllegalStateException("分享请求记录不存在")); + } + + public static final class Claim { + private final boolean newRequest; + private final boolean completed; + private final String recordId; + private final String responseJson; + + Claim(boolean newRequest, boolean completed, String recordId, String responseJson) { + this.newRequest = newRequest; + this.completed = completed; + this.recordId = recordId; + this.responseJson = responseJson; + } + + public boolean isNewRequest() { return newRequest; } + public boolean isCompleted() { return completed; } + public String getRecordId() { return recordId; } + public String getResponseJson() { return responseJson; } + } +} diff --git a/campus-server/src/main/java/com/campus/service/ShareService.java b/campus-server/src/main/java/com/campus/service/ShareService.java new file mode 100644 index 0000000..5a63542 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ShareService.java @@ -0,0 +1,411 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ServiceUnavailableException; +import com.campus.dto.request.ShareToFriendsRequest; +import com.campus.dto.request.ShareToGroupRequest; +import com.campus.dto.response.ChatMessageDTO; +import com.campus.dto.response.ShareCardDTO; +import com.campus.dto.response.ShareDestinationResultDTO; +import com.campus.dto.response.ShareSendResultDTO; +import com.campus.entity.Friendship; +import com.campus.entity.Moment; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * App 内分享:将可见内容批量发给好友,或发到一个群聊。 + * 卡片标题、摘要与封面均由服务端生成,客户端只提交业务类型、业务 ID 与接收目标。 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ShareService { + @org.springframework.beans.factory.annotation.Value("${app.interaction-rate-limit.window-ms:5000}") + private long interactionRateWindowMs; + @org.springframework.beans.factory.annotation.Value("${app.interaction-rate-limit.max-requests:20}") + private long interactionRateMaxRequests; + + public static final String TYPE_MOMENT = "MOMENT"; + public static final String SCOPE_SHARE_FRIENDS = "share-friends"; + public static final String SCOPE_SHARE_GROUP = "share-group"; + public static final int MAX_FRIEND_RECEIVERS = 20; + + private static final String REVIEW_APPROVED = "approved"; + private static final String STATUS_SENT = "SENT"; + private static final String STATUS_FAILED = "FAILED"; + private static final String DESTINATION_PRIVATE = "PRIVATE"; + private static final String DESTINATION_GROUP = "GROUP"; + private static final String SERVICE_UNAVAILABLE_MESSAGE = "分享服务暂不可用,请稍后重试"; + private static final String CONTENT_UNAVAILABLE_MESSAGE = "分享内容不存在或不可见"; + + private final ShareTargetResolver shareTargetResolver; + private final MomentRepository momentRepository; + private final FriendshipRepository friendshipRepository; + private final UserRepository userRepository; + private final PrivacySettingsService privacySettingsService; + private final ClickGuardService clickGuardService; + private final ChatService chatService; + private final ObjectMapper objectMapper; + private final ShareRequestIdempotencyService requestIdempotencyService; + private final MomentShareCountService momentShareCountService; + + /** + * 批量分享到好友。此方法不包裹大事务;每位好友由 ChatService 的公共事务方法独立落库, + * 避免后一个接收人失败时回滚前面已经真实投递成功的消息。 + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public ShareSendResultDTO sendToFriends(ShareToFriendsRequest request) { + String currentUserId = requireCurrentUserId(); + List receiverIds = normalizeReceiverIds(request.getReceiverIds()); + if (receiverIds.isEmpty()) { + throw new BusinessException("请选择要分享的好友"); + } + if (receiverIds.size() > MAX_FRIEND_RECEIVERS) { + throw new BusinessException("一次最多分享给20位好友"); + } + if (receiverIds.contains(currentUserId)) { + throw new BusinessException("不能分享给自己"); + } + + ShareCardDTO card = buildCard(request.getTargetType(), request.getTargetId()); + + // 必须在第一次远端投递前完成整批业务校验,避免“先发给一部分、再发现存在非好友”。 + for (String receiverId : receiverIds) { + if (!userRepository.existsById(receiverId) || !isAcceptedFriend(currentUserId, receiverId)) { + throw new BusinessException("只能分享给好友"); + } + if (TYPE_MOMENT.equals(card.getTargetType())) { + requireShareableMoment(card.getTargetType(), card.getTargetId(), receiverId); + } + } + + shareTargetResolver.validateRecipients(card, receiverIds); + String requestId = normalizeRequestId(request.getRequestId()); + String guardTarget = card.getTargetType() + ':' + card.getTargetId() + ":FRIENDS:" + receiverSetHash(receiverIds); + String requestRecordId = null; + if (requestId == null) { + clickGuardService.ensureNotTooFrequent(SCOPE_SHARE_FRIENDS, currentUserId, guardTarget); + } else { + clickGuardService.ensureWithinRate("share-send", currentUserId, interactionRateWindowMs, interactionRateMaxRequests); + String payloadHash = ShareRequestIdempotencyService.sha256("FRIENDS\n" + card.getTargetType() + + '\n' + card.getTargetId() + '\n' + receiverSetHash(receiverIds)); + ShareRequestIdempotencyService.Claim claim = requestIdempotencyService.claim(currentUserId, + ShareRequestIdempotencyService.TYPE_FRIENDS, requestId, payloadHash); + if (claim.isCompleted()) return readCompletedResult(claim.getResponseJson()); + requestRecordId = claim.getRecordId(); + } + + String cardJson = toJson(card); + String displayText = "[分享] " + card.getTitle(); + List results = new ArrayList<>(); + int sentCount = 0; + BusinessException firstBusinessFailure = null; + + for (String receiverId : receiverIds) { + try { + ChatMessageDTO message = chatService.sendSharePrivateMessage(receiverId, cardJson, displayText); + results.add(sent(DESTINATION_PRIVATE, receiverId, message.getId())); + sentCount++; + } catch (ServiceUnavailableException ex) { + results.add(failed(DESTINATION_PRIVATE, receiverId, ex.getMessage())); + } catch (BusinessException ex) { + // 整批预校验后好友关系仍可能在投递间隙变化;已成功的远端消息无法回滚。 + if (firstBusinessFailure == null) { + firstBusinessFailure = ex; + } + results.add(failed(DESTINATION_PRIVATE, receiverId, ex.getMessage())); + } + if (requestRecordId != null) { + requestIdempotencyService.checkpoint(requestRecordId, + result(card, receiverIds.size(), sentCount, results)); + } + } + + if (sentCount == 0) { + RuntimeException failure = firstBusinessFailure != null + ? firstBusinessFailure : new ServiceUnavailableException(SERVICE_UNAVAILABLE_MESSAGE); + failRequest(requestRecordId, failure); + throw failure; + } + ShareSendResultDTO response = result(card, receiverIds.size(), sentCount, results); + if (requestRecordId != null) { + if (TYPE_MOMENT.equals(card.getTargetType())) momentShareCountService.incrementShare(card.getTargetId(), requestId); + requestIdempotencyService.complete(requestRecordId, response); + } + return response; + } + + /** 分享到一个群聊;群成员身份、待审核状态、禁言与环信群状态由 ChatService 统一校验。 */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public ShareSendResultDTO sendToGroup(ShareToGroupRequest request) { + String currentUserId = requireCurrentUserId(); + String groupId = trimToNull(request.getGroupId()); + if (groupId == null) { + throw new BusinessException("请选择要分享的群聊"); + } + + ShareCardDTO card = buildCard(request.getTargetType(), request.getTargetId()); + shareTargetResolver.validateGroup(card, groupId); + String requestId = normalizeRequestId(request.getRequestId()); + String requestRecordId = null; + if (requestId == null) { + clickGuardService.ensureNotTooFrequent(SCOPE_SHARE_GROUP, currentUserId, + card.getTargetType() + ':' + card.getTargetId() + ":GROUP:" + groupId); + } else { + clickGuardService.ensureWithinRate("share-send", currentUserId, interactionRateWindowMs, interactionRateMaxRequests); + String payloadHash = ShareRequestIdempotencyService.sha256("GROUP\n" + card.getTargetType() + + '\n' + card.getTargetId() + '\n' + groupId); + ShareRequestIdempotencyService.Claim claim = requestIdempotencyService.claim(currentUserId, + ShareRequestIdempotencyService.TYPE_GROUP, requestId, payloadHash); + if (claim.isCompleted()) return readCompletedResult(claim.getResponseJson()); + requestRecordId = claim.getRecordId(); + } + + String cardJson = toJson(card); + String displayText = "[分享] " + card.getTitle(); + try { + ChatMessageDTO message = chatService.sendGroupShareMessage(groupId, cardJson, displayText); + ShareSendResultDTO response = result(card, 1, 1, + Collections.singletonList(sent(DESTINATION_GROUP, groupId, message.getId()))); + if (requestRecordId != null) { + if (TYPE_MOMENT.equals(card.getTargetType())) momentShareCountService.incrementShare(card.getTargetId(), requestId); + requestIdempotencyService.complete(requestRecordId, response); + } + return response; + } catch (RuntimeException ex) { + failRequest(requestRecordId, ex); + throw ex; + } + } + + /** 统一解析分享目标,失败直接抛业务异常。 */ + public ShareCardDTO buildCard(String targetType, String targetId) { + String currentUserId = requireCurrentUserId(); + if (targetType == null || targetType.trim().isEmpty()) { + throw new BusinessException("分享目标类型不能为空"); + } + if (TYPE_MOMENT.equalsIgnoreCase(targetType.trim())) { + return buildMomentCard(requireShareableMoment(targetType, targetId, currentUserId)); + } + return shareTargetResolver.resolve(targetType, targetId, currentUserId); + } + + private Moment requireShareableMoment(String targetType, String targetId, String currentUserId) { + String normalizedType = targetType == null ? "" : targetType.trim().toUpperCase(Locale.ROOT); + if (!TYPE_MOMENT.equals(normalizedType)) { + throw new BusinessException("不支持的分享类型"); + } + String normalizedId = trimToNull(targetId); + if (normalizedId == null) { + throw new BusinessException("分享目标ID不能为空"); + } + + Moment moment = momentRepository.findById(normalizedId) + .orElseThrow(() -> new BusinessException(CONTENT_UNAVAILABLE_MESSAGE)); + if (Boolean.TRUE.equals(moment.getDeleted()) + || !REVIEW_APPROVED.equals(moment.getReviewStatus()) + || Moment.SOURCE_TOPIC.equals(moment.getContentSource()) + || privacySettingsService.isBlocked(currentUserId, moment.getUserId())) { + throw new BusinessException(CONTENT_UNAVAILABLE_MESSAGE); + } + return moment; + } + + private ShareCardDTO buildMomentCard(Moment moment) { + ShareCardDTO card = new ShareCardDTO(); + card.setTargetType(TYPE_MOMENT); + card.setTargetId(moment.getId()); + card.setTitle(momentTitle(moment)); + card.setSummary(summarize(moment.getContent(), 80)); + card.setCoverUrl(momentCover(moment)); + return card; + } + + private boolean isAcceptedFriend(String userA, String userB) { + return friendshipRepository.findByUserIdAndFriendId(userA, userB) + .map(this::isAccepted) + .orElseGet(() -> friendshipRepository.findByUserIdAndFriendId(userB, userA) + .map(this::isAccepted) + .orElse(false)); + } + + private boolean isAccepted(Friendship friendship) { + return "ACCEPTED".equals(friendship.getStatus()); + } + + private String requireCurrentUserId() { + String currentUserId = UserContext.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + return currentUserId; + } + + private List normalizeReceiverIds(List receiverIds) { + Set unique = new LinkedHashSet<>(); + if (receiverIds != null) { + for (String receiverId : receiverIds) { + String normalized = trimToNull(receiverId); + if (normalized != null) { + unique.add(normalized); + } + } + } + return new ArrayList<>(unique); + } + + private String receiverSetHash(List receiverIds) { + List sorted = new ArrayList<>(receiverIds); + Collections.sort(sorted); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(String.join(",", sorted).getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte value : bytes) { + hex.append(String.format("%02x", value)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("无法生成分享批次摘要", ex); + } + } + + private String normalizeRequestId(String requestId) { + if (requestId == null) return null; + String normalized = requestId.trim(); + if (normalized.isEmpty() || normalized.length() > 64 + || !normalized.matches("[A-Za-z0-9._:-]{1,64}")) { + throw new BusinessException("分享请求标识格式不正确"); + } + return normalized; + } + + private ShareSendResultDTO readCompletedResult(String responseJson) { + if (responseJson == null || responseJson.trim().isEmpty()) { + throw new BusinessException("分享请求结果暂不可用,请发起新的分享"); + } + try { + return objectMapper.readValue(responseJson, ShareSendResultDTO.class); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("分享结果反序列化失败", ex); + } + } + + private void failRequest(String requestRecordId, RuntimeException failure) { + if (requestRecordId == null) return; + // 未知异常可能发生在远端已投递之后,保留 PROCESSING,不泄露异常信息或自动重投。 + String type; + if (failure instanceof BusinessException) type = "BUSINESS"; + else if (failure instanceof com.campus.common.ForbiddenException) type = "FORBIDDEN"; + else if (failure instanceof ServiceUnavailableException) type = "UNAVAILABLE"; + else return; + requestIdempotencyService.fail(requestRecordId, type, failure.getMessage()); + } + + private ShareDestinationResultDTO sent(String destinationType, String destinationId, String messageId) { + ShareDestinationResultDTO result = new ShareDestinationResultDTO(); + result.setDestinationType(destinationType); + result.setDestinationId(destinationId); + result.setStatus(STATUS_SENT); + result.setMessageId(messageId); + return result; + } + + private ShareDestinationResultDTO failed(String destinationType, String destinationId, String message) { + ShareDestinationResultDTO result = new ShareDestinationResultDTO(); + result.setDestinationType(destinationType); + result.setDestinationId(destinationId); + result.setStatus(STATUS_FAILED); + result.setMessage(message == null ? SERVICE_UNAVAILABLE_MESSAGE : message); + return result; + } + + private ShareSendResultDTO result(ShareCardDTO card, int requestedCount, int sentCount, + List results) { + ShareSendResultDTO result = new ShareSendResultDTO(); + int failedCount = requestedCount - sentCount; + result.setCard(card); + result.setRequestedCount(requestedCount); + result.setSentCount(sentCount); + result.setFailedCount(failedCount); + result.setPartialSuccess(sentCount > 0 && failedCount > 0); + result.setResults(results); + return result; + } + + private String momentTitle(Moment moment) { + String title = summarize(moment.getContent(), 36); + if (title != null) { + return title; + } + if (moment.hasVideoContent()) { + return "视频动态"; + } + if (trimToNull(moment.getMediaUrls()) != null) { + return "图片动态"; + } + return "动态"; + } + + private String momentCover(Moment moment) { + String mediaUrls = trimToNull(moment.getMediaUrls()); + if (mediaUrls != null) { + try { + List urls = objectMapper.readValue(mediaUrls, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + if (!urls.isEmpty()) { + return urls.get(0); + } + } catch (JsonProcessingException ignored) { + // 历史脏数据不应阻断分享;视频封面仍可作为兜底。 + } + } + return moment.getVideoCoverUrl(); + } + + private String summarize(String text, int maxLength) { + if (text == null) { + return null; + } + String compact = text.replaceAll("\\s+", " ").trim(); + if (compact.isEmpty()) { + return null; + } + return compact.length() <= maxLength ? compact : compact.substring(0, maxLength) + "…"; + } + + private String toJson(ShareCardDTO card) { + try { + return objectMapper.writeValueAsString(card); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("分享卡片序列化失败", ex); + } + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/campus-server/src/main/java/com/campus/service/ShareTargetResolver.java b/campus-server/src/main/java/com/campus/service/ShareTargetResolver.java new file mode 100644 index 0000000..ca641d1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/ShareTargetResolver.java @@ -0,0 +1,211 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.response.ShareCardDTO; +import com.campus.entity.*; +import com.campus.repository.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.*; + +/** 分享目标只读解析;卡片元数据不得使用客户端提供的内容。 */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ShareTargetResolver { + private final TopicRepository topicRepository; + private final ActivityRepository activityRepository; + private final AnnouncementRepository announcementRepository; + private final AchievementDefinitionRepository achievementDefinitionRepository; + private final UserAchievementRepository userAchievementRepository; + private final UserRepository userRepository; + private final GroupMemberRepository groupMemberRepository; + private final PrivacySettingsService privacySettingsService; + private final ObjectMapper objectMapper; + + public ShareCardDTO resolve(String type, String id, String senderId) { + String normalized = type == null ? "" : type.trim().toUpperCase(Locale.ROOT); + if (normalized.isEmpty()) throw new BusinessException("分享目标类型不能为空"); + if (!Arrays.asList("TOPIC", "ACTIVITY", "ANNOUNCEMENT", "BADGE", "USER").contains(normalized)) { + throw new BusinessException("不支持的分享类型"); + } + if (id == null || id.trim().isEmpty()) throw new BusinessException("分享目标ID不能为空"); + id = id.trim(); + ShareCardDTO card = new ShareCardDTO(); + card.setTargetType(normalized); + card.setTargetId(id); + switch (normalized) { + case "TOPIC": { + Topic topic = topicRepository.findById(id).orElseThrow(() -> unavailable("TOPIC")); + if (Boolean.TRUE.equals(topic.getDeleted()) || !"APPROVED".equals(topic.getStatus()) + || topic.getMergedIntoId() != null) throw unavailable("TOPIC"); + card.setTitle("#" + topic.getName() + "#"); + card.setSummary(compact(topic.getDescription())); + card.setCoverUrl(topic.getCoverUrl()); + break; + } + case "ACTIVITY": { + Activity activity = activity(id); + requireActivityVisible(activity, senderId, null); + card.setTitle(activity.getDisplayTitle()); + card.setSummary(join(format(activity.getEventTime()), activity.getLocation())); + card.setCoverUrl(activity.getCoverImage()); + break; + } + case "ANNOUNCEMENT": { + Announcement announcement = announcement(id); + requireAnnouncementVisible(announcement, senderId, null); + card.setTitle(announcement.getTitle()); + card.setSummary(join(announcement.getSubtitle(), formatDate(announcement.getEventDate()), + announcement.getEventTime(), announcement.getLocation())); + card.setCoverUrl(announcement.getCoverImage()); + break; + } + case "BADGE": { + AchievementDefinition badge = achievementDefinitionRepository.findByCode(id) + .orElseThrow(() -> unavailable("BADGE")); + if (Boolean.TRUE.equals(badge.getDeleted()) || !Boolean.TRUE.equals(badge.getEnabled()) + || !userAchievementRepository.existsByUserIdAndAchievementCode(senderId, id)) { + throw unavailable("BADGE"); + } + card.setTitle(badge.getName()); + card.setSummary(compact(badge.getDescription())); + card.setCoverUrl(badge.getColorIconUrl()); + break; + } + case "USER": { + User user = userRepository.findById(id).orElseThrow(() -> new BusinessException("用户不存在或不可用")); + if (Boolean.TRUE.equals(user.getDeleted()) || !"ACTIVE".equals(user.getAccountStatus())) { + throw new BusinessException("用户不存在或不可用"); + } + requireProfileVisible(id, senderId); + card.setTitle(user.getNickname()); + card.setSummary(join(user.getUniversityName(), user.getMajorName())); + card.setCoverUrl(user.getAvatar()); + break; + } + default: throw new BusinessException("不支持的分享类型"); + } + return card; + } + + /** 所有目的地在第一次真实投递前完成校验。 */ + public void validateRecipients(ShareCardDTO card, List receiverIds) { + if ("ACTIVITY".equals(card.getTargetType())) { + Activity activity = activity(card.getTargetId()); + Map users = users(receiverIds); + for (String id : receiverIds) requireActivityVisible(activity, id, users.get(id)); + } else if ("ANNOUNCEMENT".equals(card.getTargetType())) { + Announcement announcement = announcement(card.getTargetId()); + Map users = users(receiverIds); + for (String id : receiverIds) requireAnnouncementVisible(announcement, id, users.get(id)); + } else if ("USER".equals(card.getTargetType())) { + for (String id : receiverIds) requireProfileVisible(card.getTargetId(), id); + } + } + + public void validateGroup(ShareCardDTO card, String groupId) { + // 动态沿用原有群分享契约;话题和徽章没有额外的接收者范围。 + if (!Arrays.asList("ACTIVITY", "ANNOUNCEMENT", "USER").contains(card.getTargetType())) return; + List ids = new ArrayList<>(); + for (GroupMember member : groupMemberRepository.findByGroupId(groupId)) { + if (!Boolean.TRUE.equals(member.getDeleted()) && !"PENDING".equals(member.getRole())) { + ids.add(member.getUserId()); + } + } + try { + validateRecipients(card, ids); + } catch (BusinessException ex) { + throw new BusinessException("群内存在无权查看该内容的成员,无法分享"); + } + } + + private Activity activity(String id) { + Activity activity = activityRepository.findById(id).orElseThrow(() -> unavailable("ACTIVITY")); + if (Boolean.TRUE.equals(activity.getDeleted()) || !"approved".equalsIgnoreCase(activity.getReviewStatus()) + || !("ACTIVE".equals(activity.getStatus()) || "ENDED".equals(activity.getStatus()))) { + throw unavailable("ACTIVITY"); + } + return activity; + } + + private Announcement announcement(String id) { + Announcement announcement = announcementRepository.findById(id).orElseThrow(() -> unavailable("ANNOUNCEMENT")); + if (Boolean.TRUE.equals(announcement.getDeleted()) || !"approved".equals(announcement.getStatus()) + || (announcement.getScheduledPublishAt() != null && announcement.getScheduledPublishAt().after(new Date()))) { + throw unavailable("ANNOUNCEMENT"); + } + return announcement; + } + + private void requireActivityVisible(Activity activity, String viewerId, User viewer) { + if (privacySettingsService.isBlocked(viewerId, activity.getCreatorId())) throw unavailable("ACTIVITY"); + String scope = activity.getScope(); + if (scope == null || scope.trim().isEmpty() || "ALL".equals(scope)) return; + User user = viewer == null ? userRepository.findById(viewerId).orElse(null) : viewer; + if (user == null || !inScope(scope, user.getUniversityName())) throw unavailable("ACTIVITY"); + } + + private void requireAnnouncementVisible(Announcement announcement, String viewerId, User viewer) { + if (announcement.getScopeType() == null || "all".equals(announcement.getScopeType())) return; + User user = viewer == null ? userRepository.findById(viewerId).orElse(null) : viewer; + if (!"specified".equals(announcement.getScopeType()) || user == null + || !inScope(announcement.getUniversityIds(), user.getUniversityId())) throw unavailable("ANNOUNCEMENT"); + } + + private void requireProfileVisible(String targetId, String viewerId) { + if (!privacySettingsService.isProfileVisible(targetId) || privacySettingsService.isBlocked(viewerId, targetId)) { + throw new BusinessException("对方设置了隐私保护,无法分享"); + } + } + + private Map users(List ids) { + Map result = new HashMap<>(); + userRepository.findAllById(ids).forEach(user -> result.put(user.getId(), user)); + return result; + } + + private boolean inScope(String scope, String value) { + if (scope == null || value == null || value.isEmpty()) return false; + if (scope.equals(value)) return true; + try { + List values = objectMapper.readValue(scope, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + return values != null && values.contains(value); + } catch (JsonProcessingException ex) { + return false; + } + } + + private BusinessException unavailable(String type) { + switch (type) { + case "TOPIC": return new BusinessException("话题不存在或尚未上线"); + case "ACTIVITY": return new BusinessException("活动不存在或已取消"); + case "ANNOUNCEMENT": return new BusinessException("公告不存在或未发布"); + default: return new BusinessException("该徽章尚未获得,无法分享"); + } + } + + private String format(Date date) { return date == null ? null : formatter("yyyy-MM-dd HH:mm:ss").format(date); } + private String formatDate(Date date) { return date == null ? null : formatter("yyyy-MM-dd").format(date); } + private SimpleDateFormat formatter(String pattern) { + SimpleDateFormat format = new SimpleDateFormat(pattern); + format.setTimeZone(TimeZone.getTimeZone("GMT+8")); + return format; + } + private String compact(String value) { + if (value == null) return null; + String text = value.replaceAll("\\s+", " ").trim(); + return text.isEmpty() ? null : text.length() <= 120 ? text : text.substring(0, 120) + "…"; + } + private String join(String... values) { + List parts = new ArrayList<>(); + for (String value : values) if (value != null && !value.trim().isEmpty()) parts.add(value.trim()); + return compact(String.join(" · ", parts)); + } +} diff --git a/campus-server/src/main/java/com/campus/service/SocialGraphService.java b/campus-server/src/main/java/com/campus/service/SocialGraphService.java new file mode 100644 index 0000000..b2f7967 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/SocialGraphService.java @@ -0,0 +1,214 @@ +package com.campus.service; + +import com.campus.dto.response.SocialGraphDTO; +import com.campus.dto.response.SocialGraphEdgeDTO; +import com.campus.dto.response.SocialGraphNodeDTO; +import com.campus.entity.Activity; +import com.campus.entity.ActivityParticipant; +import com.campus.entity.Follow; +import com.campus.entity.Friendship; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.MatchPair; +import com.campus.entity.User; +import com.campus.repository.ActivityParticipantRepository; +import com.campus.repository.ActivityRepository; +import com.campus.repository.FollowRepository; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.MatchPairRepository; +import com.campus.repository.UserRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** 当前用户中心的社交关系图谱。 */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SocialGraphService { + + private final UserRepository userRepository; + private final FriendshipRepository friendshipRepository; + private final FollowRepository followRepository; + private final GroupMemberRepository groupMemberRepository; + private final GroupRepository groupRepository; + private final ActivityParticipantRepository activityParticipantRepository; + private final ActivityRepository activityRepository; + private final MatchPairRepository matchPairRepository; + private final PrivacySettingsService privacySettingsService; + + public SocialGraphDTO getCurrentGraph(Integer maxNodes) { + String currentUserId = currentUserId(); + int nodeLimit = maxNodes == null || maxNodes < 10 ? 200 : Math.min(maxNodes, 500); + User current = userRepository.findById(currentUserId) + .orElseThrow(() -> new RuntimeException("当前用户不存在")); + + Map nodes = new LinkedHashMap<>(); + List edges = new ArrayList<>(); + Set blacklisted = blockedIds(currentUserId); + addUserNode(nodes, current, "SELF"); + + List acceptedOut = friendshipRepository.findByUserIdAndStatus(currentUserId, "ACCEPTED"); + List acceptedIn = friendshipRepository.findByFriendIdAndStatus(currentUserId, "ACCEPTED"); + Set friendIds = new LinkedHashSet<>(); + acceptedOut.forEach(f -> friendIds.add(f.getFriendId())); + acceptedIn.forEach(f -> friendIds.add(f.getUserId())); + addUserRelations(nodes, edges, currentUserId, friendIds, "FRIEND", blacklisted, nodeLimit); + + List following = followRepository.findByFollowerId(currentUserId); + List followers = followRepository.findAllByFollowingId(currentUserId); + Set followingIds = following.stream().map(Follow::getFollowingId).collect(Collectors.toCollection(LinkedHashSet::new)); + Set followerIds = followers.stream().map(Follow::getFollowerId).collect(Collectors.toCollection(LinkedHashSet::new)); + addUserRelations(nodes, edges, currentUserId, followingIds, "FOLLOWING", blacklisted, nodeLimit); + addUserRelations(nodes, edges, currentUserId, followerIds, "FOLLOWER", blacklisted, nodeLimit); + + Set groupIds = groupMemberRepository.findByUserId(currentUserId).stream() + .filter(member -> !"PENDING".equals(member.getRole())) + .map(GroupMember::getGroupId).collect(Collectors.toCollection(LinkedHashSet::new)); + Map groups = groupRepository.findAllById(groupIds).stream() + .collect(Collectors.toMap(Group::getId, group -> group)); + for (String groupId : groupIds) { + if (nodes.size() >= nodeLimit) break; + Group group = groups.get(groupId); + if (group == null) continue; + addGroupNode(nodes, group); + addEdge(edges, currentUserId, groupId, "GROUP_MEMBER"); + for (GroupMember member : groupMemberRepository.findByGroupId(groupId)) { + if (nodes.size() >= nodeLimit) break; + if (currentUserId.equals(member.getUserId()) || blacklisted.contains(member.getUserId())) continue; + User user = userRepository.findById(member.getUserId()).orElse(null); + if (user != null && privacySettingsService.isProfileVisible(user.getId())) { + addUserNode(nodes, user, "GROUP_MEMBER"); + addEdge(edges, user.getId(), groupId, "GROUP_MEMBER"); + } + } + } + + Set activityIds = new LinkedHashSet<>(); + for (ActivityParticipant participant : activityParticipantRepository.findByUserId(currentUserId)) { + if ("CONFIRMED".equals(participant.getStatus())) activityIds.add(participant.getActivityId()); + } + Map activities = activityRepository.findAllById(activityIds).stream() + .collect(Collectors.toMap(Activity::getId, activity -> activity)); + for (String activityId : activityIds) { + if (nodes.size() >= nodeLimit) break; + Activity activity = activities.get(activityId); + if (activity == null) continue; + addActivityNode(nodes, activity); + addEdge(edges, currentUserId, activityId, "PARTICIPATE"); + } + + List pairs = matchPairRepository.findByUserLowIdOrUserHighIdOrderByCreatedAtDesc(currentUserId, currentUserId); + int matchedCount = 0; + for (MatchPair pair : pairs) { + if (!"ACTIVE".equals(pair.getStatus()) || nodes.size() >= nodeLimit) continue; + String otherId = currentUserId.equals(pair.getUserLowId()) ? pair.getUserHighId() : pair.getUserLowId(); + if (blacklisted.contains(otherId)) continue; + User other = userRepository.findById(otherId).orElse(null); + if (other != null && privacySettingsService.isProfileVisible(otherId)) { + addUserNode(nodes, other, "MATCHED"); + addEdge(edges, currentUserId, otherId, "MATCHED"); + matchedCount++; + } + } + + SocialGraphDTO result = new SocialGraphDTO(); + result.setNodes(new ArrayList<>(nodes.values())); + result.setEdges(edges); + result.setUserCount((int) nodes.values().stream().filter(node -> "USER".equals(node.getType())).count()); + result.setGroupCount((int) nodes.values().stream().filter(node -> "GROUP".equals(node.getType())).count()); + result.setActivityCount((int) nodes.values().stream().filter(node -> "ACTIVITY".equals(node.getType())).count()); + result.setFriendCount((int) friendIds.stream().filter(id -> !blacklisted.contains(id)).count()); + result.setFollowingCount((int) followingIds.stream().filter(id -> !blacklisted.contains(id)).count()); + result.setFollowerCount((int) followerIds.stream().filter(id -> !blacklisted.contains(id)).count()); + result.setMatchedCount(matchedCount); + return result; + } + + private void addUserRelations(Map nodes, List edges, + String currentUserId, Set ids, String relation, + Set blacklisted, int nodeLimit) { + for (String id : ids) { + if (nodes.size() >= nodeLimit || currentUserId.equals(id) || blacklisted.contains(id)) continue; + User user = userRepository.findById(id).orElse(null); + if (user != null && privacySettingsService.isProfileVisible(id)) { + addUserNode(nodes, user, relation); + addEdge(edges, currentUserId, id, relation); + } + } + } + + private void addUserNode(Map nodes, User user, String relation) { + SocialGraphNodeDTO node = nodes.get(user.getId()); + if (node == null) { + node = new SocialGraphNodeDTO(); + node.setId(user.getId()); + node.setType("USER"); + node.setName(user.getNickname()); + node.setAvatar(user.getAvatar()); + node.setUniversityName(user.getUniversityName()); + nodes.put(user.getId(), node); + } + if (node.getRelation() == null || "SELF".equals(relation)) node.setRelation(relation); + } + + private void addGroupNode(Map nodes, Group group) { + SocialGraphNodeDTO node = new SocialGraphNodeDTO(); + node.setId(group.getId()); + node.setType("GROUP"); + node.setName(group.getName()); + node.setAvatar(group.getAvatar()); + node.setRelation("GROUP_MEMBER"); + nodes.putIfAbsent(group.getId(), node); + } + + private void addActivityNode(Map nodes, Activity activity) { + SocialGraphNodeDTO node = new SocialGraphNodeDTO(); + node.setId(activity.getId()); + node.setType("ACTIVITY"); + node.setName(activity.getTitle()); + node.setRelation("PARTICIPATE"); + nodes.putIfAbsent(activity.getId(), node); + } + + private void addEdge(List edges, String source, String target, String relation) { + for (SocialGraphEdgeDTO edge : edges) { + if (edge.getSourceId().equals(source) && edge.getTargetId().equals(target) + && edge.getRelation().equals(relation)) return; + } + SocialGraphEdgeDTO edge = new SocialGraphEdgeDTO(); + edge.setSourceId(source); + edge.setTargetId(target); + edge.setRelation(relation); + edges.add(edge); + } + + private Set blockedIds(String userId) { + Set ids = new HashSet<>(); + friendshipRepository.findByUserIdAndStatus(userId, "BLOCKED") + .forEach(friendship -> ids.add(friendship.getFriendId())); + friendshipRepository.findByFriendIdAndStatus(userId, "BLOCKED") + .forEach(friendship -> ids.add(friendship.getUserId())); + return ids; + } + + private String currentUserId() { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) throw new RuntimeException("用户未登录"); + return userId; + } +} diff --git a/campus-server/src/main/java/com/campus/service/SseService.java b/campus-server/src/main/java/com/campus/service/SseService.java new file mode 100644 index 0000000..d5e484e --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/SseService.java @@ -0,0 +1,152 @@ +package com.campus.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * SSE 长连接服务 — 管理用户连接池,支持向指定用户实时推送通知 + *

+ * 每个用户只保留最近一条连接(单设备登录场景),新连接自动关闭旧连接。 + */ +@Slf4j +@Service +public class SseService { + + /** 连接超时:30分钟(比前端重连间隔更长即可) */ + private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L; + + /** userId -> 该用户的所有连接(通常只有一个) */ + private final Map> connections = new ConcurrentHashMap<>(); + + /** + * 创建 SSE 连接并注册到连接池 + * + * @param userId 当前用户 ID + * @return SseEmitter 实例 + */ + public SseEmitter subscribe(String userId) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + + // 注册新连接 + connections.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()).add(emitter); + + // 发送初始连接确认事件 + try { + emitter.send(SseEmitter.event() + .name("connected") + .data("{\"userId\":\"" + userId + "\"}")); + } catch (IOException e) { + log.warn("[SSE] 初始事件发送失败 -> userId={}", userId); + } + + // 连接断开时清理 + emitter.onCompletion(() -> removeEmitter(userId, emitter)); + emitter.onTimeout(() -> removeEmitter(userId, emitter)); + emitter.onError(e -> { + log.warn("[SSE] 连接异常 -> userId={}, error={}", userId, e.getMessage()); + removeEmitter(userId, emitter); + }); + + log.info("[SSE] 新连接 -> userId={}, 当前连接数={}", userId, getConnectionCount(userId)); + return emitter; + } + + /** + * 向指定用户推送通知事件 + * + * @param userId 目标用户 ID + * @param event 事件数据(JSON 字符串) + * @param eventName 事件名称 + */ + public void sendToUser(String userId, String event, String eventName) { + CopyOnWriteArrayList userEmitters = connections.get(userId); + if (userEmitters == null || userEmitters.isEmpty()) { + return; // 用户不在线,无需推送 + } + + for (SseEmitter emitter : userEmitters) { + try { + emitter.send(SseEmitter.event() + .name(eventName) + .data(event)); + } catch (IOException e) { + log.warn("[SSE] 推送失败,移除连接 -> userId={}, error={}", userId, e.getMessage()); + removeEmitter(userId, emitter); + } + } + } + + /** + * 向指定用户推送通知(使用默认事件名 "notification") + */ + public void sendToUser(String userId, String event) { + sendToUser(userId, event, "notification"); + } + + /** + * 用户是否在线(有活跃连接) + */ + public boolean isUserOnline(String userId) { + CopyOnWriteArrayList emitters = connections.get(userId); + return emitters != null && !emitters.isEmpty(); + } + + /** + * 获取某用户的活跃连接数 + */ + public int getConnectionCount(String userId) { + CopyOnWriteArrayList emitters = connections.get(userId); + return emitters == null ? 0 : emitters.size(); + } + + /** + * 获取总在线用户数 + */ + public int getOnlineUserCount() { + return (int) connections.values().stream() + .filter(list -> !list.isEmpty()) + .count(); + } + + // ==================== 内部方法 ==================== + + private void removeEmitter(String userId, SseEmitter emitter) { + CopyOnWriteArrayList userEmitters = connections.get(userId); + if (userEmitters != null) { + userEmitters.remove(emitter); + if (userEmitters.isEmpty()) { + connections.remove(userId); + log.info("[SSE] 用户离线 -> userId={}", userId); + } + } + try { + emitter.complete(); + } catch (Exception ignored) { + } + } + + /** + * 定时心跳:每 25 秒向所有在线用户发送心跳,防止连接被代理/防火墙断开 + */ + @Scheduled(fixedRate = 25_000) + public void heartbeat() { + connections.forEach((userId, emitters) -> { + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event() + .name("heartbeat") + .data("")); + } catch (IOException e) { + removeEmitter(userId, emitter); + } + } + }); + } +} diff --git a/campus-server/src/main/java/com/campus/service/TopicAutoReviewScheduler.java b/campus-server/src/main/java/com/campus/service/TopicAutoReviewScheduler.java new file mode 100644 index 0000000..56b3670 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/TopicAutoReviewScheduler.java @@ -0,0 +1,95 @@ +package com.campus.service; + +import com.campus.entity.Topic; +import com.campus.repository.TopicRepository; +import com.campus.service.review.ReviewResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.PostConstruct; +import java.util.List; + +/** + * 存量 {@code PENDING} 话题的自动审核补偿。 + * + *

背景:话题发布改为自动审核后不再产生新的 PENDING,但历史 PENDING 如果没人处理, + * 就永远不会上线。本任务在「自动审核开关打开」时生效,默认 **dry-run**: + * 只计算"会判通过/驳回"并打日志,不写库、不发通知;把 + * {@code app.topic.auto-review.backfill-apply} 改成 {@code true} 后才会真正落库。

+ * + *

逐条 try/catch:单条失败不影响整批,失败的话题保持 PENDING 等下一轮。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TopicAutoReviewScheduler { + + private static final int BATCH_SIZE = 50; + + private final TopicRepository topicRepository; + private final ContentReviewService contentReviewService; + + @Value("${app.topic.auto-review.enabled:false}") + private boolean autoReviewEnabled; + + @Value("${app.topic.auto-review.backfill-apply:false}") + private boolean backfillApply; + + /** + * 启动时打印后端**实际读到**的开关值。 + * + *

排查踩过坑:IDEA 工作目录是仓库根目录时,Spring Boot 会优先加载 + * {@code file:./application.yml}(根目录那份),覆盖 classpath 里的副本, + * 导致"我明明改了配置却还是 PENDING"。有这行日志一眼就能确认。

+ */ + @PostConstruct + void logSwitchState() { + log.info("[TOPIC_AUTO_REVIEW] 自动审核开关 {}", switchSummary()); + } + + String switchSummary() { + return "enabled=" + autoReviewEnabled + ", backfill-apply=" + backfillApply; + } + + @Scheduled(cron = "0 0/30 * * * ?") + @Transactional + public void reviewPendingTopics() { + if (!autoReviewEnabled) { + return; + } + List pending = topicRepository.findByStatus("PENDING"); + if (pending.isEmpty()) { + return; + } + + int processed = 0; + for (Topic topic : pending) { + if (processed >= BATCH_SIZE) { + break; + } + processed++; + try { + if (!backfillApply) { + ReviewResult preview = contentReviewService.previewTopicReview( + topic.getName(), topic.getDescription(), topic.getCoverUrl()); + log.info("[TOPIC_AUTO_REVIEW][DRY-RUN] topicId={}, name={}, wouldBe={}, reasons={}, details={}", + topic.getId(), topic.getName(), + preview.isPassed() ? "APPROVED" : "REJECTED", + preview.getViolationReasons(), preview.getDetails()); + continue; + } + contentReviewService.reviewTopicSync(topic.getId(), topic.getName(), + topic.getDescription(), topic.getCoverUrl()); + log.info("[TOPIC_AUTO_REVIEW][APPLY] topicId={}, name={}", topic.getId(), topic.getName()); + } catch (Exception e) { + log.warn("[TOPIC_AUTO_REVIEW] failed for topic {}: {}", topic.getId(), e.getMessage()); + } + } + log.info("[TOPIC_AUTO_REVIEW] pending={}, processed={}, mode={}", + pending.size(), processed, backfillApply ? "APPLY" : "DRY-RUN"); + } +} diff --git a/campus-server/src/main/java/com/campus/service/TopicDiscussionService.java b/campus-server/src/main/java/com/campus/service/TopicDiscussionService.java new file mode 100644 index 0000000..a955bf8 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/TopicDiscussionService.java @@ -0,0 +1,181 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.TopicDiscussionRequest; +import com.campus.dto.response.MomentDTO; +import com.campus.entity.Topic; +import com.campus.entity.TopicAlias; +import com.campus.repository.TopicAliasRepository; +import com.campus.repository.TopicRepository; +import com.campus.service.event.TopicMergedEvent; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 话题讨论:话题板块与动态存储之间的**唯一桥接层**。 + * + *

为什么需要它:项目的历史约定是——话题域({@code TopicService})不碰 {@code momentRepository}, + * 动态域({@code MomentService})不碰 {@code topicRepository}(见 + * {@code 方案分析与实现(md)文件/实现方案-动态与话题数据解耦.md})。讨论既要"属于话题"、又存在 + * {@code moment} 表里,所以单独放一个同时依赖两边的服务,把"话题名/别名 → 话题实体"的解析 + * 和"讨论读写"拼在一起,两边都不破例。

+ * + *

概念边界:讨论的归属只认 {@code topic_id};{@code moment.tags} 是动态的分类标签, + * 本类不用它判断任何归属关系。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TopicDiscussionService { + + private static final String APPROVED = "APPROVED"; + /** 话题不存在与未上线统一文案,与 /follow、/favorite 口径一致。 */ + private static final String TOPIC_UNAVAILABLE = "话题不存在或尚未上线"; + + private final TopicRepository topicRepository; + private final TopicAliasRepository topicAliasRepository; + private final MomentService momentService; + private final ClickGuardService clickGuardService; + + /** 发布讨论。 */ + @Transactional + public MomentDTO publish(String topicName, TopicDiscussionRequest request) { + Topic topic = requireVisibleTopic(topicName); + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new BusinessException("用户未登录"); + } + clickGuardService.ensureNotTooFrequent( + ClickGuardService.SCOPE_TOPIC_DISCUSSION, userId, topic.getId()); + + MomentDTO dto = momentService.createTopicDiscussion( + topic.getId(), topic.getName(), + request == null ? null : request.getContent(), + request == null ? null : request.getMediaUrls(), + request == null ? null : request.getVodVideoId(), + request == null ? null : request.getAnonymous()); + dto.setTopicName(topic.getName()); + return dto; + } + + /** + * 编辑讨论(整体替换正文与媒体,重新审核)。 + * + *

归属不需要再从路径解析话题:讨论可能刚被合并到别的话题,按记录自身的 + * {@code topicId} 回填话题名即可。

+ */ + @Transactional + public MomentDTO update(String id, TopicDiscussionRequest request) { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new BusinessException("用户未登录"); + } + MomentDTO dto = momentService.updateTopicDiscussion(id, + request == null ? null : request.getContent(), + request == null ? null : request.getMediaUrls(), + request == null ? null : request.getVodVideoId(), + request == null ? null : request.getAnonymous()); + fillTopicNames(Collections.singletonList(dto)); + return dto; + } + + /** 删除讨论(软删;作者或管理员,校验在动态域的统一权限入口内)。 */ + @Transactional + public void delete(String id) { + momentService.deleteTopicDiscussion(id); + } + + /** 话题详情页的讨论列表(最热 / 最新)。 */ + public PageResult list(String topicName, String sort, int page, int size) { + Topic topic = requireVisibleTopic(topicName); + PageResult result = momentService.listTopicDiscussions(topic.getId(), sort, page, size); + fillTopicNames(result.getItems()); + return result; + } + + /** 我的讨论(作者管理入口,不污染动态流)。 */ + public PageResult listMine(String topicId, int page, int size) { + PageResult result = momentService.listMyDiscussions(topicId, page, size); + fillTopicNames(result.getItems()); + return result; + } + + /** + * 话题合并:把源话题下的讨论迁到目标话题。 + * + *

同步、同事务监听({@code @EventListener} 而非 {@code @TransactionalEventListener}): + * 迁移失败必须让合并一起回滚,否则会出现"话题已合并、讨论还挂在源 ID"的半成品数据。

+ */ + @EventListener + public void onTopicMerged(TopicMergedEvent event) { + if (event == null || event.getSourceId() == null || event.getTargetId() == null) { + return; + } + int moved = momentService.migrateTopicDiscussions(event.getSourceId(), event.getTargetId()); + log.info("话题合并迁移讨论: source={}, target={}, moved={}", + event.getSourceId(), event.getTargetId(), moved); + } + + /** + * 话题名/别名 → 已上线话题。别名解析属于话题域,这里只是复用它的数据。 + */ + private Topic requireVisibleTopic(String topicName) { + String normalized = normalize(topicName); + if (normalized.isEmpty()) { + throw new BusinessException(TOPIC_UNAVAILABLE); + } + TopicAlias alias = topicAliasRepository.findByAlias(normalized).orElse(null); + String resolvedName = normalized; + if (alias != null) { + resolvedName = topicRepository.findById(alias.getTopicId()) + .map(Topic::getName) + .orElse(normalized); + } + Topic topic = topicRepository.findByName(resolvedName).orElse(null); + if (topic == null || !APPROVED.equals(topic.getStatus())) { + throw new BusinessException(TOPIC_UNAVAILABLE); + } + return topic; + } + + /** 批量补 topicName,避免前端拿 tags 反推、也避免逐条查话题造成 N+1。 */ + private void fillTopicNames(List items) { + if (items == null || items.isEmpty()) { + return; + } + Set topicIds = items.stream() + .map(MomentDTO::getTopicId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (topicIds.isEmpty()) { + return; + } + Map names = topicRepository.findAllById(topicIds).stream() + .collect(Collectors.toMap(Topic::getId, Topic::getName, (a, b) -> a)); + items.forEach(dto -> dto.setTopicName(names.get(dto.getTopicId()))); + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + String name = value.trim(); + while (name.startsWith("#")) { + name = name.substring(1).trim(); + } + return name; + } +} diff --git a/campus-server/src/main/java/com/campus/service/TopicDiscussionVideoSweeper.java b/campus-server/src/main/java/com/campus/service/TopicDiscussionVideoSweeper.java new file mode 100644 index 0000000..df2d311 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/TopicDiscussionVideoSweeper.java @@ -0,0 +1,76 @@ +package com.campus.service; + +import com.campus.entity.Moment; +import com.campus.repository.MomentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; + +/** + * 话题讨论视频「播放地址」的补偿任务。 + * + *

为什么需要它:发布讨论时只落 {@code vodVideoId},{@code playUrl} 依赖 VOD 转码回调 + * ({@code /api/oss/vod-callback})按 videoId 回填。有两种情况会永久丢: + * ① 回调**早于**发布落库到达({@code findFirstByVodVideoIdOrderByCreatedAtAsc} 查不到记录); + * ② 回调推送丢失或阿里云侧未推送。两种都会让讨论卡在「转码中」。

+ * + *

发布时会先异步补拉一次({@code VodService.asyncPullPlayInfo}),本任务兜住"补拉时还没 + * 转码完"和"回调彻底没来"这两种情况。

+ * + *

注意:{@code @Scheduled} 线程里没有 {@code SecurityContext}({@link com.campus.util.UserContext} + * 返回 null),所以这里只调用不依赖登录态的方法。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TopicDiscussionVideoSweeper { + + /** 单轮最多处理多少条,避免一次把线程占满。 */ + private static final int BATCH_SIZE = 50; + + private final MomentRepository momentRepository; + private final VodService vodService; + + /** 只补「发布满 N 分钟仍未就绪」的讨论,避开刚发布、正在转码的正常情况。 */ + @Value("${app.topic.discussion.video-sweep-delay-minutes:10}") + private long sweepDelayMinutes = 10; + + @Scheduled(cron = "${app.topic.discussion.video-sweep-cron:0 */5 * * * ?}") + public void sweep() { + Date createdBefore = new Date( + System.currentTimeMillis() - Math.max(sweepDelayMinutes, 1) * 60_000L); + + List pending; + try { + pending = momentRepository.findTopicDiscussionsAwaitingVideo( + createdBefore, PageRequest.of(0, BATCH_SIZE)); + } catch (Exception e) { + // 查询失败(如数据库抖动)只跳这一轮,不要抛到调度器 + log.warn("[TOPIC_VIDEO_SWEEP] 查询待补齐讨论失败: {}", e.getMessage()); + return; + } + if (pending == null || pending.isEmpty()) { + return; + } + + int fixed = 0; + for (Moment moment : pending) { + try { + if (vodService.pullPlayInfoAndApply(moment.getVodVideoId())) { + fixed++; + } + } catch (Exception e) { + // 逐条 try/catch:单条失败不影响整批,远端不可用时下一轮再试 + log.warn("[TOPIC_VIDEO_SWEEP] 补拉失败: momentId={}, videoId={}, reason={}", + moment.getId(), moment.getVodVideoId(), e.getMessage()); + } + } + log.info("[TOPIC_VIDEO_SWEEP] pending={}, fixed={}", pending.size(), fixed); + } +} diff --git a/campus-server/src/main/java/com/campus/service/TopicService.java b/campus-server/src/main/java/com/campus/service/TopicService.java new file mode 100644 index 0000000..2b957e0 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/TopicService.java @@ -0,0 +1,1019 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.TopicReviewRequest; +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.entity.Topic; +import com.campus.entity.TopicAlias; +import com.campus.entity.TopicFavorite; +import com.campus.entity.TopicFollow; +import com.campus.entity.TopicHotSnapshot; +import com.campus.entity.TopicVisit; +import com.campus.repository.TopicAliasRepository; +import com.campus.repository.TopicFavoriteRepository; +import com.campus.repository.TopicFollowRepository; +import com.campus.repository.TopicHotSnapshotRepository; +import com.campus.repository.TopicRepository; +import com.campus.repository.TopicVisitRepository; +import com.campus.service.event.TopicMergedEvent; +import com.campus.service.review.ReviewResult; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 话题广场服务:话题数据的唯一来源是 topic 系列表。 + * + *

领域边界(重要):本类不读取 moment 表,也不调用 MomentService。 + * 话题的清单、统计、热度全部来自话题域自身: + * topic / topic_alias / topic_follow / topic_visit / topic_hot_snapshot。 + * 动态内容由动态域({@code GET /api/campus/moments?tag=})提供, + * {@code GET /api/topics/{name}/moments} 只是兼容路由。

+ * + *

话题热度 = 关注 + 访问 + 近 7 天关注/访问增长 + 推荐/置顶加权, + * 不再包含任何动态互动(点赞/评论/分享/浏览)。

+ */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TopicService { + + /** 话题热度"增长"项的统计窗口(7 天)。 */ + private static final long RECENT_WINDOW_MILLIS = 7L * 24 * 60 * 60 * 1000; + /** "新话题"标签与 new 榜单的时间窗口(7 天)。 */ + private static final long NEW_TOPIC_WINDOW_MILLIS = 7L * 24 * 60 * 60 * 1000; + + private static final List CATEGORIES = Collections.unmodifiableList(Arrays.asList( + "校园日常", "学习成长", "美食探店", "运动健身", "音乐艺术", "旅行出游", "情感交友", "社团活动")); + + /** 统一排序:热度 → 关注 → 创建时间 → 名称,保证翻页稳定。 */ + private static final Comparator HEAT_ORDER = + Comparator.comparing(TopicDTO::getHeatScore, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TopicDTO::getFollowerCount, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TopicDTO::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TopicDTO::getName, Comparator.nullsLast(Comparator.naturalOrder())); + + private final TopicFollowRepository topicFollowRepository; + private final TopicRepository topicRepository; + private final TopicAliasRepository topicAliasRepository; + private final TopicHotSnapshotRepository snapshotRepository; + private final TopicVisitRepository topicVisitRepository; + private final TopicFavoriteRepository topicFavoriteRepository; + private final AuthorizationService authorizationService; + private final AuditLogService auditLogService; + private final ContentReviewService contentReviewService; + private final ClickGuardService clickGuardService; + /** 话题合并后由桥接层迁移讨论(本类不直接碰 moment,守住域边界)。 */ + private final ApplicationEventPublisher eventPublisher; + + /** 话题发布自动审核总开关;关闭时退回旧的人工审核入口(创建后为 PENDING)。 */ + @Value("${app.topic.auto-review.enabled:false}") + private boolean autoReviewEnabled; + + @Value("${app.topic.heat.follower-weight:1}") + private long followerWeight = 1; + @Value("${app.topic.heat.visit-weight:1}") + private long visitWeight = 1; + @Value("${app.topic.heat.recent-follower-weight:5}") + private long recentFollowerWeight = 5; + @Value("${app.topic.heat.recent-visit-weight:3}") + private long recentVisitWeight = 3; + @Value("${app.topic.heat.recommended-weight:200}") + private long recommendedWeight = 200; + @Value("${app.topic.heat.pinned-weight:1000}") + private long pinnedWeight = 1000; + + public List categories() { + return CATEGORIES; + } + + public List listTopics(Integer limit) { + int safeLimit = limit == null || limit < 1 ? 20 : Math.min(limit, 100); + return publicTopics().stream().limit(safeLimit).collect(Collectors.toList()); + } + + public PageResult catalog(String keyword, String category, String sort, int page, int size) { + if (category != null && !category.trim().isEmpty() && !CATEGORIES.contains(category.trim())) { + throw new BusinessException("话题分类无效"); + } + String selectedSort = sort == null ? "hot" : sort.trim().toLowerCase(Locale.ROOT); + if (!Arrays.asList("hot", "latest", "rising").contains(selectedSort)) { + throw new BusinessException("排序方式无效"); + } + String search = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + List topics = publicTopics().stream() + .filter(topic -> category == null || category.trim().isEmpty() + || category.trim().equals(topic.getCategory())) + .filter(topic -> search.isEmpty() || matchesKeyword(topic, search)) + .collect(Collectors.toList()); + if ("latest".equals(selectedSort)) { + topics.sort(Comparator.comparing(TopicDTO::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TopicDTO::getName, Comparator.nullsLast(Comparator.naturalOrder()))); + } else if ("rising".equals(selectedSort)) { + topics.sort(Comparator.comparing((TopicDTO topic) -> topic.getHeatDelta() == null + ? Long.MIN_VALUE : topic.getHeatDelta()).reversed() + .thenComparing(TopicDTO::getHeatScore, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TopicDTO::getName, Comparator.nullsLast(Comparator.naturalOrder()))); + } else { + topics.sort(HEAT_ORDER); + } + int safePage = Math.max(1, page); + int safeSize = Math.min(Math.max(1, size), 100); + int from = (int) Math.min((long) (safePage - 1) * safeSize, topics.size()); + int to = Math.min(from + safeSize, topics.size()); + return PageResult.of(topics.subList(from, to), topics.size(), safePage, safeSize); + } + + public List rankings(String type, Integer limit) { + String selectedType = type == null ? "hot" : type.trim().toLowerCase(Locale.ROOT); + if (!Arrays.asList("hot", "rising", "new").contains(selectedType)) { + throw new BusinessException("榜单类型无效"); + } + List topics = publicTopics(); + if ("rising".equals(selectedType)) { + topics.removeIf(topic -> topic.getHeatDelta() == null || topic.getHeatDelta() <= 0); + topics.sort(Comparator.comparing(TopicDTO::getHeatDelta).reversed() + .thenComparing(TopicDTO::getHeatScore, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TopicDTO::getName, Comparator.nullsLast(Comparator.naturalOrder()))); + } else if ("new".equals(selectedType)) { + long cutoff = System.currentTimeMillis() - NEW_TOPIC_WINDOW_MILLIS; + topics.removeIf(topic -> topic.getCreatedAt() == null || topic.getCreatedAt().getTime() < cutoff); + topics.sort(Comparator.comparing(TopicDTO::getCreatedAt).reversed() + .thenComparing(TopicDTO::getHeatScore, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TopicDTO::getName, Comparator.nullsLast(Comparator.naturalOrder()))); + } else { + topics.sort(HEAT_ORDER); + } + return topics.stream().limit(limit == null || limit < 1 ? 20 : Math.min(limit, 100)) + .collect(Collectors.toList()); + } + + public List recommendations(Integer limit) { + int safeLimit = limit == null || limit < 1 ? 10 : Math.min(limit, 50); + List candidates = publicTopics().stream() + .filter(topic -> !Boolean.TRUE.equals(topic.getFollowed())) + .collect(Collectors.toList()); + List curated = candidates.stream() + .filter(topic -> Boolean.TRUE.equals(topic.getRecommended())) + .collect(Collectors.toList()); + List other = candidates.stream() + .filter(topic -> !Boolean.TRUE.equals(topic.getRecommended())) + .collect(Collectors.toList()); + Collections.shuffle(curated); + Collections.shuffle(other); + curated.addAll(other); + return curated.stream().limit(safeLimit).collect(Collectors.toList()); + } + + public List pendingTopics() { + authorizationService.requireReviewerOrAdmin(); + List pending = topicRepository.findByStatus("PENDING").stream() + .sorted(Comparator.comparing(Topic::getCreatedAt, + Comparator.nullsLast(Comparator.naturalOrder()))) + .collect(Collectors.toList()); + if (pending.isEmpty()) { + return new ArrayList<>(); + } + Map metrics = loadMetrics(pending.stream() + .map(Topic::getName).collect(Collectors.toList())); + Map> aliases = aliasesByTopicId(); + return pending.stream() + .map(topic -> toTopicDTO(topic, metrics.getOrDefault(topic.getName(), new TopicMetrics()), + aliases.getOrDefault(topic.getId(), Collections.emptyList()), false)) + .collect(Collectors.toList()); + } + + /** + * 广场可见集合:只包含 APPROVED 且"成型"(有简介、有分类)的话题。 + * 空壳话题(历史 SYSTEM 灌入的数据)一律不展示。 + */ + private List publicTopics() { + List approved = topicRepository.findAll().stream() + .filter(topic -> "APPROVED".equals(topic.getStatus())) + .filter(this::isCompleteTopic) + .collect(Collectors.toList()); + if (approved.isEmpty()) { + return new ArrayList<>(); + } + Map metrics = loadMetrics(approved.stream() + .map(Topic::getName).collect(Collectors.toList())); + String currentUserId = UserContext.getCurrentUserId(); + Set followed = followedTopicNames(currentUserId); + Set favorited = favoritedTopicNames(currentUserId); + Map> aliases = aliasesByTopicId(); + Map baseline = previousSnapshots(); + List result = new ArrayList<>(); + for (Topic topic : approved) { + TopicMetrics metric = metrics.getOrDefault(topic.getName(), new TopicMetrics()); + TopicDTO dto = toTopicDTO(topic, metric, + aliases.getOrDefault(topic.getId(), Collections.emptyList()), + followed.contains(topic.getName()), favorited.contains(topic.getName())); + TopicHotSnapshot old = baseline.get(topic.getName()); + if (old != null) { + long delta = dto.getHeatScore() - old.getHeatScore(); + dto.setHeatDelta(delta); + if (old.getHeatScore() > 0) { + dto.setHeatChangePercent((int) Math.round(100.0 * delta / old.getHeatScore())); + } + } + result.add(dto); + } + result.sort(HEAT_ORDER); + return result; + } + + private Map previousSnapshots() { + long now = System.currentTimeMillis(); + List snapshots = snapshotRepository.findByCapturedAtBetween( + new Date(now - 3L * 60 * 60 * 1000), new Date(now - 50L * 60 * 1000)); + Map previous = new HashMap<>(); + for (TopicHotSnapshot snapshot : snapshots) { + TopicHotSnapshot existing = previous.get(snapshot.getTopicName()); + if (existing == null || snapshot.getCapturedAt().after(existing.getCapturedAt())) { + previous.put(snapshot.getTopicName(), snapshot); + } + } + return previous; + } + + public TopicDTO getTopic(String topicName) { + String topic = resolveTopicName(topicName); + Topic record = topicRepository.findByName(topic) + .orElseThrow(() -> new BusinessException("话题不存在")); + if (!"APPROVED".equals(record.getStatus()) + && !Objects.equals(record.getCreatedBy(), UserContext.getCurrentUserId()) + && !authorizationService.isReviewerOrAdmin(UserContext.getCurrentUser())) { + throw new BusinessException("话题尚未上线"); + } + String currentUserId = UserContext.getCurrentUserId(); + return toTopicDTO(record, metricsFor(record.getName()), + aliasesByTopicId().getOrDefault(record.getId(), Collections.emptyList()), + followedTopicNames(currentUserId).contains(record.getName()), + isFavorited(currentUserId, record.getName())); + } + + @Transactional + public TopicFollowDTO toggleFollow(String topicName) { + String userId = currentUserId(); + String topic = resolveTopicName(topicName); + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_TOPIC_FOLLOW, userId, topic); + Topic visible = topicRepository.findByNameForInteraction(topic) + .orElseThrow(() -> new BusinessException("话题不存在或尚未上线")); + TopicFollow existing = topicFollowRepository.findForInteraction(userId, topic).orElse(null); + if (existing != null) { + topicFollowRepository.delete(existing); + TopicFollowDTO dto = new TopicFollowDTO(); + dto.setTopicName(topic); + dto.setFollowed(false); + return dto; + } + if (visible == null || !"APPROVED".equals(visible.getStatus())) { + throw new BusinessException("话题不存在或尚未上线"); + } + topicFollowRepository.upsertActive(UUID.randomUUID().toString(), userId, topic, visible.getId()); + TopicFollowDTO dto = new TopicFollowDTO(); + dto.setTopicName(topic); + dto.setFollowed(true); + return dto; + } + + /** + * 收藏 / 取消收藏话题(toggle)。 + * + *

收藏是"个人书签",与关注(订阅更新)独立计数:同一用户同一话题只有一行, + * {@code favoriteCount} 表示"收藏过该话题的人数"。

+ * + *

并发首次收藏用原生 upsert 兜底(见 {@link TopicFavoriteRepository#insertIgnore}), + * 不会因为唯一键冲突返回 500。

+ */ + @Transactional + public TopicFavoriteDTO toggleFavorite(String topicName) { + String userId = currentUserId(); + Topic topic = requireVisibleTopic(topicName); + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_TOPIC_FAVORITE, userId, topic.getName()); + topicRepository.findByNameForInteraction(topic.getName()) + .orElseThrow(() -> new BusinessException("话题不存在或尚未上线")); + TopicFavorite existing = topicFavoriteRepository + .findForInteraction(userId, topic.getName()).orElse(null); + boolean favorited; + if (existing != null) { + topicFavoriteRepository.delete(existing); + favorited = false; + } else { + topicFavoriteRepository.insertIgnore(UUID.randomUUID().toString(), userId, topic.getName(), topic.getId()); + favorited = true; + } + TopicFavoriteDTO dto = new TopicFavoriteDTO(); + dto.setTopicName(topic.getName()); + dto.setFavorited(favorited); + dto.setFavoriteCount(topicFavoriteRepository.countByTopicName(topic.getName())); + return dto; + } + + @Transactional + public TopicDTO createTopic(String name, String description) { + return createTopic(name, description, null, null); + } + + @Transactional + public TopicDTO createTopic(String name, String description, String category, String coverUrl) { + String normalized = normalize(name); + if (normalized.length() < 2 || normalized.length() > 15) { + throw new BusinessException("话题名称需要2到15个字符"); + } + if (description == null || description.trim().isEmpty() || description.trim().length() > 50) { + throw new BusinessException("话题简介需要1到50个字符"); + } + if (category == null || category.trim().isEmpty()) { + throw new BusinessException("请选择话题分类"); + } + String selectedCategory = category.trim(); + if (!CATEGORIES.contains(selectedCategory)) { + throw new BusinessException("话题分类无效"); + } + if (coverUrl != null && coverUrl.length() > 500) { + throw new BusinessException("话题封面地址过长"); + } + if (topicRepository.findByName(normalized).isPresent()) { + throw new BusinessException("话题名称已存在"); + } + // 软删的话题仍占用 uk_topic_name:必须查含已删除,否则会绕过查重、撞唯一键变成 500 + if (topicRepository.findByNameIncludingDeleted(normalized).isPresent()) { + throw new BusinessException("该名称曾被使用且已删除,请更换名称"); + } + if (topicAliasRepository.findByAlias(normalized).isPresent()) { + throw new BusinessException("该名称已被其他话题用作别名"); + } + if (topicAliasRepository.findByAliasIncludingDeleted(normalized).isPresent()) { + throw new BusinessException("该名称曾被使用且已删除,请更换名称"); + } + Topic topic = new Topic(); + topic.setName(normalized); + topic.setDescription(description.trim()); + topic.setCategory(selectedCategory); + topic.setCoverUrl(coverUrl); + topic.setCreatedBy(currentUserId()); + + // 自动审核开启:先审后存——命中违禁词直接让发布失败(400),不产生任何话题记录; + // 通过则以 APPROVED 落库并记录审核流水(发布即上线,不发通知)。 + ReviewResult review = null; + if (autoReviewEnabled) { + review = contentReviewService.previewTopicReview(normalized, description.trim(), coverUrl); + if (!review.isPassed()) { + throw new BusinessException(buildRejectMessage(review)); + } + } + + // 开关关闭时保持 PENDING,交由人工审核接口处理(回滚路径) + topic.setStatus(autoReviewEnabled ? "APPROVED" : "PENDING"); + topic = topicRepository.save(topic); + + if (review != null) { + contentReviewService.recordTopicApproval(topic.getId(), review); + } + + return toTopicDTO(topic, new TopicMetrics(), + aliasesByTopicId().getOrDefault(topic.getId(), Collections.emptyList()), false); + } + + /** 把审核原因转成发布失败文案:统一用「包含违禁词」,多条用分号连接。 */ + private String buildRejectMessage(ReviewResult review) { + List reasons = review.getViolationReasons().stream() + .map(reason -> reason.replace("包含敏感词:", "包含违禁词:")) + .distinct() + .collect(Collectors.toList()); + return reasons.isEmpty() ? "包含违禁词" : String.join(";", reasons); + } + + @Transactional + public TopicDTO updateTopic(String id, String description, Boolean recommended, Boolean pinned) { + return updateTopic(id, description, null, null, recommended, pinned); + } + + @Transactional + public TopicDTO updateTopic(String id, String description, String category, String coverUrl, + Boolean recommended, Boolean pinned) { + authorizationService.requireAdmin(); + Topic topic = findTopic(id); + if (description != null) { + if (description.trim().isEmpty() || description.trim().length() > 50) { + throw new BusinessException("话题简介需要1到50个字符"); + } + topic.setDescription(description.trim()); + } + if (category != null) { + if (!CATEGORIES.contains(category.trim())) { + throw new BusinessException("话题分类无效"); + } + topic.setCategory(category.trim()); + } + if (coverUrl != null) { + if (coverUrl.length() > 500) { + throw new BusinessException("话题封面地址过长"); + } + topic.setCoverUrl(coverUrl); + } + if (recommended != null) { + topic.setRecommended(recommended); + } + if (pinned != null) { + topic.setPinned(pinned); + } + topic = topicRepository.save(topic); + auditLogService.record("TOPIC_UPDATE", "TOPIC", id, null, + "recommended=" + topic.getRecommended() + ",pinned=" + topic.getPinned(), null); + return toTopicDTO(topic, metricsFor(topic.getName()), + aliasesByTopicId().getOrDefault(topic.getId(), Collections.emptyList()), false); + } + + /** + * 审核话题。入参同时兼容两套字段: + *
    + *
  • 新(前端约定):{@code status=APPROVED|REJECTED} + {@code reviewNote}
  • + *
  • 旧(历史后台):{@code approved=true|false} + {@code note}
  • + *
+ * 缺省或自相矛盾时报错,不再静默按"驳回"处理。 + */ + @Transactional + public TopicDTO reviewTopic(String id, TopicReviewRequest request) { + authorizationService.requireReviewerOrAdmin(); + boolean approved = resolveReviewDecision(request); + Topic topic = findTopic(id); + if ("APPROVED".equals(topic.getStatus()) || "MERGED".equals(topic.getStatus())) { + throw new BusinessException("话题已审核"); + } + topic.setStatus(approved ? "APPROVED" : "REJECTED"); + topic.setReviewedBy(UserContext.getCurrentUserId()); + topic.setReviewNote(resolveReviewNote(request)); + topic.setReviewedAt(new Date()); + topic = topicRepository.save(topic); + auditLogService.record("TOPIC_REVIEW", "TOPIC", id, null, + "status=" + topic.getStatus(), topic.getReviewNote()); + return toTopicDTO(topic, metricsFor(topic.getName()), + aliasesByTopicId().getOrDefault(topic.getId(), Collections.emptyList()), false); + } + + /** + * 删除话题:软删 + 改名释放名称。 + * + *

{@code uk_topic_name} 唯一索引包含软删行,只置 {@code deleted=true} 的话名字会一直被占用、 + * 无法重建同名话题。这里把话题名改成 {@code 原名_deleted_{id前8位}},并把按名字关联的 + * 关注 / 访问 / 热度快照(以及按 topic_id 关联的别名)一起改名,保证:

+ *
    + *
  • 原名可以被重新创建;
  • + *
  • 同名新话题不会继承旧话题的关注/访问/热度;
  • + *
  • 历史数据仍在库里,需要时还能恢复。
  • + *
+ */ + @Transactional + public void deleteTopic(String id) { + Topic topic = topicRepository.findById(id) + .orElseThrow(() -> new BusinessException("话题不存在")); + authorizationService.requireOwnerOrAdmin(topic.getCreatedBy()); + + String originalName = topic.getName(); + String suffix = "_deleted_" + (id.length() > 8 ? id.substring(0, 8) : id); + String base = originalName.length() > 80 ? originalName.substring(0, 80) : originalName; + String deletedName = base + suffix; + + // 先改从属数据,再改话题名;顺序反过来会让按旧名关联的行失去归属 + topicFollowRepository.renameTopicName(originalName, deletedName); + topicFavoriteRepository.renameTopicName(originalName, deletedName); + topicVisitRepository.renameTopicName(originalName, deletedName); + snapshotRepository.renameTopicName(originalName, deletedName); + topicAliasRepository.appendSuffixToAliases(id, suffix); + + topic.setDeleted(true); + topic.setName(deletedName); + topicRepository.save(topic); + + auditLogService.record("TOPIC_DELETE", "TOPIC", id, + "name=" + originalName, "deleted, name=" + deletedName, null); + } + + @Transactional + public TopicDTO addAlias(String id, String aliasValue) { + authorizationService.requireAdmin(); + Topic topic = findTopic(id); + String alias = normalize(aliasValue); + if (alias.isEmpty()) { + throw new BusinessException("别名不能为空"); + } + if (alias.equals(topic.getName())) { + throw new BusinessException("别名不能与话题名称相同"); + } + if (topicRepository.findByName(alias).isPresent()) { + throw new BusinessException("该名称已存在同名话题,不能作为别名"); + } + if (topicRepository.findByNameIncludingDeleted(alias).isPresent()) { + throw new BusinessException("该名称曾被使用且已删除,请更换"); + } + if (topicAliasRepository.findByAlias(alias).isPresent()) { + throw new BusinessException("话题别名已存在"); + } + if (topicAliasRepository.findByAliasIncludingDeleted(alias).isPresent()) { + throw new BusinessException("该别名曾被使用且已删除,请更换"); + } + TopicAlias item = new TopicAlias(); + item.setTopicId(id); + item.setAlias(alias); + topicAliasRepository.save(item); + auditLogService.record("TOPIC_ALIAS_ADD", "TOPIC", id, null, "alias=" + alias, null); + return toTopicDTO(topic, metricsFor(topic.getName()), + aliasesByTopicId().getOrDefault(topic.getId(), Collections.emptyList()), false); + } + + @Transactional + public TopicDTO merge(String sourceId, String targetId) { + authorizationService.requireAdmin(); + if (sourceId.equals(targetId)) { + throw new BusinessException("不能合并到自身"); + } + Topic source = findTopic(sourceId); + Topic target = findTopic(targetId); + if ("MERGED".equals(source.getStatus())) { + throw new BusinessException("该话题已合并"); + } + if (!"APPROVED".equals(target.getStatus()) || target.getMergedIntoId() != null) { + throw new BusinessException("只能合并到已上线的话题"); + } + source.setMergedIntoId(targetId); + source.setStatus("MERGED"); + topicRepository.save(source); + if (!topicAliasRepository.findByAlias(source.getName()).isPresent()) { + TopicAlias alias = new TopicAlias(); + alias.setTopicId(targetId); + alias.setAlias(source.getName()); + topicAliasRepository.save(alias); + } + for (TopicAlias alias : topicAliasRepository.findByTopicId(sourceId)) { + alias.setTopicId(targetId); + topicAliasRepository.save(alias); + } + for (TopicFollow follow : topicFollowRepository.findByTopicName(source.getName())) { + if (topicFollowRepository.findByUserIdAndTopicName(follow.getUserId(), target.getName()).isPresent()) { + topicFollowRepository.delete(follow); + } else { + follow.setTopicName(target.getName()); + follow.setTopicId(target.getId()); + topicFollowRepository.save(follow); + } + } + // 访问记录也要迁移,否则「最近访问」在合并后查不到被合并的话题 + for (TopicVisit visit : topicVisitRepository.findByTopicName(source.getName())) { + TopicVisit existing = topicVisitRepository + .findByUserIdAndTopicName(visit.getUserId(), target.getName()).orElse(null); + if (existing == null) { + visit.setTopicName(target.getName()); + visit.setTopicId(target.getId()); + topicVisitRepository.save(visit); + } else { + if (visit.getLastVisitedAt() != null + && (existing.getLastVisitedAt() == null + || visit.getLastVisitedAt().after(existing.getLastVisitedAt()))) { + existing.setTopicId(target.getId()); + existing.setLastVisitedAt(visit.getLastVisitedAt()); + topicVisitRepository.save(existing); + } + topicVisitRepository.delete(visit); + } + } + // 收藏记录同样要迁移:目标已有该用户的收藏时丢弃源行,避免撞唯一键 + for (TopicFavorite favorite : topicFavoriteRepository.findByTopicName(source.getName())) { + if (topicFavoriteRepository + .findByUserIdAndTopicName(favorite.getUserId(), target.getName()).isPresent()) { + topicFavoriteRepository.delete(favorite); + } else { + favorite.setTopicName(target.getName()); + favorite.setTopicId(target.getId()); + topicFavoriteRepository.save(favorite); + } + } + auditLogService.record("TOPIC_MERGE", "TOPIC", sourceId, "name=" + source.getName(), + "mergedInto=" + targetId, null); + // 讨论存在 moment 表(动态侧存储),按域边界不在这里直接改; + // 发事件由 TopicDiscussionService 同步、同事务迁移 topic_id,失败则合并一起回滚。 + eventPublisher.publishEvent(new TopicMergedEvent(sourceId, targetId)); + return toTopicDTO(target, metricsFor(target.getName()), + aliasesByTopicId().getOrDefault(target.getId(), Collections.emptyList()), false); + } + + /** 每小时采集一次话题热度快照(只遍历话题域,不读动态)。 */ + @Transactional + public int captureHotSnapshot() { + List approved = topicRepository.findAll().stream() + .filter(topic -> "APPROVED".equals(topic.getStatus())) + .filter(this::isCompleteTopic) + .collect(Collectors.toList()); + if (approved.isEmpty()) { + return 0; + } + Map metrics = loadMetrics(approved.stream() + .map(Topic::getName).collect(Collectors.toList())); + Date capturedAt = new Date(); + int captured = 0; + for (Topic topic : approved) { + TopicMetrics metric = metrics.getOrDefault(topic.getName(), new TopicMetrics()); + TopicHotSnapshot snapshot = new TopicHotSnapshot(); + snapshot.setTopicName(topic.getName()); + snapshot.setTopicId(topic.getId()); + // moment_count 已废弃,固定写 0(列 NOT NULL,保留以避免 DDL 风险) + snapshot.setMomentCount(0L); + snapshot.setFollowerCount(metric.followerCount); + snapshot.setVisitCount(metric.visitCount); + snapshot.setHeatScore(heatOf(topic, metric)); + snapshot.setCapturedAt(capturedAt); + snapshotRepository.save(snapshot); + captured++; + } + return captured; + } + + public List listFollowing() { + String userId = currentUserId(); + Set names = followedTopicNames(userId); + return publicTopics().stream() + .filter(topic -> names.contains(topic.getName())) + .collect(Collectors.toList()); + } + + @Transactional + public void recordVisit(String topicName) { + String userId = currentUserId(); + TopicDTO topic = getTopic(topicName); + TopicVisit visit = topicVisitRepository.findByUserIdAndTopicName(userId, topic.getName()) + .orElseGet(() -> { + TopicVisit created = new TopicVisit(); + created.setUserId(userId); + created.setTopicName(topic.getName()); + return created; + }); + visit.setTopicId(topic.getId()); + visit.setLastVisitedAt(new Date()); + topicVisitRepository.save(visit); + } + + public MyTopicsDTO myTopics() { + String userId = currentUserId(); + List publicList = publicTopics(); + Map publicByName = publicList.stream() + .collect(Collectors.toMap(TopicDTO::getName, topic -> topic, (a, b) -> a)); + Set followed = followedTopicNames(userId); + List created = topicRepository.findByCreatedByOrderByCreatedAtDesc(userId); + List visits = topicVisitRepository.findByUserIdOrderByLastVisitedAtDesc(userId); + + List notPublicNames = created.stream().map(Topic::getName) + .filter(name -> !publicByName.containsKey(name)) + .collect(Collectors.toList()); + Map metrics = loadMetrics(notPublicNames); + Map> aliases = aliasesByTopicId(); + + MyTopicsDTO result = new MyTopicsDTO(); + result.setFollowingCount(followed.stream().filter(publicByName::containsKey).count()); + result.setCreatedCount(created.size()); + result.setFollowing(publicList.stream() + .filter(topic -> followed.contains(topic.getName())) + .collect(Collectors.toList())); + result.setCreated(created.stream().map(topic -> { + TopicDTO publicTopic = publicByName.get(topic.getName()); + if (publicTopic != null) { + return publicTopic; + } + return toTopicDTO(topic, metrics.getOrDefault(topic.getName(), new TopicMetrics()), + aliases.getOrDefault(topic.getId(), Collections.emptyList()), + followed.contains(topic.getName())); + }).collect(Collectors.toList())); + + List recent = new ArrayList<>(); + for (TopicVisit visit : visits) { + TopicDTO publicTopic = publicByName.get(visit.getTopicName()); + if (publicTopic == null) { + continue; + } + TopicDTO dto = copyTopicDTO(publicTopic); + dto.setLastVisitedAt(visit.getLastVisitedAt()); + recent.add(dto); + if (recent.size() == 20) { + break; + } + } + result.setRecentVisits(recent); + + // 我收藏的话题:只保留广场可见的,按收藏时间倒序,最多 20 条 + List favoriteRows = topicFavoriteRepository.findByUserIdOrderByCreatedAtDesc(userId); + List favorites = new ArrayList<>(); + long visibleFavorites = 0; + for (TopicFavorite favorite : favoriteRows) { + TopicDTO publicTopic = publicByName.get(favorite.getTopicName()); + if (publicTopic == null) { + continue; + } + visibleFavorites++; + if (favorites.size() < 20) { + favorites.add(copyTopicDTO(publicTopic)); + } + } + result.setFavoriteCount(visibleFavorites); + result.setFavorites(favorites); + return result; + } + + private TopicDTO copyTopicDTO(TopicDTO source) { + TopicDTO dto = new TopicDTO(); + dto.setId(source.getId()); + dto.setName(source.getName()); + dto.setDescription(source.getDescription()); + dto.setCategory(source.getCategory()); + dto.setCoverUrl(source.getCoverUrl()); + dto.setStatus(source.getStatus()); + dto.setRecommended(source.getRecommended()); + dto.setPinned(source.getPinned()); + dto.setTopicTags(source.getTopicTags() == null + ? new ArrayList<>() : new ArrayList<>(source.getTopicTags())); + dto.setAliases(source.getAliases() == null + ? new ArrayList<>() : new ArrayList<>(source.getAliases())); + dto.setFollowerCount(source.getFollowerCount()); + dto.setVisitCount(source.getVisitCount()); + dto.setFavoriteCount(source.getFavoriteCount()); + dto.setHeatScore(source.getHeatScore()); + dto.setHeatDelta(source.getHeatDelta()); + dto.setHeatChangePercent(source.getHeatChangePercent()); + dto.setFollowed(source.getFollowed()); + dto.setFavorited(source.getFavorited()); + dto.setCreatedAt(source.getCreatedAt()); + return dto; + } + + // ------------------------------------------------------------------ + // 话题域指标 + // ------------------------------------------------------------------ + + /** 批量加载话题指标(固定 4 次聚合查询,与话题数量、动态数量无关)。 */ + private Map loadMetrics(Collection topicNames) { + Map metrics = new HashMap<>(); + if (topicNames == null || topicNames.isEmpty()) { + return metrics; + } + for (String name : topicNames) { + metrics.put(name, new TopicMetrics()); + } + Date recentSince = new Date(System.currentTimeMillis() - RECENT_WINDOW_MILLIS); + applyCounts(metrics, topicFollowRepository.countGroupByTopicNameIn(topicNames), + (metric, value) -> metric.followerCount = value); + applyCounts(metrics, topicFollowRepository.countRecentGroupByTopicNameIn(topicNames, recentSince), + (metric, value) -> metric.recentFollowerCount = value); + applyCounts(metrics, topicVisitRepository.countGroupByTopicNameIn(topicNames), + (metric, value) -> metric.visitCount = value); + applyCounts(metrics, topicFavoriteRepository.countGroupByTopicNameIn(topicNames), + (metric, value) -> metric.favoriteCount = value); + applyCounts(metrics, topicVisitRepository.countRecentGroupByTopicNameIn(topicNames, recentSince), + (metric, value) -> metric.recentVisitCount = value); + return metrics; + } + + private void applyCounts(Map metrics, List rows, + java.util.function.BiConsumer setter) { + if (rows == null) { + return; + } + for (Object[] row : rows) { + if (row == null || row.length < 2 || row[0] == null) { + continue; + } + TopicMetrics metric = metrics.get(String.valueOf(row[0])); + if (metric == null) { + continue; + } + setter.accept(metric, row[1] instanceof Number ? ((Number) row[1]).longValue() : 0L); + } + } + + private long heatOf(Topic topic, TopicMetrics metric) { + long heat = followerWeight * metric.followerCount + + visitWeight * metric.visitCount + + recentFollowerWeight * metric.recentFollowerCount + + recentVisitWeight * metric.recentVisitCount; + if (Boolean.TRUE.equals(topic.getRecommended())) { + heat += recommendedWeight; + } + if (Boolean.TRUE.equals(topic.getPinned())) { + heat += pinnedWeight; + } + return heat; + } + + private TopicMetrics metricsFor(String name) { + return loadMetrics(Collections.singletonList(name)) + .getOrDefault(name, new TopicMetrics()); + } + + private Map> aliasesByTopicId() { + Map> result = new HashMap<>(); + for (TopicAlias alias : topicAliasRepository.findAll()) { + result.computeIfAbsent(alias.getTopicId(), key -> new ArrayList<>()).add(alias.getAlias()); + } + return result; + } + + private Set followedTopicNames(String userId) { + if (userId == null || userId.trim().isEmpty()) { + return Collections.emptySet(); + } + return topicFollowRepository.findByUserId(userId).stream() + .map(TopicFollow::getTopicName).collect(Collectors.toSet()); + } + + /** 当前用户收藏的话题名集合(未登录返回空集合,不查库)。 */ + private Set favoritedTopicNames(String userId) { + if (userId == null || userId.trim().isEmpty()) { + return Collections.emptySet(); + } + return topicFavoriteRepository.findByUserIdOrderByCreatedAtDesc(userId).stream() + .map(TopicFavorite::getTopicName).collect(Collectors.toSet()); + } + + private boolean isFavorited(String userId, String topicName) { + if (userId == null || userId.trim().isEmpty() || topicName == null) { + return false; + } + return topicFavoriteRepository.findByUserIdAndTopicName(userId, topicName).isPresent(); + } + + private boolean matchesKeyword(TopicDTO topic, String search) { + return topic.getName() != null && topic.getName().toLowerCase(Locale.ROOT).contains(search) + || topic.getDescription() != null + && topic.getDescription().toLowerCase(Locale.ROOT).contains(search); + } + + /** 成型话题:必须有简介与分类;空壳(历史 SYSTEM 灌入)不进广场。 */ + private boolean isCompleteTopic(Topic topic) { + return !isBlank(topic.getDescription()) && !isBlank(topic.getCategory()); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private TopicDTO toTopicDTO(Topic topic, TopicMetrics metric, List aliases, boolean followed) { + return toTopicDTO(topic, metric, aliases, followed, false); + } + + private TopicDTO toTopicDTO(Topic topic, TopicMetrics metric, List aliases, + boolean followed, boolean favorited) { + TopicDTO dto = new TopicDTO(); + dto.setId(topic.getId()); + dto.setName(topic.getName()); + dto.setDescription(topic.getDescription()); + dto.setCategory(topic.getCategory()); + dto.setCoverUrl(topic.getCoverUrl()); + dto.setStatus(topic.getStatus()); + if (Objects.equals(topic.getCreatedBy(), UserContext.getCurrentUserId()) + || authorizationService.isReviewerOrAdmin(UserContext.getCurrentUser())) { + dto.setReviewNote(topic.getReviewNote()); + } + dto.setRecommended(Boolean.TRUE.equals(topic.getRecommended())); + dto.setPinned(Boolean.TRUE.equals(topic.getPinned())); + dto.setAliases(aliases == null ? new ArrayList<>() : new ArrayList<>(aliases)); + dto.setFollowerCount(metric.followerCount); + dto.setVisitCount(metric.visitCount); + dto.setFavoriteCount(metric.favoriteCount); + dto.setHeatScore(heatOf(topic, metric)); + dto.setTopicTags(buildTopicTags(topic)); + dto.setFollowed(followed); + dto.setFavorited(favorited); + dto.setCreatedAt(topic.getCreatedAt()); + return dto; + } + + private List buildTopicTags(Topic topic) { + List topicTags = new ArrayList<>(); + if (topic == null) { + return topicTags; + } + if (Boolean.TRUE.equals(topic.getRecommended())) { + topicTags.add("热门"); + } + Date createdAt = topic.getCreatedAt(); + if (createdAt != null + && createdAt.getTime() >= System.currentTimeMillis() - NEW_TOPIC_WINDOW_MILLIS) { + topicTags.add("新"); + } + return topicTags; + } + + private Boolean resolveReviewDecision(TopicReviewRequest request) { + Boolean approved = request == null ? null : request.getApproved(); + String status = request == null || request.getStatus() == null + ? "" : request.getStatus().trim().toUpperCase(Locale.ROOT); + Boolean fromStatus = null; + if (!status.isEmpty()) { + if ("APPROVED".equals(status)) { + fromStatus = Boolean.TRUE; + } else if ("REJECTED".equals(status)) { + fromStatus = Boolean.FALSE; + } else { + throw new BusinessException("审核状态无效"); + } + } + if (approved == null && fromStatus == null) { + throw new BusinessException("审核结果不能为空"); + } + if (approved != null && fromStatus != null && !approved.equals(fromStatus)) { + throw new BusinessException("审核结果不明确"); + } + return approved != null ? approved : fromStatus; + } + + private String resolveReviewNote(TopicReviewRequest request) { + if (request == null) { + return null; + } + String reviewNote = request.getReviewNote(); + if (reviewNote != null && !reviewNote.trim().isEmpty()) { + return reviewNote.trim(); + } + String note = request.getNote(); + return note == null || note.trim().isEmpty() ? null : note.trim(); + } + + private Topic findTopic(String id) { + return topicRepository.findById(id).orElseThrow(() -> new BusinessException("话题不存在")); + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + String topic = value.trim(); + while (topic.startsWith("#")) { + topic = topic.substring(1).trim(); + } + return topic; + } + + private String resolveTopicName(String value) { + String normalized = normalize(value); + TopicAlias alias = topicAliasRepository.findByAlias(normalized).orElse(null); + if (alias == null) { + return normalized; + } + return topicRepository.findById(alias.getTopicId()).map(Topic::getName).orElse(normalized); + } + + /** + * 话题名/别名 → 已上线话题。 + * + *

收藏这类"用户主动关联"的动作只允许作用在 {@code APPROVED} 话题上; + * 不存在与未上线统一返回「话题不存在或尚未上线」,与 {@code /follow} 口径一致。

+ */ + private Topic requireVisibleTopic(String topicName) { + String name = resolveTopicName(topicName); + Topic record = topicRepository.findByName(name) + .orElseThrow(() -> new BusinessException("话题不存在或尚未上线")); + if (!"APPROVED".equals(record.getStatus())) { + throw new BusinessException("话题不存在或尚未上线"); + } + return record; + } + + private String currentUserId() { + String userId = UserContext.getCurrentUserId(); + if (userId == null || userId.trim().isEmpty()) { + throw new BusinessException("用户未登录"); + } + return userId; + } + + /** 话题域指标:关注、访问、收藏及其 7 天增长。 */ + private static class TopicMetrics { + private long followerCount; + private long visitCount; + private long favoriteCount; + private long recentFollowerCount; + private long recentVisitCount; + } +} diff --git a/campus-server/src/main/java/com/campus/service/TopicSnapshotScheduler.java b/campus-server/src/main/java/com/campus/service/TopicSnapshotScheduler.java new file mode 100644 index 0000000..7275003 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/TopicSnapshotScheduler.java @@ -0,0 +1,16 @@ +package com.campus.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TopicSnapshotScheduler { + private final TopicService topicService; + + @Scheduled(cron = "0 5 * * * ?") + public void capture() { + topicService.captureHotSnapshot(); + } +} diff --git a/campus-server/src/main/java/com/campus/service/UniversityService.java b/campus-server/src/main/java/com/campus/service/UniversityService.java new file mode 100644 index 0000000..16e7f48 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/UniversityService.java @@ -0,0 +1,313 @@ +package com.campus.service; + +import com.campus.dto.request.UniversityQueryRequest; +import com.campus.dto.response.*; +import com.campus.entity.College; +import com.campus.entity.Major; +import com.campus.entity.University; +import com.campus.repository.CollegeRepository; +import com.campus.repository.MajorRepository; +import com.campus.repository.UniversityRepository; +import com.campus.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import com.campus.util.PageUtils; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UniversityService { + + private final UniversityRepository universityRepository; + private final CollegeRepository collegeRepository; + private final MajorRepository majorRepository; + private final UserRepository userRepository; + + @Cacheable(value = "universities", key = "'all'") + public List getAllUniversities() { + List universities = universityRepository.findAll(); + return universities.stream().map(this::toDTO).collect(Collectors.toList()); + } + + public PageResult searchUniversities(UniversityQueryRequest request) { + int page = request.getPage() != null ? request.getPage() : 1; + int size = request.getSize() != null ? request.getSize() : 20; + Pageable pageRequest = PageUtils.request(page, size, Sort.by("name").ascending()); + + Page pageResult; + if (StringUtils.hasText(request.getKeyword())) { + pageResult = universityRepository.findByNameContaining(request.getKeyword(), pageRequest); + } else { + pageResult = universityRepository.findByConditions( + request.getProvince(), request.getCity(), + request.getLevel(), request.getType(), pageRequest); + } + + List dtos = pageResult.getContent().stream() + .map(this::toDTO) + .collect(Collectors.toList()); + + return PageResult.of(dtos, pageResult.getTotalElements(), page, size); + } + + public UniversityDTO getUniversityById(String id) { + University university = universityRepository.findById(id) + .orElseThrow(() -> new RuntimeException("大学不存在")); + return toDTO(university); + } + + public List getCollegesByUniversity(String universityId) { + University university = universityRepository.findById(universityId) + .orElseThrow(() -> new RuntimeException("大学不存在")); + List colleges = collegeRepository.findByUniversityId(universityId); + return colleges.stream() + .map(c -> toCollegeDTO(c, university.getName())) + .collect(Collectors.toList()); + } + + public List getAllColleges() { + List colleges = collegeRepository.findAll(); + if (colleges.isEmpty()) { + return Collections.emptyList(); + } + List universityIds = colleges.stream() + .map(College::getUniversityId) + .distinct() + .collect(Collectors.toList()); + Map universityNameMap = universityRepository.findAllById(universityIds) + .stream() + .collect(Collectors.toMap(University::getId, University::getName)); + + return colleges.stream() + .map(c -> toCollegeDTO(c, universityNameMap.get(c.getUniversityId()))) + .collect(Collectors.toList()); + } + + public List getMajorsByCollege(String collegeId) { + College college = collegeRepository.findById(collegeId) + .orElseThrow(() -> new RuntimeException("学院不存在")); + University university = universityRepository.findById(college.getUniversityId()) + .orElse(null); + String universityName = university != null ? university.getName() : null; + + List majors = majorRepository.findByCollegeId(collegeId); + return majors.stream() + .map(m -> toMajorDTO(m, college.getName(), universityName)) + .collect(Collectors.toList()); + } + + public List getMajorsByUniversity(String universityId) { + List colleges = collegeRepository.findByUniversityId(universityId); + if (colleges.isEmpty()) { + return Collections.emptyList(); + } + + University university = universityRepository.findById(universityId) + .orElse(null); + String universityName = university != null ? university.getName() : null; + + Map collegeNameMap = colleges.stream() + .collect(Collectors.toMap(College::getId, College::getName)); + + List collegeIds = colleges.stream() + .map(College::getId) + .collect(Collectors.toList()); + + List majors = majorRepository.findAll().stream() + .filter(m -> collegeIds.contains(m.getCollegeId())) + .collect(Collectors.toList()); + + return majors.stream() + .map(m -> toMajorDTO(m, collegeNameMap.get(m.getCollegeId()), universityName)) + .collect(Collectors.toList()); + } + + public List getAllMajors() { + List majors = majorRepository.findAll(); + if (majors.isEmpty()) { + return Collections.emptyList(); + } + List collegeIds = majors.stream() + .map(Major::getCollegeId) + .distinct() + .collect(Collectors.toList()); + List colleges = collegeRepository.findAllById(collegeIds); + Map collegeNameMap = colleges.stream() + .collect(Collectors.toMap(College::getId, College::getName)); + Map collegeUniversityMap = colleges.stream() + .collect(Collectors.toMap(College::getId, College::getUniversityId)); + List universityIdStrings = colleges.stream() + .map(College::getUniversityId) + .distinct() + .collect(Collectors.toList()); + Map universityNameMap = universityRepository.findAllById(universityIdStrings) + .stream() + .collect(Collectors.toMap(University::getId, University::getName)); + + return majors.stream() + .map(m -> { + MajorDTO dto = toMajorDTO(m, collegeNameMap.get(m.getCollegeId()), + universityNameMap.get(collegeUniversityMap.get(m.getCollegeId()))); + return dto; + }) + .collect(Collectors.toList()); + } + + /** + * 查询附近大学(按距离由近及远) + * + * @param latitude 中心点纬度 + * @param longitude 中心点经度 + * @return 附近大学列表(含距离) + */ + public List getNearbyUniversities(BigDecimal latitude, BigDecimal longitude) { + List universities = universityRepository.findWithCoordinates(); + double centerLat = latitude.doubleValue(); + double centerLng = longitude.doubleValue(); + + return universities.stream() + .map(u -> { + UniversityDistanceDTO dto = new UniversityDistanceDTO(); + dto.setId(u.getId()); + dto.setName(u.getName()); + dto.setShortName(u.getShortName()); + dto.setLogoUrl(u.getLogoUrl()); + dto.setProvince(u.getProvince()); + dto.setCity(u.getCity()); + dto.setLevel(u.getLevel()); + dto.setType(u.getType()); + dto.setLatitude(u.getLatitude()); + dto.setLongitude(u.getLongitude()); + dto.setDistanceKm(haversine(centerLat, centerLng, + u.getLatitude().doubleValue(), u.getLongitude().doubleValue())); + return dto; + }) + .sorted((a, b) -> Double.compare(a.getDistanceKm(), b.getDistanceKm())) + .collect(Collectors.toList()); + } + + /** 认证校友地图聚合:返回高校坐标及平台注册用户/认证用户数量。 */ + @Cacheable(value = "universities", key = "'map:' + (#province ?: 'all') + ':' + (#city ?: 'all')") + public List getMapStats(String province, String city) { + Map statsByUniversityId = toUniversityStats( + userRepository.countUsersGroupedByUniversityId(), 0); + Map statsByUniversityName = toUniversityStats( + userRepository.countUsersGroupedByUniversityName(), 0); + + return universityRepository.findByConditions(province, city, null, null).stream() + .filter(u -> u.getLatitude() != null && u.getLongitude() != null) + .map(u -> { + UniversityMapStatsDTO dto = new UniversityMapStatsDTO(); + dto.setId(u.getId()); + dto.setName(u.getName()); + dto.setShortName(u.getShortName()); + dto.setProvince(u.getProvince()); + dto.setCity(u.getCity()); + dto.setLatitude(u.getLatitude()); + dto.setLongitude(u.getLongitude()); + + long[] stats = u.getId() != null ? statsByUniversityId.get(u.getId()) : null; + if (stats == null) { + stats = statsByUniversityName.get(u.getName()); + } + dto.setUserCount(stats != null ? stats[0] : 0L); + dto.setCertifiedUserCount(stats != null ? stats[1] : 0L); + return dto; + }).sorted((a, b) -> Long.compare(b.getCertifiedUserCount(), a.getCertifiedUserCount())) + .collect(Collectors.toList()); + } + + private Map toUniversityStats(List rows, int keyIndex) { + Map result = new HashMap<>(); + for (Object[] row : rows) { + if (row == null || row.length < 3 || row[keyIndex] == null) { + continue; + } + String key = String.valueOf(row[keyIndex]); + long users = row[1] instanceof Number ? ((Number) row[1]).longValue() : 0L; + long certified = row[2] instanceof Number ? ((Number) row[2]).longValue() : 0L; + result.put(key, new long[]{users, certified}); + } + return result; + } + + /** + * Haversine公式计算两点间距离(km) + */ + private static double haversine(double lat1, double lng1, double lat2, double lng2) { + double dLat = Math.toRadians(lat2 - lat1); + double dLng = Math.toRadians(lng2 - lng1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLng / 2) * Math.sin(dLng / 2); + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + private UniversityDTO toDTO(University u) { + UniversityDTO dto = new UniversityDTO(); + dto.setId(u.getId()); + dto.setName(u.getName()); + dto.setSchoolCode(u.getSchoolCode()); + dto.setShortName(u.getShortName()); + dto.setLogoUrl(u.getLogoUrl()); + dto.setProvince(u.getProvince()); + dto.setCity(u.getCity()); + dto.setLevel(u.getLevel()); + dto.setType(u.getType()); + dto.setFoundedYear(u.getFoundedYear()); + dto.setDescription(u.getDescription()); + dto.setLatitude(u.getLatitude()); + dto.setLongitude(u.getLongitude()); + dto.setCreatedAt(u.getCreatedAt()); + dto.setUpdatedAt(u.getUpdatedAt()); + return dto; + } + + private CollegeDTO toCollegeDTO(College c, String universityName) { + CollegeDTO dto = new CollegeDTO(); + dto.setId(c.getId()); + dto.setUniversityId(c.getUniversityId()); + dto.setUniversityName(universityName); + dto.setName(c.getName()); + dto.setShortName(c.getShortName()); + dto.setDescription(c.getDescription()); + dto.setCreatedAt(c.getCreatedAt()); + dto.setUpdatedAt(c.getUpdatedAt()); + return dto; + } + + private MajorDTO toMajorDTO(Major m, String collegeName, String universityName) { + MajorDTO dto = new MajorDTO(); + dto.setId(m.getId()); + dto.setCollegeId(m.getCollegeId()); + dto.setCollegeName(collegeName); + dto.setUniversityId(null); + dto.setUniversityName(universityName); + dto.setName(m.getName()); + dto.setShortName(m.getShortName()); + dto.setDegreeType(m.getDegreeType()); + dto.setLevel(m.getLevel()); + dto.setDescription(m.getDescription()); + dto.setCreatedAt(m.getCreatedAt()); + dto.setUpdatedAt(m.getUpdatedAt()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/UserAccountCleanupService.java b/campus-server/src/main/java/com/campus/service/UserAccountCleanupService.java new file mode 100644 index 0000000..c32de5b --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/UserAccountCleanupService.java @@ -0,0 +1,179 @@ +package com.campus.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 注销账号时的用户数据清理。 + * + * 测试阶段“注销”语义按“清除账号数据、允许重新注册”处理, + * 与退出登录不同:退出登录只删除 Redis 会话,这里会删除用户主记录和该用户产生的业务数据。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserAccountCleanupService { + + private final JdbcTemplate jdbcTemplate; + + @Transactional + public void deleteUserData(String userId) { + String uid = userId; + + // 删除关系前按现有有效明细修正仍存活对象的计数。 + update("UPDATE activity a SET current_count=(SELECT COUNT(*) FROM activity_participant p WHERE p.activity_id=a.id AND p.deleted=false AND p.status='CONFIRMED' AND p.user_id<>?) WHERE a.creator_id<>? AND a.id IN (SELECT activity_id FROM activity_participant WHERE user_id=?)", uid, uid, uid); + update("UPDATE chat_group g SET member_count=(SELECT COUNT(*) FROM group_member m WHERE m.group_id=g.id AND m.deleted=false AND m.role<>'PENDING' AND m.user_id<>?) WHERE g.owner_id<>? AND g.id IN (SELECT group_id FROM group_member WHERE user_id=?)", uid, uid, uid); + update("UPDATE user u SET followers_count=(SELECT COUNT(*) FROM user_follow f WHERE f.following_id=u.id AND f.deleted=false AND f.follower_id<>?), following_count=(SELECT COUNT(*) FROM user_follow f WHERE f.follower_id=u.id AND f.deleted=false AND f.following_id<>?) WHERE u.id<>? AND (u.id IN (SELECT following_id FROM user_follow WHERE follower_id=?) OR u.id IN (SELECT follower_id FROM user_follow WHERE following_id=?))", uid,uid,uid,uid,uid); + update("UPDATE moment m SET like_count=(SELECT COUNT(*) FROM moment_like l WHERE l.moment_id=m.id AND l.deleted=false AND l.user_id<>?) WHERE m.user_id<>? AND m.id IN (SELECT moment_id FROM moment_like WHERE user_id=?)",uid,uid,uid); + update("UPDATE moment m SET comment_count=(SELECT COUNT(*) FROM moment_comment c WHERE c.moment_id=m.id AND c.deleted=false AND c.status='ACTIVE' AND c.user_id<>?) WHERE m.user_id<>? AND m.id IN (SELECT moment_id FROM moment_comment WHERE user_id=?)",uid,uid,uid); + update("UPDATE moment_comment c SET like_count=(SELECT COUNT(*) FROM moment_comment_like l WHERE l.comment_id=c.id AND l.deleted=false AND l.user_id<>?) WHERE c.user_id<>? AND c.id IN (SELECT comment_id FROM moment_comment_like WHERE user_id=?)",uid,uid,uid); + update("UPDATE moment_comment c SET reply_count=(SELECT COUNT(*) FROM (SELECT parent_id,user_id,deleted,status FROM moment_comment) replies WHERE replies.parent_id=c.id AND replies.deleted=false AND replies.status='ACTIVE' AND replies.user_id<>?) WHERE c.user_id<>? AND c.id IN (SELECT parent_id FROM (SELECT parent_id,user_id FROM moment_comment) changed WHERE changed.user_id=?)",uid,uid,uid); + // 1. 个人设置、积分、任务、匹配偏好等强绑定数据 + updateIfTableExists("user_achievement", "DELETE FROM `user_achievement` WHERE `user_id` = ?", uid); + updateIfTableExists("share_request", "DELETE FROM `share_request` WHERE `sender_id` = ?", uid); + update("DELETE FROM `user_interests` WHERE `user_id` = ?", uid); + update("DELETE FROM `user_device_token` WHERE `user_id` = ?", uid); + update("DELETE FROM `user_points` WHERE `user_id` = ?", uid); + update("DELETE FROM `user_privacy_setting` WHERE `user_id` = ?", uid); + update("DELETE FROM `match_preference` WHERE `user_id` = ?", uid); + update("DELETE FROM `task_claim` WHERE `user_id` = ?", uid); + updateIfTableExists("user_task_progress", "DELETE FROM `user_task_progress` WHERE `user_id` = ?", uid); + updateIfTableExists("user_entitlement", "DELETE FROM `user_entitlement` WHERE `user_id` = ?", uid); + updateIfTableExists("reward_issuance_log", "DELETE FROM `reward_issuance_log` WHERE `user_id` = ?", uid); + updateIfTableExists("idempotency_record", "DELETE FROM `idempotency_record` WHERE `user_id` = ?", uid); + updateIfTableExists("user_session", "DELETE FROM `user_session` WHERE `user_id` = ?", uid); + updateIfTableExists("notification_preference", "DELETE FROM `notification_preference` WHERE `user_id` = ?", uid); + updateIfTableExists("content_draft_version", "DELETE FROM `content_draft_version` WHERE `user_id` = ?", uid); + updateIfTableExists("user_blocked_moment", "DELETE FROM `user_blocked_moment` WHERE `user_id` = ?", uid); + update("DELETE FROM `topic_follow` WHERE `user_id` = ?", uid); + // 访问记录此前漏清理,导致注销后访问人数虚高,这里一并补上 + updateIfTableExists("topic_visit", "DELETE FROM `topic_visit` WHERE `user_id` = ?", uid); + // 收藏表可能尚未建(迁移脚本需人工执行),用存在性检查避免整个注销事务回滚 + updateIfTableExists("topic_favorite", "DELETE FROM `topic_favorite` WHERE `user_id` = ?", uid); + + updateIfTableExists("user_restriction", "UPDATE `user_restriction` SET `created_by` = 'SYSTEM' WHERE `created_by` = ?", uid); + updateIfTableExists("user_restriction", "UPDATE `user_restriction` SET `revoked_by` = NULL WHERE `revoked_by` = ?", uid); + updateIfTableExists("user_restriction", "DELETE FROM `user_restriction` WHERE `user_id` = ?", uid); + updateIfTableExists("content_appeal", "UPDATE `content_appeal` SET `reviewed_by` = NULL WHERE `reviewed_by` = ?", uid); + updateIfTableExists("content_appeal", "DELETE FROM `content_appeal` WHERE `user_id` = ?", uid); + + // 2. 好友、关注、打招呼、匹配关系 + update("DELETE FROM `user_follow` WHERE `follower_id` = ? OR `following_id` = ?", uid, uid); + update("DELETE FROM `friendship` WHERE `user_id` = ? OR `friend_id` = ?", uid, uid); + update("DELETE FROM `greeting` WHERE `user_id` = ? OR `target_user_id` = ?", uid, uid); + update("DELETE FROM `match_action` WHERE `user_id` = ? OR `target_user_id` = ?", uid, uid); + update("DELETE FROM `match_pair` WHERE `user_low_id` = ? OR `user_high_id` = ?", uid, uid); + + // 3. 私聊、群聊、通知 + update("DELETE FROM `chat_conversation` WHERE `user_id` = ?", uid); + update("DELETE FROM `chat_message` WHERE `sender_id` = ? OR `conversation_id` LIKE CONCAT('%', ?, '%')", uid, uid); + update("DELETE FROM `notification` WHERE `user_id` = ? OR `sender_id` = ?", uid, uid); + updateIfTableExists("chat_report", "UPDATE `chat_report` SET `reviewed_by` = NULL WHERE `reviewed_by` = ?", uid); + updateIfTableExists("chat_report", "DELETE FROM `chat_report` WHERE `reporter_id` = ? OR `reported_user_id` = ?", uid, uid); + + // 4. 动态、评论、点赞、收藏 + update("DELETE FROM `moment_comment_like` WHERE `user_id` = ? OR `comment_id` IN " + + "(SELECT `id` FROM `moment_comment` WHERE `user_id` = ? OR `moment_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?))", uid, uid, uid); + update("DELETE FROM `moment_comment` WHERE `user_id` = ? OR `moment_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)", uid, uid); + update("DELETE FROM `moment_like` WHERE `user_id` = ? OR `moment_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)", uid, uid); + update("DELETE FROM `moment_favorite` WHERE `user_id` = ? OR `moment_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)", uid, uid); + updateIfTableExists("moment_view_record", "DELETE FROM `moment_view_record` WHERE `user_id` = ? OR `moment_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)", uid, uid); + // 必须在动态主记录物理删除前清理:其他用户对该用户动态产生的数据也属于从属记录。 + updateIfTableExists("user_blocked_moment", "DELETE FROM `user_blocked_moment` WHERE `moment_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)", uid); + update("DELETE FROM `video_like` WHERE `user_id` = ? OR `video_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)", uid, uid); + update("DELETE FROM `video_play_record` WHERE `user_id` = ? OR `video_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)", uid, uid); + update("DELETE FROM `moment` WHERE `user_id` = ?", uid); + + // 5. 礼物 + update("DELETE FROM `gift_record` WHERE `sender_id` = ? OR `receiver_id` = ?", uid, uid); + + // 6. 商城、抽奖、积分流水、举报 + update("DELETE FROM `commerce_order` WHERE `user_id` = ?", uid); + update("DELETE FROM `lottery_draw` WHERE `user_id` = ?", uid); + update("UPDATE `point_transaction` SET `related_user_id` = NULL WHERE `related_user_id` = ?", uid); + update("DELETE FROM `point_transaction` WHERE `user_id` = ?", uid); + update("UPDATE `content_report` SET `reviewed_by` = NULL WHERE `reviewed_by` = ?", uid); + update("DELETE FROM `content_report` WHERE `reporter_id` = ? " + + "OR (`target_type` = 'USER' AND `target_id` = ?)", uid, uid); + + // 7. 活动报名、票档、支付订单及用户创建的活动 + updateIfTableExists("activity_operation_log", "DELETE FROM `activity_operation_log` WHERE `operator_id` = ? " + + "OR `target_id` = ? OR `activity_id` IN (SELECT `id` FROM `activity` WHERE `creator_id` = ?)", uid, uid, uid); + update("DELETE FROM `activity_payment_order` WHERE `user_id` = ? OR `registration_id` IN " + + "(SELECT `id` FROM `activity_registration` WHERE `user_id` = ? OR `activity_id` IN " + + "(SELECT `id` FROM `activity` WHERE `creator_id` = ?))", uid, uid, uid); + update("DELETE FROM `activity_registration` WHERE `user_id` = ? OR `activity_id` IN " + + "(SELECT `id` FROM `activity` WHERE `creator_id` = ?)", uid, uid); + update("DELETE FROM `activity_participant` WHERE `user_id` = ? OR `activity_id` IN " + + "(SELECT `id` FROM `activity` WHERE `creator_id` = ?)", uid, uid); + update("DELETE FROM `activity_ticket` WHERE `activity_id` IN " + + "(SELECT `id` FROM `activity` WHERE `creator_id` = ?)", uid); + update("DELETE FROM `activity` WHERE `creator_id` = ?", uid); + + // 8. 用户创建的公告、审核记录、群组与群成员 + update("DELETE FROM `announcement` WHERE `creator_id` = ?", uid); + update("UPDATE `content_review` SET `reviewed_by` = NULL WHERE `reviewed_by` = ?", uid); + update("DELETE FROM `chat_message` WHERE `conversation_id` IN " + + "(SELECT `id` FROM `chat_group` WHERE `owner_id` = ?)", uid); + updateIfTableExists("group_announcement", "DELETE FROM `group_announcement` WHERE `group_id` IN " + + "(SELECT `id` FROM `chat_group` WHERE `owner_id` = ?)", uid); + update("DELETE FROM `group_member` WHERE `user_id` = ? OR `group_id` IN " + + "(SELECT `id` FROM `chat_group` WHERE `owner_id` = ?)", uid, uid); + update("DELETE FROM `chat_group` WHERE `owner_id` = ?", uid); + + // 9. 历史功能表。虽然当前主流程已不使用这些表,仍需清理遗留用户数据。 + updateIfTableExists("ephemeral_messages", "DELETE FROM `ephemeral_messages` WHERE `sender_id` = ?", uid); + updateIfTableExists("friend_request", + "DELETE FROM `friend_request` WHERE `from_user_id` = ? OR `to_user_id` = ?", uid, uid); + updateIfTableExists("school_lightups", "DELETE FROM `school_lightups` WHERE `user_id` = ?", uid); + updateIfTableExists("stranger_message_limits", + "DELETE FROM `stranger_message_limits` WHERE `sender_id` = ?", uid); + updateIfTableExists("youth_video_follow", + "DELETE FROM `youth_video_follow` WHERE `creator_id` = ? OR `follower_id` = ?", uid, uid); + updateIfTableExists("youth_video_comment", + "DELETE FROM `youth_video_comment` WHERE `author_id` = ? OR `video_id` IN " + + "(SELECT `id` FROM `youth_video` WHERE `creator_id` = ?)", uid, uid); + updateIfTableExists("video_gift", "DELETE FROM `video_gift` WHERE `sender_id` = ? OR `video_id` IN " + + "(SELECT `id` FROM `youth_video` WHERE `creator_id` = ?)", uid, uid); + updateIfTableExists("youth_video", "DELETE FROM `youth_video` WHERE `creator_id` = ?", uid); + + // 旧 comment.author_id 是 bigint,而当前用户 ID 是 UUID,两者不属于同一账号体系,不能强行比较。 + + updateIfTableExists("xuexin_report", "DELETE FROM xuexin_report WHERE user_id = ?", uid); + // 10. 最后删除用户主记录;不保留 deleted=true 的旧账号占位。 + updateCritical("DELETE FROM `user` WHERE `id` = ?", uid); + log.info("用户账号数据已清理: userId={}", uid); + } + + private void update(String sql, Object... args) { + // 注销承诺清除账号数据,任一清理步骤失败都必须抛出并回滚,不能返回虚假的成功。 + jdbcTemplate.update(sql, args); + } + + private void updateCritical(String sql, Object... args) { + jdbcTemplate.update(sql, args); + } + + private void updateIfTableExists(String tableName, String sql, Object... args) { + Integer tableCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = DATABASE() AND table_name = ?", + Integer.class, + tableName); + if (tableCount != null && tableCount > 0) { + update(sql, args); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/UserDataExportService.java b/campus-server/src/main/java/com/campus/service/UserDataExportService.java new file mode 100644 index 0000000..dc1404d --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/UserDataExportService.java @@ -0,0 +1,72 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.User; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class UserDataExportService { + private final UserService userService; + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public byte[] export(String userId) { + User user = userService.getUserById(userId); + Map root = new LinkedHashMap<>(); + root.put("exportedAt", new Date()); + root.put("profile", safeProfile(user)); + root.put("interests", query("SELECT interest FROM user_interests WHERE user_id = ?", userId)); + root.put("moments", query("SELECT id, content, category, scope, review_status, created_at FROM moment WHERE user_id = ?", userId)); + root.put("comments", query("SELECT id, moment_id, parent_id, content, status, created_at FROM moment_comment WHERE user_id = ?", userId)); + root.put("createdActivities", query("SELECT id, title, type, location, event_time, status, review_status, created_at FROM activity WHERE creator_id = ?", userId)); + root.put("activityRegistrations", query("SELECT id, activity_id, status, created_at FROM activity_registration WHERE user_id = ?", userId)); + root.put("announcements", query("SELECT id, title, type, status, created_at FROM announcement WHERE creator_id = ?", userId)); + root.put("follows", query("SELECT follower_id, following_id, created_at FROM user_follow WHERE follower_id = ? OR following_id = ?", userId, userId)); + root.put("friendships", query("SELECT user_id, friend_id, status, created_at FROM friendship WHERE user_id = ? OR friend_id = ?", userId, userId)); + root.put("notifications", query("SELECT id, type, title, content, is_read, created_at FROM notification WHERE user_id = ?", userId)); + root.put("pointTransactions", query("SELECT id, rule_code, type, points, balance_after, remark, created_at FROM point_transaction WHERE user_id = ?", userId)); + root.put("reports", query("SELECT id, target_type, target_id, reason, status, created_at FROM content_report WHERE reporter_id = ?", userId)); + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(root) + .getBytes(StandardCharsets.UTF_8); + } catch (JsonProcessingException ex) { + throw new BusinessException("用户数据导出失败", ex); + } + } + + private Map safeProfile(User user) { + Map profile = new LinkedHashMap<>(); + profile.put("id", user.getId()); + profile.put("number", user.getNumber()); + profile.put("nickname", user.getNickname()); + profile.put("phone", user.getPhone()); + profile.put("gender", user.getGender()); + profile.put("birthday", user.getBirthday()); + profile.put("hometown", user.getHometown()); + profile.put("location", user.getLocation()); + profile.put("universityName", user.getUniversityName()); + profile.put("collegeName", user.getCollegeName()); + profile.put("majorName", user.getMajorName()); + profile.put("grade", user.getGrade()); + profile.put("bio", user.getBio()); + profile.put("accountStatus", user.getAccountStatus()); + profile.put("createdAt", user.getCreatedAt()); + return profile; + } + + private java.util.List> query(String sql, Object... args) { + return jdbcTemplate.queryForList(sql, args); + } +} diff --git a/campus-server/src/main/java/com/campus/service/UserPointsService.java b/campus-server/src/main/java/com/campus/service/UserPointsService.java new file mode 100644 index 0000000..60bd631 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/UserPointsService.java @@ -0,0 +1,59 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.UserPoints; +import com.campus.repository.UserPointsRepository; +import com.campus.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserPointsService { + + private final UserPointsRepository userPointsRepository; + private final UserRepository userRepository; + private final AuditLogService auditLogService; + + @Transactional + public UserPoints getUserPoints(String userId) { + // 先校验用户是否存在 + if (!userRepository.existsById(userId)) { + throw new RuntimeException("用户不存在"); + } + return userPointsRepository.findByUserId(userId) + .orElseGet(() -> { + UserPoints points = new UserPoints(); + points.setUserId(userId); + return userPointsRepository.save(points); + }); + } + + @Transactional + public UserPoints addPoints(String userId, Integer amount) { + // 先校验用户是否存在 + if (!userRepository.existsById(userId)) { + throw new BusinessException("用户不存在"); + } + if (amount == null || amount == 0) throw new BusinessException("调整积分不能为 0"); + UserPoints points = userPointsRepository.findByUserIdForUpdate(userId).orElseGet(() -> { + UserPoints created = new UserPoints(); + created.setUserId(userId); + return userPointsRepository.save(created); + }); + int before = points.getPoints(); + if (before + amount < 0) throw new BusinessException("扣减后积分不能小于 0"); + points.setPoints(points.getPoints() + amount); + if (amount > 0) { + points.setTotalEarned(points.getTotalEarned() + amount); + } else { + points.setTotalSpent(points.getTotalSpent() + Math.abs(amount)); + } + points = userPointsRepository.save(points); + auditLogService.record("ADJUST_POINTS", "USER_POINTS", userId, + "points=" + before, "points=" + points.getPoints(), "amount=" + amount); + return points; + } +} diff --git a/campus-server/src/main/java/com/campus/service/UserRestrictionService.java b/campus-server/src/main/java/com/campus/service/UserRestrictionService.java new file mode 100644 index 0000000..8a5c0b4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/UserRestrictionService.java @@ -0,0 +1,146 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.UserRestrictionRequest; +import com.campus.dto.response.UserRestrictionDTO; +import com.campus.entity.UserRestriction; +import com.campus.repository.UserRepository; +import com.campus.repository.UserRestrictionRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class UserRestrictionService { + private static final Set TYPES = new HashSet<>(Arrays.asList( + "ALL", "LOGIN", "PUBLISH", "COMMENT", "MESSAGE")); + + private final UserRestrictionRepository repository; + private final UserRepository userRepository; + private final AuthorizationService authorizationService; + private final AuditLogService auditLogService; + + @Transactional(readOnly = true) + public void ensureAllowed(String userId, String capability) { + if (userId == null) throw new BusinessException("请先登录"); + String type = normalizeType(capability); + java.util.List restrictions = repository.findActive( + userId, Arrays.asList("ALL", type), new Date()); + if (!restrictions.isEmpty()) { + UserRestriction restriction = restrictions.get(0); + String until = restriction.getExpiresAt() == null ? "永久" : "至 " + restriction.getExpiresAt(); + throw new BusinessException("账号当前被限制" + capabilityName(type) + "(" + until + "):" + restriction.getReason()); + } + } + + @Transactional(readOnly = true) + public boolean isRestricted(String userId, String capability) { + if (userId == null) return true; + String type = normalizeType(capability); + return !repository.findActive(userId, Arrays.asList("ALL", type), new Date()).isEmpty(); + } + + @Transactional + public UserRestrictionDTO create(String userId, UserRestrictionRequest request) { + authorizationService.requireAdmin(); + if (!userRepository.existsById(userId)) throw new BusinessException("用户不存在"); + String type = normalizeType(request.getRestrictionType()); + Date now = new Date(); + Date startsAt = request.getStartsAt() == null ? now : request.getStartsAt(); + if (request.getExpiresAt() != null && !request.getExpiresAt().after(startsAt)) { + throw new BusinessException("限制到期时间必须晚于生效时间"); + } + UserRestriction item = new UserRestriction(); + item.setUserId(userId); + item.setRestrictionType(type); + item.setReason(request.getReason().trim()); + item.setStatus("ACTIVE"); + item.setStartsAt(startsAt); + item.setExpiresAt(request.getExpiresAt()); + item.setCreatedBy(UserContext.getCurrentUserId()); + item = repository.save(item); + auditLogService.record("USER_RESTRICTION_CREATE", "USER_RESTRICTION", item.getId(), null, + summary(item), "userId=" + userId); + return toDTO(item); + } + + @Transactional + public UserRestrictionDTO revoke(String restrictionId, String reason) { + authorizationService.requireAdmin(); + UserRestriction item = repository.findById(restrictionId) + .orElseThrow(() -> new BusinessException("限制记录不存在")); + if (!"ACTIVE".equals(item.getStatus())) throw new BusinessException("限制记录已撤销"); + String before = summary(item); + item.setStatus("REVOKED"); + item.setRevokedBy(UserContext.getCurrentUserId()); + item.setRevokedAt(new Date()); + item = repository.save(item); + auditLogService.record("USER_RESTRICTION_REVOKE", "USER_RESTRICTION", item.getId(), before, + summary(item), reason); + return toDTO(item); + } + + @Transactional(readOnly = true) + public PageResult list(String userId, int page, int size) { + authorizationService.requireAdmin(); + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), 100); + Page data = repository.findByUserIdOrderByCreatedAtDesc( + userId, PageRequest.of(safePage - 1, safeSize)); + return PageResult.of(data.getContent().stream().map(this::toDTO).collect(Collectors.toList()), + data.getTotalElements(), safePage, safeSize); + } + + private String normalizeType(String value) { + String type = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!TYPES.contains(type)) throw new BusinessException("限制类型必须是 ALL、LOGIN、PUBLISH、COMMENT 或 MESSAGE"); + return type; + } + + private String capabilityName(String type) { + switch (type) { + case "LOGIN": return "登录"; + case "PUBLISH": return "发布"; + case "COMMENT": return "评论"; + case "MESSAGE": return "发送消息"; + default: return "使用平台"; + } + } + + private String summary(UserRestriction item) { + return "type=" + item.getRestrictionType() + ",status=" + item.getStatus() + + ",startsAt=" + item.getStartsAt() + ",expiresAt=" + item.getExpiresAt(); + } + + private UserRestrictionDTO toDTO(UserRestriction item) { + Date now = new Date(); + UserRestrictionDTO dto = new UserRestrictionDTO(); + dto.setId(item.getId()); + dto.setUserId(item.getUserId()); + dto.setRestrictionType(item.getRestrictionType()); + dto.setReason(item.getReason()); + dto.setStatus(item.getStatus()); + dto.setStartsAt(item.getStartsAt()); + dto.setExpiresAt(item.getExpiresAt()); + dto.setPermanent(item.getExpiresAt() == null); + dto.setEffective("ACTIVE".equals(item.getStatus()) && !item.getStartsAt().after(now) + && (item.getExpiresAt() == null || item.getExpiresAt().after(now))); + dto.setCreatedBy(item.getCreatedBy()); + dto.setRevokedBy(item.getRevokedBy()); + dto.setRevokedAt(item.getRevokedAt()); + dto.setCreatedAt(item.getCreatedAt()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/UserService.java b/campus-server/src/main/java/com/campus/service/UserService.java new file mode 100644 index 0000000..8eafbd4 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/UserService.java @@ -0,0 +1,462 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.config.JwtConfig; +import com.campus.dto.request.LoginRequest; +import com.campus.dto.request.RegisterRequest; +import com.campus.dto.request.ChangePasswordRequest; +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtConfig jwtConfig; + private final RedisTemplate redisTemplate; + private final UserAccountCleanupService userAccountCleanupService; + private final UserRestrictionService userRestrictionService; + private final RedisRetryExecutor redisRetryExecutor; + private final LoginSecurityService loginSecurityService; + private final UserSessionService userSessionService; + private final CommerceService commerceService; + private final ClickGuardService clickGuardService; + private final AccountDeactivationCleanupService deactivationCleanupService; + + @Value("${jwt.expiration:86400000}") + private long jwtExpiration; + + private static final String TOKEN_PREFIX = "user:token:"; + private static final String USER_TOKEN_PREFIX = "user:usertoken:"; + private static final String DRAFT_KEY_PREFIX = "user:draft:"; + + public User getUserById(String id) { + return userRepository.findById(id) + .orElseThrow(() -> new RuntimeException("用户不存在")); + } + + public java.util.Map deactivationPreview(String uid) { + if (!StringUtils.hasText(uid)) throw new BusinessException("用户未登录"); + return deactivationCleanupService.preview(uid); + } + + public User getPublicUserById(String id) { + User user = getUserById(id); + if ("DEACTIVATING".equalsIgnoreCase(user.getAccountStatus())) throw new BusinessException("用户不存在"); + return user; + } + + public User getUserByPhone(String phone) { + List accounts = userRepository.findAllByPhoneIncludingDeleted(phone); + User activeAccount = null; + int activeCount = 0; + for (User account : accounts) { + if (!Boolean.TRUE.equals(account.getDeleted())) { + activeAccount = account; + activeCount++; + } + } + if (activeCount == 0) { + throw new BusinessException("该手机号未注册"); + } + if (activeCount > 1) { + log.error("手机号存在重复有效账号,拒绝登录: phone={}, count={}", maskPhone(phone), activeCount); + throw new BusinessException("账号数据异常,请联系管理员处理"); + } + return activeAccount; + } + + @Transactional + public LoginResult login(LoginRequest request) { + return login(request, null, null); + } + + @Transactional + public LoginResult login(LoginRequest request, String ipAddress, String userAgent) { + loginSecurityService.checkNotLocked(request.getPhone()); + User user = lockAccount(getUserByPhone(request.getPhone()).getId()); + ensureAccountCanLogin(user); + userRestrictionService.ensureAllowed(user.getId(), "LOGIN"); + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + boolean locked = loginSecurityService.recordFailure(request.getPhone()); + throw new BusinessException(locked + ? "密码错误次数过多,账号登录已锁定 15 分钟" + : "手机号或密码错误"); + } + restoreOnSuccessfulLogin(user); + loginSecurityService.clearFailures(request.getPhone()); + String token = generateAndStoreToken(user, request.getDeviceId(), request.getDeviceName(), + request.getPlatform(), ipAddress, userAgent); + return new LoginResult(token, user); + } + + /** + * 为指定用户生成并存储 token + */ + private String generateAndStoreToken(User user) { + return generateAndStoreToken(user, null, null, null, null, null); + } + + private String generateAndStoreToken(User user, String deviceId, String deviceName, + String platform, String ipAddress, String userAgent) { + ensureNickname(user); + String token = jwtConfig.generateToken(user.getPhone(), user.getId()); + try { + userSessionService.create(user.getId(), token, jwtExpiration, deviceId, deviceName, + platform, ipAddress, userAgent); + } catch (DataAccessException ex) { + log.error("登录会话服务不可用: userId={}", user.getId(), ex); + throw new com.campus.common.ServiceUnavailableException("登录服务暂时不可用,请稍后重试", ex); + } + commerceService.grantNewcomerRewards(user.getId(), true); + return token; + } + + /** + * 手机号+验证码登录 + */ + @Transactional + public LoginResult loginByPhone(String phone) { + return loginByPhone(phone, null, null, null, null, null); + } + + @Transactional + public LoginResult loginByPhone(String phone, String deviceId, String deviceName, + String platform, String ipAddress, String userAgent) { + User user = lockAccount(getUserByPhone(phone).getId()); + ensureAccountCanLogin(user); + userRestrictionService.ensureAllowed(user.getId(), "LOGIN"); + restoreOnSuccessfulLogin(user); + loginSecurityService.clearFailures(phone); + String token = generateAndStoreToken(user, deviceId, deviceName, platform, ipAddress, userAgent); + return new LoginResult(token, user); + } + + /** + * 修改已登录用户的密码。修改成功后使旧 token 失效,要求客户端重新登录。 + */ + @Transactional + public void changePassword(String userId, ChangePasswordRequest request) { + User user = getUserById(userId); + if (!passwordEncoder.matches(request.getOldPassword(), user.getPassword())) { + throw new BusinessException("旧密码不正确"); + } + if (request.getOldPassword().equals(request.getNewPassword())) { + throw new BusinessException("新密码不能与旧密码相同"); + } + user.setPassword(passwordEncoder.encode(request.getNewPassword())); + userRepository.save(user); + logout(userId); + } + + /** + * 通过短信验证码重置密码。调用方负责在重置前完成验证码校验。 + */ + @Transactional + public void resetPassword(String phone, String newPassword) { + User user = getUserByPhone(phone); + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + // 重置密码后主动清理旧会话,避免旧设备继续使用原密码对应的会话。 + logout(user.getId()); + } + + /** 管理员重置指定用户密码,调用方负责权限校验与审计。 */ + @Transactional + public void adminResetPassword(String userId, String newPassword) { + User user = getUserById(userId); + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + loginSecurityService.clearFailures(user.getPhone()); + kickout(userId); + } + + /** 发起注销:进入 7 天冷静期,到期后由定时任务执行数据清理。 */ + @Transactional + public void deactivateAccount(String userId) { + User user = lockAccount(userId); + clickGuardService.ensureNotTooFrequent("account-deactivation", userId, userId); + deactivationCleanupService.requireNoPendingPayments(userId); + if ("DEACTIVATING".equalsIgnoreCase(user.getAccountStatus())) { + throw new BusinessException("账号已处于注销冷静期"); + } + Date now = new Date(); + user.setAccountStatus("DEACTIVATING"); + user.setDeactivationRequestedAt(now); + user.setScheduledDeletionAt(new Date(now.getTime() + TimeUnit.DAYS.toMillis(7))); + userRepository.save(user); + if (org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive()) + org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization( + new org.springframework.transaction.support.TransactionSynchronization() { + @Override public void afterCommit() { + try { logout(userId); } catch (Exception ex) { log.warn("注销已提交,会话清理待重试: userId={}", userId); } + } + }); + } + + @Transactional + public User recoverAccount(String userId) { + User user = lockAccount(userId); + clickGuardService.ensureNotTooFrequent("account-deactivation", userId, userId); + if (!"DEACTIVATING".equalsIgnoreCase(user.getAccountStatus())) { + throw new BusinessException("账号不在注销冷静期"); + } + if (user.getScheduledDeletionAt() == null || !user.getScheduledDeletionAt().after(new Date())) { + throw new BusinessException("账号注销冷静期已结束,无法恢复"); + } + user.setAccountStatus("ACTIVE"); + user.setDeactivationRequestedAt(null); + user.setScheduledDeletionAt(null); + return userRepository.save(user); + } + + private User lockAccount(String userId) { + if (!StringUtils.hasText(userId)) throw new BusinessException("用户未登录"); + return userRepository.findByIdForUpdate(userId).orElseThrow(() -> new BusinessException("用户不存在")); + } + + public int purgeExpiredDeactivations() { + int count = 0; + for (User user : userRepository.findExpiredDeactivations(new Date(), org.springframework.data.domain.PageRequest.of(0, 100))) { + try { if (deactivationCleanupService.cleanup(user.getId())) count++; } + catch (Exception ex) { log.warn("账号注销清理失败,后续重试: userId={}", user.getId()); } + } + return count; + } + + /** + * 登录结果:包含 token 和用户对象 + */ + @lombok.Data + @lombok.AllArgsConstructor + public static class LoginResult { + private String token; + private User user; + } + + /** + * 验证 token 是否有效(在 Redis 中存在) + */ + public boolean validateToken(String token) { + String tokenKey = TOKEN_PREFIX + token; + return Boolean.TRUE.equals(redisTemplate.hasKey(tokenKey)); + } + + /** + * 根据 token 获取用户 ID + */ + public String getUserIdByToken(String token) { + String tokenKey = TOKEN_PREFIX + token; + Object userId = redisTemplate.opsForValue().get(tokenKey); + if (userId != null) { + return userId.toString(); + } + return null; + } + + /** + * 退出登录,删除当前用户的 token + */ + public void logout(String userId) { + if (!StringUtils.hasText(userId)) { + log.warn("logout: userId 为空,跳过"); + return; + } + try { + userSessionService.revokeAll(userId, userId); + String userTokenKey = USER_TOKEN_PREFIX + userId; + Object token = redisTemplate.opsForValue().get(userTokenKey); + if (token != null) { + redisTemplate.delete(TOKEN_PREFIX + token); + } + redisTemplate.delete(userTokenKey); + log.info("用户 {} 已退出登录", userId); + } catch (DataAccessException ex) { + log.error("退出登录时会话服务不可用: userId={}", userId, ex); + throw new com.campus.common.ServiceUnavailableException("登录服务暂时不可用,请稍后重试", ex); + } + } + + /** + * 根据 token 退出登录(删除指定 token) + */ + public void logoutByToken(String token) { + if (!StringUtils.hasText(token)) { + log.warn("logoutByToken: token 为空,跳过"); + return; + } + userSessionService.revokeByToken(token, null); + } + + /** + * 踢出用户登录(强制下线) + */ + public void kickout(String userId) { + if (!StringUtils.hasText(userId)) { + log.warn("kickout: userId 为空,跳过"); + return; + } + userSessionService.revokeAll(userId, com.campus.util.UserContext.getCurrentUserId()); + String userTokenKey = USER_TOKEN_PREFIX + userId; + Object token = redisTemplate.opsForValue().get(userTokenKey); + if (token != null) { + redisTemplate.delete(TOKEN_PREFIX + token); + } + redisTemplate.delete(userTokenKey); + log.info("用户 {} 已被强制下线", userId); + } + + @Transactional + public LoginResult register(RegisterRequest request) { + // 兼容此前逻辑注销留下的 deleted=true 历史账号,并显式识别历史重复数据。 + List existingAccounts = userRepository.findAllByPhoneIncludingDeleted(request.getPhone()); + for (User existingAccount : existingAccounts) { + if (!Boolean.TRUE.equals(existingAccount.getDeleted())) { + throw new BusinessException("手机号已注册"); + } + } + for (User deletedAccount : existingAccounts) { + userAccountCleanupService.deleteUserData(deletedAccount.getId()); + } + User user = new User(); + user.setPhone(request.getPhone()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setNickname(defaultNickname(request.getNickname(), request.getPhone())); + user.setUniversityName(request.getUniversityName()); + user.setMajorName(request.getMajorName()); + user.setGrade(request.getGrade()); + user.setIsAdmin(false); + user.setIsReviewer(false); + user.setAccountStatus("ACTIVE"); + user.setUniversityId(request.getUniversityId()); + user.setMajorId(request.getMajorId()); + try { + // saveAndFlush 让数据库手机号唯一约束在返回成功前立即生效,阻断并发重复注册。 + user = userRepository.saveAndFlush(user); + } catch (DataIntegrityViolationException ex) { + log.warn("注册唯一约束冲突: phone={}", maskPhone(request.getPhone())); + throw new BusinessException("手机号已注册", ex); + } + + String token = generateAndStoreToken(user, request.getDeviceId(), request.getDeviceName(), + request.getPlatform(), null, null); + return new LoginResult(token, user); + } + + public Page getUsersBySchool(String school, String excludeId, Pageable pageable) { + return userRepository.findBySchoolExclude(school, excludeId, pageable); + } + + public List getRecommendedUsers(String excludeId, int limit) { + Pageable pageable = org.springframework.data.domain.PageRequest.of(0, limit); + return userRepository.findRandomUsers(excludeId, pageable).getContent(); + } + + @Transactional + public User saveUser(User user) { + User saved = userRepository.save(user); + commerceService.grantNewcomerRewards(saved.getId(), false); + return saved; + } + + private void ensureNickname(User user) { + if (user != null && !StringUtils.hasText(user.getNickname())) { + user.setNickname(defaultNickname(null, user.getPhone())); + userRepository.save(user); + } + } + + private String defaultNickname(String nickname, String phone) { + if (StringUtils.hasText(nickname)) { + return nickname.trim(); + } + if (StringUtils.hasText(phone) && phone.length() >= 4) { + return "用户" + phone.substring(phone.length() - 4); + } + return "青核用户"; + } + + private String maskPhone(String phone) { + if (!StringUtils.hasText(phone) || phone.length() < 7) { + return "***"; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + /** 调用方已验证身份并持有账号行锁;与登录共享事务,失败一并回滚。 */ + private void restoreOnSuccessfulLogin(User user) { + ensureAccountCanLogin(user); + if ("DEACTIVATING".equalsIgnoreCase(user.getAccountStatus())) { + user.setAccountStatus("ACTIVE"); + user.setDeactivationRequestedAt(null); + user.setScheduledDeletionAt(null); + userRepository.save(user); + } + } + + private void ensureAccountCanLogin(User user) { + if (user != null && "DEACTIVATING".equalsIgnoreCase(user.getAccountStatus()) + && (user.getScheduledDeletionAt() == null || !user.getScheduledDeletionAt().after(new Date()))) + throw new BusinessException("账号注销冷静期已结束,无法恢复"); + if (user != null && "DISABLED".equalsIgnoreCase(user.getAccountStatus())) { + throw new BusinessException("账号已被管理员停用"); + } + } + /** + * 通过环信用户名反查用户ID和昵称 + * @param easemobId 环信用户名,如 u326314559fb24aa4a1067201a51c27 + * @return User 或 null + */ + public User getUserByEasemobId(String easemobId) { + if (easemobId == null || easemobId.length() < 2 || easemobId.charAt(0) != 'u') { + return null; + } + String hexPrefix = easemobId.substring(1); // 去掉 'u' 前缀 + User user = userRepository.findByEasemobHexPrefix(hexPrefix).orElse(null); + return user != null && "DEACTIVATING".equalsIgnoreCase(user.getAccountStatus()) ? null : user; + } + + public User updateUserProfile(String userId, com.campus.dto.request.RegisterRequest request) { + User user = getUserById(userId); + if (request.getNickname() != null) { + user.setNickname(request.getNickname()); + } + if (request.getUniversityName() != null) { + user.setUniversityName(request.getUniversityName()); + } + if (request.getMajorName() != null) { + user.setMajorName(request.getMajorName()); + } + if (request.getUniversityId() != null) { + user.setUniversityId(request.getUniversityId()); + } + if (request.getMajorId() != null) { + user.setMajorId(request.getMajorId()); + } + if (request.getGrade() != null) { + user.setGrade(request.getGrade()); + } + return userRepository.save(user); + } +} diff --git a/campus-server/src/main/java/com/campus/service/UserSessionService.java b/campus-server/src/main/java/com/campus/service/UserSessionService.java new file mode 100644 index 0000000..07a72ab --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/UserSessionService.java @@ -0,0 +1,152 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.response.UserSessionDTO; +import com.campus.entity.UserSession; +import com.campus.repository.UserSessionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class UserSessionService { + private static final String TOKEN_PREFIX = "user:token:"; + private static final String SESSION_TOKEN_PREFIX = "user:session-token:"; + private static final String TOKEN_SESSION_PREFIX = "user:token-session:"; + + private final UserSessionRepository repository; + private final RedisTemplate redisTemplate; + private final RedisRetryExecutor retryExecutor; + + @Transactional + public UserSession create(String userId, String token, long ttlMillis, + String deviceId, String deviceName, String platform, + String ipAddress, String userAgent) { + Date now = new Date(); + UserSession session = new UserSession(); + session.setUserId(userId); + session.setTokenHash(hash(token)); + session.setDeviceId(trim(deviceId, 100)); + session.setDeviceName(trim(deviceName, 100)); + session.setPlatform(trim(platform, 30)); + session.setIpAddress(trim(ipAddress, 64)); + session.setUserAgent(trim(userAgent, 500)); + session.setStatus("ACTIVE"); + session.setLastSeenAt(now); + session.setExpiresAt(new Date(now.getTime() + ttlMillis)); + session = repository.save(session); + UserSession saved = session; + retryExecutor.execute("session-create", () -> { + redisTemplate.opsForValue().set(TOKEN_PREFIX + token, userId, ttlMillis, TimeUnit.MILLISECONDS); + redisTemplate.opsForValue().set(SESSION_TOKEN_PREFIX + saved.getId(), token, ttlMillis, TimeUnit.MILLISECONDS); + redisTemplate.opsForValue().set(TOKEN_SESSION_PREFIX + token, saved.getId(), ttlMillis, TimeUnit.MILLISECONDS); + }); + return session; + } + + @Transactional(readOnly = true) + public List list(String userId, String currentToken) { + String currentHash = currentToken == null ? null : hash(currentToken); + return repository.findByUserIdAndStatusOrderByLastSeenAtDesc(userId, "ACTIVE").stream() + .filter(item -> item.getExpiresAt().after(new Date())) + .map(item -> toDTO(item, item.getTokenHash().equals(currentHash))) + .collect(Collectors.toList()); + } + + @Transactional + public void revoke(String userId, String sessionId, String revokedBy) { + UserSession session = repository.findById(sessionId) + .orElseThrow(() -> new BusinessException("会话不存在")); + if (!userId.equals(session.getUserId())) throw new BusinessException("无权操作该会话"); + revokeEntity(session, revokedBy); + } + + @Transactional + public void revokeByToken(String token, String revokedBy) { + if (token == null || token.trim().isEmpty()) return; + repository.findByTokenHash(hash(token)).ifPresent(item -> revokeEntity(item, revokedBy)); + deleteTokenMappings(token, null); + } + + @Transactional + public void revokeAll(String userId, String revokedBy) { + List sessions = repository.findByUserIdAndStatusOrderByLastSeenAtDesc(userId, "ACTIVE"); + for (UserSession session : sessions) revokeEntity(session, revokedBy); + } + + @Transactional + public void touch(String token) { + if (token == null) return; + repository.findByTokenHash(hash(token)).ifPresent(item -> { + Date now = new Date(); + if (item.getLastSeenAt() == null || now.getTime() - item.getLastSeenAt().getTime() >= 300_000L) { + item.setLastSeenAt(now); + repository.save(item); + } + }); + } + + private void revokeEntity(UserSession session, String revokedBy) { + if (!"ACTIVE".equals(session.getStatus())) return; + Object token = retryExecutor.execute("session-token-read", + () -> redisTemplate.opsForValue().get(SESSION_TOKEN_PREFIX + session.getId())); + if (token != null) deleteTokenMappings(token.toString(), session.getId()); + else redisTemplate.delete(SESSION_TOKEN_PREFIX + session.getId()); + session.setStatus("REVOKED"); + session.setRevokedAt(new Date()); + session.setRevokedBy(revokedBy); + repository.save(session); + } + + private void deleteTokenMappings(String token, String sessionId) { + retryExecutor.execute("session-revoke", () -> { + redisTemplate.delete(TOKEN_PREFIX + token); + redisTemplate.delete(TOKEN_SESSION_PREFIX + token); + if (sessionId != null) redisTemplate.delete(SESSION_TOKEN_PREFIX + sessionId); + }); + } + + private String hash(String token) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256") + .digest(token.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte value : bytes) hex.append(String.format("%02x", value)); + return hex.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 unavailable", ex); + } + } + + private String trim(String value, int max) { + if (value == null || value.trim().isEmpty()) return null; + String result = value.trim(); + return result.length() > max ? result.substring(0, max) : result; + } + + private UserSessionDTO toDTO(UserSession item, boolean current) { + UserSessionDTO dto = new UserSessionDTO(); + dto.setId(item.getId()); + dto.setDeviceId(item.getDeviceId()); + dto.setDeviceName(item.getDeviceName()); + dto.setPlatform(item.getPlatform()); + dto.setIpAddress(item.getIpAddress()); + dto.setUserAgent(item.getUserAgent()); + dto.setStatus(item.getStatus()); + dto.setCurrent(current); + dto.setLastSeenAt(item.getLastSeenAt()); + dto.setExpiresAt(item.getExpiresAt()); + dto.setCreatedAt(item.getCreatedAt()); + return dto; + } +} diff --git a/campus-server/src/main/java/com/campus/service/VideoMessageConsumer.java b/campus-server/src/main/java/com/campus/service/VideoMessageConsumer.java new file mode 100644 index 0000000..7b842d5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/VideoMessageConsumer.java @@ -0,0 +1,57 @@ +package com.campus.service; + +import com.campus.config.RabbitMQConfig; +import com.rabbitmq.client.Channel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.Exchange; +import org.springframework.amqp.rabbit.annotation.Queue; +import org.springframework.amqp.rabbit.annotation.QueueBinding; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.support.AmqpHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.LinkedHashMap; + +/** + * RabbitMQ 消息消费者(已废弃) + * + * @deprecated 视频上传已迁移至 VOD 直传(CreateUploadVideo + 上传到 VOD Bucket), + * 不再需要通过 OSS → RabbitMQ → RegisterMedia 的旧流程。 + * 转码和审核由 VOD 模板组自动触发,通过回调通知。 + * 保留此消费者仅用于处理队列中的存量消息,避免消息丢失。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@Deprecated +public class VideoMessageConsumer { + + private final VodService vodService; + + /** + * @deprecated VOD 直传已替代此流程,此监听器仅消费存量消息 + */ + @Deprecated + @RabbitListener(queues = RabbitMQConfig.VIDEO_TRANSCODE_QUEUE) + public void handleVideoTranscode(LinkedHashMap message, + Channel channel, + @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) { + try { + log.warn("[已废弃] 收到旧版视频转码消息(VOD直传已替代此流程),消息将被丢弃: message={}", message); + + // 直接 ACK 丢弃,不再处理 + channel.basicAck(deliveryTag, false); + + } catch (Exception e) { + log.error("消息处理失败, deliveryTag={}", deliveryTag, e); + try { + channel.basicNack(deliveryTag, false, false); + } catch (IOException ioException) { + log.error("消息NACK失败", ioException); + } + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/VideoPlayService.java b/campus-server/src/main/java/com/campus/service/VideoPlayService.java new file mode 100644 index 0000000..20581d6 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/VideoPlayService.java @@ -0,0 +1,81 @@ +package com.campus.service; + +import com.campus.entity.Moment; +import com.campus.entity.VideoPlayRecord; +import com.campus.repository.MomentRepository; +import com.campus.repository.VideoPlayRecordRepository; +import com.campus.util.UserContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; + +@Slf4j +@Service +@RequiredArgsConstructor +public class VideoPlayService { + + private static final long PLAY_SESSION_MILLIS = 30 * 60 * 1000L; + + private final VideoPlayRecordRepository videoPlayRecordRepository; + private final MomentRepository momentRepository; + + @Transactional + public Moment recordVideoOpen(String momentId) { + return recordView(momentId, null); + } + + /** + * 更新视频播放进度 + */ + @Transactional + public void updatePlayProgress(String momentId, Integer playPosition) { + recordView(momentId, playPosition == null ? 0 : Math.max(0, playPosition)); + } + + private Moment recordView(String momentId, Integer playPosition) { + Moment moment = momentRepository.findByIdForView(momentId) + .orElseThrow(() -> new RuntimeException("动态不存在")); + if (!moment.hasVideoContent()) { + throw new RuntimeException("不是视频动态"); + } + String userId = UserContext.getCurrentUserId(); + if (userId == null) return moment; + + VideoPlayRecord record = videoPlayRecordRepository.findByVideoIdAndUserId(momentId, userId) + .orElseGet(() -> { + VideoPlayRecord created = new VideoPlayRecord(); + created.setVideoId(momentId); + created.setUserId(userId); + created.setPlayCount(0); + return created; + }); + Date now = new Date(); + Date lastCounted = record.getLastPlayTime(); + if (lastCounted == null || now.getTime() - lastCounted.getTime() >= PLAY_SESSION_MILLIS) { + record.setPlayCount(record.getPlayCount() + 1); + record.setLastPlayTime(now); + moment.setViewCount(moment.getViewCount() + 1); + } + if (playPosition != null) record.setPlayPosition(playPosition); + videoPlayRecordRepository.save(record); + log.debug("记录视频观看: momentId={}, userId={}, position={}", momentId, userId, playPosition); + return moment; + } + + /** + * 获取播放进度 + */ + public Integer getPlayProgress(String momentId) { + String userId = UserContext.getCurrentUserId(); + if (userId == null) { + return 0; + } + + return videoPlayRecordRepository.findByVideoIdAndUserId(momentId, userId) + .map(VideoPlayRecord::getPlayPosition) + .orElse(0); + } +} diff --git a/campus-server/src/main/java/com/campus/service/VodService.java b/campus-server/src/main/java/com/campus/service/VodService.java new file mode 100644 index 0000000..8fa1677 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/VodService.java @@ -0,0 +1,959 @@ +package com.campus.service; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.ObjectMetadata; +import com.aliyuncs.CommonRequest; +import com.aliyuncs.CommonResponse; +import com.aliyuncs.DefaultAcsClient; +import com.aliyuncs.http.MethodType; +import com.aliyuncs.profile.DefaultProfile; +import com.aliyuncs.vod.model.v20170321.*; +import com.campus.config.VodProperties; +import com.campus.dto.response.VodUploadCredential; +import com.campus.entity.Moment; +import com.campus.repository.MomentRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.InputStream; +import java.time.Duration; +import java.util.*; + +/** + * 阿里云 VOD 视频点播服务(V2.0 规范) + * + * 核心流程: + * 1. CreateUploadVideo → 获取 VideoId + 临时上传凭证 + * 2. 使用临时凭证初始化 OSS 客户端 → 直传 VOD 系统 Bucket + * 3. VOD 自动转码/审核(模板组配置)→ 回调更新数据库 + * 4. GetVideoPlayAuth → 安全播放凭证 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VodService { + + private final VodProperties vodProperties; + private final RedisTemplate redisTemplate; + private final MomentRepository momentRepository; + + private static final String VIDEO_INFO_CACHE_PREFIX = "video:info:"; + + // ==================== 上传凭证 ==================== + + /** + * 创建视频上传任务 — 获取 VideoId + 临时上传凭证 + * + * @param fileName 文件名 + * @return VodUploadCredential(videoId + 解码后的上传凭证) + */ + public VodUploadCredential createUploadVideo(String fileName) { + DefaultAcsClient client = buildVodClient(); + + try { + CreateUploadVideoRequest request = new CreateUploadVideoRequest(); + request.setFileName(fileName); + request.setTitle(fileName); + + // 设置转码模板组(支持多清晰度 HLS + 水印 + AI 审核) + String tgId = vodProperties.getTranscodeTemplateGroupId(); + if (tgId != null && !tgId.isEmpty()) { + request.setTemplateGroupId(tgId); + } + + // 设置回调地址 + String cbUrl = vodProperties.getCallbackUrl(); + if (cbUrl != null && !cbUrl.isEmpty()) { + request.setUserData("{\"MessageCallback\":{\"CallbackURL\":\"" + cbUrl + "\"}}"); + } + + CreateUploadVideoResponse response = client.getAcsResponse(request); + + String videoId = response.getVideoId(); + String uploadAddress = response.getUploadAddress(); + String uploadAuth = response.getUploadAuth(); + + log.info("CreateUploadVideo 成功: videoId={}", videoId); + + VodUploadCredential credential = new VodUploadCredential(); + credential.setVideoId(videoId); + credential.setRequestId(response.getRequestId()); + credential.setUploadAddress(uploadAddress); + credential.setUploadAuth(uploadAuth); + + return credential; + } catch (Exception e) { + log.error("CreateUploadVideo 失败: fileName={}", fileName, e); + throw new RuntimeException("创建上传任务失败: " + e.getMessage(), e); + } + } + + /** + * 创建图片上传任务 — 获取 ImageId + ImageURL + 临时上传凭证 + * + * @param fileName 文件名 + * @return VodUploadCredential(imageId + imageUrl + 上传凭证) + */ + public VodUploadCredential createUploadImage(String fileName) { + DefaultAcsClient client = buildVodClient(); + + try { + // 使用 CommonRequest 直发请求,避免封装的 CreateUploadImageRequest 额外添加 ServiceCode 等参数导致 API 报错 + String ext = "jpg"; + if (fileName.contains(".")) { + ext = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); + } + + CommonRequest request = new CommonRequest(); + request.setSysMethod(MethodType.POST); + request.setSysDomain("vod." + vodProperties.getRegionId() + ".aliyuncs.com"); + request.setSysVersion("2017-03-21"); + request.setSysAction("CreateUploadImage"); + request.putQueryParameter("ImageType", "default"); + request.putQueryParameter("ImageExt", ext); + request.putQueryParameter("Title", fileName); + // 使用 CDN 加速域名获取图片地址,签名有效期 100 年(3153600000秒),确保地址永久有效 + request.putQueryParameter("PlayConfig", "{\"PlayDomain\":\"video.yoyoeco.com\",\"AuthTimeout\":3153600000}"); + + String cbUrl = vodProperties.getCallbackUrl(); + if (cbUrl != null && !cbUrl.isEmpty()) { + request.putQueryParameter("UserData", + "{\"MessageCallback\":{\"CallbackURL\":\"" + cbUrl + "\"}}"); + } + + CommonResponse response = client.getCommonResponse(request); + String data = response.getData(); + + @SuppressWarnings("unchecked") + Map resultMap = + new com.fasterxml.jackson.databind.ObjectMapper().readValue(data, Map.class); + + String imageId = (String) resultMap.get("ImageId"); + String imageUrl = (String) resultMap.get("ImageURL"); + String uploadAddress = (String) resultMap.get("UploadAddress"); + String uploadAuth = (String) resultMap.get("UploadAuth"); + + // 清理 imageUrl 中的签名参数(auth_key、sign、expire),使 URL 变为永久可访问 + imageUrl = cleanVodUrl(imageUrl); + + log.info("CreateUploadImage 成功: imageId={}, imageUrl={}", imageId, imageUrl); + + VodUploadCredential credential = new VodUploadCredential(); + credential.setVideoId(imageId); + credential.setRequestId((String) resultMap.get("RequestId")); + credential.setUploadAddress(uploadAddress); + credential.setUploadAuth(uploadAuth); + credential.setImageUrl(imageUrl); + + return credential; + } catch (Exception e) { + log.error("CreateUploadImage 失败: fileName={}", fileName, e); + throw new RuntimeException("创建图片上传任务失败: " + e.getMessage(), e); + } + } + + /** + * 刷新上传凭证(凭证有效期 3000 秒,超时需刷新) + * + * @param videoId VOD 视频ID + * @return 新的上传凭证 + */ + public VodUploadCredential refreshUploadVideo(String videoId) { + DefaultAcsClient client = buildVodClient(); + + try { + RefreshUploadVideoRequest request = new RefreshUploadVideoRequest(); + request.setVideoId(videoId); + + RefreshUploadVideoResponse response = client.getAcsResponse(request); + + VodUploadCredential credential = new VodUploadCredential(); + credential.setVideoId(videoId); + credential.setRequestId(response.getRequestId()); + credential.setUploadAddress(response.getUploadAddress()); + credential.setUploadAuth(response.getUploadAuth()); + + log.info("RefreshUploadVideo 成功: videoId={}", videoId); + return credential; + } catch (Exception e) { + log.error("RefreshUploadVideo 失败: videoId={}", videoId, e); + throw new RuntimeException("刷新上传凭证失败: " + e.getMessage(), e); + } + } + + // ==================== 上传到 VOD Bucket ==================== + + /** + * 使用临时凭证上传文件到 VOD 系统 Bucket + * + * @param inputStream 文件流 + * @param fileSize 文件大小 + * @param credential 上传凭证(CreateUploadVideo 返回) + * @param contentType 文件 MIME 类型 + * @return videoId + */ + public String uploadToVodBucket(InputStream inputStream, long fileSize, + VodUploadCredential credential, String contentType) { + OSS ossClient = null; + try { + // 解码 UploadAddress (Base64 → JSON) + String addressJson = new String(Base64.getDecoder().decode(credential.getUploadAddress())); + @SuppressWarnings("unchecked") + Map addressMap = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(addressJson, Map.class); + + log.info("VOD UploadAddress 解码: {}", addressJson); + + String bucket = addressMap.get("Bucket"); + String endpoint = extractEndpoint(addressMap); + String fileName = addressMap.get("FileName"); + + // 解码 UploadAuth (Base64 → JSON → temp AK/SK/Token) + String authJson = new String(Base64.getDecoder().decode(credential.getUploadAuth())); + @SuppressWarnings("unchecked") + Map authMap = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(authJson, Map.class); + + String accessKeyId = authMap.get("AccessKeyId"); + String accessKeySecret = authMap.get("AccessKeySecret"); + String securityToken = authMap.get("SecurityToken"); + + log.info("VOD上传准备: bucket={}, endpoint={}, fileName={}", bucket, endpoint, fileName); + + // 创建临时 OSS 客户端(使用 STS Token) + ossClient = new OSSClientBuilder().build( + "https://" + endpoint, + accessKeyId, + accessKeySecret, + securityToken + ); + + // 上传文件到 VOD 系统 Bucket + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(fileSize); + metadata.setContentType(contentType != null ? contentType : "video/mp4"); + + ossClient.putObject(bucket, fileName, inputStream, metadata); + + log.info("VOD Bucket 上传成功: videoId={}, fileName={}", credential.getVideoId(), fileName); + return credential.getVideoId(); + + } catch (Exception e) { + log.error("VOD Bucket 上传失败: videoId={}", credential.getVideoId(), e); + throw new RuntimeException("VOD上传失败: " + e.getMessage(), e); + } finally { + if (ossClient != null) { + ossClient.shutdown(); + } + try { inputStream.close(); } catch (Exception ignored) {} + } + } + + // ==================== 播放凭证 ==================== + + /** + * 获取视频播放凭证(PlayAuth) + * 前端使用 PlayAuth 初始化阿里云播放器,无需暴露视频 URL + * + * @param videoId VOD 视频ID + * @return PlayAuth 字符串 + */ + public String getPlayAuth(String videoId) { + DefaultAcsClient client = buildVodClient(); + + try { + GetVideoPlayAuthRequest request = new GetVideoPlayAuthRequest(); + request.setVideoId(videoId); + // PlayAuth 有效期默认 100 秒,前端拿到后应立即初始化播放器 + request.setAuthInfoTimeout(3000L); + + GetVideoPlayAuthResponse response = client.getAcsResponse(request); + String playAuth = response.getPlayAuth(); + + log.info("获取 PlayAuth 成功: videoId={}", videoId); + + // 同时获取视频基础信息(封面、时长等)并缓存 + cacheVideoMeta(client, videoId); + + return playAuth; + } catch (Exception e) { + log.error("获取 PlayAuth 失败: videoId={}", videoId, e); + throw new RuntimeException("获取播放凭证失败: " + e.getMessage(), e); + } + } + + /** + * 获取图片信息(GetImageInfo) + * 使用 CDN 加速域名获取图片地址,确保地址永久有效 + * + * @param imageId VOD 图片ID + * @return 包含 imageUrl 的 Map + */ + public Map getImageInfo(String imageId) { + DefaultAcsClient client = buildVodClient(); + + try { + CommonRequest request = new CommonRequest(); + request.setSysMethod(MethodType.POST); + request.setSysDomain("vod." + vodProperties.getRegionId() + ".aliyuncs.com"); + request.setSysVersion("2017-03-21"); + request.setSysAction("GetImageInfo"); + request.putQueryParameter("ImageId", imageId); + // 使用 CDN 加速域名获取图片地址,签名有效期 100 年(3153600000秒),确保地址永久有效 + request.putQueryParameter("PlayConfig", "{\"PlayDomain\":\"video.yoyoeco.com\",\"AuthTimeout\":3153600000}"); + + CommonResponse response = client.getCommonResponse(request); + String data = response.getData(); + + @SuppressWarnings("unchecked") + Map resultMap = + new com.fasterxml.jackson.databind.ObjectMapper().readValue(data, Map.class); + + String imageUrl = (String) resultMap.get("ImageURL"); + imageUrl = cleanVodUrl(imageUrl); + + Map result = new HashMap<>(); + result.put("imageId", imageId); + result.put("imageUrl", imageUrl); + + log.info("GetImageInfo 成功: imageId={}, imageUrl={}", imageId, imageUrl); + + return result; + } catch (Exception e) { + log.error("获取图片信息失败: imageId={}", imageId, e); + throw new RuntimeException("获取图片信息失败: " + e.getMessage(), e); + } + } + + /** + * 获取视频播放地址(不推荐直接暴露,使用 PlayAuth 更安全) + * 保留用于需要直接 URL 的场景(如分享链接) + */ + public Map getPlayInfo(String videoId) { + DefaultAcsClient client = buildVodClient(); + + try { + GetPlayInfoRequest request = new GetPlayInfoRequest(); + request.setVideoId(videoId); + // 不限制 Formats,兼容 MP4 / HLS(m3u8) 等多种转码输出 + // 使用 CDN 加速域名获取播放地址(非OSS回源地址) + request.setOutputType("cdn"); + // 设置播放域名 + request.setPlayConfig("{\"PlayDomain\":\"video.yoyoeco.com\"}"); + // 设置签名有效期(100年 = 3153600000秒),确保地址永久有效 + // 注意:AuthTimeout 是独立参数,不是放在 PlayConfig 中 + request.setAuthTimeout(3153600000L); + + GetPlayInfoResponse response = client.getAcsResponse(request); + + log.info("GetPlayInfoResponse received for videoId={}", videoId); + if (response.getPlayInfoList() != null && !response.getPlayInfoList().isEmpty()) { + log.info("PlayInfoList size={}", response.getPlayInfoList().size()); + } else { + log.warn("PlayInfoList is empty for videoId={}", videoId); + } + + String playUrl = null; + GetPlayInfoResponse.PlayInfo bestPlayInfo = selectBestPlayInfo(response.getPlayInfoList()); + if (bestPlayInfo != null) { + playUrl = bestPlayInfo.getPlayURL(); + } + + log.info("Raw playUrl from GetPlayInfo: {}", playUrl); + + String coverUrl = null; + Float duration = null; + + if (response.getVideoBase() != null) { + coverUrl = response.getVideoBase().getCoverURL(); + String durationStr = response.getVideoBase().getDuration(); + if (durationStr != null && !durationStr.isEmpty()) { + try { + duration = Float.parseFloat(durationStr); + } catch (NumberFormatException e) { + log.warn("解析视频时长失败: durationStr={}", durationStr); + } + } + log.info("从 VideoBase 获取封面: coverUrl={}, duration={}", coverUrl, duration); + } + + if (coverUrl == null) { + log.info("VideoBase 封面为空,尝试从 GetVideoInfo 获取"); + GetVideoInfoRequest infoRequest = new GetVideoInfoRequest(); + infoRequest.setVideoId(videoId); + GetVideoInfoResponse infoResponse = client.getAcsResponse(infoRequest); + if (infoResponse.getVideo() != null) { + coverUrl = infoResponse.getVideo().getCoverURL(); + log.info("从 GetVideoInfo 获取封面: coverUrl={}", coverUrl); + } + } + + // 保留签名参数(auth_key),但清理其他临时参数 + String cleanedPlayUrl = cleanVodUrl(playUrl); + String cleanedCoverUrl = cleanVodUrl(coverUrl); + log.info("Cleaned playUrl: {}, contains auth_key: {}", cleanedPlayUrl, + cleanedPlayUrl != null && cleanedPlayUrl.contains("auth_key")); + + Map result = new HashMap<>(); + result.put("videoId", videoId); + result.put("playUrl", cleanedPlayUrl); + result.put("coverUrl", cleanedCoverUrl); + result.put("duration", duration != null ? duration.intValue() : 0); + + // 缓存(CDN 加速地址,签名有效期30天) + if (cleanedPlayUrl != null) { + Map cache = new HashMap<>(); + cache.put("playUrl", cleanedPlayUrl); + if (cleanedCoverUrl != null) cache.put("coverUrl", cleanedCoverUrl); + if (duration != null) cache.put("duration", duration.intValue()); + redisTemplate.opsForValue().set(VIDEO_INFO_CACHE_PREFIX + videoId, cache, Duration.ofDays(30)); + } + + return result; + } catch (Exception e) { + log.error("获取播放信息失败: videoId={}", videoId, e); + throw new RuntimeException("获取播放信息失败: " + e.getMessage(), e); + } + } + + // ==================== VOD 回调处理 ==================== + + /** + * 处理 VOD 回调 — 更新 Moment 表 + * 回调包含:播放地址、封面、审核结果 + */ + @Transactional + public void handleVodCallback(Map body) { + String eventType = (String) body.getOrDefault("EventType", ""); + log.info("VOD回调处理: eventType={}, body={}", eventType, body); + + if ("TranscodeComplete".equals(eventType)) { + handleTranscodeComplete(body); + } else if ("AIMediaAuditComplete".equals(eventType)) { + handleAuditComplete(body); + } else if ("ImageUploadComplete".equals(eventType)) { + handleImageUploadComplete(body); + } else if ("FileUploadComplete".equals(eventType)) { + log.info("VOD FileUploadComplete 回调,等待转码完成"); + } + } + + /** + * 转码完成回调 — 更新播放地址 + */ + private void handleTranscodeComplete(Map body) { + try { + // VOD 回调格式可能不同:MediaId 可能在顶层,也可能嵌套在 TranscodeComplete 对象中 + String videoId = extractMediaId(body); + if (videoId == null || videoId.isEmpty()) { + log.error("VOD转码回调中无法提取 MediaId,body={}", body); + return; + } + + // 调用 VOD API 获取 CDN 加速播放地址(非OSS回源地址) + DefaultAcsClient client = buildVodClient(); + GetPlayInfoRequest request = new GetPlayInfoRequest(); + request.setVideoId(videoId); + // 不限制 Formats,兼容 MP4 / HLS(m3u8) 等多种转码输出 + // 使用 CDN 加速域名获取播放地址,签名有效期 100 年(3153600000秒),确保地址永久有效 + request.setOutputType("cdn"); + // 设置播放域名 + request.setPlayConfig("{\"PlayDomain\":\"video.yoyoeco.com\"}"); + // 设置签名有效期(100年 = 3153600000秒),确保地址永久有效 + // 注意:AuthTimeout 是独立参数,不是放在 PlayConfig 中 + request.setAuthTimeout(3153600000L); + + GetPlayInfoResponse response = client.getAcsResponse(request); + + String playUrl = null; + GetPlayInfoResponse.PlayInfo bestPlayInfo = selectBestPlayInfo(response.getPlayInfoList()); + if (bestPlayInfo != null) { + playUrl = bestPlayInfo.getPlayURL(); + } + + String coverUrl = null; + Float duration = null; + + if (response.getVideoBase() != null) { + coverUrl = response.getVideoBase().getCoverURL(); + String durationStr = response.getVideoBase().getDuration(); + if (durationStr != null && !durationStr.isEmpty()) { + try { + duration = Float.parseFloat(durationStr); + } catch (NumberFormatException e) { + log.warn("解析视频时长失败: durationStr={}", durationStr); + } + } + log.info("从 VideoBase 获取封面: coverUrl={}, duration={}", coverUrl, duration); + } + + if (coverUrl == null) { + log.info("VideoBase 封面为空,尝试从 GetVideoInfo 获取"); + GetVideoInfoRequest infoRequest = new GetVideoInfoRequest(); + infoRequest.setVideoId(videoId); + GetVideoInfoResponse infoResponse = client.getAcsResponse(infoRequest); + if (infoResponse.getVideo() != null) { + coverUrl = infoResponse.getVideo().getCoverURL(); + log.info("从 GetVideoInfo 获取封面: coverUrl={}", coverUrl); + } + } + + // 清理 URL 中的临时参数,但保留签名参数(auth_key) + final String cleanedPlayUrl = cleanVodUrl(playUrl); + final String cleanedCoverUrl = cleanVodUrl(coverUrl); + final Float finalDuration = duration; + + log.info("VOD转码完成: videoId={}, playUrl={}, coverUrl={}", videoId, cleanedPlayUrl, cleanedCoverUrl); + + // 1. 写入 Redis 缓存(CDN 加速地址) + if (cleanedPlayUrl != null) { + Map cache = new HashMap<>(); + cache.put("playUrl", cleanedPlayUrl); + if (cleanedCoverUrl != null) cache.put("coverUrl", cleanedCoverUrl); + redisTemplate.opsForValue().set(VIDEO_INFO_CACHE_PREFIX + videoId, cache, Duration.ofDays(30)); + } + + // 2. 更新数据库 Moment 表 + momentRepository.findFirstByVodVideoIdOrderByCreatedAtAsc(videoId).ifPresent(moment -> { + if (cleanedPlayUrl != null) { + moment.setPlayUrl(cleanedPlayUrl); + } + if (cleanedCoverUrl != null) { + moment.setVideoCoverUrl(cleanedCoverUrl); + } + // VOD 返回的时长单位是秒,转为整数 + if (finalDuration != null) { + moment.setVideoDuration(finalDuration.intValue()); + } + momentRepository.save(moment); + log.info("Moment 视频信息更新: id={}, playUrl={}, coverUrl={}, duration={}s", + moment.getId(), cleanedPlayUrl, cleanedCoverUrl, finalDuration != null ? finalDuration.intValue() : 0); + }); + } catch (Exception e) { + log.error("处理转码完成回调失败", e); + } + } + + /** + * AI 审核完成回调 — 更新审核状态 + */ + private void handleAuditComplete(Map body) { + try { + String videoId = extractMediaId(body); + if (videoId == null || videoId.isEmpty()) { + log.error("VOD审核回调中无法提取 MediaId,body={}", body); + return; + } + + String suggestion = (String) body.getOrDefault("Suggestion", "pass"); + + // Suggestion 也可能嵌套在 AIMediaAuditComplete 对象中 + if (suggestion == null || "pass".equals(suggestion)) { + Object auditObj = body.get("AIMediaAuditComplete"); + if (auditObj instanceof Map) { + @SuppressWarnings("unchecked") + Map auditMap = (Map) auditObj; + suggestion = (String) auditMap.getOrDefault("Suggestion", suggestion); + } + } + + String auditStatus; + if ("block".equals(suggestion)) { + auditStatus = "REJECTED"; + } else if ("review".equals(suggestion)) { + auditStatus = "PENDING_REVIEW"; + } else { + auditStatus = "APPROVED"; + } + + log.info("VOD审核完成: videoId={}, suggestion={}, auditStatus={}", videoId, suggestion, auditStatus); + + // 1. 更新 Redis 缓存 + Object cached = redisTemplate.opsForValue().get(VIDEO_INFO_CACHE_PREFIX + videoId); + if (cached instanceof Map) { + @SuppressWarnings("unchecked") + Map cache = (Map) cached; + cache.put("auditStatus", auditStatus); + redisTemplate.opsForValue().set(VIDEO_INFO_CACHE_PREFIX + videoId, cache, Duration.ofDays(30)); + } + + // 2. 更新数据库 Moment 表 + momentRepository.findFirstByVodVideoIdOrderByCreatedAtAsc(videoId).ifPresent(moment -> { + moment.setAuditStatus(auditStatus); + momentRepository.save(moment); + log.info("Moment 审核状态更新: id={}, auditStatus={}", moment.getId(), auditStatus); + }); + } catch (Exception e) { + log.error("处理审核完成回调失败", e); + } + } + + /** + * 图片上传完成回调 — 通过 GetImageInfo 获取 CDN 加速地址并缓存 + * 回调返回的地址是 OSS 回源地址,需通过 API 转换为 CDN 加速域名地址 + */ + private void handleImageUploadComplete(Map body) { + try { + String imageId = extractMediaId(body); + + if (imageId == null || imageId.isEmpty()) { + log.info("图片上传回调无 ImageId,跳过"); + return; + } + + // 通过 GetImageInfo API 获取 CDN 加速域名地址(非 OSS 回源地址) + String cdnImageUrl; + try { + Map imageInfo = getImageInfo(imageId); + cdnImageUrl = (String) imageInfo.get("imageUrl"); + } catch (Exception e) { + log.warn("GetImageInfo 获取 CDN 地址失败,回退使用回调地址: imageId={}", imageId, e); + cdnImageUrl = extractCallbackImageUrl(body); + } + + log.info("VOD图片上传完成: imageId={}, cdnImageUrl={}", imageId, cdnImageUrl); + + // Redis 缓存 CDN 加速地址 + Map cache = new HashMap<>(); + cache.put("imageUrl", cdnImageUrl); + redisTemplate.opsForValue().set("image:info:" + imageId, cache, Duration.ofDays(30)); + + } catch (Exception e) { + log.error("处理图片上传完成回调失败", e); + } + } + + /** + * 从回调 body 中提取原始图片 URL(作为 CDN 地址获取失败时的回退) + */ + private String extractCallbackImageUrl(Map body) { + String imageUrl = (String) body.get("ImageURL"); + if (imageUrl != null) return imageUrl; + imageUrl = (String) body.get("FileURL"); + if (imageUrl != null) return imageUrl; + Object imageObj = body.get("ImageUploadComplete"); + if (imageObj instanceof Map) { + @SuppressWarnings("unchecked") + Map imageMap = (Map) imageObj; + imageUrl = (String) imageMap.get("ImageURL"); + if (imageUrl != null) return imageUrl; + return (String) imageMap.get("FileURL"); + } + return null; + } + + // ==================== 辅助方法 ==================== + + /** + * VOD 清晰度优先级(从高到低) + */ + private static final List DEFINITION_PRIORITY = Arrays.asList( + "OD", "4K", "2K", "FHD", "HD", "SD", "LD", "FD" + ); + + /** + * 从 GetPlayInfo 返回的播放信息列表中,选择清晰度最高的一条。 + * 阿里云 VOD 返回的列表按清晰度从低到高排序,直接取第一条会拿到最模糊的档位。 + * + * @param playInfoList 播放信息列表 + * @return 清晰度最高的播放信息;列表为空时返回 null + */ + private GetPlayInfoResponse.PlayInfo selectBestPlayInfo(List playInfoList) { + if (playInfoList == null || playInfoList.isEmpty()) { + return null; + } + // 打印全部可用流,便于排查清晰度问题(definition/format/bitrate) + for (GetPlayInfoResponse.PlayInfo info : playInfoList) { + log.info("GetPlayInfo 可用流: definition={}, format={}, bitrate={}, size={}x{}, url={}", + info.getDefinition(), info.getFormat(), info.getBitrate(), + info.getWidth(), info.getHeight(), info.getPlayURL()); + } + for (String definition : DEFINITION_PRIORITY) { + for (GetPlayInfoResponse.PlayInfo info : playInfoList) { + if (definition.equalsIgnoreCase(info.getDefinition())) { + log.info("选择最高清晰度流: definition={}, format={}, bitrate={}, url={}", + info.getDefinition(), info.getFormat(), info.getBitrate(), info.getPlayURL()); + return info; + } + } + } + // 未匹配到已知清晰度时,兜底返回第一条 + return playInfoList.get(0); + } + + /** + * 从 VOD 回调 body 中提取 MediaId / VideoId / ImageId + * 兼容不同格式:顶层 MediaId / 嵌套在事件对象中的 MediaId / 图片回调的 ImageId + */ + @SuppressWarnings("unchecked") + private String extractMediaId(Map body) { + // 1. 尝试顶层字段 + String videoId = (String) body.get("MediaId"); + if (videoId != null && !videoId.isEmpty()) return videoId; + + videoId = (String) body.get("VideoId"); + if (videoId != null && !videoId.isEmpty()) return videoId; + + videoId = (String) body.get("ImageId"); + if (videoId != null && !videoId.isEmpty()) return videoId; + + // 2. 尝试从事件类型对应的嵌套对象中提取 + String eventType = (String) body.get("EventType"); + if (eventType != null) { + Object eventObj = body.get(eventType); + if (eventObj instanceof Map) { + Map eventMap = (Map) eventObj; + videoId = (String) eventMap.get("MediaId"); + if (videoId != null && !videoId.isEmpty()) return videoId; + videoId = (String) eventMap.get("VideoId"); + if (videoId != null && !videoId.isEmpty()) return videoId; + videoId = (String) eventMap.get("ImageId"); + if (videoId != null && !videoId.isEmpty()) return videoId; + } + } + + // 3. 遍历所有值尝试找到 MediaId/VideoId/ImageId(兜底) + for (Object value : body.values()) { + if (value instanceof Map) { + Map nested = (Map) value; + String id = (String) nested.get("MediaId"); + if (id != null && !id.isEmpty()) return id; + id = (String) nested.get("VideoId"); + if (id != null && !id.isEmpty()) return id; + id = (String) nested.get("ImageId"); + if (id != null && !id.isEmpty()) return id; + } + } + + return null; + } + + /** + * 缓存视频元信息 + */ + private void cacheVideoMeta(DefaultAcsClient client, String videoId) { + try { + GetVideoInfoRequest request = new GetVideoInfoRequest(); + request.setVideoId(videoId); + GetVideoInfoResponse response = client.getAcsResponse(request); + + if (response.getVideo() != null) { + Map cache = new HashMap<>(); + String coverUrl = response.getVideo().getCoverURL(); + Float duration = response.getVideo().getDuration(); + if (coverUrl != null) cache.put("coverUrl", coverUrl); + if (duration != null) cache.put("duration", duration.intValue()); + if (!cache.isEmpty()) { + redisTemplate.opsForValue().set(VIDEO_INFO_CACHE_PREFIX + videoId, cache, Duration.ofDays(30)); + } + } + } catch (Exception e) { + log.warn("缓存视频元信息失败: videoId={}", videoId, e); + } + } + + /** + * 从 UploadAddress JSON 中提取 OSS Endpoint + * 兼容新版 VOD API 中 Endpoint / StorageLocation 两种字段格式 + */ + private String extractEndpoint(Map addressMap) { + String endpoint = addressMap.get("Endpoint"); + + // 如果 Endpoint 为空,尝试从 StorageLocation 中提取 + if (endpoint == null || endpoint.isEmpty()) { + endpoint = addressMap.get("StorageLocation"); + if (endpoint != null && !endpoint.isEmpty()) { + // StorageLocation 格式: "bucket.oss-cn-shanghai.aliyuncs.com" + // 去掉开头的 bucket 名,只保留 OSS 域名部分 + int dotIndex = endpoint.indexOf('.'); + if (dotIndex > 0) { + endpoint = endpoint.substring(dotIndex + 1); + } + } + } + + if (endpoint == null || endpoint.isEmpty()) { + // 兜底:从 Bucket 名 + 地域拼接 + String bucket = addressMap.get("Bucket"); + endpoint = vodProperties.getRegionId().replace("cn-", "oss-cn-") + ".aliyuncs.com"; + log.warn("UploadAddress 中未找到 Endpoint,使用推测地址: bucket={}, endpoint={}", bucket, endpoint); + } + + // 去掉已包含的协议前缀 + if (endpoint.startsWith("https://")) { + endpoint = endpoint.substring(8); + } else if (endpoint.startsWith("http://")) { + endpoint = endpoint.substring(7); + } + + return endpoint; + } + + /** + * 构建 VOD 客户端 + */ + public void deleteAccountVideo(String videoId) throws com.aliyuncs.exceptions.ClientException { + DefaultAcsClient client = buildVodClient(); + try { + DeleteVideoRequest request = new DeleteVideoRequest(); + request.setVideoIds(videoId); + try { + client.getAcsResponse(request); + } catch (com.aliyuncs.exceptions.ClientException ex) { + if (!"InvalidVideo.NotFound".equals(ex.getErrCode())) throw ex; + } + } finally { client.shutdown(); } + } + + protected DefaultAcsClient buildVodClient() { + DefaultProfile profile = DefaultProfile.getProfile( + vodProperties.getRegionId(), + vodProperties.getAccessKeyId(), + vodProperties.getAccessKeySecret() + ); + return new DefaultAcsClient(profile); + } + + // ==================== 播放地址补拉(话题讨论视频) ==================== + + /** + * 异步补拉一次播放地址并回填,失败只 warn。 + * + *

场景:发布话题讨论时视频可能**已经**转码完成(VOD 回调早于落库到达,那次回填会丢), + * 也可能还在转码。落库后立刻补拉一次可以吃掉"回调先到"的竞态;拿不到就交给 + * {@code TopicDiscussionVideoSweeper}。

+ * + *

绝不抛异常:发布已经成功,补拉失败只影响播放地址,不能反过来让用户看到发布失败。

+ */ + @Async + public void asyncPullPlayInfo(String videoId) { + try { + if (!pullPlayInfoAndApply(videoId)) { + log.info("视频尚未转码完成,等待回调或定时任务补齐: videoId={}", videoId); + } + } catch (Exception e) { + log.warn("异步补拉播放地址失败: videoId={}, reason={}", videoId, e.getMessage()); + } + } + + /** + * 同步补拉播放地址并回填到对应内容。 + * + * @return 是否成功写入({@code false} = 还没转码好 / 找不到对应内容) + */ + public boolean pullPlayInfoAndApply(String videoId) { + if (videoId == null || videoId.trim().isEmpty()) { + return false; + } + Map info = getPlayInfo(videoId); + String playUrl = (String) info.get("playUrl"); + if (playUrl == null || playUrl.trim().isEmpty()) { + return false; + } + String coverUrl = (String) info.get("coverUrl"); + Object duration = info.get("duration"); + Integer durationSeconds = duration instanceof Number ? ((Number) duration).intValue() : null; + + final boolean[] applied = {false}; + momentRepository.findFirstByVodVideoIdOrderByCreatedAtAsc(videoId).ifPresent(moment -> { + moment.setPlayUrl(playUrl); + if (coverUrl != null && !coverUrl.trim().isEmpty()) { + moment.setVideoCoverUrl(coverUrl); + } + if (durationSeconds != null && durationSeconds > 0) { + moment.setVideoDuration(durationSeconds); + } + momentRepository.save(moment); + applied[0] = true; + log.info("播放地址补拉成功: momentId={}, videoId={}", moment.getId(), videoId); + }); + return applied[0]; + } + + /** + * 清理 VOD URL 中的临时参数,但保留签名参数(auth_key) + * + * 阿里云 VOD 返回的 URL 会带有签名参数(auth_key)和其他临时参数。 + * 签名参数是 CDN 防盗链必需的,需要保留,但可以设置较长的有效期。 + * 仅清理非必要的临时参数。 + * + * 注意:对于 m3u8 格式的 URL,保持原始格式不变,因为 CDN 签名可能依赖于整个 URL 的完整性。 + * + * @param url 原始 URL + * @return 清理后的 URL + */ + private String cleanVodUrl(String url) { + if (url == null || url.isEmpty()) { + return url; + } + + try { + java.net.URL u = new java.net.URL(url); + String query = u.getQuery(); + String path = u.getPath(); + + if (query == null || query.isEmpty()) { + return url; + } + + if (path != null && path.toLowerCase().endsWith(".m3u8")) { + log.info("m3u8 URL 保持原始格式不变: url={}", url); + return url; + } + + StringBuilder cleanedQuery = new StringBuilder(); + String[] params = query.split("&"); + + for (String param : params) { + String[] keyValue = param.split("=", 2); + String key = keyValue[0].toLowerCase(); + + if (!key.equals("sign") && !key.equals("expire")) { + if (cleanedQuery.length() > 0) { + cleanedQuery.append("&"); + } + cleanedQuery.append(param); + } + } + + String newUrl; + if (cleanedQuery.length() > 0) { + newUrl = u.getProtocol() + "://" + u.getAuthority() + u.getPath() + "?" + cleanedQuery.toString(); + } else { + newUrl = u.getProtocol() + "://" + u.getAuthority() + u.getPath(); + } + + return newUrl; + } catch (java.net.MalformedURLException e) { + log.warn("清理 VOD URL 失败: url={}", url, e); + return url; + } + } + + /** + * 将 OSS 地址转换为 CDN 地址 + * + * @param url 原始 OSS URL + * @param cdnDomain CDN 域名 + * @return CDN 地址 + */ + private String convertToCdnUrl(String url, String cdnDomain) { + if (url == null || url.isEmpty()) { + return url; + } + + try { + java.net.URL u = new java.net.URL(url); + return u.getProtocol() + "://" + cdnDomain + u.getPath() + (u.getQuery() != null ? "?" + u.getQuery() : ""); + } catch (java.net.MalformedURLException e) { + log.warn("转换 CDN URL 失败: url={}", url, e); + return url; + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/XuexinReportCleanupScheduler.java b/campus-server/src/main/java/com/campus/service/XuexinReportCleanupScheduler.java new file mode 100644 index 0000000..1075869 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/XuexinReportCleanupScheduler.java @@ -0,0 +1,54 @@ +package com.campus.service; + +import com.campus.entity.XuexinReport; +import com.campus.repository.XuexinReportRepository; +import com.campus.util.OssUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; + +/** + * 清理过期的学信网认证临时材料。 + * + *

{@code PENDING} 记录只是「30 分钟内的占位」:用户上传后没确认就放弃了。 + * 它们会一直占用 {@code uk_xuexin_report_dedup_key},导致同一份材料无法重新提交, + * 所以必须**物理删除**(本表不做软删,软删行仍占唯一索引)。

+ * + *

注意:{@code @Scheduled} 线程里没有 {@code UserContext}(SecurityContext 不跨线程), + * 本任务不依赖登录用户,正常。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class XuexinReportCleanupScheduler { + + private final XuexinReportRepository reportRepository; + private final OssUtil ossUtil; + + /** 每小时第 17 分执行,避开整点上的其它任务。 */ + @Scheduled(cron = "${app.xuexin.cleanup-cron:0 17 * * * ?}") + @Transactional + public void purgeExpiredPending() { + long removed = 0; + for (XuexinReport report : reportRepository.findByStatusAndExpireAtBefore( + XuexinReport.STATUS_PENDING, new Date())) { + try { + if (report.getReportPdfUrl() != null && !report.getReportPdfUrl().trim().isEmpty()) { + ossUtil.deleteFile(report.getReportPdfUrl()); + } + reportRepository.delete(report); + removed++; + } catch (Exception e) { + // 远端失败时保留本地记录和 URL,下一轮继续重试,避免失去清理依据。 + log.warn("清理过期学信网认证材料失败,将保留记录重试: reportId={}", report.getId(), e); + } + } + if (removed > 0) { + log.info("清理过期学信网认证临时材料: {} 条", removed); + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/XuexinReportService.java b/campus-server/src/main/java/com/campus/service/XuexinReportService.java new file mode 100644 index 0000000..af1b550 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/XuexinReportService.java @@ -0,0 +1,603 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ServiceUnavailableException; +import com.campus.dto.request.XuexinSubmitRequest; +import com.campus.dto.response.ChsiReportDTO; +import com.campus.dto.response.XuexinDedupDTO; +import com.campus.entity.University; +import com.campus.entity.User; +import com.campus.entity.XuexinReport; +import com.campus.repository.UniversityRepository; +import com.campus.repository.UserRepository; +import com.campus.repository.XuexinReportRepository; +import com.campus.util.EducationRank; +import com.campus.util.OcrTextNormalizer; +import com.campus.util.OssUtil; +import com.campus.util.StudentNumberGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import com.campus.entity.College; +import com.campus.entity.Major; +import com.campus.repository.CollegeRepository; +import com.campus.repository.MajorRepository; +import java.util.Date; +import java.util.Optional; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 学信网认证材料:落库、判重、提交。 + * + *

职责边界(与 {@code ProfileController} 的分工): + *

    + *
  • {@link #register}:OCR 成功后把识别结果落成 {@code PENDING},并给出「该报告是否已被认证」;
  • + *
  • {@link #submit}:把 {@code PENDING} 转 {@code CONFIRMED},并以库中记录为准写 {@link User}。
  • + *

+ * + *

为什么 register 不走事务:它要 catch 唯一键冲突后继续查库判定。若包在事务里, + * Hibernate 在 flush 失败后会把 session 置为 rollback-only,后续查询会抛 + * {@code AssertionFailure}。所以用 {@code NOT_SUPPORTED} 挂起事务,让每次 repo 调用各自成事务, + * 冲突后在新事务里重新读取即可安全降级。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class XuexinReportService { + + /** OCR 临时材料的默认有效期(分钟)。 */ + @Value("${app.xuexin.pending-ttl-minutes:30}") + private long pendingTtlMinutes = 30; + + /** 更换认证材料的冷静期(小时),默认 72 = 3 天。 */ + @Value("${app.xuexin.change-cooldown-hours:72}") + private long changeCooldownHours = 72; + + /** 学历升级是否豁免冷静期。默认 false(与「更换材料冷静期 3 天」的产品口径一致)。 */ + @Value("${app.xuexin.upgrade-bypass-cooldown:false}") + private boolean upgradeBypassCooldown = false; + + private final XuexinReportRepository reportRepository; + private final UserRepository userRepository; + private final UniversityRepository universityRepository; + private final CollegeRepository collegeRepository; + private final MajorRepository majorRepository; + private final StudentNumberGenerator studentNumberGenerator; + private final ClickGuardService clickGuardService; + private final OssUtil ossUtil; + private final CommerceService commerceService; + + private static final Pattern YEAR_PATTERN = Pattern.compile("(19|20)\\d{2}"); + + private static final String FIELD_SEPARATOR = "\u0001"; + + // ==================== 1. OCR 落库 + 判重 ==================== + + /** + * 把一次 OCR 结果落成临时材料,并判定这份材料是否已被认证。 + * + * @param userId 当前登录用户 + * @param dto OCR 结果,方法内会回填 {@code reportId} 与 {@code dedup} + * @param reportPdfUrl 报告原始 PDF 地址,可为 null + * @return 判重结果,恒不为 null + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public XuexinDedupDTO register(String userId, ChsiReportDTO dto, String reportPdfUrl) { + XuexinReport candidate = buildCandidate(userId, dto, reportPdfUrl); + Date now = new Date(); + + Optional existing = reportRepository.findByDedupKeyIncludingDeleted(candidate.getDedupKey()); + if (existing.isPresent() && Boolean.TRUE.equals(existing.get().getDeleted())) { + // 本表约定永不软删;若真出现软删行(历史误操作),先物理清掉再按新记录处理 + reportRepository.delete(existing.get()); + reportRepository.flush(); + existing = Optional.empty(); + } + if (existing.isPresent()) { + return judgeExisting(userId, existing.get(), candidate, now, dto); + } + return createPending(candidate, now, dto); + } + + private XuexinDedupDTO createPending(XuexinReport candidate, Date now, ChsiReportDTO dto) { + XuexinReport entity = candidate; + entity.setStatus(XuexinReport.STATUS_PENDING); + entity.setActiveMarker(null); + entity.setExpireAt(expireAt(now)); + try { + XuexinReport saved = reportRepository.saveAndFlush(entity); + dto.setReportId(saved.getId()); + return available(); + } catch (DataIntegrityViolationException e) { + // 并发下两个账号同时上传同一份材料,唯一键兜底;此时对方的记录已经可见,按已有记录判定 + log.warn("学信网材料落库撞唯一键,改按已有记录判定: dedupKey={}", candidate.getDedupKey()); + XuexinReport row = reportRepository.findByDedupKeyIncludingDeleted(candidate.getDedupKey()) + .orElseThrow(() -> new BusinessException("认证材料保存失败,请重新上传报告")); + return judgeExisting(candidate.getUserId(), row, candidate, now, dto); + } + } + + private XuexinDedupDTO judgeExisting(String userId, XuexinReport existing, XuexinReport candidate, + Date now, ChsiReportDTO dto) { + boolean sameUser = userId != null && userId.equals(existing.getUserId()); + String status = existing.getStatus(); + + // 已被认证(含已被替换的历史材料):材料一旦被用过,本人和他人都不再允许提交 + if (XuexinReport.STATUS_CONFIRMED.equals(status) + || XuexinReport.STATUS_SUPERSEDED.equals(status)) { + dto.setReportId(null); + if (sameUser) { + return dedup(XuexinDedupDTO.ALREADY_CONFIRMED_BY_SELF, XuexinDedupDTO.MSG_BY_SELF, + existing.getConfirmedAt()); + } + return dedup(XuexinDedupDTO.ALREADY_CONFIRMED_BY_OTHER, XuexinDedupDTO.MSG_BY_OTHER, + existing.getConfirmedAt()); + } + + // PENDING:本人可续期;他人占位过期则抢占(原提交者 30 分钟内没确认,视为放弃) + boolean expired = existing.getExpireAt() == null || !existing.getExpireAt().after(now); + if (sameUser || expired) { + if (!sameUser) { + log.info("学信网材料占位已过期,转归当前用户: reportId={}, fromUser={}, toUser={}", + existing.getId(), existing.getUserId(), userId); + existing.setUserId(userId); + } + deleteReplacedPdf(candidate, existing); + copyRecognizedFields(candidate, existing); + existing.setExpireAt(expireAt(now)); + reportRepository.save(existing); + dto.setReportId(existing.getId()); + return available(); + } + + dto.setReportId(null); + return dedup(XuexinDedupDTO.IN_PROGRESS_BY_OTHER, XuexinDedupDTO.MSG_IN_PROGRESS, null); + } + + /** 同一条 PENDING 被新 PDF 刷新时,先删旧 PDF;失败则不覆盖数据库地址。 */ + private void deleteReplacedPdf(XuexinReport candidate, XuexinReport existing) { + String oldUrl = existing.getReportPdfUrl(); + String newUrl = candidate.getReportPdfUrl(); + if (!isBlank(oldUrl) && !oldUrl.equals(newUrl)) { + try { + ossUtil.deleteFile(oldUrl); + } catch (RuntimeException e) { + throw new ServiceUnavailableException("认证材料存储服务暂时不可用,请稍后重试"); + } + } + } + + // ==================== 2. 提交认证 ==================== + + /** + * 用库中 OCR 记录完成认证。所有教育字段一律以 {@code xuexin_report} 为准, + * 请求体只提供 {@code reportId},不允许客户端覆盖 PDF 识别字段。 + */ + @Transactional + public User submit(String userId, XuexinSubmitRequest req) { + if (isBlank(userId)) { + throw new BusinessException("用户未登录"); + } + String reportId = req == null ? null : req.getReportId(); + if (isBlank(reportId)) { + throw new BusinessException("认证材料标识缺失,请重新上传报告"); + } + clickGuardService.ensureNotTooFrequent(ClickGuardService.SCOPE_XUEXIN_SUBMIT, userId, reportId); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException("用户不存在")); + + // 越权与不存在合并同一文案,避免他人探测 reportId 是否有效 + XuexinReport report = reportRepository.findByIdAndUserId(reportId.trim(), userId) + .orElseThrow(() -> new BusinessException("未找到认证材料,请重新上传报告")); + + // 幂等:本次提交的材料正是当前生效的那条 + if (XuexinReport.STATUS_CONFIRMED.equals(report.getStatus()) && report.getActiveMarker() != null) { + log.info("学信网认证重复提交,幂等返回: userId={}, reportId={}", userId, reportId); + commerceService.grantNewcomerRewards(userId, false); + return user; + } + if (!XuexinReport.STATUS_PENDING.equals(report.getStatus())) { + throw new BusinessException("该认证材料已失效,请重新上传报告"); + } + + Date now = new Date(); + ensureChangeAllowed(user, report, now); + // 顺序不能反:先释放旧材料的 active_marker(唯一键),再确认新材料 + reportRepository.supersedeActive(userId, now); + if (reportRepository.markConfirmed(report.getId(), userId, now) == 0) { + throw new BusinessException("该认证材料已失效,请重新上传报告"); + } + + applyReportToUser(user, report, now); + assignStudentNumberIfAbsent(user, report); + + try { + User saved = userRepository.save(user); + commerceService.grantNewcomerRewards(userId, false); + return saved; + } catch (DataIntegrityViolationException e) { + log.warn("学信网认证保存用户失败(学号唯一键冲突/?): userId={}", userId, e); + throw new BusinessException("学号分配冲突,请稍后重试"); + } + } + + /** + * 当前生效的认证材料({@code active_marker} 非空即 CONFIRMED),供认证详情页展示 + * 报告上的在线验证码等。没有则返回 null。 + */ + public XuexinReport findActiveReport(String userId) { + if (isBlank(userId)) { + return null; + } + return reportRepository.findByUserIdAndActiveMarkerIsNotNull(userId).orElse(null); + } + + /** + * 更换材料的前置校验:不允许降级学历,且必须已过冷静期。 + * + *

只在「本人已认证且当前生效材料不是这一份」时才校验;首次认证直接放行。

+ */ + private void ensureChangeAllowed(User user, XuexinReport report, Date now) { + if (!Boolean.TRUE.equals(user.getXuexinCertified())) { + return; + } + String activeReportId = user.getXuexinReportId(); + if (activeReportId == null || activeReportId.equals(report.getId())) { + return; + } + + int currentRank = EducationRank.of(user.getEducation()); + int newRank = report.getLevelRank() == null ? EducationRank.UNKNOWN : report.getLevelRank(); + if (EducationRank.isDowngrade(currentRank, newRank)) { + throw new BusinessException("当前已认证学历为「" + nullToDash(user.getEducation()) + + "」,不支持更换为更低学历「" + nullToDash(report.getLevel()) + "」"); + } + + if (upgradeBypassCooldown && newRank > currentRank) { + log.info("学信网认证学历升级,豁免冷静期: userId={}, {} -> {}", + user.getId(), currentRank, newRank); + return; + } + + Date certifiedAt = user.getXuexinCertifiedAt(); + if (certifiedAt == null) { + // 历史数据没有认证时间,视为已过冷静期,不阻塞 + return; + } + long cooldownMs = changeCooldownHours * 60L * 60L * 1000L; + long remainMs = certifiedAt.getTime() + cooldownMs - now.getTime(); + if (remainMs > 0) { + throw new BusinessException("认证通过后 " + cooldownText() + "内不可更换认证材料,请在 " + + formatTime(new Date(now.getTime() + remainMs)) + " 后重试"); + } + } + + /** 以库中的 PDF 识别记录为准写用户教育信息,客户端不能覆盖。 */ + private void applyReportToUser(User user, XuexinReport report, Date now) { + String oldSchool = user.getUniversityName(); + String oldCollege = user.getCollegeName(); + String oldEducation = user.getEducation(); + String oldGrade = user.getGrade(); + String dept = report.getDepartment(); + String major = report.getMajor(); + String enrollment = report.getEnrollmentDate(); + String departure = report.getDepartureDate(); + String status = report.getEnrollmentStatus(); + + // User 上这些列都比 xuexin_report 短,必须截断,否则 OCR 出长串会直接 500 + if (!isBlank(report.getSchool())) { + user.setUniversityName(truncate(report.getSchool(), 100)); + } + if (!isBlank(report.getLevel())) { + user.setEducation(truncate(report.getLevel(), 20)); + } + if (!isBlank(dept)) { + user.setCollegeName(truncate(dept, 50)); + } + if (!isBlank(major)) { + user.setMajorName(truncate(major, 50)); + } + String studyForm = report.getStudyForm(); + if (!isBlank(studyForm)) { + user.setEducationType(truncate(studyForm, 20)); + } + + boolean schoolChanged = !Objects.equals(oldSchool, user.getUniversityName()); + boolean collegeChanged = !Objects.equals(oldCollege, user.getCollegeName()); + if (schoolChanged && isBlank(dept)) user.setCollegeName(null); + if ((schoolChanged || collegeChanged) && isBlank(major)) user.setMajorName(null); + syncEducationIds(user, report); + user.setGrade(null); + // 年级:优先取本次入学年份;缺失时按毕业年份减学制推算 + if (!isBlank(enrollment)) { + Matcher m = YEAR_PATTERN.matcher(enrollment); + if (m.find()) { + user.setGrade(truncate(m.group(0) + "级", 20)); + } + } + if (isBlank(user.getGrade()) || "-".equals(user.getGrade().trim()) || "null".equals(user.getGrade())) { + guessGradeFromDeparture(user, departure, report.getLevel()); + } + + if (isBlank(user.getGrade()) && !schoolChanged + && Objects.equals(oldEducation, user.getEducation())) user.setGrade(oldGrade); + if (!isBlank(report.getName())) user.setNickname(truncate(report.getName(), 50)); + Date birthday = parseBirthday(report.getBirthDate()); + if (birthday != null) user.setBirthday(birthday); + String gender = normalizeGender(report.getGender()); + if (gender != null) user.setGender(gender); + + user.setXuexinCertified(true); + if (!isBlank(status)) { + user.setXuexinEnrollmentStatus(truncate(status, 30)); + } + user.setXuexinCertifiedAt(now); + user.setXuexinReportId(report.getId()); + } + + /** + * 认证通过后分配学号:院校代码 + 7 位序号。 + * + *

先查学号是否已被占用(含软删行),冲突则重新取号,最多 3 次; + * 仍拿不到就跳过 —— 学号失败不能阻塞认证主流程。

+ */ + private void assignStudentNumberIfAbsent(User user, XuexinReport report) { + if (user.getNumber() != null) { + return; + } + try { + University university = null; + if (user.getUniversityId() != null) { + university = universityRepository.findById(user.getUniversityId()).orElse(null); + } + if (university == null) { + log.warn("生成学号跳过:未找到学校信息, userId={}, universityId={}, school={}", + user.getId(), user.getUniversityId(), report.getSchool()); + return; + } + if (isBlank(university.getSchoolCode())) { + log.warn("生成学号跳过:学校未配置院校代码(schoolCode), universityId={}, name={}", + university.getId(), university.getName()); + return; + } + for (int attempt = 0; attempt < 3; attempt++) { + String candidate = studentNumberGenerator.generateStudentNumber( + university.getId(), university.getSchoolCode()); + if (userRepository.countByNumberIncludingDeleted(candidate) == 0) { + user.setNumber(candidate); + return; + } + log.warn("生成学号冲突,重试: userId={}, number={}, attempt={}", user.getId(), candidate, attempt); + } + log.error("生成学号连续冲突,放弃本次分配: userId={}, universityId={}", user.getId(), university.getId()); + } catch (Exception e) { + // 学号生成失败不阻塞认证流程,但记录日志便于排查 + log.error("生成学号异常: userId={}", user.getId(), e); + } + } + + private void guessGradeFromDeparture(User user, String departureDate, String level) { + if (isBlank(departureDate)) { + return; + } + Matcher m = YEAR_PATTERN.matcher(departureDate); + if (!m.find()) { + return; + } + int gradYear; + try { + gradYear = Integer.parseInt(m.group(0)); + } catch (NumberFormatException e) { + return; + } + int minus = 4; + String lv = level == null ? "" : level; + if (lv.contains("专科") || lv.contains("大专") || lv.contains("高职")) { + minus = 3; + } else if (lv.contains("硕士") || lv.contains("专升本") || lv.contains("第二学士")) { + minus = 3; + } else if (lv.contains("博士")) { + minus = 3; + } + int enrollingYear = gradYear - minus; + if (enrollingYear >= 1990 && enrollingYear <= 2099) { + user.setGrade(truncate(enrollingYear + "级", 20)); + } + } + + /** 不为缺失日信息编造日期。 */ + private static Date parseBirthday(String birthDate) { + if (isBlank(birthDate) || !birthDate.trim().matches("\\d{4}-\\d{2}-\\d{2}")) return null; + try { + SimpleDateFormat full = new SimpleDateFormat("yyyy-MM-dd"); + full.setLenient(false); + full.setTimeZone(TimeZone.getTimeZone("GMT+8")); + return full.parse(birthDate.trim()); + } catch (ParseException e) { + return null; + } + } + + private void syncEducationIds(User user, XuexinReport report) { + String school = isBlank(report.getSchool()) ? user.getUniversityName() : report.getSchool().trim(); + List schools = isBlank(school) ? java.util.Collections.emptyList() + : universityRepository.findByNameContaining(school).stream() + .filter(item -> school.equals(item.getName())).collect(Collectors.toList()); + user.setUniversityId(schools.size() == 1 ? schools.get(0).getId() : null); + String college = isBlank(report.getDepartment()) ? user.getCollegeName() : report.getDepartment().trim(); + List colleges = user.getUniversityId() == null ? java.util.Collections.emptyList() + : collegeRepository.findByUniversityId(user.getUniversityId()).stream() + .filter(item -> Objects.equals(college, item.getName())).collect(Collectors.toList()); + user.setCollegeId(colleges.size() == 1 ? colleges.get(0).getId() : null); + String major = isBlank(report.getMajor()) ? user.getMajorName() : report.getMajor().trim(); + List majors = user.getCollegeId() == null ? java.util.Collections.emptyList() + : majorRepository.findByCollegeId(user.getCollegeId()).stream() + .filter(item -> Objects.equals(major, item.getName())).collect(Collectors.toList()); + user.setMajorId(majors.size() == 1 ? majors.get(0).getId() : null); + } + + // ==================== 3. 判重键与实体构造 ==================== + + private XuexinReport buildCandidate(String userId, ChsiReportDTO dto, String reportPdfUrl) { + XuexinReport r = new XuexinReport(); + r.setUserId(userId); + r.setReportName(dto.getReportName()); + r.setReportUpdatedDate(dto.getReportUpdatedDate()); + r.setName(dto.getName()); + r.setGender(dto.getGender()); + r.setBirthDate(dto.getBirthDate()); + r.setEthnicity(dto.getEthnicity()); + r.setSchool(dto.getSchool()); + r.setLevel(dto.getLevel()); + r.setBranchCampus(dto.getBranchCampus()); + r.setDepartment(dto.getDepartment()); + r.setMajor(dto.getMajor()); + r.setStudyDuration(dto.getStudyDuration()); + r.setEducationCategory(dto.getEducationCategory()); + r.setStudyForm(dto.getStudyForm()); + r.setEnrollmentDate(dto.getEnrollmentDate()); + r.setDepartureDate(dto.getDepartureDate()); + r.setEnrollmentStatus(dto.getStatus()); + r.setOnlineVerificationCode(dto.getOnlineVerificationCode()); + r.setReportPdfUrl(reportPdfUrl); + r.setValidChsiReport(Boolean.TRUE.equals(dto.isValidChsiReport())); + if (dto.getRawLines() != null && !dto.getRawLines().isEmpty()) { + r.setRawText(String.join("\n", dto.getRawLines())); + } + + r.setLevelRank(EducationRank.of(dto.getLevel())); + r.setDataFingerprint(buildFingerprint(r)); + r.setDedupKey(buildDedupKey(r)); + return r; + } + + /** + * 判重键继续使用稳定学籍字段,刻意不把在线验证码和报告更新日期纳入。 + * + *

原因:学信网报告的「在线验证码」有有效期,同一份学籍重新生成报告时 + * 验证码就会变。若按验证码判重,同一份材料换一次验证码就能再认证一次, + * 甚至可以被另一个账号拿去认证——判重直接失效。姓名、学校、层次、专业、 + * 入学、离校和学制在报告重新生成后通常不会变, + * 才真正代表「同一份数据」。

+ * + *

验证码仍落库用于报告原文存证,但不参与“同一学籍数据”判重。

+ */ + static String buildDedupKey(XuexinReport r) { + return "FP:" + r.getDataFingerprint(); + } + + static String buildFingerprint(XuexinReport r) { + StringBuilder sb = new StringBuilder(); + sb.append(OcrTextNormalizer.normalize(r.getName())).append(FIELD_SEPARATOR); + // 保留旧指纹中“身份证为空”时的槽位,兼容历史无身份证报告。 + sb.append("").append(FIELD_SEPARATOR); + sb.append(OcrTextNormalizer.normalize(r.getSchool())).append(FIELD_SEPARATOR); + sb.append(OcrTextNormalizer.normalize(r.getLevel())).append(FIELD_SEPARATOR); + sb.append(OcrTextNormalizer.normalize(r.getMajor())).append(FIELD_SEPARATOR); + sb.append(OcrTextNormalizer.normalizeMonth(r.getEnrollmentDate())).append(FIELD_SEPARATOR); + sb.append(OcrTextNormalizer.normalizeMonth(r.getDepartureDate())).append(FIELD_SEPARATOR); + sb.append(OcrTextNormalizer.normalizeDuration(r.getStudyDuration())); + return OcrTextNormalizer.sha256(sb.toString()); + } + + private static void copyRecognizedFields(XuexinReport source, XuexinReport target) { + target.setReportName(source.getReportName()); + target.setReportUpdatedDate(source.getReportUpdatedDate()); + target.setOnlineVerificationCode(source.getOnlineVerificationCode()); + target.setReportPdfUrl(source.getReportPdfUrl()); + target.setName(source.getName()); + target.setGender(source.getGender()); + target.setBirthDate(source.getBirthDate()); + target.setEthnicity(source.getEthnicity()); + target.setSchool(source.getSchool()); + target.setLevel(source.getLevel()); + target.setBranchCampus(source.getBranchCampus()); + target.setDepartment(source.getDepartment()); + target.setMajor(source.getMajor()); + target.setStudyDuration(source.getStudyDuration()); + target.setEducationCategory(source.getEducationCategory()); + target.setStudyForm(source.getStudyForm()); + target.setEnrollmentDate(source.getEnrollmentDate()); + target.setDepartureDate(source.getDepartureDate()); + target.setEnrollmentStatus(source.getEnrollmentStatus()); + target.setRawText(source.getRawText()); + target.setValidChsiReport(source.getValidChsiReport()); + } + + // ==================== 4. 文案与工具 ==================== + + /** 冷静期文案:整天的按天显示(3 天),否则按小时显示。 */ + private String cooldownText() { + if (changeCooldownHours > 0 && changeCooldownHours % 24 == 0) { + return (changeCooldownHours / 24) + " 天"; + } + return changeCooldownHours + " 小时"; + } + + private static String formatTime(Date date) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + sdf.setTimeZone(TimeZone.getTimeZone("GMT+8")); + return sdf.format(date); + } + + private Date expireAt(Date now) { + return new Date(now.getTime() + pendingTtlMinutes * 60L * 1000L); + } + + /** 性别归一化:M/m/男 → M,F/f/女 → F,其余返回 null 表示非法。 */ + private static String normalizeGender(String raw) { + String s = OcrTextNormalizer.normalize(raw); + if (s.isEmpty()) { + return null; + } + if ("m".equals(s) || "男".equals(s)) { + return "M"; + } + if ("f".equals(s) || "女".equals(s)) { + return "F"; + } + return null; + } + + private static XuexinDedupDTO available() { + return dedup(XuexinDedupDTO.AVAILABLE, XuexinDedupDTO.MSG_AVAILABLE, null); + } + + private static XuexinDedupDTO dedup(String status, String message, Date certifiedAt) { + XuexinDedupDTO dto = new XuexinDedupDTO(); + dto.setStatus(status); + dto.setMessage(message); + dto.setCertifiedAt(certifiedAt); + return dto; + } + + private static String truncate(String value, int max) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.length() <= max ? trimmed : trimmed.substring(0, max); + } + + private static String nullToDash(String v) { + return isBlank(v) ? "-" : v.trim(); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} diff --git a/campus-server/src/main/java/com/campus/service/event/GroupAnnouncementPublishedEvent.java b/campus-server/src/main/java/com/campus/service/event/GroupAnnouncementPublishedEvent.java new file mode 100644 index 0000000..884690d --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/event/GroupAnnouncementPublishedEvent.java @@ -0,0 +1,12 @@ +package com.campus.service.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GroupAnnouncementPublishedEvent { + private final String groupId; + private final String publisherId; + private final String content; +} diff --git a/campus-server/src/main/java/com/campus/service/event/InteractionCommittedAction.java b/campus-server/src/main/java/com/campus/service/event/InteractionCommittedAction.java new file mode 100644 index 0000000..f96dc1e --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/event/InteractionCommittedAction.java @@ -0,0 +1,11 @@ +package com.campus.service.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** 仅用于进程内的提交后辅助动作,不作为可靠消息或跨进程任务使用。 */ +@Getter +@RequiredArgsConstructor +public class InteractionCommittedAction { + private final Runnable action; +} diff --git a/campus-server/src/main/java/com/campus/service/event/TopicMergedEvent.java b/campus-server/src/main/java/com/campus/service/event/TopicMergedEvent.java new file mode 100644 index 0000000..a4ae6d5 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/event/TopicMergedEvent.java @@ -0,0 +1,20 @@ +package com.campus.service.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 话题合并事件。 + * + *

话题域({@code TopicService.merge})只负责"话题自己的数据"(关注/访问/收藏/别名), + * 讨论属于动态侧存储,因此由话题域发事件、桥接层监听并迁移 {@code moment.topic_id}。 + * 监听器是同步、同事务的({@code @EventListener}),保证"话题合了、讨论没迁"不会发生。

+ */ +@Getter +@AllArgsConstructor +public class TopicMergedEvent { + /** 被合并的源话题 ID。 */ + private final String sourceId; + /** 合并目标话题 ID。 */ + private final String targetId; +} diff --git a/campus-server/src/main/java/com/campus/service/impl/AliyunPushServiceImpl.java b/campus-server/src/main/java/com/campus/service/impl/AliyunPushServiceImpl.java new file mode 100644 index 0000000..fbab79e --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/impl/AliyunPushServiceImpl.java @@ -0,0 +1,446 @@ +package com.campus.service.impl; + +import com.aliyuncs.DefaultAcsClient; +import com.aliyuncs.IAcsClient; +import com.aliyuncs.profile.DefaultProfile; +import com.aliyuncs.push.model.v20160801.PushRequest; +import com.campus.config.AliyunPushConfig; +import com.campus.service.PushService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AliyunPushServiceImpl implements PushService { + + private final AliyunPushConfig pushConfig; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String KEY_TYPE = "type"; + private static final String KEY_RELATED_ID = "relatedId"; + private static final String KEY_SUB_RELATED_ID = "subRelatedId"; + private static final String KEY_SENDER_ID = "senderId"; + private static final String KEY_SENDER_NICKNAME = "senderNickname"; + private static final String KEY_SENDER_AVATAR = "senderAvatar"; + private static final String KEY_TEXT = "text"; + private static final String KEY_STATUS = "status"; + private static final String KEY_GIFT_NAME = "giftName"; + private static final String KEY_GIFT_COUNT = "giftCount"; + private static final String KEY_GROUP_ID = "groupId"; + private static final String KEY_GROUP_NAME = "groupName"; + private static final String KEY_INVITATION_ID = "invitationId"; + + @Override + @Async + public void pushToUser(String userId, String title, String content, Map extraData) { + if (userId == null || userId.isEmpty()) { + log.warn("[推送] 用户ID为空,跳过推送"); + return; + } + if (!isEnabled()) { + log.info("[推送] 推送功能未启用,跳过推送 -> userId={}, title={}", userId, title); + return; + } + doPush("ACCOUNT", userId, title, content, extraData); + } + + @Override + @Async + public void pushToTag(String tag, String title, String content, Map extraData) { + if (!isEnabled()) { + log.info("[推送] 推送功能未启用,跳过按标签推送"); + return; + } + doPush("TAG", tag, title, content, extraData); + } + + @Override + @Async + public void pushBroadcast(String title, String content, Map extraData) { + if (!isEnabled()) { + log.info("[推送] 推送功能未启用,跳过全量广播"); + return; + } + doPush("ALL", null, title, content, extraData); + } + + private boolean isEnabled() { + return pushConfig.getEnabled() != null && pushConfig.getEnabled() + && pushConfig.getAccessKeyId() != null + && !pushConfig.getAccessKeyId().startsWith("your-"); + } + + private void doPush(String targetType, String targetValue, String title, String content, Map extra) { + try { + Map allExtra = new HashMap<>(); + allExtra.put("title", title); + if (extra != null) { + allExtra.putAll(extra); + } + String extraJson = objectMapper.writeValueAsString(allExtra); + + // 分别推送到 Android 和 iOS(两边 AppKey 不同) + pushToPlatform(pushConfig.getAndroidAppKey(), "ANDROID", + targetType, targetValue, title, content, extraJson); + pushToPlatform(pushConfig.getIosAppKey(), "iOS", + targetType, targetValue, title, content, extraJson); + + } catch (Exception e) { + log.error("[推送] 推送失败 -> targetType={}, target={}, title={}, error={}", + targetType, targetValue, title, e.getMessage(), e); + } + } + + /** + * 实际调用阿里云推送 OpenAPI 向指定平台发送推送 + */ + private void pushToPlatform(String appKey, String deviceType, + String targetType, String targetValue, + String title, String content, String extraJson) { + if (appKey == null || appKey.isEmpty() || appKey.startsWith("your-")) { + log.warn("[推送] {} AppKey 未配置,跳过推送", deviceType); + return; + } + try { + DefaultProfile profile = DefaultProfile.getProfile( + pushConfig.getRegionId(), + pushConfig.getAccessKeyId(), + pushConfig.getAccessKeySecret() + ); + IAcsClient client = new DefaultAcsClient(profile); + PushRequest request = new PushRequest(); + request.setAppKey(Long.parseLong(appKey)); + request.setDeviceType(deviceType); + request.setTarget(targetType); + request.setTargetValue(targetValue); + request.setTitle(title); + request.setBody(content); + // 平台专属扩展参数(用于客户端解析跳转) + if ("iOS".equals(deviceType)) { + request.setIOSExtParameters(extraJson); + } else { + request.setAndroidExtParameters(extraJson); + } + // NOTICE = 通知栏消息,确保在通知栏展示 + request.setPushType("NOTICE"); + // 离线消息存储(App 不在线时由系统通知栏展示) + request.setStoreOffline(true); + + if ("ANDROID".equals(deviceType)) { + // [FIX] 通知通道:必须与客户端创建的 channelId 一致, + // 否则通知走默认低优先级通道,息屏时无声音无弹窗 + request.setAndroidNotificationChannel("CollegeCliNotification"); + // 点击通知后打开 App + request.setAndroidOpenType("APPLICATION"); + // 注意:setAndroidNotifyType 在当前 SDK 版本(3.13.10)下任何值都会报 InvalidAndroidNotifyType, + // 前台通知展示由客户端 MyMessageReceiver.onNotificationReceivedInApp() 手动创建。 + } + + client.getAcsResponse(request); + log.info("[推送] {} 推送成功 -> target={}, title={}", + deviceType, targetValue, title); + } catch (Exception e) { + // iOS 推送失败通常是 APNs 证书未配置,属于预期行为,用 warn 级别 + if ("iOS".equals(deviceType)) { + log.warn("[推送] iOS 推送失败(可能未配置 APNs 证书) -> target={}, title={}, error={}", + targetValue, title, e.getMessage()); + } else { + log.error("[推送] {} 推送失败 -> target={}, title={}, error={}", + deviceType, targetValue, title, e.getMessage(), e); + } + } + } + + // ============== 以下是各个业务场景的推送方法 ============== + + @Override + public void pushMomentLike(String momentPublisherId, String likerNickname, String likerAvatar, + String likerId, String momentId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "MOMENT_LIKE"); + extra.put(KEY_RELATED_ID, momentId); + extra.put(KEY_SENDER_ID, likerId); + extra.put(KEY_SENDER_NICKNAME, likerNickname); + extra.put(KEY_SENDER_AVATAR, likerAvatar); + pushToUser(momentPublisherId, "新的点赞", + likerNickname + " 点赞了你的动态", extra); + } + + @Override + public void pushMomentComment(String momentPublisherId, String commenterNickname, String commenterAvatar, + String commenterId, String momentId, String commentId, String commentText) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "MOMENT_COMMENT"); + extra.put(KEY_RELATED_ID, momentId); + extra.put(KEY_SUB_RELATED_ID, commentId); + extra.put(KEY_SENDER_ID, commenterId); + extra.put(KEY_SENDER_NICKNAME, commenterNickname); + extra.put(KEY_SENDER_AVATAR, commenterAvatar); + extra.put(KEY_TEXT, commentText); + pushToUser(momentPublisherId, "新的评论", + commenterNickname + " 评论了你的动态:" + (commentText == null ? "" : commentText), extra); + } + + @Override + public void pushCommentReply(String commentAuthorId, String replierNickname, String replierAvatar, + String replierId, String momentId, String commentId, String replyText) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "COMMENT_REPLY"); + extra.put(KEY_RELATED_ID, momentId); + extra.put(KEY_SUB_RELATED_ID, commentId); + extra.put(KEY_SENDER_ID, replierId); + extra.put(KEY_SENDER_NICKNAME, replierNickname); + extra.put(KEY_SENDER_AVATAR, replierAvatar); + extra.put(KEY_TEXT, replyText); + pushToUser(commentAuthorId, "新的回复", + replierNickname + " 回复了你的评论:" + (replyText == null ? "" : replyText), extra); + } + + @Override + public void pushCommentLike(String commentAuthorId, String likerNickname, String likerAvatar, + String likerId, String momentId, String commentId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "COMMENT_LIKE"); + extra.put(KEY_RELATED_ID, momentId); + extra.put(KEY_SUB_RELATED_ID, commentId); + extra.put(KEY_SENDER_ID, likerId); + extra.put(KEY_SENDER_NICKNAME, likerNickname); + extra.put(KEY_SENDER_AVATAR, likerAvatar); + pushToUser(commentAuthorId, "新的点赞", + likerNickname + " 点赞了你的评论", extra); + } + + @Override + public void pushActivityJoin(String activityCreatorId, String participantNickname, String participantAvatar, + String participantId, String activityId, String activityTitle) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "ACTIVITY_JOIN"); + extra.put(KEY_RELATED_ID, activityId); + extra.put(KEY_SENDER_ID, participantId); + extra.put(KEY_SENDER_NICKNAME, participantNickname); + extra.put(KEY_SENDER_AVATAR, participantAvatar); + pushToUser(activityCreatorId, "活动报名通知", + participantNickname + " 报名参加了你的活动「" + activityTitle + "」", extra); + } + + @Override + public void pushActivityApply(String activityCreatorId, String participantNickname, String participantAvatar, + String participantId, String activityId, String activityTitle) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "ACTIVITY_JOIN_REQUEST"); + extra.put(KEY_RELATED_ID, activityId); + extra.put(KEY_SENDER_ID, participantId); + extra.put(KEY_SENDER_NICKNAME, participantNickname); + extra.put(KEY_SENDER_AVATAR, participantAvatar); + pushToUser(activityCreatorId, "活动报名申请", + participantNickname + " 申请参加你的活动「" + activityTitle + "」,等待你审核", extra); + } + + @Override + public void pushActivityApplyCancelled(String activityCreatorId, String participantNickname, + String participantAvatar, String participantId, + String activityId, String activityTitle) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "ACTIVITY_QUIT"); + extra.put(KEY_RELATED_ID, activityId); + extra.put(KEY_SENDER_ID, participantId); + extra.put(KEY_SENDER_NICKNAME, participantNickname); + extra.put(KEY_SENDER_AVATAR, participantAvatar); + pushToUser(activityCreatorId, "报名申请已取消", + participantNickname + " 取消了活动「" + activityTitle + "」的报名申请", extra); + } + + @Override + public void pushActivityQuit(String activityCreatorId, String quitterNickname, String quitterAvatar, + String quitterId, String activityId, String activityTitle) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "ACTIVITY_QUIT"); + extra.put(KEY_RELATED_ID, activityId); + extra.put(KEY_SENDER_ID, quitterId); + extra.put(KEY_SENDER_NICKNAME, quitterNickname); + extra.put(KEY_SENDER_AVATAR, quitterAvatar); + pushToUser(activityCreatorId, "活动退出通知", + quitterNickname + " 退出了你的活动「" + activityTitle + "」", extra); + } + + @Override + public void pushActivityReview(String activityCreatorId, boolean approved, String activityId, String activityTitle, String reason) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, approved ? "ACTIVITY_REVIEW_APPROVED" : "ACTIVITY_REVIEW_REJECTED"); + extra.put(KEY_RELATED_ID, activityId); + pushToUser(activityCreatorId, + approved ? "活动审核通过" : "活动审核未通过", + "你的活动「" + activityTitle + "」" + + (approved ? "已通过审核" : "审核未通过,原因:" + (reason == null ? "" : reason)), + extra); + } + + @Override + public void pushFriendRequest(String receiverId, String senderNickname, String senderAvatar, String senderId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "FRIEND_REQUEST"); + extra.put(KEY_RELATED_ID, senderId); + extra.put(KEY_SENDER_ID, senderId); + extra.put(KEY_SENDER_NICKNAME, senderNickname); + extra.put(KEY_SENDER_AVATAR, senderAvatar); + pushToUser(receiverId, "新的好友申请", + senderNickname + " 想添加你为好友", extra); + } + + @Override + public void pushFriendAccepted(String receiverId, String accepterNickname, String accepterAvatar, String accepterId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "FRIEND_ACCEPTED"); + extra.put(KEY_RELATED_ID, accepterId); + extra.put(KEY_SENDER_ID, accepterId); + extra.put(KEY_SENDER_NICKNAME, accepterNickname); + extra.put(KEY_SENDER_AVATAR, accepterAvatar); + pushToUser(receiverId, "好友申请已通过", + accepterNickname + " 已同意添加你为好友", extra); + } + + @Override + public void pushFollowYou(String targetUserId, String followerNickname, String followerAvatar, String followerId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "FOLLOW_YOU"); + extra.put(KEY_RELATED_ID, followerId); + extra.put(KEY_SENDER_ID, followerId); + extra.put(KEY_SENDER_NICKNAME, followerNickname); + extra.put(KEY_SENDER_AVATAR, followerAvatar); + pushToUser(targetUserId, "新的关注", + followerNickname + " 关注了你", extra); + } + + @Override + public void pushGiftReceived(String receiverId, String senderNickname, String senderAvatar, + String senderId, String giftName, Integer giftCount, String momentId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "GIFT_RECEIVED"); + extra.put(KEY_RELATED_ID, momentId != null ? momentId : senderId); + extra.put(KEY_SENDER_ID, senderId); + extra.put(KEY_SENDER_NICKNAME, senderNickname); + extra.put(KEY_SENDER_AVATAR, senderAvatar); + extra.put(KEY_GIFT_NAME, giftName); + extra.put(KEY_GIFT_COUNT, String.valueOf(giftCount == null ? 1 : giftCount)); + pushToUser(receiverId, "收到新礼物", + senderNickname + " 给你送了" + (giftName == null ? "礼物" : giftName) + " x" + (giftCount == null ? 1 : giftCount), extra); + } + + @Override + public void pushPostLike(String postAuthorId, String likerNickname, String likerAvatar, + String likerId, String postId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "POST_LIKE"); + extra.put(KEY_RELATED_ID, postId); + extra.put(KEY_SENDER_ID, likerId); + extra.put(KEY_SENDER_NICKNAME, likerNickname); + extra.put(KEY_SENDER_AVATAR, likerAvatar); + pushToUser(postAuthorId, "新的点赞", + likerNickname + " 点赞了你的帖子", extra); + } + + @Override + public void pushPostReview(String postAuthorId, boolean approved, String postId, String reason) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, approved ? "POST_REVIEW_APPROVED" : "POST_REVIEW_REJECTED"); + extra.put(KEY_RELATED_ID, postId); + pushToUser(postAuthorId, + approved ? "帖子审核通过" : "帖子审核未通过", + approved ? "你的帖子已通过审核" : "你的帖子审核未通过,原因:" + (reason == null ? "" : reason), + extra); + } + + @Override + public void pushMomentReview(String momentAuthorId, boolean approved, String momentId, String reason) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, approved ? "MOMENT_REVIEW_APPROVED" : "MOMENT_REVIEW_REJECTED"); + extra.put(KEY_RELATED_ID, momentId); + pushToUser(momentAuthorId, + approved ? "动态审核通过" : "动态审核未通过", + approved ? "你的动态已通过审核" : "你的动态审核未通过,原因:" + (reason == null ? "" : reason), + extra); + } + + @Override + public void pushTopicReview(String topicAuthorId, boolean approved, String topicId, String topicName, String reason) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, approved ? "TOPIC_REVIEW_APPROVED" : "TOPIC_REVIEW_REJECTED"); + extra.put(KEY_RELATED_ID, topicId); + pushToUser(topicAuthorId, + approved ? "话题审核通过" : "话题审核未通过", + approved ? "你创建的话题「" + topicName + "」已通过审核" + : "你创建的话题「" + topicName + "」未通过审核,原因:" + (reason == null ? "" : reason), + extra); + } + + @Override + public void pushAnnouncement(String title, String content, String announcementId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "ANNOUNCEMENT"); + extra.put(KEY_RELATED_ID, announcementId); + // 系统公告使用全量广播(如果AppKey未配置,按标签推送给所有用户) + pushBroadcast(title, content, extra); + } + + @Override + @Async + public void pushChatMessage(String receiverId, String senderId, String senderNickname, String content) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "CHAT_MESSAGE"); + extra.put(KEY_RELATED_ID, senderId); + extra.put(KEY_SENDER_ID, senderId); + extra.put(KEY_SENDER_NICKNAME, senderNickname); + // 截取消息内容作为推送正文(避免过长) + String pushContent = content != null && content.length() > 50 + ? content.substring(0, 50) + "..." : content; + pushToUser(receiverId, senderNickname, pushContent, extra); + } + + @Override + @Async + public void pushGroupChatMessage(String receiverId, String senderId, String senderNickname, String groupId, String content) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "GROUP_CHAT_MESSAGE"); + extra.put(KEY_RELATED_ID, groupId); + extra.put(KEY_SENDER_ID, senderId); + extra.put(KEY_SENDER_NICKNAME, senderNickname); + String pushContent = content != null && content.length() > 50 + ? content.substring(0, 50) + "..." : content; + pushToUser(receiverId, senderNickname, pushContent, extra); + } + + @Override + public void pushGroupInvite(String inviteeId, String inviterNickname, String inviterAvatar, + String groupId, String groupName, String invitationId) { + Map extra = new HashMap<>(); + extra.put(KEY_TYPE, "GROUP_INVITE"); + // relatedId 用邀请 ID:前端点通知后要按它调「允许」接口,而不是按群 ID + extra.put(KEY_RELATED_ID, invitationId); + extra.put(KEY_INVITATION_ID, invitationId); + extra.put(KEY_GROUP_ID, groupId); + extra.put(KEY_GROUP_NAME, groupName); + extra.put(KEY_SENDER_NICKNAME, inviterNickname); + extra.put(KEY_SENDER_AVATAR, inviterAvatar); + pushToUser(inviteeId, "群聊邀请", + (inviterNickname == null ? "有人" : inviterNickname) + " 邀请你加入群聊「" + groupName + "」", + extra); + } + + @Override + public void bindAccount(String userId, String deviceToken, String platform) { + if (!isEnabled()) { + log.info("[推送] 推送功能未启用,跳过绑定 Account -> userId={}, platform={}", userId, platform); + return; + } + // 账号绑定由客户端 SDK (AliyunPush.bindAccount) 完成, + // 服务端只需在 PushController 中记录 deviceToken 到数据库即可 + log.info("[推送] 设备已注册 -> userId={}, platform={}", userId, platform); + } +} diff --git a/campus-server/src/main/java/com/campus/service/location/ActivityLocationService.java b/campus-server/src/main/java/com/campus/service/location/ActivityLocationService.java new file mode 100644 index 0000000..04ac6d2 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/location/ActivityLocationService.java @@ -0,0 +1,174 @@ +package com.campus.service.location; + +import com.campus.common.*; +import com.campus.config.ActivityLocationProperties; +import com.campus.dto.request.ActivityLocationRequest; +import com.campus.dto.request.ActivityRequest; +import com.campus.dto.response.ActivityLocationDTO; +import com.campus.dto.response.ActivityLocationDTO.Candidate; +import com.campus.service.ClickGuardService; +import com.campus.util.UserContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.DigestUtils; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityLocationService { + private final ActivityLocationProperties properties; + private final ObjectProvider provider; + private final StringRedisTemplate redis; + private final ObjectMapper mapper; + private final ClickGuardService clickGuard; + private static final String PREFIX = "activity-location:"; + private static final DefaultRedisScript RATE = new DefaultRedisScript<>( + "local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],60) end; return n", Long.class); + + public ActivityLocationDTO resolve(ActivityLocationRequest request) { + String user = login(); + enabled(); + String text = clean(request.getText()); + if (text.isEmpty()) throw new BusinessException("请输入活动举办地点"); + if (text.length() < 2 || text.length() > 200) throw new BusinessException("活动地点长度必须为2至200个字符"); + String city = clean(request.getCityCode()); + if (!city.matches("[0-9]{6}")) throw new BusinessException("请补充活动所在城市"); + String hint = clean(request.getCampusHint()); + if (hint.length() > 100) throw new BusinessException("校区提示不能超过100个字符"); + clickGuard.ensureNotTooFrequent(ClickGuardService.SCOPE_LOCATION_RESOLVE, user, "resolve"); + rate(user); + List candidates = provider.getObject().search(text, city, hint.isEmpty() ? null : hint); + for (Candidate candidate : candidates) candidate.setCandidateId(UUID.randomUUID().toString()); + ActivityLocationDTO result = new ActivityLocationDTO(); + result.setResolveId(UUID.randomUUID().toString()); + result.setCandidates(candidates); + result.setExpiresInSeconds(properties.getResolveTtlSeconds()); + Snapshot snapshot = new Snapshot(); snapshot.setUserId(user); snapshot.setResult(result); + put("resolve:" + result.getResolveId(), snapshot, properties.getResolveTtlSeconds()); + return result; + } + + public ActivityLocationDTO confirm(ActivityLocationRequest request) { + String user = login(); enabled(); + String resolveId = identifier(request.getResolveId(), "地点候选已过期,请重新识别"); + Snapshot snapshot = get("resolve:" + resolveId); + if (snapshot == null) throw new BusinessException("地点候选已过期,请重新识别"); + owner(snapshot, user); + boolean selected = request.getCandidateId() != null; + Candidate candidate; + String identity; + if (selected) { + if (request.getLatitude() != null || request.getLongitude() != null) throw new BusinessException("请选择本次识别的有效地点"); + candidate = snapshot.getResult().getCandidates().stream() + .filter(c -> c.getCandidateId().equals(request.getCandidateId())).findFirst() + .orElseThrow(() -> new BusinessException("请选择本次识别的有效地点")); + identity = candidate.getCandidateId(); + } else { + validateCoordinates(request.getLatitude(), request.getLongitude()); + if (request.getLatitude() == null) throw new BusinessException("请选择本次识别的有效地点"); + if (!"GCJ02".equals(request.getCoordinateSystem())) throw new BusinessException("不支持的坐标系"); + candidate = null; + identity = request.getLatitude() + "," + request.getLongitude(); + } + String confirmationKey = "confirm:" + resolveId + ":" + digest(identity); + Snapshot existing = get(confirmationKey); + if (existing != null) { + Snapshot token = get("token:" + existing.getResult().getLocationToken()); + if (token != null) return token.getResult(); + } + clickGuard.ensureNotTooFrequent(ClickGuardService.SCOPE_LOCATION_CONFIRM, user, resolveId); + rate(user); + if (candidate == null) candidate = provider.getObject().reverse(request.getLatitude(), request.getLongitude()); + ActivityLocationDTO result = new ActivityLocationDTO(); + result.setLocationToken(UUID.randomUUID().toString()); + String location = candidate.getName().equals(candidate.getAddress()) ? candidate.getName() : candidate.getName() + "(" + candidate.getAddress() + ")"; + result.setLocation(location.substring(0, Math.min(200, location.length()))); + result.setLatitude(candidate.getLatitude()); result.setLongitude(candidate.getLongitude()); + result.setExpiresInSeconds(properties.getTokenTtlSeconds()); + Snapshot confirmed = new Snapshot(); confirmed.setUserId(user); confirmed.setResult(result); + // 原子选定同一确认请求的结果;先写 token,失败时最多留下短期孤立 token。 + put("token:" + result.getLocationToken(), confirmed, properties.getTokenTtlSeconds()); + try { + Boolean first = redis.opsForValue().setIfAbsent(PREFIX + confirmationKey, mapper.writeValueAsString(confirmed), properties.getTokenTtlSeconds(), TimeUnit.SECONDS); + if (!Boolean.TRUE.equals(first)) { + Snapshot winner = get(confirmationKey); + if (winner == null) throw unavailable(); + return winner.getResult(); + } + } catch (BusinessException | ForbiddenException | ServiceUnavailableException ex) { throw ex; } + catch (Exception ex) { throw unavailable(); } + return result; + } + + /** 在业务保存及远端调用之前调用;旧客户端不传凭据仍走原契约。 */ + public void applyConfirmedLocation(ActivityRequest request, String userId, String target) { + if (request.getLocationToken() == null) { + validateCoordinates(request.getLatitude(), request.getLongitude()); + return; + } + enabled(); + String token = identifier(request.getLocationToken(), "地点确认已过期,请重新确认"); + Snapshot snapshot = get("token:" + token); + if (snapshot == null) throw new BusinessException("地点确认已过期,请重新确认"); + owner(snapshot, userId); + clickGuard.ensureNotTooFrequent(ClickGuardService.SCOPE_LOCATION_SUBMIT, userId, digest(token + ":" + target)); + request.setLocation(snapshot.getResult().getLocation()); + request.setLatitude(snapshot.getResult().getLatitude()); + request.setLongitude(snapshot.getResult().getLongitude()); + } + + public static void validateCoordinates(Double latitude, Double longitude) { + if ((latitude == null) != (longitude == null)) throw new BusinessException("活动经纬度必须同时提供"); + if (latitude != null && (!Double.isFinite(latitude) || !Double.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180)) + throw new BusinessException("无效的经纬度范围"); + } + + private void rate(String user) { + try { + Long count = redis.execute(RATE, Collections.singletonList(PREFIX + "rate:user:" + user)); + if (count == null) throw unavailable(); + if (count > properties.getRequestsPerMinute()) throw new TooManyRequestsException(ClickGuardService.TOO_FREQUENT_MESSAGE); + Long global = redis.execute(RATE, Collections.singletonList(PREFIX + "rate:global")); + if (global == null || global > properties.getGlobalRequestsPerMinute()) throw unavailable(); + } catch (TooManyRequestsException | ServiceUnavailableException ex) { throw ex; } + catch (RuntimeException ex) { throw unavailable(); } + } + private Snapshot get(String key) { + try { + String value = redis.opsForValue().get(PREFIX + key); + return value == null ? null : mapper.readValue(value, Snapshot.class); + } catch (Exception ex) { throw unavailable(); } + } + private void put(String key, Snapshot value, int seconds) { + try { redis.opsForValue().set(PREFIX + key, mapper.writeValueAsString(value), seconds, TimeUnit.SECONDS); } + catch (Exception ex) { throw unavailable(); } + } + private void owner(Snapshot snapshot, String user) { + if (user == null) throw new BusinessException("用户未登录"); + if (!user.equals(snapshot.getUserId())) throw new ForbiddenException("无权限访问该地点确认信息"); + } + private String login() { + String user = UserContext.getCurrentUserId(); + if (user == null) throw new BusinessException("用户未登录"); + return user; + } + private void enabled() { if (!properties.isEnabled() || provider.getIfAvailable() == null) throw unavailable(); } + private static String clean(String value) { return value == null ? "" : value.trim().replaceAll("\\s+", " "); } + private String identifier(String value, String message) { + if (value == null || !value.matches("[0-9a-fA-F-]{36}")) throw new BusinessException(message); + return value; + } + private String digest(String value) { return DigestUtils.md5DigestAsHex(value.getBytes(StandardCharsets.UTF_8)); } + private ServiceUnavailableException unavailable() { return new ServiceUnavailableException("地点识别服务暂不可用,请稍后重试"); } + @Data + public static class Snapshot { private String userId; private ActivityLocationDTO result; } +} diff --git a/campus-server/src/main/java/com/campus/service/location/AmapLocationProvider.java b/campus-server/src/main/java/com/campus/service/location/AmapLocationProvider.java new file mode 100644 index 0000000..9ab1cdf --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/location/AmapLocationProvider.java @@ -0,0 +1,122 @@ +package com.campus.service.location; + +import com.campus.common.ServiceUnavailableException; +import com.campus.config.ActivityLocationProperties; +import com.campus.dto.response.ActivityLocationDTO.Candidate; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import java.util.*; +import java.util.concurrent.Semaphore; + +@Component +@ConditionalOnProperty(prefix = "app.activity-location", name = "enabled", havingValue = "true") +public class AmapLocationProvider implements LocationProvider { + private final ActivityLocationProperties properties; + private final RestTemplate restTemplate; + private final Semaphore concurrent; + + public AmapLocationProvider(ActivityLocationProperties properties) { + this.properties = properties; + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(properties.getConnectTimeoutMs()); + factory.setReadTimeout(properties.getReadTimeoutMs()); + this.restTemplate = new PrivateRestTemplate(factory); + this.concurrent = new Semaphore(Math.max(1, properties.getMaxConcurrent())); + } + + public List search(String text, String cityCode, String campusHint) { + String keyword = campusHint == null || text.contains(campusHint) ? text : campusHint + " " + text; + JsonNode root = call("/v5/place/text", "keywords", keyword, "region", cityCode, "city_limit", "true", "page_size", "5"); + List result = new ArrayList<>(); + for (JsonNode poi : root.path("pois")) { + Candidate candidate = candidate(poi, "POI"); + if (candidate != null) result.add(candidate); + if (result.size() == 5) break; + } + if (result.isEmpty()) { + root = call("/v3/geocode/geo", "address", keyword, "city", cityCode); + for (JsonNode geo : root.path("geocodes")) { + Candidate candidate = candidate(geo, "ADDRESS"); + if (candidate != null) result.add(candidate); + if (result.size() == 5) break; + } + } + return result; + } + + public Candidate reverse(double latitude, double longitude) { + JsonNode geo = call("/v3/geocode/regeo", "location", longitude + "," + latitude).path("regeocode"); + String address = geo.path("formatted_address").asText(""); + if (address.trim().isEmpty()) throw unavailable(); + Candidate candidate = new Candidate(); + candidate.setName(address); + candidate.setAddress(address); + candidate.setLatitude(latitude); + candidate.setLongitude(longitude); + candidate.setPrecision("MAP_POINT"); + return candidate; + } + + private Candidate candidate(JsonNode node, String precision) { + try { + String[] point = node.path("location").asText().split(","); + double longitude = Double.parseDouble(point[0]), latitude = Double.parseDouble(point[1]); + if (!Double.isFinite(latitude) || !Double.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) return null; + Candidate result = new Candidate(); + result.setName(node.path("name").asText(node.path("formatted_address").asText(""))); + String address = node.has("formatted_address") ? node.path("formatted_address").asText() : + node.path("pname").asText("") + node.path("cityname").asText("") + node.path("adname").asText("") + node.path("address").asText(""); + if (result.getName().trim().isEmpty()) return null; + result.setAddress(address); + result.setLatitude(latitude); + result.setLongitude(longitude); + result.setPrecision(precision); + return result; + } catch (RuntimeException ignored) { return null; } + } + + private JsonNode call(String path, String... parameters) { + if (properties.getKey() == null || properties.getKey().trim().isEmpty() || !concurrent.tryAcquire()) throw unavailable(); + try { + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("https://restapi.amap.com" + path) + .queryParam("key", properties.getKey()); + for (int i = 0; i < parameters.length; i += 2) builder.queryParam(parameters[i], "{p" + i + "}"); + Map values = new HashMap<>(); + for (int i = 0; i < parameters.length; i += 2) values.put("p" + i, parameters[i + 1]); + JsonNode root = restTemplate.getForObject(builder.buildAndExpand(values).encode().toUri(), JsonNode.class); + if (root == null || !"1".equals(root.path("status").asText())) throw unavailable(); + return root; + } catch (RuntimeException ex) { + // 不携带原异常:RestTemplate 的异常可能包含带密钥的 URL。 + throw unavailable(); + } finally { concurrent.release(); } + } + + private ServiceUnavailableException unavailable() { + return new ServiceUnavailableException("地点识别服务暂不可用,请稍后重试"); + } + + /** RestTemplate 默认 DEBUG 会打印带 key 的请求 URL,此客户端禁止该日志路径。 */ + private static class PrivateRestTemplate extends RestTemplate { + PrivateRestTemplate(SimpleClientHttpRequestFactory factory) { super(factory); } + @Override + protected T doExecute(java.net.URI url, org.springframework.http.HttpMethod method, + org.springframework.web.client.RequestCallback callback, + org.springframework.web.client.ResponseExtractor extractor) { + org.springframework.http.client.ClientHttpResponse response = null; + try { + org.springframework.http.client.ClientHttpRequest request = getRequestFactory().createRequest(url, method); + if (callback != null) callback.doWithRequest(request); + response = request.execute(); + if (getErrorHandler().hasError(response)) throw new ServiceUnavailableException("地点识别服务暂不可用,请稍后重试"); + return extractor == null ? null : extractor.extractData(response); + } catch (java.io.IOException ex) { + throw new ServiceUnavailableException("地点识别服务暂不可用,请稍后重试"); + } finally { if (response != null) response.close(); } + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/location/LocationProvider.java b/campus-server/src/main/java/com/campus/service/location/LocationProvider.java new file mode 100644 index 0000000..d3cb39e --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/location/LocationProvider.java @@ -0,0 +1,9 @@ +package com.campus.service.location; + +import com.campus.dto.response.ActivityLocationDTO.Candidate; +import java.util.List; + +public interface LocationProvider { + List search(String text, String cityCode, String campusHint); + Candidate reverse(double latitude, double longitude); +} diff --git a/campus-server/src/main/java/com/campus/service/review/ImageReviewStrategy.java b/campus-server/src/main/java/com/campus/service/review/ImageReviewStrategy.java new file mode 100644 index 0000000..c10b5c9 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/review/ImageReviewStrategy.java @@ -0,0 +1,24 @@ +package com.campus.service.review; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ImageReviewStrategy implements ReviewStrategy { + + @Override + public ReviewResult review(String imageUrls) { + ReviewResult result = new ReviewResult(); + result.setPassed(true); + result.setScore(100); + + if (imageUrls == null || imageUrls.trim().isEmpty()) { + return result; + } + + log.info("图片审核当前默认通过,可后续集成阿里云内容安全或其他审核方案"); + + return result; + } +} diff --git a/campus-server/src/main/java/com/campus/service/review/ReviewResult.java b/campus-server/src/main/java/com/campus/service/review/ReviewResult.java new file mode 100644 index 0000000..7e72bb1 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/review/ReviewResult.java @@ -0,0 +1,41 @@ +package com.campus.service.review; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class ReviewResult { + + /** 是否通过 */ + private boolean passed; + + /** 审核分数 */ + private int score; + + /** 违规原因列表 */ + private List violationReasons = new ArrayList<>(); + + /** 审核详情 */ + private String details; + + public static ReviewResult passed() { + ReviewResult result = new ReviewResult(); + result.setPassed(true); + result.setScore(100); + return result; + } + + public static ReviewResult rejected(String reason) { + ReviewResult result = new ReviewResult(); + result.setPassed(false); + result.setScore(0); + result.getViolationReasons().add(reason); + return result; + } + + public void addViolationReason(String reason) { + this.violationReasons.add(reason); + } +} diff --git a/campus-server/src/main/java/com/campus/service/review/ReviewStrategy.java b/campus-server/src/main/java/com/campus/service/review/ReviewStrategy.java new file mode 100644 index 0000000..5907faf --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/review/ReviewStrategy.java @@ -0,0 +1,6 @@ +package com.campus.service.review; + +public interface ReviewStrategy { + + ReviewResult review(String content); +} diff --git a/campus-server/src/main/java/com/campus/service/review/TextReviewStrategy.java b/campus-server/src/main/java/com/campus/service/review/TextReviewStrategy.java new file mode 100644 index 0000000..9a4f06f --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/review/TextReviewStrategy.java @@ -0,0 +1,90 @@ +package com.campus.service.review; + +import com.campus.entity.SensitiveWord; +import com.campus.repository.SensitiveWordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class TextReviewStrategy implements ReviewStrategy { + + /** 全级别拦截(动态沿用原行为:任何级别命中即不通过)。 */ + public static final Set ALL_LEVELS = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("HIGH", "MEDIUM", "LOW"))); + + /** 只拦截高/中危词:用于话题名这类短文本,避免「垃圾/滚」等 LOW 词误杀。 */ + public static final Set HIGH_AND_MEDIUM = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("HIGH", "MEDIUM"))); + + private final SensitiveWordRepository sensitiveWordRepository; + + /** 原签名:全级别拦截(动态等既有调用方行为不变)。 */ + @Override + public ReviewResult review(String content) { + return review(content, ALL_LEVELS); + } + + /** + * 分级审核:只有命中 {@code blockingLevels} 内的词才判不通过; + * 其余命中(如 LOW)只记录到 {@link ReviewResult#getDetails()},不影响 passed。 + * + * @param blockingLevels 需要拦截的敏感词级别(HIGH/MEDIUM/LOW);传 null 等价于全级别拦截 + */ + public ReviewResult review(String content, Set blockingLevels) { + ReviewResult result = new ReviewResult(); + result.setPassed(true); + result.setScore(100); + + if (content == null || content.trim().isEmpty()) { + return result; + } + + Set blocking = blockingLevels == null ? ALL_LEVELS : blockingLevels; + StringBuilder recorded = new StringBuilder(); + List sensitiveWords = sensitiveWordRepository.findByEnabledTrue(); + + for (SensitiveWord word : sensitiveWords) { + if (word.getWord() == null || !content.contains(word.getWord())) { + continue; + } + String level = word.getLevel() == null ? "" : word.getLevel().trim().toUpperCase(Locale.ROOT); + String reason = "包含敏感词:" + word.getWord(); + if (blocking.contains(level)) { + result.setPassed(false); + result.addViolationReason(reason); + result.setScore(Math.max(0, result.getScore() - deductionOf(level))); + } else { + if (recorded.length() > 0) { + recorded.append("; "); + } + recorded.append(level.isEmpty() ? "UNKNOWN" : level).append(":").append(reason); + } + } + + if (recorded.length() > 0) { + result.setDetails(recorded.toString()); + } + return result; + } + + private int deductionOf(String level) { + switch (level) { + case "HIGH": + return 50; + case "MEDIUM": + return 30; + case "LOW": + return 10; + default: + return 0; + } + } +} diff --git a/campus-server/src/main/java/com/campus/service/review/VideoReviewStrategy.java b/campus-server/src/main/java/com/campus/service/review/VideoReviewStrategy.java new file mode 100644 index 0000000..1814174 --- /dev/null +++ b/campus-server/src/main/java/com/campus/service/review/VideoReviewStrategy.java @@ -0,0 +1,28 @@ +package com.campus.service.review; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Slf4j +@Component +public class VideoReviewStrategy implements ReviewStrategy { + + @Override + public ReviewResult review(String videoUrls) { + ReviewResult result = new ReviewResult(); + result.setPassed(true); + result.setScore(100); + + if (videoUrls == null || videoUrls.trim().isEmpty()) { + return result; + } + + log.info("视频审核当前默认通过,可后续集成阿里云视频审核或其他方案"); + + return result; + } +} diff --git a/campus-server/src/main/java/com/campus/util/EducationRank.java b/campus-server/src/main/java/com/campus/util/EducationRank.java new file mode 100644 index 0000000..f141c0d --- /dev/null +++ b/campus-server/src/main/java/com/campus/util/EducationRank.java @@ -0,0 +1,62 @@ +package com.campus.util; + +/** + * 学历层次等级:用于「按最高学历保留认证」的升降级判定。 + * + *

只做粗粒度排序,不做精确学制换算。同一等级内的专业/院系差异不参与比较。

+ */ +public final class EducationRank { + + public static final int UNKNOWN = 0; + /** 专科 / 大专 / 中等专科 */ + public static final int JUNIOR_COLLEGE = 30; + /** 高职(专科) */ + public static final int HIGHER_VOCATIONAL = 40; + /** 本科 / 专升本 / 高起本 / 第二学士学位 */ + public static final int BACHELOR = 50; + /** 硕士研究生 / 研究生 */ + public static final int MASTER = 60; + /** 博士研究生 */ + public static final int DOCTOR = 70; + + private EducationRank() { + } + + /** + * 由层次文案算等级。无法识别时返回 {@link #UNKNOWN}。 + * + *

判断顺序不能调换:「博士研究生」同时包含「研究生」,必须先判博士。

+ */ + public static int of(String level) { + String s = OcrTextNormalizer.normalize(level); + if (s.isEmpty()) { + return UNKNOWN; + } + if (s.contains("博士")) { + return DOCTOR; + } + if (s.contains("硕士") || s.contains("研究生")) { + return MASTER; + } + if (s.contains("本科") || s.contains("学士") || s.contains("高起本")) { + return BACHELOR; + } + if (s.contains("高职")) { + return HIGHER_VOCATIONAL; + } + if (s.contains("专科") || s.contains("大专")) { + return JUNIOR_COLLEGE; + } + return UNKNOWN; + } + + /** + * 是否构成「学历降级」。 + * + *

任一方为 {@link #UNKNOWN} 时一律视为非降级:宁可放过一次生僻层次词, + * 也不要把正常用户误拦在「不支持更换为更低学历」的文案上。

+ */ + public static boolean isDowngrade(int currentRank, int newRank) { + return currentRank > UNKNOWN && newRank > UNKNOWN && newRank < currentRank; + } +} diff --git a/campus-server/src/main/java/com/campus/util/OcrTextNormalizer.java b/campus-server/src/main/java/com/campus/util/OcrTextNormalizer.java new file mode 100644 index 0000000..12f081d --- /dev/null +++ b/campus-server/src/main/java/com/campus/util/OcrTextNormalizer.java @@ -0,0 +1,99 @@ +package com.campus.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * OCR 文本归一化工具:把同一份报告在不同次识别中的抖动抹平,保证判重键稳定。 + * + *

抖动来源(实测):OCR 会在「4年 / 4 年」「本科 / 本 科」「全角括号 / 半角括号」 + * 「yyyy年MM月 / yyyy-MM / yyyy-MM-dd」之间摇摆。若直接用原始值拼哈希, + * 同一份报告每次算出的指纹都不同,重复认证永远判不中。

+ */ +public final class OcrTextNormalizer { + + private static final Pattern WHITESPACE = Pattern.compile("[\\s\\u3000]+"); + + /** 只取数字,用于把「4年」「四 年」「4 年」统一成「4年」中的数字。 */ + private static final Pattern LEADING_NUMBER = Pattern.compile("^\\s*(\\d{1,2})"); + + private static final Pattern YEAR_MONTH = Pattern.compile("(\\d{4})\\D{0,3}(\\d{1,2})"); + + private OcrTextNormalizer() { + } + + /** 通用归一化:去空白、全角括号转半角、转小写。null 视为空串。 */ + public static String normalize(String raw) { + if (raw == null) { + return ""; + } + String s = WHITESPACE.matcher(raw.trim()).replaceAll(""); + s = s.replace('(', '(').replace(')', ')') + .replace(':', ':') + .replace(',', ','); + return s.toLowerCase(); + } + + /** + * 月份归一化成 {@code yyyy-MM}。 + * + *

「2010年09月」「2010-09-01」「2010.9」「2010/09」都归到 {@code 2010-09}; + * 解析失败则退回 {@link #normalize(String)} 的结果,保证不抛异常。

+ */ + public static String normalizeMonth(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return ""; + } + Matcher m = YEAR_MONTH.matcher(raw.trim()); + if (m.find()) { + String year = m.group(1); + String month = m.group(2); + try { + int mm = Integer.parseInt(month); + String cleaned = year + "-" + (mm < 10 ? "0" + mm : String.valueOf(mm)); + // 防御性校验:≥ 10 位的「月」其实是日期被误当月份(如 2010-0901),放弃格式化 + if (mm >= 1 && mm <= 12) { + return cleaned; + } + } catch (NumberFormatException ignore) { + // 落到下面的兜底 + } + } + return normalize(raw); + } + + /** + * 学制归一化成「N年」。只处理阿拉伯数字;中文数字(如「四年」)保持原归一化结果。 + */ + public static String normalizeDuration(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return ""; + } + Matcher m = LEADING_NUMBER.matcher(raw); + if (m.find()) { + return m.group(1) + "年"; + } + return normalize(raw); + } + + /** SHA-256 小写十六进制。入参为 null 时按空串处理。 */ + public static String sha256(String raw) { + String input = raw == null ? "" : raw; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + // JDK 必然支持 SHA-256,走到这里说明运行环境异常 + throw new IllegalStateException("SHA-256 不可用", e); + } + } +} diff --git a/campus-server/src/main/java/com/campus/util/OssUtil.java b/campus-server/src/main/java/com/campus/util/OssUtil.java new file mode 100644 index 0000000..e917735 --- /dev/null +++ b/campus-server/src/main/java/com/campus/util/OssUtil.java @@ -0,0 +1,394 @@ +package com.campus.util; + +import com.aliyun.oss.HttpMethod; +import com.aliyun.oss.OSS; +import com.aliyun.oss.model.*; +import com.campus.config.OssProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class OssUtil { + + @Autowired + private OSS ossClient; + + @Autowired + private OssProperties ossProperties; + + /** + * 上传文件到OSS + * + * @param file 文件 + * @param folder 文件夹名称 (如: images, videos, docs) + * @return 上传后的URL + */ + public String uploadFile(MultipartFile file, String folder) { + // 验证文件 + validateFile(file); + + // 生成文件名 + String fileName = generateFileName(file, folder); + long startTime = System.currentTimeMillis(); + + try { + long fileSize = file.getSize(); + // 大文件(>5MB)使用 OSS 分片并行上传,速度提升明显 + if (fileSize > 5 * 1024 * 1024) { + Path tempFile = null; + try { + // MultipartFile → 临时文件(Spring 可能已将文件存盘,transferTo 不产生额外 I/O) + tempFile = Files.createTempFile("oss-upload-", ".tmp"); + file.transferTo(tempFile.toFile()); + + UploadFileRequest uploadFileRequest = new UploadFileRequest( + ossProperties.getBucketName(), fileName); + uploadFileRequest.setUploadFile(tempFile.toFile().getAbsolutePath()); + uploadFileRequest.setTaskNum(5); // 5 线程并行 + uploadFileRequest.setPartSize(5 * 1024 * 1024); // 5MB 分片 + uploadFileRequest.setEnableCheckpoint(false); + + ossClient.uploadFile(uploadFileRequest); + } catch (Throwable e) { + // ossClient.uploadFile 声明 throws Throwable + log.error("OSS分片上传失败", e); + throw new RuntimeException("OSS分片上传失败: " + e.getMessage(), e); + } finally { + if (tempFile != null) { + try { Files.deleteIfExists(tempFile); } catch (IOException ignored) {} + } + } + } else { + // 小文件:直接 PUT + try (InputStream inputStream = file.getInputStream()) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + ossClient.putObject(ossProperties.getBucketName(), fileName, inputStream, metadata); + } + } + + String url = buildFileUrl(fileName); + long elapsed = System.currentTimeMillis() - startTime; + log.info("文件上传成功: {}, 大小: {}MB, 耗时: {}ms", + url, String.format("%.1f", fileSize / 1024.0 / 1024.0), elapsed); + return url; + } catch (IOException e) { + log.error("文件上传失败", e); + throw new RuntimeException("文件上传失败: " + e.getMessage()); + } + } + + /** + * 上传图片 + */ + public String uploadImage(MultipartFile file) { + return uploadFile(file, "images"); + } + + /** + * 上传视频 + */ + public String uploadVideo(MultipartFile file) { + return uploadFile(file, "videos"); + } + + /** + * 上传文档 + */ + public String uploadDocument(MultipartFile file) { + return uploadFile(file, "docs"); + } + + /** + * 上传头像 + */ + public String uploadAvatar(MultipartFile file) { + return uploadFile(file, "avatars"); + } + + /** + * 批量上传文件 + */ + public List uploadFiles(MultipartFile[] files, String folder) { + return Arrays.stream(files) + .map(file -> uploadFile(file, folder)) + .collect(Collectors.toList()); + } + + /** + * 删除文件 + */ + public void deleteFile(String fileUrl) { + try { + String fileName = extractFileName(fileUrl); + ossClient.deleteObject(ossProperties.getBucketName(), fileName); + log.info("文件删除成功: {}", fileName); + } catch (Exception e) { + log.error("文件删除失败", e); + throw new RuntimeException("文件删除失败: " + e.getMessage()); + } + } + + /** 注销专用:按域名和路径解析对象名,避免带签名参数时删除错误对象。 */ + public void deleteAccountFile(String fileUrl) { + java.net.URI resource = java.net.URI.create(fileUrl); + String configuredDomain = ossProperties.getDomain(); + java.net.URI configured = java.net.URI.create(configuredDomain.startsWith("http") ? configuredDomain : "https://" + configuredDomain); + if (!java.util.Objects.equals(resource.getHost(), configured.getHost()) || resource.getPath() == null + || resource.getRawPath().contains("%") || resource.getPath().contains("..")) + throw new IllegalArgumentException("注销文件不属于当前存储空间"); + String prefix=configured.getPath()==null ? "" : configured.getPath(); + String path=resource.getPath(); + if(!path.startsWith(prefix)) throw new IllegalArgumentException("注销文件路径不合法"); + String key=path.substring(prefix.length()).replaceFirst("^/", ""); + if(key.isEmpty()) throw new IllegalArgumentException("注销文件路径不合法"); + ossClient.deleteObject(ossProperties.getBucketName(),key); + if(ossClient.doesObjectExist(ossProperties.getBucketName(),key)) throw new IllegalStateException("注销文件尚未删除"); + } + + /** + * 批量删除文件 + */ + public void deleteFiles(List fileUrls) { + fileUrls.forEach(this::deleteFile); + } + + /** + * 初始化分片上传 + * + * @param key 文件路径 + * @return UploadId + */ + public String initMultipartUpload(String key) { + InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest( + ossProperties.getBucketName(), key); + InitiateMultipartUploadResult result = ossClient.initiateMultipartUpload(request); + log.info("初始化分片上传成功, UploadId: {}, Key: {}", result.getUploadId(), key); + return result.getUploadId(); + } + + /** + * 上传分片 + * + * @param key 文件路径 + * @param uploadId UploadId + * @param partNumber 分片编号(从1开始) + * @param inputStream 分片数据流 + * @param length 分片长度 + * @return ETag + */ + public String uploadPart(String key, String uploadId, int partNumber, InputStream inputStream, long length) { + UploadPartRequest request = new UploadPartRequest(); + request.setBucketName(ossProperties.getBucketName()); + request.setKey(key); + request.setUploadId(uploadId); + request.setPartNumber(partNumber); + request.setInputStream(inputStream); + request.setPartSize(length); + + UploadPartResult result = ossClient.uploadPart(request); + log.debug("分片上传成功, UploadId: {}, PartNumber: {}, ETag: {}", uploadId, partNumber, result.getETag()); + return result.getETag(); + } + + /** + * 上传分片(MultipartFile版本) + */ + public String uploadPart(String key, String uploadId, int partNumber, MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + return uploadPart(key, uploadId, partNumber, inputStream, file.getSize()); + } catch (IOException e) { + log.error("分片上传失败", e); + throw new RuntimeException("分片上传失败: " + e.getMessage()); + } + } + + /** + * 完成分片上传 + * + * @param key 文件路径 + * @param uploadId UploadId + * @param parts 分片ETag列表 + * @return 文件URL + */ + public String completeMultipartUpload(String key, String uploadId, List parts) { + CompleteMultipartUploadRequest request = new CompleteMultipartUploadRequest( + ossProperties.getBucketName(), key, uploadId, parts); + CompleteMultipartUploadResult result = ossClient.completeMultipartUpload(request); + String url = buildFileUrl(key); + log.info("分片上传完成, URL: {}, Key: {}", url, key); + return url; + } + + /** + * 取消分片上传 + * + * @param key 文件路径 + * @param uploadId UploadId + */ + public void abortMultipartUpload(String key, String uploadId) { + AbortMultipartUploadRequest request = new AbortMultipartUploadRequest( + ossProperties.getBucketName(), key, uploadId); + ossClient.abortMultipartUpload(request); + log.info("取消分片上传, UploadId: {}, Key: {}", uploadId, key); + } + + /** + * 获取已上传的分片列表 + * + * @param key 文件路径 + * @param uploadId UploadId + * @return 分片ETag列表 + */ + public List listParts(String key, String uploadId) { + ListPartsRequest request = new ListPartsRequest(ossProperties.getBucketName(), key, uploadId); + PartListing result = ossClient.listParts(request); + List parts = new ArrayList<>(); + for (PartSummary part : result.getParts()) { + parts.add(new PartETag(part.getPartNumber(), part.getETag())); + } + return parts; + } + + /** + * 生成带日期路径的文件名 + */ + public String generateFilePath(String folder, String originalFileName) { + String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); + String uuid = UUID.randomUUID().toString().replace("-", ""); + String extension = getFileExtension(originalFileName); + return String.format("%s/%s/%s.%s", folder, datePath, uuid, extension); + } + + /** + * 上传字节数组到OSS + */ + public String uploadBytes(byte[] bytes, String key, String contentType) { + try (InputStream inputStream = new ByteArrayInputStream(bytes)) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(bytes.length); + metadata.setContentType(contentType); + ossClient.putObject(ossProperties.getBucketName(), key, inputStream, metadata); + String url = buildFileUrl(key); + log.info("字节数据上传成功: {}", url); + return url; + } catch (Exception e) { + log.error("字节数据上传失败", e); + throw new RuntimeException("字节数据上传失败: " + e.getMessage()); + } + } + + /** + * 检查文件是否存在 + */ + public boolean doesObjectExist(String fileUrl) { + String fileName = extractFileName(fileUrl); + return ossClient.doesObjectExist(ossProperties.getBucketName(), fileName); + } + + /** + * 生成 Presigned PUT URL(用于前端直传 OSS) + * + * @param key OSS 对象 key + * @param contentType 文件 MIME 类型 + * @param expireSeconds 签名有效期(秒),默认 3600 + * @return 预签名 URL + */ + public String generatePresignedUrl(String key, String contentType, long expireSeconds) { + java.util.Date expiration = new java.util.Date(System.currentTimeMillis() + expireSeconds * 1000); + GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest( + ossProperties.getBucketName(), key, HttpMethod.PUT); + request.setExpiration(expiration); + request.setContentType(contentType); + java.net.URL url = ossClient.generatePresignedUrl(request); + log.info("生成预签名URL: key={}, 有效期={}秒", key, expireSeconds); + return url.toString(); + } + + /** + * 获取文件输入流 + */ + public InputStream getFileInputStream(String fileUrl) { + String fileName = extractFileName(fileUrl); + OSSObject ossObject = ossClient.getObject(ossProperties.getBucketName(), fileName); + return ossObject.getObjectContent(); + } + + /** + * 验证文件 + */ + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new RuntimeException("文件不能为空"); + } + + // 验证文件大小 + long maxSize = ossProperties.getMaxFileSize() * 1024 * 1024; + if (file.getSize() > maxSize) { + throw new RuntimeException("文件大小不能超过 " + ossProperties.getMaxFileSize() + "MB"); + } + + // 验证文件类型 + String fileType = getFileExtension(file.getOriginalFilename()); + List allowedTypes = ossProperties.getAllowedFileTypes(); + if (allowedTypes != null && !allowedTypes.isEmpty() && !allowedTypes.contains(fileType.toLowerCase())) { + throw new RuntimeException("不支持的文件类型: " + fileType + ",支持的类型: " + allowedTypes); + } + } + + /** + * 生成文件名 + */ + private String generateFileName(MultipartFile file, String folder) { + String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); + String uuid = UUID.randomUUID().toString().replace("-", ""); + String extension = getFileExtension(file.getOriginalFilename()); + return String.format("%s/%s/%s.%s", folder, datePath, uuid, extension); + } + + /** + * 构建文件访问URL + */ + private String buildFileUrl(String fileName) { + String domain = ossProperties.getDomain(); + if (!domain.endsWith("/")) { + domain += "/"; + } + return domain + fileName; + } + + /** + * 从URL中提取文件名 + */ + private String extractFileName(String fileUrl) { + String domain = ossProperties.getDomain(); + if (fileUrl.startsWith(domain)) { + return fileUrl.substring(domain.length()); + } + return fileUrl; + } + + /** + * 获取文件扩展名 + */ + private String getFileExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) { + return ""; + } + return fileName.substring(fileName.lastIndexOf(".") + 1); + } +} diff --git a/campus-server/src/main/java/com/campus/util/PageUtils.java b/campus-server/src/main/java/com/campus/util/PageUtils.java new file mode 100644 index 0000000..40dcf8b --- /dev/null +++ b/campus-server/src/main/java/com/campus/util/PageUtils.java @@ -0,0 +1,34 @@ +package com.campus.util; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** Normalizes one-based page inputs used by the mobile API. */ +public final class PageUtils { + + private static final int DEFAULT_SIZE = 20; + private static final int MAX_SIZE = 100; + + private PageUtils() { + } + + public static int normalizePage(Integer page) { + return page == null || page < 1 ? 1 : page; + } + + public static int normalizeSize(Integer size) { + if (size == null || size < 1) { + return DEFAULT_SIZE; + } + return Math.min(size, MAX_SIZE); + } + + public static Pageable request(Integer page, Integer size) { + return PageRequest.of(normalizePage(page) - 1, normalizeSize(size)); + } + + public static Pageable request(Integer page, Integer size, Sort sort) { + return PageRequest.of(normalizePage(page) - 1, normalizeSize(size), sort); + } +} \ No newline at end of file diff --git a/campus-server/src/main/java/com/campus/util/StudentNumberGenerator.java b/campus-server/src/main/java/com/campus/util/StudentNumberGenerator.java new file mode 100644 index 0000000..838bda3 --- /dev/null +++ b/campus-server/src/main/java/com/campus/util/StudentNumberGenerator.java @@ -0,0 +1,90 @@ +package com.campus.util; + +import com.campus.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * 学生学号生成器 + * 学号格式:院校代码(10位) + 7位补零序号(如:41110100010000001) + * 使用 Redis INCR 保证高并发下的原子性 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StudentNumberGenerator { + + private final StringRedisTemplate stringRedisTemplate; + private final UserRepository userRepository; + + private static final String UNIVERSITY_SEQ_PREFIX = "university:seq:"; + private static final int SEQUENCE_LENGTH = 7; + private static final long INITIAL_SEQUENCE = 1; + + /** + * 根据学校标识码生成下一个学生学号 + * + * @param universityId 大学ID(用于 Redis 计数器 key) + * @param schoolCode 学校标识码(学号前缀) + * @return 学生学号,如:41110100010000001 + */ + public String generateStudentNumber(String universityId, String schoolCode) { + String redisKey = UNIVERSITY_SEQ_PREFIX + universityId; + + // 检查 Redis 计数器是否存在 + Boolean exists = stringRedisTemplate.hasKey(redisKey); + if (!Boolean.TRUE.equals(exists)) { + // 从数据库初始化计数器(避免与已有数据冲突) + initCounterFromDatabase(universityId, schoolCode, redisKey); + } + + // 原子自增 + Long sequence = stringRedisTemplate.opsForValue().increment(redisKey); + + // 格式化为学校标识码 + 7位补零序号 + String formattedSeq = String.format("%0" + SEQUENCE_LENGTH + "d", sequence); + String studentNumber = schoolCode + formattedSeq; + + log.info("生成学号: universityId={}, schoolCode={}, sequence={}, studentNumber={}", + universityId, schoolCode, sequence, studentNumber); + + return studentNumber; + } + + /** + * 从数据库初始化 Redis 计数器 + */ + private void initCounterFromDatabase(String universityId, String schoolCode, String redisKey) { + String maxNumber = userRepository.findMaxNumberByUniversityId(universityId); + + long startSequence; + if (maxNumber != null && !maxNumber.isEmpty() && maxNumber.startsWith(schoolCode)) { + // 解析已有最大学号的序号部分 + String seqPart = maxNumber.substring(schoolCode.length()); + try { + long maxSeq = Long.parseLong(seqPart); + startSequence = maxSeq + 1; + } catch (NumberFormatException e) { + log.warn("解析学号序号失败,使用初始值1: {}", maxNumber); + startSequence = INITIAL_SEQUENCE; + } + } else { + startSequence = INITIAL_SEQUENCE; + } + + // setIfAbsent 保证并发安全:只有首次创建时写入初始值,后续 INCR 从该值开始 + stringRedisTemplate.opsForValue().setIfAbsent(redisKey, String.valueOf(startSequence - 1)); + log.info("初始化学号计数器: universityId={}, startSequence={}", universityId, startSequence); + } + + /** + * 重置指定大学的学号计数器(用于管理员操作) + */ + public void resetCounter(String universityId) { + String redisKey = UNIVERSITY_SEQ_PREFIX + universityId; + stringRedisTemplate.delete(redisKey); + log.info("重置学号计数器: universityId={}", universityId); + } +} diff --git a/campus-server/src/main/java/com/campus/util/UserContext.java b/campus-server/src/main/java/com/campus/util/UserContext.java new file mode 100644 index 0000000..e6cdaae --- /dev/null +++ b/campus-server/src/main/java/com/campus/util/UserContext.java @@ -0,0 +1,26 @@ +package com.campus.util; + +import com.campus.entity.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class UserContext { + + public static User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof User) { + return (User) authentication.getPrincipal(); + } + return null; + } + + public static String getCurrentUserId() { + User user = getCurrentUser(); + return user != null ? user.getId() : null; + } + + public static String getCurrentPhone() { + User user = getCurrentUser(); + return user != null ? user.getPhone() : null; + } +} diff --git a/campus-server/src/main/resources/application-dev.sanitized.yml b/campus-server/src/main/resources/application-dev.sanitized.yml new file mode 100644 index 0000000..5553256 --- /dev/null +++ b/campus-server/src/main/resources/application-dev.sanitized.yml @@ -0,0 +1,15 @@ +spring: + datasource: + url: "jdbc:mysql://db.example.invalid:3306/example_db" + username: "example_user" + password: "REDACTED_DO_NOT_USE" + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + show-sql: true + hibernate: + ddl-auto: update +logging: + level: + com.campus: debug + org.hibernate.SQL: debug + org.hibernate.type.descriptor.sql.BasicBinder: trace diff --git a/campus-server/src/main/resources/application.example.yml b/campus-server/src/main/resources/application.example.yml new file mode 100644 index 0000000..d3bd2da --- /dev/null +++ b/campus-server/src/main/resources/application.example.yml @@ -0,0 +1,150 @@ +# 脱敏本地部署模板,不会自动加载;Start-Local.ps1 / Docker 显式指定此文件。 +# 保留此模板无真实凭据。真实值通过 .env -> 启动器 / Compose 环境变量注入。 +server: + address: 0.0.0.0 + port: ${SERVER_PORT:8009} +spring: + application: + name: campus-server + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + datasource: + url: jdbc:mysql://${DB_HOST:127.0.0.1}:${DB_PORT:13306}/${DB_NAME:qinghe_local}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai + username: ${DB_USERNAME:qinghe} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 30000 + jpa: + hibernate: + ddl-auto: ${DB_DDL_AUTO:update} + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + flyway: + enabled: false + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + serialization: + write-dates-as-timestamps: false + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:16379} + password: ${REDIS_PASSWORD} + database: 0 + timeout: 10s + cache: + type: redis + redis: + time-to-live: 3600000 + cache-null-values: false + rabbitmq: + host: ${RABBITMQ_HOST:127.0.0.1} + port: ${RABBITMQ_PORT:15672} + username: ${RABBITMQ_USERNAME:qinghe} + password: ${RABBITMQ_PASSWORD} + virtual-host: / + publisher-confirm-type: correlated + publisher-returns: true + listener: + simple: + acknowledge-mode: manual + prefetch: 1 + retry: + enabled: true + max-attempts: 3 + initial-interval: 1000 +jwt: + secret: ${JWT_SECRET} + expiration: 86400000 +aliyun: + sms: + # 现有 @Value 无默认值,必须提供配置键;占位值不是可用的云服务凭据。 + access-key-id: ${SMS_ACCESS_KEY_ID:your-local-placeholder} + access-key-secret: ${SMS_ACCESS_KEY_SECRET:your-local-placeholder} + sign-name: ${SMS_SIGN_NAME:local-placeholder} + template-code: ${SMS_TEMPLATE_CODE:local-placeholder} + region-id: ${SMS_REGION_ID:cn-hangzhou} + oss: + # OssUtil 强依赖 OSS Bean,不能直接改成 false;本地占位不会提供存储功能。 + enabled: true + endpoint: ${OSS_ENDPOINT:http://127.0.0.1:19000} + access-key-id: ${OSS_ACCESS_KEY_ID:your-local-placeholder} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:your-local-placeholder} + bucket-name: ${OSS_BUCKET_NAME:qinghe-local-placeholder} + domain: ${OSS_DOMAIN:http://127.0.0.1:19000} + max-file-size: 100 + allowed-file-types: + - jpg + - jpeg + - png + - gif + - webp + - pdf + - mp4 + push: + enabled: ${PUSH_ENABLED:false} + access-key-id: ${PUSH_ACCESS_KEY_ID:} + access-key-secret: ${PUSH_ACCESS_KEY_SECRET:} + region-id: cn-hangzhou + android-app-key: ${PUSH_ANDROID_APP_KEY:} + ios-app-key: ${PUSH_IOS_APP_KEY:} + ocr: + # enabled 字段并非统一禁用保障;无凭据时不要调用 OCR 接口。 + enabled: false + access-key-id: ${OCR_ACCESS_KEY_ID:} + access-key-secret: ${OCR_ACCESS_KEY_SECRET:} + region-id: cn-hangzhou + endpoint: ocr-api.cn-hangzhou.aliyuncs.com + connect-timeout: 10000 + read-timeout: 30000 +vod: + region-id: cn-shanghai + access-key-id: ${VOD_ACCESS_KEY_ID:} + access-key-secret: ${VOD_ACCESS_KEY_SECRET:} + transcode-template-group-id: ${VOD_TRANSCODE_TEMPLATE_GROUP_ID:} + callback-url: ${VOD_CALLBACK_URL:} + # 沿用现有协议,不编造回调签名校验。 + callback-auth: '' +easemob: + app-key: ${EASEMOB_APP_KEY:} + client-id: ${EASEMOB_CLIENT_ID:} + client-secret: ${EASEMOB_CLIENT_SECRET:} + org-name: ${EASEMOB_ORG_NAME:} + app-name: ${EASEMOB_APP_NAME:} + base-url: ${EASEMOB_BASE_URL:https://a1.easemob.com} +chat: + rest-send-forward-im: false +security: + captcha: + universal-enabled: false +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: never +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html +app: + admin: + bootstrap-phones: '' + click-guard: + window-ms: 2000 + topic: + auto-review: + enabled: false + backfill-apply: false + discussion: + allowed-media-hosts: ${DISCUSSION_ALLOWED_MEDIA_HOSTS:localhost,127.0.0.1} diff --git a/campus-server/src/main/resources/application.sanitized.yml b/campus-server/src/main/resources/application.sanitized.yml new file mode 100644 index 0000000..36b9ffd --- /dev/null +++ b/campus-server/src/main/resources/application.sanitized.yml @@ -0,0 +1,237 @@ +# PRIVATE: actual internal credentials. Do not upload to Git. +# OSS upload credentials currently use direct AK/SK, not STS AssumeRole. +server: + address: 0.0.0.0 + port: 8009 + tomcat: + max-http-form-post-size: 100MB + connection-timeout: 300000 + max-swallow-size: -1 +spring: + application: + name: campus + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + datasource: + url: "jdbc:mysql://db.example.invalid:3306/example_db" + username: "example_user" + password: "REDACTED_DO_NOT_USE" + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + pool-name: campus-pool + # 连接数预算:实例数 × maximum-pool-size + 其他客户端(运维/同步任务,预留 20) < MySQL max_connections + maximum-pool-size: 10 + # 关键项:Hikari 默认 minimum-idle = maximum-pool-size,会让每个实例空闲时也常驻 10 条连接; + # 共享同一个远程库(本地 IDEA + 联调 + 生产)时最容易把 max_connections 打满,故降到 3 + minimum-idle: 3 + # 拿不到连接时快速失败(默认 30s 会让请求线程长时间挂住) + connection-timeout: 10000 + idle-timeout: 300000 + # 必须小于 MySQL wait_timeout;配合 keepalive 防止服务端主动断开后出现「通信链路故障」 + max-lifetime: 1500000 + keepalive-time: 120000 + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + flyway: + enabled: false + baseline-on-migrate: true + baseline-version: 0 + validate-on-migrate: true + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + serialization: + write-dates-as-timestamps: false + redis: + host: "example.invalid" + port: 6379 + database: 0 + password: "REDACTED_DO_NOT_USE" + timeout: 10s + lettuce: + pool: + max-active: 8 + max-wait: -1 + max-idle: 8 + min-idle: 0 + time-between-eviction-runs: 30s + cache: + type: redis + redis: + time-to-live: 2592000000 + cache-null-values: false + rabbitmq: + host: "example.invalid" + port: 5672 + username: "example_user" + password: "REDACTED_DO_NOT_USE" + virtual-host: "example-vhost" + publisher-confirm-type: correlated + publisher-returns: true + listener: + simple: + acknowledge-mode: manual + prefetch: 1 + retry: + enabled: true + max-attempts: 3 + initial-interval: 3000ms +jwt: + secret: "REDACTED_DO_NOT_USE" + expiration: '2592000000' +aliyun: + sms: + access-key-id: "REDACTED_DO_NOT_USE" + access-key-secret: "REDACTED_DO_NOT_USE" + sign-name: 油软网络 + template-code: SMS_152715262 + region-id: cn-hangzhou + oss: + enabled: true + endpoint: "https://oss.example.invalid" + access-key-id: "REDACTED_DO_NOT_USE" + access-key-secret: "REDACTED_DO_NOT_USE" + bucket-name: "example-bucket" + domain: "example.invalid" + max-file-size: 100 + allowed-file-types: + - jpg + - jpeg + - png + - gif + - mp4 + - avi + - mov + - pdf + - doc + - docx + push: + enabled: true + access-key-id: "REDACTED_DO_NOT_USE" + access-key-secret: "REDACTED_DO_NOT_USE" + region-id: cn-hangzhou + android-app-key: "REDACTED_DO_NOT_USE" + ios-app-key: "REDACTED_DO_NOT_USE" + ocr: + enabled: true + access-key-id: "REDACTED_DO_NOT_USE" + access-key-secret: "REDACTED_DO_NOT_USE" + region-id: cn-hangzhou + endpoint: "https://example.invalid" + connect-timeout: 10000 + read-timeout: 30000 +chat: + rest-send-forward-im: false +video: + enabled: true + max-file-size: 500 + upload-prefix: videos/ +oss: + sts: + duration-seconds: 3600 + upload-prefix: videos/ +vod: + region-id: cn-shanghai + access-key-id: "REDACTED_DO_NOT_USE" + access-key-secret: "REDACTED_DO_NOT_USE" + transcode-template-group-id: 00cb1a20fc02860b0d1cbbae3be5c35a + callback-url: "https://example.invalid/callback" + callback-auth: "REDACTED_DO_NOT_USE" +easemob: + app-key: "REDACTED_DO_NOT_USE" + client-id: "REDACTED_DO_NOT_USE" + client-secret: "REDACTED_DO_NOT_USE" + org-name: 1124260527225551 + app-name: qinghe + base-url: "https://im.example.invalid" +logging: + level: + com.campus: debug +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: never +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html +app: + account-deletion: + cleanup-enabled: ${APP_ACCOUNT_DELETION_CLEANUP_ENABLED:false} + activity-location: + enabled: ${ACTIVITY_LOCATION_ENABLED:false} + key: ${AMAP_WEB_SERVICE_KEY:} + radar-coordinates-verified: ${ACTIVITY_LOCATION_RADAR_VERIFIED:false} + connect-timeout-ms: 2000 + read-timeout-ms: 3000 + resolve-ttl-seconds: 900 + token-ttl-seconds: 1800 + requests-per-minute: 20 + global-requests-per-minute: 300 + max-concurrent: 8 + observability: + slow-request-ms: '1000' + activity: + # 报名额度:被拒绝与撤回分别计数、互相独立,任一用尽即返回 400「报名次数已达上限」 + max-reject-count: '3' + max-cancel-count: '3' + admin: + bootstrap-phones: "00000000000" + group: + # 群聊邀请有效期(天):被邀请人点「允许」必须在这个期限内,超时由懒判定刷成过期(EXPIRED) + invite-expire-days: 7 + topic: + # 话题热度权重:只统计话题域指标(关注 / 访问 / 近 7 天增长),不含任何动态互动。 + # 公式见 TopicService.heatOf()。 + heat: + follower-weight: '1' # 每个累计关注(加入) + visit-weight: '1' # 每个累计访问用户 + recent-follower-weight: '5' # 近 7 天新增关注 + recent-visit-weight: '3' # 近 7 天访问用户 + recommended-weight: '200' # 运营推荐 + pinned-weight: '1000' # 置顶 + auto-review: + # 话题发布自动审核总开关:false = 关闭(创建后为 PENDING,走人工审核回滚路径) + enabled: true + # 存量 PENDING 补偿:false = dry-run(只打日志不落库);确认清单后改 true 真正执行 + backfill-apply: false + discussion: + # 一条讨论最多几张图(图片与视频互斥:视频只能 1 条,且不与图片共存) + max-images: '9' + # 讨论图片 URL 必须命中的域名(逗号分隔,允许子域);防外链与跨站注入 + allowed-media-hosts: "example.invalid" + # 视频超过这么多分钟还没拿到播放地址,videoStatus 判 FAILED(前端可提示"处理失败") + video-timeout-minutes: '30' + # 播放地址补偿任务:扫描间隔 / 只补发布满 N 分钟仍未就绪的讨论 + video-sweep-cron: 0 */5 * * * ? + video-sweep-delay-minutes: '10' + xuexin: + pdf: + # 只接收学信网下载的单页 PDF;全局上传上限更大,此处单独收紧 + max-size-mb: 10 + # 无可用文本层时,将首页按该分辨率渲染后交给 OCR + render-dpi: 300 + # 学信网认证材料临时态(PENDING)有效期:OCR 成功后这么多分钟内必须提交,否则材料作废并被清理 + pending-ttl-minutes: 30 + # 更换认证材料的冷静期(小时):72 = 3 天。认证通过后满 3 天才允许换用新材料 + change-cooldown-hours: 72 + # 学历升级是否豁免冷静期:false = 与「更换材料统一冷静期 3 天」的产品口径一致 + upgrade-bypass-cooldown: false + # 过期临时材料清理任务执行时间(每小时第 17 分,避开整点任务) + cleanup-cron: '0 17 * * * ?' +security: + captcha: + universal-enabled: true diff --git a/campus-server/src/main/resources/db/migration/V10__add_reject_and_cancel_count_to_activity_participant.sql b/campus-server/src/main/resources/db/migration/V10__add_reject_and_cancel_count_to_activity_participant.sql new file mode 100644 index 0000000..139d5cf --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V10__add_reject_and_cancel_count_to_activity_participant.sql @@ -0,0 +1,63 @@ +-- 活动报名额度改造:被拒绝次数与撤回次数分别计数、互相独立(各默认 3 次)。 +-- +-- 背景:V7 引入的 apply_count 统计的是「提交申请次数」,撤回与拒绝本身不增加, +-- 但撤回后重新申请会 +1,等于撤回也会间接消耗额度,不符合「撤回 3 次、拒绝 3 次」的产品口径。 +-- +-- 语义: +-- reject_count = 累计被活动创建者拒绝的次数(仅 PENDING -> REJECTED 时 +1; +-- 对已通过者执行 POST review 的 REJECT 属「移出」语义,不计数) +-- cancel_count = 累计主动撤回报名申请的次数(仅 PENDING -> CANCELLED 时 +1; +-- 退出已通过的活动不计,否则正常退出也会被扣额度) +-- apply_count = 累计提交申请次数,仅用于展示「第 N 次申请」,不再参与拦截 +-- +-- 拦截规则:任一额度 >= 上限即返回 400「报名次数已达上限」,上限由 +-- app.activity.max-reject-count / app.activity.max-cancel-count 配置(默认 3)。 +-- +-- 注意:Flyway 当前关闭(spring.flyway.enabled=false),本脚本不会自动执行,需在目标库手工执行; +-- 请先在测试库执行并用文末校验语句确认结果。 +-- 注意:DDL(ALTER TABLE) 会隐式提交,脚本无法整体回滚,务必先做第 0 步备份。 +-- 注意:activity_participant 由 Hibernate(ddl-auto=update) 自动维护,应用启动时可能已自动加上这两列, +-- 此时 ALTER 会报 Duplicate column name,该报错可忽略,继续执行第 2、3 步即可。 +-- 注意:本脚本只加列 + 回填 reject_count,不修改任何报名状态、不影响已通过的活动参与者。 + +-- ============ 0. 备份(回滚依据) ============ +CREATE TABLE IF NOT EXISTS activity_participant_bak_v10_quota AS SELECT * FROM activity_participant; + +-- ============ 1. 增加两个额度计数列 ============ +ALTER TABLE activity_participant + ADD COLUMN reject_count INT NOT NULL DEFAULT 0 + COMMENT '累计被活动创建者拒绝的次数(仅待审核->被拒绝时+1)', + ADD COLUMN cancel_count INT NOT NULL DEFAULT 0 + COMMENT '累计主动撤回报名申请的次数(仅待审核->撤回时+1)'; + +-- ============ 2. 历史数据回填(按产品决策:只回填 REJECTED) ============ +-- 2.1 历史被拒绝的记录按「至少被拒 1 次」处理(历史多次无法还原,取最低值,不会误伤用户) +UPDATE activity_participant SET reject_count = 1 WHERE status = 'REJECTED'; + +-- 2.2 历史 CANCELLED 不回填(产品决策):历史数据无法区分「撤回待审核申请」还是「退出已通过活动」, +-- 宁可宽松,避免把正常退出活动算成撤回而扣掉用户额度。cancel_count 保持默认 0。 + +-- ============ 3. 校验(全部应为 0) ============ +-- 3.1 不应有 NULL +SELECT 'activity_participant_reject_count_null' AS chk, COUNT(*) AS should_be_0 +FROM activity_participant WHERE reject_count IS NULL; + +SELECT 'activity_participant_cancel_count_null' AS chk, COUNT(*) AS should_be_0 +FROM activity_participant WHERE cancel_count IS NULL; + +-- 3.2 被拒绝的记录必须至少有 1 次拒绝计数 +SELECT 'activity_participant_rejected_not_counted' AS chk, COUNT(*) AS should_be_0 +FROM activity_participant WHERE status = 'REJECTED' AND reject_count = 0; + +-- 3.3 撤回次数不应超过上限(默认 3;若历史异常导致超限,需人工核对是否是脏数据) +SELECT 'activity_participant_cancel_over_limit' AS chk, COUNT(*) AS should_be_0 +FROM activity_participant WHERE cancel_count > 3; + +-- ============ 4. 只读核对(可选,观察分布) ============ +SELECT status, COUNT(*) AS rows_cnt, + SUM(reject_count) AS reject_sum, SUM(cancel_count) AS cancel_sum +FROM activity_participant GROUP BY status; + +-- ============ 回滚说明 ============ +-- ALTER TABLE activity_participant DROP COLUMN reject_count, DROP COLUMN cancel_count; +-- 需要恢复数据时:DELETE FROM activity_participant; INSERT INTO activity_participant SELECT * FROM activity_participant_bak_v10_quota; diff --git a/campus-server/src/main/resources/db/migration/V11__moment_review_visibility_index.sql b/campus-server/src/main/resources/db/migration/V11__moment_review_visibility_index.sql new file mode 100644 index 0000000..58c00b3 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V11__moment_review_visibility_index.sql @@ -0,0 +1,53 @@ +-- 动态可见性修复:公开列表改为「只返回 review_status = 'approved'」, +-- 之前 GET /api/campus/moments 走 findAll / findByScope,不带任何审核状态条件, +-- 导致自动审核已判 rejected 的动态(例如含敏感词)依然出现在推荐/校园/关注流里。 +-- +-- 本次只补索引,不改表结构、不改数据: +-- idx_moment_review_scope_created (review_status, scope, created_at) —— 带 scope 的 feed 列表 +-- idx_moment_review_created (review_status, created_at) —— 不带 scope 的 feed 列表 +-- +-- 注意:Flyway 当前关闭(spring.flyway.enabled=false),本脚本不会自动执行,需在目标库手工执行。 +-- 注意:index 已由 Hibernate(ddl-auto=update) 按 @Index 自动建好时,下面的守卫会跳过,不会报错。 +-- 注意:MySQL 8 不支持 CREATE INDEX IF NOT EXISTS,故用 information_schema 判断 + 动态 SQL 保证可重复执行。 + +-- ============ 1. (review_status, scope, created_at) ============ +SET @idx_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'moment' + AND index_name = 'idx_moment_review_scope_created' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE moment ADD INDEX idx_moment_review_scope_created (review_status, scope, created_at)', + 'SELECT ''skip: idx_moment_review_scope_created already exists'' AS note'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ============ 2. (review_status, created_at) ============ +SET @idx_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'moment' + AND index_name = 'idx_moment_review_created' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE moment ADD INDEX idx_moment_review_created (review_status, created_at)', + 'SELECT ''skip: idx_moment_review_created already exists'' AS note'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ============ 3. 校验:两个索引都应存在(应返回 2 行) ============ +SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS cols +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'moment' + AND index_name IN ('idx_moment_review_scope_created', 'idx_moment_review_created') +GROUP BY index_name; + +-- ============ 4. 只读核对:当前被驳回/待审动态数量(修复后这些不再出现在公开列表) ============ +SELECT review_status, COUNT(*) AS rows_cnt +FROM moment +WHERE deleted = 0 AND review_status <> 'approved' +GROUP BY review_status; diff --git a/campus-server/src/main/resources/db/migration/V12__release_deleted_topic_names.sql b/campus-server/src/main/resources/db/migration/V12__release_deleted_topic_names.sql new file mode 100644 index 0000000..73ebd02 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V12__release_deleted_topic_names.sql @@ -0,0 +1,61 @@ +-- 话题删除口径改为「软删 + 改名释放名称」后的一次性数据清理。 +-- +-- 背景:uk_topic_name 唯一索引包含软删行。历史上有若干话题是"只置 deleted=1、名字没改"的, +-- 它们的名字会一直被占用,用户无法重建同名话题(接口会返回「该名称曾被使用且已删除」)。 +-- 本脚本把所有 **已软删但名字未加后缀** 的话题改成 `原名_deleted_{id前8位}`,并同步改名 +-- 按名字关联的关注 / 访问 / 热度快照,以及别名,从而释放原名。 +-- +-- 注意:Flyway 关闭(spring.flyway.enabled=false),本脚本不会自动执行,需在目标库手工执行; +-- DDL/DML 混用会隐式提交,无法整体回滚,所以先做备份(第 0 步)。 +-- 注意:本脚本是幂等的:已经带 `_deleted_` 后缀的行会被跳过,可重复执行。 +-- 注意:只处理 deleted=1 的行,不影响任何在线话题、关注、访问数据。 + +-- ============ 0. 备份(回滚依据) ============ +CREATE TABLE IF NOT EXISTS topic_bak_v12 AS SELECT * FROM topic; +CREATE TABLE IF NOT EXISTS topic_follow_bak_v12 AS SELECT * FROM topic_follow; +CREATE TABLE IF NOT EXISTS topic_visit_bak_v12 AS SELECT * FROM topic_visit; +CREATE TABLE IF NOT EXISTS topic_hot_snapshot_bak_v12 AS SELECT * FROM topic_hot_snapshot; +CREATE TABLE IF NOT EXISTS topic_alias_bak_v12 AS SELECT * FROM topic_alias; + +-- ============ 1. 先改从属表(按旧名关联的三张) ============ +UPDATE topic_follow f +JOIN topic t ON t.name = f.topic_name +SET f.topic_name = CONCAT(LEFT(t.name, 80), '_deleted_', LEFT(t.id, 8)) +WHERE t.deleted = 1 AND INSTR(t.name, '_deleted_') = 0; + +UPDATE topic_visit v +JOIN topic t ON t.name = v.topic_name +SET v.topic_name = CONCAT(LEFT(t.name, 80), '_deleted_', LEFT(t.id, 8)) +WHERE t.deleted = 1 AND INSTR(t.name, '_deleted_') = 0; + +UPDATE topic_hot_snapshot s +JOIN topic t ON t.name = s.topic_name +SET s.topic_name = CONCAT(LEFT(t.name, 80), '_deleted_', LEFT(t.id, 8)) +WHERE t.deleted = 1 AND INSTR(t.name, '_deleted_') = 0; + +-- 别名按 topic_id 关联,统一加后缀(uk_topic_alias 同样包含软删行) +UPDATE topic_alias a +JOIN topic t ON t.id = a.topic_id +SET a.alias = CONCAT(a.alias, '_deleted_', LEFT(t.id, 8)) +WHERE t.deleted = 1 AND INSTR(t.name, '_deleted_') = 0; + +-- ============ 2. 再改话题自身 ============ +UPDATE topic +SET name = CONCAT(LEFT(name, 80), '_deleted_', LEFT(id, 8)) +WHERE deleted = 1 AND INSTR(name, '_deleted_') = 0; + +-- ============ 3. 校验 ============ +-- 3.1 不应再有"已删但名字未加后缀"的话题(期望 0) +SELECT 'soft_deleted_without_suffix' AS chk, COUNT(*) AS should_be_0 +FROM topic WHERE deleted = 1 AND INSTR(name, '_deleted_') = 0; + +-- 3.2 不应出现重名(期望空结果) +SELECT name, COUNT(*) AS cnt FROM topic GROUP BY name HAVING COUNT(*) > 1; + +-- 3.3 只读核对:已软删话题的新名字 +SELECT id, name, deleted FROM topic WHERE deleted = 1 ORDER BY updated_at DESC LIMIT 20; + +-- ============ 回滚说明 ============ +-- 若需回滚:先按 id 从 topic_bak_v12 / topic_follow_bak_v12 / topic_visit_bak_v12 / +-- topic_hot_snapshot_bak_v12 / topic_alias_bak_v12 还原对应行(按 id 匹配),再删除备份表。 +-- 注意:回滚会把原名重新占住,可能再次出现"无法重建同名话题"的问题。 diff --git a/campus-server/src/main/resources/db/migration/V13__create_topic_favorite.sql b/campus-server/src/main/resources/db/migration/V13__create_topic_favorite.sql new file mode 100644 index 0000000..453657d --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V13__create_topic_favorite.sql @@ -0,0 +1,36 @@ +-- ============================================================================ +-- V13:话题收藏表(统计栏「收藏」数据源) +-- +-- 背景:话题详情页统计栏由「关注 / 访问 / 热度」改为「关注 / 访问 / 收藏」, +-- 热度降级为内部排序信号不再展示。后端此前没有任何话题收藏能力,需新建。 +-- +-- ⚠️ Flyway 已关闭(spring.flyway.enabled=false),本脚本不会自动执行,需人工在目标库执行。 +-- ⚠️ 应用启动时 Hibernate(ddl-auto=update)通常也会自动建表;手工执行只是保证 +-- 索引名与唯一键可控,并与 topic_follow 的既有 DDL 完全对齐。 +-- ⚠️ 本脚本只建表,不涉及任何存量数据迁移,执行后 topic_favorite 为空,收藏数从 0 开始。 +-- +-- 字段类型与线上 topic_follow 一致(已核对 SHOW CREATE TABLE): +-- id varchar(36) / created_at datetime(6) / updated_at datetime(6) / deleted bit(1) +-- +-- 回滚:DROP TABLE IF EXISTS topic_favorite; +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS `topic_favorite` ( + `id` varchar(36) NOT NULL, + `created_at` datetime(6) NOT NULL, + `deleted` bit(1) NOT NULL, + `updated_at` datetime(6) NOT NULL, + `topic_name` varchar(100) NOT NULL, + `user_id` varchar(36) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_topic_favorite_user_topic` (`user_id`,`topic_name`), + KEY `idx_topic_favorite_topic` (`topic_name`), + KEY `idx_topic_favorite_user_time` (`user_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============ 校验 ============ +-- 1. 表结构(应能看到唯一键 uk_topic_favorite_user_topic) +SHOW CREATE TABLE `topic_favorite`; + +-- 2. 初始应为 0 +SELECT COUNT(*) AS favorite_rows FROM `topic_favorite`; diff --git a/campus-server/src/main/resources/db/migration/V14__moment_topic_discussion.sql b/campus-server/src/main/resources/db/migration/V14__moment_topic_discussion.sql new file mode 100644 index 0000000..0cbb656 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V14__moment_topic_discussion.sql @@ -0,0 +1,77 @@ +-- ============================================================================ +-- V14:话题讨论与动态流分离(概念分离:标签 ≠ 话题) +-- +-- 背景: +-- 话题页发布的"讨论"和普通动态共用 moment 一张表,且没有来源维度; +-- 更根本的问题是 moment.tags(动态的分类标签)被当成了"内容属于哪个话题"的凭据, +-- 于是同一条内容既出现在话题详情页,也出现在首页动态流。 +-- +-- 本脚本只做两件事: +-- 1) moment 加 content_source / topic_id 两列(老行 content_source 归一为 FEED、topic_id 保持 NULL) +-- 2) 补两个索引 +-- +-- ⚠️ 本脚本**不做任何"按标签猜话题"的数据迁移**。 +-- 原因:moment.tags 是动态的分类标签,话题是另一个板块,两者不是一回事。 +-- 用 FIND_IN_SET(tag, tags) 反推归属本身就是混用,不能写进系统逻辑,也不做自动迁移。 +-- +-- 后果(上线前要跟产品/运营对齐): +-- * 上线后话题详情页的讨论列表是空的(今天话题页内容 100% 来自标签匹配,它们本质是动态); +-- * 这些历史内容继续留在动态流,行为不变; +-- * 话题页讨论从上线当天开始积累。 +-- 如确需保留历史观感,走"人工指定"的一次性数据修正(见 +-- 方案分析与实现(md)文件/实现方案-话题讨论与动态流分离.md §6.5),不要在这里自动跑。 +-- +-- 回滚:ALTER TABLE moment DROP COLUMN topic_id, DROP COLUMN content_source; +-- (回滚前先备份:CREATE TABLE moment_bak_20261020 AS SELECT * FROM moment;) +-- ============================================================================ + +-- ---------- 0. 备份(按需,保留 30 天) ---------- +-- CREATE TABLE moment_bak_20261020 AS SELECT * FROM moment; + +-- ---------- 1. 加列(幂等) ---------- +SET @col_exists := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'moment' AND column_name = 'content_source'); +SET @sql := IF(@col_exists = 0, + 'ALTER TABLE moment ADD COLUMN content_source varchar(20) NOT NULL DEFAULT ''FEED'' COMMENT ''内容来源:FEED-动态流,TOPIC-话题讨论''', + 'SELECT ''skip: content_source exists'' AS note'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'moment' AND column_name = 'topic_id'); +SET @sql := IF(@col_exists = 0, + 'ALTER TABLE moment ADD COLUMN topic_id varchar(36) NULL COMMENT ''话题讨论所属话题ID(普通动态为NULL)''', + 'SELECT ''skip: topic_id exists'' AS note'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------- 2. 老行归一(Hibernate 自动加列时可能留下空串) ---------- +UPDATE moment SET content_source = 'FEED' +WHERE content_source IS NULL OR content_source = ''; + +-- ---------- 3. 索引(幂等) ---------- +SET @idx_exists := (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'moment' AND index_name = 'idx_moment_source_created'); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE moment ADD INDEX idx_moment_source_created (content_source, created_at)', + 'SELECT ''skip: idx_moment_source_created exists'' AS note'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_exists := (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'moment' AND index_name = 'idx_moment_topic_created'); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE moment ADD INDEX idx_moment_topic_created (topic_id, created_at)', + 'SELECT ''skip: idx_moment_topic_created exists'' AS note'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------- 4. 校验 ---------- +-- 4.1 两列都应存在 +SHOW COLUMNS FROM moment LIKE 'content_source'; +SHOW COLUMNS FROM moment LIKE 'topic_id'; + +-- 4.2 老数据应全部归一为 FEED(应为 0) +SELECT COUNT(*) AS bad_source FROM moment WHERE content_source IS NULL OR content_source = ''; + +-- 4.3 本脚本不做迁移,所以不应存在任何 topic_id(应为 0) +SELECT COUNT(*) AS topic_rows FROM moment WHERE topic_id IS NOT NULL; + +-- 4.4 内容类型分布(上线前应全是 FEED) +SELECT content_source, COUNT(*) AS rows_cnt FROM moment WHERE deleted = 0 GROUP BY content_source; diff --git a/campus-server/src/main/resources/db/migration/V15__create_xuexin_report.sql b/campus-server/src/main/resources/db/migration/V15__create_xuexin_report.sql new file mode 100644 index 0000000..f6f68c3 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V15__create_xuexin_report.sql @@ -0,0 +1,128 @@ +-- ============================================================================ +-- V15:学信网认证材料落库 + 重复认证拦截 + 一人一条生效认证 +-- +-- 背景:原实现中 /api/ocr/chsi-report 只返回识别结果、不落库; +-- /api/profile/xuexin/submit 完全相信前端回传的 validChsiReport=true, +-- 同一份报告可被多个账号反复提交,也没有任何存证。 +-- +-- 本脚本引入 xuexin_report: +-- PENDING OCR 成功后的临时态(默认 30 分钟过期,由定时任务物理删除) +-- CONFIRMED 认证生效态 +-- SUPERSEDED 被新材料替换的历史态(保留存证,不占用唯一键) +-- +-- 三个数据库级约束: +-- 1) uk_xuexin_report_dedup_key 同一份材料全局只能落一次(核心判重) +-- 2) uk_xuexin_report_active_user 「一人只有一条生效认证」—— +-- active_marker 在 CONFIRMED 时 = user_id, +-- 其他状态为 NULL;MySQL 唯一索引允许多个 NULL, +-- 因此 SUPERSEDED 行不占键,同事务内先清旧再插新即可 +-- 3) idx_xuexin_report_status_expire 供过期 PENDING 清理任务走索引 +-- +-- ⚠️ Flyway 已关闭(spring.flyway.enabled=false),本脚本不会自动执行,需人工在目标库执行。 +-- ⚠️ 应用启动时 Hibernate(ddl-auto=update)也会自动建表/补列;手工执行是为了让索引名与唯一键可控。 +-- 若应用已先启动,第 2 步的每条 ALTER 都可能报 Duplicate column name / Duplicate key name, +-- 单个报错可忽略;但**必须**用 2.3 节的两条校验确认两列都真的存在,不要看到报错就直接跳过。 +-- 本脚本初版把两列写在同一条 ALTER 里,结果一列重复导致另一列被连带漏建,已拆开。 +-- ⚠️ DDL 隐式提交、无法整体回滚,务必先做第 0 步备份。 +-- ⚠️ 本表**不做软删**:deleted 列保留是为了与 BaseEntity 对齐,业务上永远为 false。 +-- 过期 PENDING 一律物理 DELETE,否则软删行会永久占用 uk_xuexin_report_dedup_key。 +-- +-- 回滚:DROP TABLE IF EXISTS xuexin_report; ALTER TABLE `user` DROP COLUMN xuexin_certified_at, DROP COLUMN xuexin_report_id; +-- ============================================================================ + +-- ============ 0. 备份(回滚依据) ============ +CREATE TABLE IF NOT EXISTS `user_bak_v15_xuexin` AS SELECT id, xuexin_certified, xuexin_enrollment_status FROM `user`; + +-- ============ 1. 认证材料表 ============ +CREATE TABLE IF NOT EXISTS `xuexin_report` ( + `id` varchar(36) NOT NULL, + `created_at` datetime(6) NOT NULL, + `updated_at` datetime(6) NOT NULL, + `deleted` bit(1) NOT NULL DEFAULT b'0', + + `user_id` varchar(36) NOT NULL COMMENT '提交账号', + `status` varchar(20) NOT NULL COMMENT 'PENDING/CONFIRMED/SUPERSEDED', + `active_marker` varchar(36) DEFAULT NULL COMMENT 'CONFIRMED 时=user_id,其余为 NULL;用于「一人一条生效认证」', + `dedup_key` varchar(160) NOT NULL COMMENT 'FP:sha256(8个强身份字段);刻意不含在线验证码——验证码有有效期可重新生成', + `data_fingerprint` char(64) NOT NULL COMMENT 'sha256(8个强身份字段),仅排查用', + `level_rank` int NOT NULL DEFAULT 0 COMMENT '学历等级:博士70/硕士60/本科50/高职40/专科30/未知0', + + `certificate_no` varchar(64) DEFAULT NULL COMMENT '学信网在线验证码', + `report_image_url` varchar(500) DEFAULT NULL COMMENT '报告原图 OSS 地址,存证用', + + `name` varchar(50) DEFAULT NULL, + `gender` varchar(8) DEFAULT NULL, + `birth_date` varchar(10) DEFAULT NULL, + `id_card_hash` char(64) DEFAULT NULL COMMENT '身份证号 SHA-256,禁止存明文', + `id_card_tail` varchar(4) DEFAULT NULL COMMENT '身份证后 4 位,供本人核对', + `school` varchar(200) DEFAULT NULL, + `level` varchar(50) DEFAULT NULL, + `department` varchar(100) DEFAULT NULL, + `major` varchar(100) DEFAULT NULL, + `study_duration` varchar(20) DEFAULT NULL, + `enrollment_date` varchar(20) DEFAULT NULL, + `graduation_date` varchar(20) DEFAULT NULL, + `enrollment_status` varchar(30) DEFAULT NULL, + `student_no` varchar(50) DEFAULT NULL, + `study_form` varchar(30) DEFAULT NULL, + `education_category` varchar(30) DEFAULT NULL, + `ethnicity` varchar(20) DEFAULT NULL, + + `raw_lines` mediumtext DEFAULT NULL COMMENT 'OCR 原始文本行,审计/复现用', + `valid_chsi_report` bit(1) NOT NULL DEFAULT b'1', + `expire_at` datetime DEFAULT NULL COMMENT '仅 PENDING 有值', + `confirmed_at` datetime DEFAULT NULL, + `superseded_at` datetime DEFAULT NULL, + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_xuexin_report_dedup_key` (`dedup_key`), + UNIQUE KEY `uk_xuexin_report_active_user` (`active_marker`), + KEY `idx_xuexin_report_user_time` (`user_id`,`created_at`), + KEY `idx_xuexin_report_status_expire` (`status`,`expire_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学信网认证材料(OCR 落库 + 判重 + 存证)'; + +-- ============ 2. user 表补认证时间与当前材料指针 ============ +-- ⚠️ 两条必须分开写:MySQL 的 ALTER TABLE 是整条原子的, +-- 若两列写在同一条语句里、其中一列已存在(Hibernate 先建过),整条会回滚, +-- 另一列也就建不上(本脚本初版踩过:xuexin_certified_at 已存在 → +-- xuexin_report_id 被连带漏建)。分开写则各自失败互不影响,配合第 2.3 节校验。 +ALTER TABLE `user` + ADD COLUMN `xuexin_certified_at` datetime DEFAULT NULL COMMENT '学信网认证通过时间(冷静期计算基准)'; + +ALTER TABLE `user` + ADD COLUMN `xuexin_report_id` varchar(36) DEFAULT NULL COMMENT '当前生效的 xuexin_report.id'; + +-- 2.3 列存在性校验(两行都应为 1;若某行为 0,说明该列没建上,单独补一条 ALTER) +SELECT 'xuexin_certified_at_exists' AS col, COUNT(*) AS should_be_1 + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'user' AND column_name = 'xuexin_certified_at'; +SELECT 'xuexin_report_id_exists' AS col, COUNT(*) AS should_be_1 + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'user' AND column_name = 'xuexin_report_id'; + +-- ============ 3. 存量数据回填 ============ +-- 3.1 历史上已认证的用户补认证时间:无原始记录,只能取账号更新时间兜底, +-- 避免冷静期判定时把 NULL 当成「刚认证」(NULL 一律视为已过冷静期)。 +UPDATE `user` SET xuexin_certified_at = updated_at +WHERE xuexin_certified = b'1' AND xuexin_certified_at IS NULL; + +-- 3.2 不回填 xuexin_report:历史认证没有 OCR 原文与验证码,无法安全构造 dedup_key, +-- 强行回填会让「同一份材料」的判定出现假阳性。历史认证按「已认证」保留, +-- 防重只对新提交生效(即历史上同一份报告认证过多个账号的情况不追溯)。 + +-- ============ 4. 校验 ============ +-- 4.1 表结构(应能看到 uk_xuexin_report_dedup_key 与 uk_xuexin_report_active_user) +SHOW CREATE TABLE `xuexin_report`; + +-- 4.2 初始应为 0 +SELECT COUNT(*) AS report_rows FROM `xuexin_report`; + +-- 4.3 已认证用户必须都有认证时间(应为 0) +SELECT 'certified_without_time' AS chk, COUNT(*) AS should_be_0 +FROM `user` WHERE xuexin_certified = b'1' AND xuexin_certified_at IS NULL; + +-- 4.4 生效认证只能有一条/人(应为 0) +SELECT 'active_marker_duplicated' AS chk, COUNT(*) AS should_be_0 FROM ( + SELECT active_marker FROM `xuexin_report` + WHERE active_marker IS NOT NULL GROUP BY active_marker HAVING COUNT(*) > 1 +) t; diff --git a/campus-server/src/main/resources/db/migration/V16__clean_topic_discussion_dynamic_classification.sql b/campus-server/src/main/resources/db/migration/V16__clean_topic_discussion_dynamic_classification.sql new file mode 100644 index 0000000..f6f9424 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V16__clean_topic_discussion_dynamic_classification.sql @@ -0,0 +1,75 @@ +-- ===================================================================== +-- V16:清理话题讨论上残留的「动态分类体系」脏值 +-- --------------------------------------------------------------------- +-- 背景 +-- 话题讨论以 content_source = 'TOPIC' 存在 moment 表里,按设计不占用动态的分类体系, +-- 即 tags / category 必须为 NULL(不变量由 MomentTopicDiscussionTest 守护)。 +-- +-- 但历史上编辑讨论没有自己的接口,前端只能借道 PUT /api/campus/moments: +-- 该接口的 MomentRequest.category / scope 都是 @NotBlank,讨论本来没有分类, +-- 前端只能编造一个填进去,而 MomentService.update 会把假分类 / 假标签写进讨论。 +-- +-- 现状 +-- 已新增专用编辑入口 PUT /api/topics/discussions/{id}(整体替换正文与媒体、先审后存), +-- 并在编辑时强制把 tags / category 置回 NULL(作者编辑一次即自愈); +-- 通用接口 PUT /api/campus/moments 对 TOPIC 行直接返回 400「话题讨论请在话题页编辑」, +-- 通道已堵死。本脚本只负责把"没人再编辑过"的存量一次性打平。 +-- +-- 性质 +-- 纯数据 UPDATE,**不含 DDL**(唯一例外是备份表),可重复执行; +-- 不改 content_source / topic_id,不删任何行,不动 review_status。 +-- +-- 回滚 +-- 执行前会先把命中行备份到 moment_topic_classification_backup(见步骤 1)。 +-- 需要回滚时按 id 关联把 category / tags 写回即可(脚本末尾附回滚 SQL)。 +-- ===================================================================== + +-- 0) 执行前先看一眼命中多少行(只读) +SELECT COUNT(*) AS dirty_rows +FROM moment +WHERE content_source = 'TOPIC' + AND (category IS NOT NULL OR tags IS NOT NULL); + +-- 1) 备份命中行(幂等:已存在则不重建;重复执行会追加/覆盖同一批 id) +CREATE TABLE IF NOT EXISTS moment_topic_classification_backup ( + id VARCHAR(36) NOT NULL, + content_source VARCHAR(20) NULL, + topic_id VARCHAR(36) NULL, + category VARCHAR(50) NULL, + tags VARCHAR(500) NULL, + updated_at DATETIME NULL, + backup_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + +INSERT INTO moment_topic_classification_backup + (id, content_source, topic_id, category, tags, updated_at) +SELECT id, content_source, topic_id, category, tags, updated_at +FROM moment +WHERE content_source = 'TOPIC' + AND (category IS NOT NULL OR tags IS NOT NULL) +ON DUPLICATE KEY UPDATE + category = VALUES(category), + tags = VALUES(tags), + updated_at = VALUES(updated_at); + +-- 2) 清理 +UPDATE moment +SET category = NULL, + tags = NULL +WHERE content_source = 'TOPIC' + AND (category IS NOT NULL OR tags IS NOT NULL); + +-- 3) 校验:应为 0 +SELECT COUNT(*) AS remaining_dirty +FROM moment +WHERE content_source = 'TOPIC' + AND (category IS NOT NULL OR tags IS NOT NULL); + +-- 4) 附:如需回滚,执行下面这条(会把备份里的旧脏值写回讨论) +-- 注意:回滚等于把"假分类"重新灌回讨论,仅用于补救误清理,正常不要执行。 +-- UPDATE moment m +-- JOIN moment_topic_classification_backup b ON b.id = m.id +-- SET m.category = b.category, +-- m.tags = b.tags +-- WHERE m.content_source = 'TOPIC'; diff --git a/campus-server/src/main/resources/db/migration/V17__create_group_invitation.sql b/campus-server/src/main/resources/db/migration/V17__create_group_invitation.sql new file mode 100644 index 0000000..504c425 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V17__create_group_invitation.sql @@ -0,0 +1,43 @@ +-- 群聊邀请入群:已建群拉人必须经被邀请人同意才能进群(建群带人仍是直接入群,不写本表) +-- +-- 唯一性设计:pending_key 是「唯一性辅助列」 +-- * status = 'PENDING' 时固定写 'PENDING' +-- * 进入终态(ACCEPTED / CANCELLED / EXPIRED)后置为 NULL +-- 配合 uk_gi_pending(group_id, invitee_id, pending_key): +-- 「同一群 + 同一被邀请人」最多只能有一条待处理邀请, +-- 而历史记录可以有任意多条(MySQL 唯一索引允许多个 NULL)。 +-- +-- 为什么不用数据库生成列(GENERATED ALWAYS AS ... STORED): +-- 本项目 jpa.hibernate.ddl-auto=update 且 flyway.enabled=false,应用启动时 Hibernate 会 +-- 先按实体建表,随后的 CREATE TABLE IF NOT EXISTS 变成 no-op,生成列与唯一键都不会被补上。 +-- 用普通列后,唯一键随实体的 @UniqueConstraint 一起由 Hibernate 建出,脚本与实体两边一致。 +-- +-- 执行提示:若应用已先启动(表已由 Hibernate 创建),本脚本为 no-op,请用文末校验语句确认唯一键存在。 + +CREATE TABLE IF NOT EXISTS group_invitation ( + id VARCHAR(36) NOT NULL, + group_id VARCHAR(36) NOT NULL, + inviter_id VARCHAR(36) NOT NULL, + invitee_id VARCHAR(36) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + pending_key VARCHAR(20) NULL, + message VARCHAR(200) NULL, + expire_at DATETIME(6) NOT NULL, + responded_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + deleted TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_gi_pending (group_id, invitee_id, pending_key), + KEY idx_gi_invitee_status (invitee_id, status), + KEY idx_gi_group_status (group_id, status), + CONSTRAINT fk_gi_group + FOREIGN KEY (group_id) REFERENCES chat_group (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 校验(人工执行后跑一次,应能看到 uk_gi_pending): +-- SHOW INDEX FROM group_invitation; +-- +-- 若唯一键缺失(表由旧版本 Hibernate 建出且实体未声明),手动补: +-- ALTER TABLE group_invitation +-- ADD UNIQUE KEY uk_gi_pending (group_id, invitee_id, pending_key); diff --git a/campus-server/src/main/resources/db/migration/V18__adjust_xuexin_report_for_pdf.sql b/campus-server/src/main/resources/db/migration/V18__adjust_xuexin_report_for_pdf.sql new file mode 100644 index 0000000..c82336a --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V18__adjust_xuexin_report_for_pdf.sql @@ -0,0 +1,77 @@ +-- ============================================================================ +-- V18:学信网认证改为 PDF 上传,并按新版《教育部学籍在线验证报告》补齐字段 +-- +-- ⚠️ Flyway 当前关闭,本脚本不会自动执行,需人工执行。 +-- ⚠️ DDL 会隐式提交,执行前先完成第 0 步备份。 +-- ⚠️ 本次只新增列并切换新代码读写,不立即删除历史列。旧列语义混装,无法安全回填; +-- 待新版本稳定且确认无旧代码读取后,再用后续迁移删除。 +-- ============================================================================ + +-- 0. 备份认证材料表(重复执行不会覆盖首次备份) +CREATE TABLE IF NOT EXISTS `xuexin_report_bak_v18_pdf` AS +SELECT * FROM `xuexin_report`; + +-- 1. 新版报告字段 +ALTER TABLE `xuexin_report` + ADD COLUMN `report_name` varchar(100) DEFAULT NULL COMMENT '报告名称:教育部学籍在线验证报告'; + +ALTER TABLE `xuexin_report` + ADD COLUMN `report_updated_date` varchar(10) DEFAULT NULL COMMENT '报告更新日期 yyyy-MM-dd'; + +ALTER TABLE `xuexin_report` + ADD COLUMN `branch_campus` varchar(100) DEFAULT NULL COMMENT '分院/校区,与系所分开保存'; + +ALTER TABLE `xuexin_report` + ADD COLUMN `departure_date` varchar(20) DEFAULT NULL COMMENT '离校日期 yyyy-MM-dd'; + +ALTER TABLE `xuexin_report` + ADD COLUMN `online_verification_code` varchar(64) DEFAULT NULL COMMENT '学信网在线验证码'; + +ALTER TABLE `xuexin_report` + ADD COLUMN `report_pdf_url` varchar(500) DEFAULT NULL COMMENT '报告原始 PDF 的 OSS 地址'; + +ALTER TABLE `xuexin_report` + ADD COLUMN `raw_text` mediumtext DEFAULT NULL COMMENT 'PDF 文本提取或 OCR 原始文本'; + +-- 2. 只回填含义完全一致的数据。其余历史列不猜测、不强迁。 +UPDATE `xuexin_report` +SET `report_name` = '教育部学籍在线验证报告' +WHERE `report_name` IS NULL + AND `raw_lines` LIKE '%教育部学籍在线验证报告%'; + +UPDATE `xuexin_report` +SET `raw_text` = `raw_lines` +WHERE `raw_text` IS NULL AND `raw_lines` IS NOT NULL; + +-- 3. 结构校验:以下每项 should_be_1 都应为 1 +SELECT 'report_name' AS col, COUNT(*) AS should_be_1 +FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'xuexin_report' AND column_name = 'report_name' +UNION ALL +SELECT 'report_updated_date', COUNT(*) FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'xuexin_report' AND column_name = 'report_updated_date' +UNION ALL +SELECT 'branch_campus', COUNT(*) FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'xuexin_report' AND column_name = 'branch_campus' +UNION ALL +SELECT 'departure_date', COUNT(*) FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'xuexin_report' AND column_name = 'departure_date' +UNION ALL +SELECT 'online_verification_code', COUNT(*) FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'xuexin_report' AND column_name = 'online_verification_code' +UNION ALL +SELECT 'report_pdf_url', COUNT(*) FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'xuexin_report' AND column_name = 'report_pdf_url' +UNION ALL +SELECT 'raw_text', COUNT(*) FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'xuexin_report' AND column_name = 'raw_text'; + +-- 4. 业务校验 +SELECT 'active_marker_duplicated' AS chk, COUNT(*) AS should_be_0 FROM ( + SELECT active_marker FROM `xuexin_report` + WHERE active_marker IS NOT NULL GROUP BY active_marker HAVING COUNT(*) > 1 +) t; + +-- 后续稳定版本待删的历史列(本脚本不执行): +-- student_no, id_card_hash, id_card_tail, certificate_no, +-- graduation_date, report_image_url, raw_lines diff --git a/campus-server/src/main/resources/db/migration/V19__fix_interaction_duplicates_and_counters.sql b/campus-server/src/main/resources/db/migration/V19__fix_interaction_duplicates_and_counters.sql new file mode 100644 index 0000000..9d536be --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V19__fix_interaction_duplicates_and_counters.sql @@ -0,0 +1,152 @@ +-- MySQL 5.7 / 8.0;人工执行,不会自动迁移。必须先停止所有实例的互动写入,并确认当前数据库。 +-- DDL 会隐式提交,本脚本不能整体回滚。执行前还应完成数据库级备份。 +-- *_bak_v19 是首次执行时的完整快照;重跑不会覆盖快照。 +-- 若 friendship 存在同一方向且状态不同的重复行,必须先人工判定保留哪条,再执行。 +-- 检查语句(有返回时先处理,不得盲目继续): +SELECT user_id, friend_id, COUNT(DISTINCT status) AS conflicting_statuses +FROM friendship WHERE deleted = 0 GROUP BY user_id, friend_id HAVING COUNT(DISTINCT status) > 1; + +-- 用临时表主键冲突硬失败,兼容 MySQL 5.7(该版本不会执行 CHECK 约束)。 +-- 禁止使用 mysql --force;报错后必须停止并人工检查,不能继续执行清理。 +CREATE TEMPORARY TABLE v19_friendship_preflight (guard_id INT PRIMARY KEY); +INSERT INTO v19_friendship_preflight VALUES (1); +INSERT INTO v19_friendship_preflight +SELECT 1 FROM ( + SELECT user_id, friend_id FROM friendship WHERE deleted = 0 + GROUP BY user_id, friend_id HAVING COUNT(DISTINCT status) > 1 +) conflicts LIMIT 1; +DROP TEMPORARY TABLE v19_friendship_preflight; + +CREATE TABLE IF NOT EXISTS moment_like_bak_v19 AS SELECT * FROM moment_like; +CREATE TABLE IF NOT EXISTS moment_comment_like_bak_v19 AS SELECT * FROM moment_comment_like; +CREATE TABLE IF NOT EXISTS moment_favorite_bak_v19 AS SELECT * FROM moment_favorite; +CREATE TABLE IF NOT EXISTS user_follow_bak_v19 AS SELECT * FROM user_follow; +CREATE TABLE IF NOT EXISTS topic_follow_bak_v19 AS SELECT * FROM topic_follow; +CREATE TABLE IF NOT EXISTS topic_favorite_bak_v19 AS SELECT * FROM topic_favorite; +CREATE TABLE IF NOT EXISTS friendship_bak_v19 AS SELECT * FROM friendship; +CREATE TABLE IF NOT EXISTS greeting_bak_v19 AS SELECT * FROM greeting; +CREATE TABLE IF NOT EXISTS `moment_bak_v19` AS SELECT * FROM `moment`; +CREATE TABLE IF NOT EXISTS `moment_comment_bak_v19` AS SELECT * FROM `moment_comment`; +CREATE TABLE IF NOT EXISTS `user_bak_v19` AS SELECT * FROM `user`; + +-- 清除占用唯一键的软删关系;每组有效关系保留 ID 最小的一条,备份可追溯。 +DELETE FROM moment_like WHERE deleted = 1; +DELETE a FROM moment_like a JOIN moment_like b ON a.moment_id = b.moment_id AND a.user_id = b.user_id AND a.id > b.id; +DELETE FROM moment_comment_like WHERE deleted = 1; +DELETE a FROM moment_comment_like a JOIN moment_comment_like b ON a.comment_id = b.comment_id AND a.user_id = b.user_id AND a.id > b.id; +DELETE FROM moment_favorite WHERE deleted = 1; +DELETE a FROM moment_favorite a JOIN moment_favorite b ON a.moment_id = b.moment_id AND a.user_id = b.user_id AND a.id > b.id; +DELETE FROM user_follow WHERE deleted = 1; +DELETE a FROM user_follow a JOIN user_follow b ON a.follower_id = b.follower_id AND a.following_id = b.following_id AND a.id > b.id; +DELETE FROM topic_follow WHERE deleted = 1; +DELETE a FROM topic_follow a JOIN topic_follow b ON a.user_id = b.user_id AND a.topic_name = b.topic_name AND a.id > b.id; +DELETE FROM topic_favorite WHERE deleted = 1; +DELETE a FROM topic_favorite a JOIN topic_favorite b ON a.user_id = b.user_id AND a.topic_name = b.topic_name AND a.id > b.id; +DELETE FROM friendship WHERE deleted = 1; +DELETE a FROM friendship a JOIN friendship b ON a.user_id = b.user_id AND a.friend_id = b.friend_id AND a.id > b.id; +DELETE FROM greeting WHERE deleted = 1; +DELETE a FROM greeting a JOIN greeting b ON a.user_id = b.user_id AND a.target_user_id = b.target_user_id AND a.id > b.id; + +-- 按索引的实际列判断,兼容历史环境使用不同索引名;已存在等价唯一键则跳过。 +SET @v19_index_exists = (SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'moment_like' AND non_unique = 0 + GROUP BY index_name HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'moment_id,user_id' +) existing_indexes); +SET @v19_sql = IF(@v19_index_exists > 0, 'SELECT 1', 'ALTER TABLE moment_like ADD CONSTRAINT uk_like_moment_user UNIQUE (moment_id,user_id)'); +PREPARE v19_stmt FROM @v19_sql; +EXECUTE v19_stmt; +DEALLOCATE PREPARE v19_stmt; +SET @v19_index_exists = (SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'moment_comment_like' AND non_unique = 0 + GROUP BY index_name HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'comment_id,user_id' +) existing_indexes); +SET @v19_sql = IF(@v19_index_exists > 0, 'SELECT 1', 'ALTER TABLE moment_comment_like ADD CONSTRAINT uk_clike_comment_user UNIQUE (comment_id,user_id)'); +PREPARE v19_stmt FROM @v19_sql; +EXECUTE v19_stmt; +DEALLOCATE PREPARE v19_stmt; +SET @v19_index_exists = (SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'moment_favorite' AND non_unique = 0 + GROUP BY index_name HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'moment_id,user_id' +) existing_indexes); +SET @v19_sql = IF(@v19_index_exists > 0, 'SELECT 1', 'ALTER TABLE moment_favorite ADD CONSTRAINT uk_favorite_moment_user UNIQUE (moment_id,user_id)'); +PREPARE v19_stmt FROM @v19_sql; +EXECUTE v19_stmt; +DEALLOCATE PREPARE v19_stmt; +SET @v19_index_exists = (SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'user_follow' AND non_unique = 0 + GROUP BY index_name HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'follower_id,following_id' +) existing_indexes); +SET @v19_sql = IF(@v19_index_exists > 0, 'SELECT 1', 'ALTER TABLE user_follow ADD CONSTRAINT uk_follow_user_target UNIQUE (follower_id,following_id)'); +PREPARE v19_stmt FROM @v19_sql; +EXECUTE v19_stmt; +DEALLOCATE PREPARE v19_stmt; +SET @v19_index_exists = (SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'topic_follow' AND non_unique = 0 + GROUP BY index_name HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'user_id,topic_name' +) existing_indexes); +SET @v19_sql = IF(@v19_index_exists > 0, 'SELECT 1', 'ALTER TABLE topic_follow ADD CONSTRAINT uk_topic_follow_user_topic UNIQUE (user_id,topic_name)'); +PREPARE v19_stmt FROM @v19_sql; +EXECUTE v19_stmt; +DEALLOCATE PREPARE v19_stmt; +SET @v19_index_exists = (SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'topic_favorite' AND non_unique = 0 + GROUP BY index_name HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'user_id,topic_name' +) existing_indexes); +SET @v19_sql = IF(@v19_index_exists > 0, 'SELECT 1', 'ALTER TABLE topic_favorite ADD CONSTRAINT uk_topic_favorite_user_topic UNIQUE (user_id,topic_name)'); +PREPARE v19_stmt FROM @v19_sql; +EXECUTE v19_stmt; +DEALLOCATE PREPARE v19_stmt; +SET @v19_index_exists = (SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'friendship' AND non_unique = 0 + GROUP BY index_name HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'user_id,friend_id' +) existing_indexes); +SET @v19_sql = IF(@v19_index_exists > 0, 'SELECT 1', 'ALTER TABLE friendship ADD CONSTRAINT idx_friendship_unique UNIQUE (user_id,friend_id)'); +PREPARE v19_stmt FROM @v19_sql; +EXECUTE v19_stmt; +DEALLOCATE PREPARE v19_stmt; +SET @v19_index_exists = (SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'greeting' AND non_unique = 0 + GROUP BY index_name HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index) = 'user_id,target_user_id' +) existing_indexes); +SET @v19_sql = IF(@v19_index_exists > 0, 'SELECT 1', 'ALTER TABLE greeting ADD CONSTRAINT uk_greeting_user_target UNIQUE (user_id,target_user_id)'); +PREPARE v19_stmt FROM @v19_sql; +EXECUTE v19_stmt; +DEALLOCATE PREPARE v19_stmt; + +-- 以有效明细校准计数,空明细归零;与业务写入停机隔离。 +UPDATE moment m LEFT JOIN ( + SELECT moment_id, COUNT(*) AS n FROM moment_like WHERE deleted = 0 GROUP BY moment_id +) l ON l.moment_id = m.id SET m.like_count = COALESCE(l.n, 0); +UPDATE moment_comment c LEFT JOIN ( + SELECT comment_id, COUNT(*) AS n FROM moment_comment_like WHERE deleted = 0 GROUP BY comment_id +) l ON l.comment_id = c.id SET c.like_count = COALESCE(l.n, 0); +UPDATE moment m LEFT JOIN ( + SELECT moment_id, COUNT(*) AS n FROM moment_comment WHERE deleted = 0 AND status = 'ACTIVE' GROUP BY moment_id +) c ON c.moment_id = m.id SET m.comment_count = COALESCE(c.n, 0); +UPDATE `user` u LEFT JOIN ( + SELECT follower_id, COUNT(*) AS n FROM user_follow WHERE deleted = 0 GROUP BY follower_id +) f ON f.follower_id = u.id LEFT JOIN ( + SELECT following_id, COUNT(*) AS n FROM user_follow WHERE deleted = 0 GROUP BY following_id +) t ON t.following_id = u.id +SET u.following_count = COALESCE(f.n, 0), u.followers_count = COALESCE(t.n, 0); + +-- 验收:以下不一致数量必须为 0。 +SELECT COUNT(*) AS moment_like_mismatches FROM moment m WHERE m.deleted = 0 +AND m.like_count <> (SELECT COUNT(*) FROM moment_like l WHERE l.moment_id = m.id AND l.deleted = 0); +SELECT COUNT(*) AS comment_like_mismatches FROM moment_comment c WHERE c.deleted = 0 +AND c.like_count <> (SELECT COUNT(*) FROM moment_comment_like l WHERE l.comment_id = c.id AND l.deleted = 0); +SELECT COUNT(*) AS comment_count_mismatches FROM moment m WHERE m.deleted = 0 +AND m.comment_count <> (SELECT COUNT(*) FROM moment_comment c WHERE c.moment_id = m.id AND c.deleted = 0 AND c.status = 'ACTIVE'); + +SELECT COUNT(*) AS following_count_mismatches FROM `user` u +WHERE u.following_count <> (SELECT COUNT(*) FROM user_follow f WHERE f.follower_id = u.id AND f.deleted = 0); +SELECT COUNT(*) AS followers_count_mismatches FROM `user` u +WHERE u.followers_count <> (SELECT COUNT(*) FROM user_follow f WHERE f.following_id = u.id AND f.deleted = 0); diff --git a/campus-server/src/main/resources/db/migration/V1__baseline_marker.sql b/campus-server/src/main/resources/db/migration/V1__baseline_marker.sql new file mode 100644 index 0000000..a2137b6 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V1__baseline_marker.sql @@ -0,0 +1,3 @@ +-- Flyway baseline marker for databases previously managed by Hibernate ddl-auto. +-- Future schema changes must be added as ordered V2+ migrations. +SELECT 1; diff --git a/campus-server/src/main/resources/db/migration/V20__create_achievement.sql b/campus-server/src/main/resources/db/migration/V20__create_achievement.sql new file mode 100644 index 0000000..3494809 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V20__create_achievement.sql @@ -0,0 +1,53 @@ +-- 部署前人工执行;必须在 Hibernate 自动建表前执行以确保外键存在。 +CREATE TABLE IF NOT EXISTS achievement_definition ( + id VARCHAR(36) NOT NULL PRIMARY KEY, deleted TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + code VARCHAR(40) NOT NULL, name VARCHAR(50) NOT NULL, description VARCHAR(200) NOT NULL, + category_code VARCHAR(40) NOT NULL, category_name VARCHAR(50) NOT NULL, icon_url VARCHAR(500), + target_value BIGINT NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, sort INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_achievement_code(code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- 外键字符串列必须与父列字符集/排序规则一致,不能假定已有 user.id 的排序规则。 +-- 只读取元数据,不修改 user 或已创建的 achievement_definition。 +SET @achievement_user_column = ( + SELECT CONCAT(COLUMN_TYPE, ' CHARACTER SET ', CHARACTER_SET_NAME, ' COLLATE ', COLLATION_NAME) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user' AND COLUMN_NAME = 'id' +); +SET @achievement_code_column = ( + SELECT CONCAT(COLUMN_TYPE, ' CHARACTER SET ', CHARACTER_SET_NAME, ' COLLATE ', COLLATION_NAME) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'achievement_definition' AND COLUMN_NAME = 'code' +); +SET @achievement_create_sql = CONCAT( + 'CREATE TABLE IF NOT EXISTS `user_achievement` (', + '`id` VARCHAR(36) NOT NULL PRIMARY KEY, `deleted` TINYINT(1) NOT NULL DEFAULT 0,', + '`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),', + '`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),', + '`user_id` ', @achievement_user_column, ' NOT NULL,', + '`achievement_code` ', @achievement_code_column, ' NOT NULL,', + '`progress_value` BIGINT NOT NULL, `unlocked_at` DATETIME(6) NOT NULL,', + 'UNIQUE KEY `uk_user_achievement` (`user_id`,`achievement_code`),', + 'CONSTRAINT `fk_ua_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE,', + 'CONSTRAINT `fk_ua_definition` FOREIGN KEY (`achievement_code`) REFERENCES `achievement_definition` (`code`) ON DELETE CASCADE', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' +); +PREPARE achievement_create_statement FROM @achievement_create_sql; +EXECUTE achievement_create_statement; +DEALLOCATE PREPARE achievement_create_statement; + +-- 必须使用同一连接执行整个脚本(上面使用连接级变量)。 +-- 若 user_achievement 已由 Hibernate 自动建好,CREATE IF NOT EXISTS 不会补外键, +-- 请检查下面结果;外键缺失时不要误认为迁移完成,需要按实际已有列定义单独修复。 +SHOW CREATE TABLE `user_achievement`; +SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, CHARACTER_SET_NAME, COLLATION_NAME +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND ((TABLE_NAME = 'user' AND COLUMN_NAME = 'id') + OR (TABLE_NAME = 'achievement_definition' AND COLUMN_NAME = 'code') + OR (TABLE_NAME = 'user_achievement' AND COLUMN_NAME IN ('user_id','achievement_code'))); +SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME +FROM information_schema.KEY_COLUMN_USAGE +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_achievement' + AND REFERENCED_TABLE_NAME IS NOT NULL; +-- 预期两个外键:fk_ua_user、fk_ua_definition。 diff --git a/campus-server/src/main/resources/db/migration/V21__quarantine_orphan_moment_dependencies.sql b/campus-server/src/main/resources/db/migration/V21__quarantine_orphan_moment_dependencies.sql new file mode 100644 index 0000000..fbb9480 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V21__quarantine_orphan_moment_dependencies.sql @@ -0,0 +1,64 @@ +-- V21:隔离并清理主动态物理缺失的评论、评论点赞和视频播放记录。 +-- 人工执行;Flyway 保持关闭。仅在停写窗口、独立完整备份和 dry-run 核对通过后执行。 +-- 软删除的 moment 仍存在,不在本次清理范围。 +-- 每次执行使用同一连接;客户端遇错必须停止,禁止 --force。 +-- 先设置 SET @qinghe_v21_apply = 1; 才允许写入;默认仅建临时会话快照并返回计数。 +-- DDL 隐式提交;永久隔离表只由显式 apply 分支创建,不覆盖已有备份。 + +SET @qinghe_v21_apply = IFNULL(@qinghe_v21_apply, 0); +DROP TEMPORARY TABLE IF EXISTS qinghe_v21_comments; +DROP TEMPORARY TABLE IF EXISTS qinghe_v21_likes; +DROP TEMPORARY TABLE IF EXISTS qinghe_v21_progress; +CREATE TEMPORARY TABLE qinghe_v21_comments AS + SELECT c.* FROM moment_comment c LEFT JOIN moment m ON m.id=c.moment_id WHERE m.id IS NULL; +CREATE TEMPORARY TABLE qinghe_v21_likes AS + SELECT l.* FROM moment_comment_like l LEFT JOIN moment_comment c ON c.id=l.comment_id + LEFT JOIN moment m ON m.id=c.moment_id WHERE c.id IS NULL OR m.id IS NULL; +CREATE TEMPORARY TABLE qinghe_v21_progress AS + SELECT v.* FROM video_play_record v LEFT JOIN moment m ON m.id=v.video_id WHERE m.id IS NULL; + +SELECT 'orphan_comments' AS check_name,COUNT(*) AS candidate_count FROM qinghe_v21_comments +UNION ALL SELECT 'orphan_comment_likes',COUNT(*) FROM qinghe_v21_likes +UNION ALL SELECT 'orphan_video_progress',COUNT(*) FROM qinghe_v21_progress; + +SET @v21_sql = IF(@qinghe_v21_apply=1, + 'CREATE TABLE IF NOT EXISTS moment_comment_bak_v21_orphan LIKE moment_comment', 'SELECT 1'); +PREPARE v21_stmt FROM @v21_sql; EXECUTE v21_stmt; DEALLOCATE PREPARE v21_stmt; +SET @v21_sql = IF(@qinghe_v21_apply=1, + 'CREATE TABLE IF NOT EXISTS moment_comment_like_bak_v21_orphan LIKE moment_comment_like', 'SELECT 1'); +PREPARE v21_stmt FROM @v21_sql; EXECUTE v21_stmt; DEALLOCATE PREPARE v21_stmt; +SET @v21_sql = IF(@qinghe_v21_apply=1, + 'CREATE TABLE IF NOT EXISTS video_play_record_bak_v21_orphan LIKE video_play_record', 'SELECT 1'); +PREPARE v21_stmt FROM @v21_sql; EXECUTE v21_stmt; DEALLOCATE PREPARE v21_stmt; + +START TRANSACTION; +-- 不使用 INSERT IGNORE 吞掉截断/结构错误。备份主键冲突时停止并人工核对,不覆盖首次快照。 +SET @v21_sql = IF(@qinghe_v21_apply=1, + 'INSERT INTO moment_comment_bak_v21_orphan SELECT * FROM qinghe_v21_comments', 'SELECT 1'); +PREPARE v21_stmt FROM @v21_sql; EXECUTE v21_stmt; DEALLOCATE PREPARE v21_stmt; +SET @v21_sql = IF(@qinghe_v21_apply=1, + 'INSERT INTO moment_comment_like_bak_v21_orphan SELECT * FROM qinghe_v21_likes', 'SELECT 1'); +PREPARE v21_stmt FROM @v21_sql; EXECUTE v21_stmt; DEALLOCATE PREPARE v21_stmt; +SET @v21_sql = IF(@qinghe_v21_apply=1, + 'INSERT INTO video_play_record_bak_v21_orphan SELECT * FROM qinghe_v21_progress', 'SELECT 1'); +PREPARE v21_stmt FROM @v21_sql; EXECUTE v21_stmt; DEALLOCATE PREPARE v21_stmt; + +-- 再核对父记录依然缺失;只删除本次会话快照圈定的主键。 +DELETE l FROM moment_comment_like l JOIN qinghe_v21_likes q ON q.id=l.id + LEFT JOIN moment_comment c ON c.id=l.comment_id LEFT JOIN moment m ON m.id=c.moment_id + WHERE @qinghe_v21_apply=1 AND (c.id IS NULL OR m.id IS NULL); +DELETE c FROM moment_comment c JOIN qinghe_v21_comments q ON q.id=c.id + LEFT JOIN moment m ON m.id=c.moment_id WHERE @qinghe_v21_apply=1 AND m.id IS NULL; +DELETE v FROM video_play_record v JOIN qinghe_v21_progress q ON q.id=v.id + LEFT JOIN moment m ON m.id=v.video_id WHERE @qinghe_v21_apply=1 AND m.id IS NULL; +COMMIT; + +-- 全部应为 0;不为 0 时记录差异,不盲目重复执行。 +SELECT 'remaining_orphan_comments' AS check_name,COUNT(*) AS should_be_0 + FROM moment_comment c LEFT JOIN moment m ON m.id=c.moment_id WHERE m.id IS NULL +UNION ALL SELECT 'remaining_orphan_progress',COUNT(*) + FROM video_play_record v LEFT JOIN moment m ON m.id=v.video_id WHERE m.id IS NULL +UNION ALL SELECT 'remaining_orphan_comment_likes',COUNT(*) + FROM moment_comment_like l LEFT JOIN moment_comment c ON c.id=l.comment_id + LEFT JOIN moment m ON m.id=c.moment_id WHERE c.id IS NULL OR m.id IS NULL; +SET @qinghe_v21_apply = 0; diff --git a/campus-server/src/main/resources/db/migration/V22__optimize_chat_notification_indexes.sql b/campus-server/src/main/resources/db/migration/V22__optimize_chat_notification_indexes.sql new file mode 100644 index 0000000..46b67ad --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V22__optimize_chat_notification_indexes.sql @@ -0,0 +1,55 @@ +-- V22:聊天/通知查询索引,随后仅移除精确重复副本。人工执行,Flyway保持关闭。 +-- 执行前独立备份、保存SHOW CREATE TABLE/索引快照;先部署同步注解的新版本。 +-- DDL隐式提交,无法整体回滚;batch模式遇错即停,禁止--force,禁止自动重连。 +-- 同名索引定义不符、保留索引缺失或定义不符时,故意访问不存在的系统表使客户端硬失败。 +-- 可重复执行:新增索引正确存在时跳过;重复副本已不存在时跳过。 +SET SESSION group_concat_max_len = 8192; + +-- 1. 先增加查询索引 + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='chat_message' AND INDEX_NAME='idx_chat_conv_deleted_seq') IS NULL, 'ALTER TABLE `chat_message` ADD INDEX `idx_chat_conv_deleted_seq` (`conversation_id`,`deleted`,`seq_id`), ALGORITHM=INPLACE, LOCK=NONE', IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='chat_message' AND INDEX_NAME='idx_chat_conv_deleted_seq') = '1|conversation_id:FULL:BTREE:A,deleted:FULL:BTREE:A,seq_id:FULL:BTREE:A', 'SELECT 1', 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH')); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='chat_message' AND INDEX_NAME='idx_chat_conv_deleted_time_seq') IS NULL, 'ALTER TABLE `chat_message` ADD INDEX `idx_chat_conv_deleted_time_seq` (`conversation_id`,`deleted`,`created_at`,`seq_id`), ALGORITHM=INPLACE, LOCK=NONE', IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='chat_message' AND INDEX_NAME='idx_chat_conv_deleted_time_seq') = '1|conversation_id:FULL:BTREE:A,deleted:FULL:BTREE:A,created_at:FULL:BTREE:A,seq_id:FULL:BTREE:A', 'SELECT 1', 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH')); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='chat_message' AND INDEX_NAME='idx_chat_message_id') IS NULL, 'ALTER TABLE `chat_message` ADD INDEX `idx_chat_message_id` (`message_id`), ALGORITHM=INPLACE, LOCK=NONE', IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='chat_message' AND INDEX_NAME='idx_chat_message_id') = '1|message_id:FULL:BTREE:A', 'SELECT 1', 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH')); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notification' AND INDEX_NAME='idx_notification_user_deleted_read') IS NULL, 'ALTER TABLE `notification` ADD INDEX `idx_notification_user_deleted_read` (`user_id`,`deleted`,`is_read`), ALGORITHM=INPLACE, LOCK=NONE', IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notification' AND INDEX_NAME='idx_notification_user_deleted_read') = '1|user_id:FULL:BTREE:A,deleted:FULL:BTREE:A,is_read:FULL:BTREE:A', 'SELECT 1', 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH')); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notification' AND INDEX_NAME='idx_notification_user_deleted_type') IS NULL, 'ALTER TABLE `notification` ADD INDEX `idx_notification_user_deleted_type` (`user_id`,`deleted`,`type`), ALGORITHM=INPLACE, LOCK=NONE', IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notification' AND INDEX_NAME='idx_notification_user_deleted_type') = '1|user_id:FULL:BTREE:A,deleted:FULL:BTREE:A,type:FULL:BTREE:A', 'SELECT 1', 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH')); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + +-- 2. 再删除已核实的精确副本;唯一约束保留,左前缀普通索引不动。 +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='activity_payment_order' AND INDEX_NAME='uk_payment_registration') = '0|registration_id:FULL:BTREE:A' AND ((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='activity_payment_order' AND INDEX_NAME='UK_dw3dsgr7ik4xqb0p4l5lwt1t7') IS NULL OR (SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='activity_payment_order' AND INDEX_NAME='UK_dw3dsgr7ik4xqb0p4l5lwt1t7') = '0|registration_id:FULL:BTREE:A'), IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='activity_payment_order' AND INDEX_NAME='UK_dw3dsgr7ik4xqb0p4l5lwt1t7') IS NULL, 'SELECT 1', 'ALTER TABLE `activity_payment_order` DROP INDEX `UK_dw3dsgr7ik4xqb0p4l5lwt1t7`, ALGORITHM=INPLACE, LOCK=NONE'), 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH'); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_session' AND INDEX_NAME='idx_session_token_hash') = '0|token_hash:FULL:BTREE:A' AND ((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_session' AND INDEX_NAME='UK_au1tr2kouh0rhp1us6nqjpmyi') IS NULL OR (SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_session' AND INDEX_NAME='UK_au1tr2kouh0rhp1us6nqjpmyi') = '0|token_hash:FULL:BTREE:A'), IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_session' AND INDEX_NAME='UK_au1tr2kouh0rhp1us6nqjpmyi') IS NULL, 'SELECT 1', 'ALTER TABLE `user_session` DROP INDEX `UK_au1tr2kouh0rhp1us6nqjpmyi`, ALGORITHM=INPLACE, LOCK=NONE'), 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH'); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gift_record' AND INDEX_NAME='idx_gr_sender') = '1|sender_id:FULL:BTREE:A' AND ((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gift_record' AND INDEX_NAME='idx_gift_record_sender') IS NULL OR (SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gift_record' AND INDEX_NAME='idx_gift_record_sender') = '1|sender_id:FULL:BTREE:A'), IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gift_record' AND INDEX_NAME='idx_gift_record_sender') IS NULL, 'SELECT 1', 'ALTER TABLE `gift_record` DROP INDEX `idx_gift_record_sender`, ALGORITHM=INPLACE, LOCK=NONE'), 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH'); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gift_record' AND INDEX_NAME='idx_gr_receiver') = '1|receiver_id:FULL:BTREE:A' AND ((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gift_record' AND INDEX_NAME='idx_gift_record_receiver') IS NULL OR (SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gift_record' AND INDEX_NAME='idx_gift_record_receiver') = '1|receiver_id:FULL:BTREE:A'), IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='gift_record' AND INDEX_NAME='idx_gift_record_receiver') IS NULL, 'SELECT 1', 'ALTER TABLE `gift_record` DROP INDEX `idx_gift_record_receiver`, ALGORITHM=INPLACE, LOCK=NONE'), 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH'); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='activity_participant' AND INDEX_NAME='uk_ap_activity_user') = '0|activity_id:FULL:BTREE:A,user_id:FULL:BTREE:A' AND ((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='activity_participant' AND INDEX_NAME='idx_ap_activity_user') IS NULL OR (SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='activity_participant' AND INDEX_NAME='idx_ap_activity_user') = '1|activity_id:FULL:BTREE:A,user_id:FULL:BTREE:A'), IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='activity_participant' AND INDEX_NAME='idx_ap_activity_user') IS NULL, 'SELECT 1', 'ALTER TABLE `activity_participant` DROP INDEX `idx_ap_activity_user`, ALGORITHM=INPLACE, LOCK=NONE'), 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH'); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='moment_like' AND INDEX_NAME='uk_like_moment_user') = '0|moment_id:FULL:BTREE:A,user_id:FULL:BTREE:A' AND ((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='moment_like' AND INDEX_NAME='idx_like_moment_user') IS NULL OR (SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='moment_like' AND INDEX_NAME='idx_like_moment_user') = '1|moment_id:FULL:BTREE:A,user_id:FULL:BTREE:A'), IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='moment_like' AND INDEX_NAME='idx_like_moment_user') IS NULL, 'SELECT 1', 'ALTER TABLE `moment_like` DROP INDEX `idx_like_moment_user`, ALGORITHM=INPLACE, LOCK=NONE'), 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH'); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + +SET @v22_sql = IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='moment_comment_like' AND INDEX_NAME='uk_clike_comment_user') = '0|comment_id:FULL:BTREE:A,user_id:FULL:BTREE:A' AND ((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='moment_comment_like' AND INDEX_NAME='idx_clike_comment_user') IS NULL OR (SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='moment_comment_like' AND INDEX_NAME='idx_clike_comment_user') = '1|comment_id:FULL:BTREE:A,user_id:FULL:BTREE:A'), IF((SELECT CONCAT(MIN(NON_UNIQUE),'|',GROUP_CONCAT(CONCAT(COLUMN_NAME,':',IFNULL(SUB_PART,'FULL'),':',INDEX_TYPE,':',IFNULL(COLLATION,'NULL')) ORDER BY SEQ_IN_INDEX SEPARATOR ',')) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='moment_comment_like' AND INDEX_NAME='idx_clike_comment_user') IS NULL, 'SELECT 1', 'ALTER TABLE `moment_comment_like` DROP INDEX `idx_clike_comment_user`, ALGORITHM=INPLACE, LOCK=NONE'), 'SELECT * FROM information_schema.QINGHE_V22_ABORT_INDEX_DEFINITION_MISMATCH'); +PREPARE v22_stmt FROM @v22_sql; EXECUTE v22_stmt; DEALLOCATE PREPARE v22_stmt; + +-- 3. 保存结果与迁移台账。 +SELECT TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,SUB_PART,INDEX_TYPE +FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() +AND TABLE_NAME IN ('chat_message','notification','activity_payment_order','user_session','gift_record','activity_participant','moment_like','moment_comment_like') +ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX; diff --git a/campus-server/src/main/resources/db/migration/V23__expand_topic_stable_id.sql b/campus-server/src/main/resources/db/migration/V23__expand_topic_stable_id.sql new file mode 100644 index 0000000..902a46f --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V23__expand_topic_stable_id.sql @@ -0,0 +1,23 @@ +-- V23 扩展阶段:只加可空稳定ID及非唯一索引,不回填、不删除名称约束、不启用外键。 +-- 人工执行,先完整备份与结构快照;扩展成功后才能部署双写版本。 +-- MySQL 5.7 DDL隐式提交;客户端batch遇错停止,禁止--force。 +SET @topic_id_type = (SELECT CONCAT(COLUMN_TYPE,' CHARACTER SET ',CHARACTER_SET_NAME,' COLLATE ',COLLATION_NAME) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic' AND COLUMN_NAME='id'); +SET @topic_sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic_follow' AND COLUMN_NAME='topic_id')=0, CONCAT('ALTER TABLE `topic_follow` ADD COLUMN `topic_id` ',@topic_id_type,' NULL'), 'SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SET @topic_sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic_follow' AND INDEX_NAME='idx_topic_follow_stable')=0, 'ALTER TABLE `topic_follow` ADD INDEX `idx_topic_follow_stable` (topic_id,user_id), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SET @topic_sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic_favorite' AND COLUMN_NAME='topic_id')=0, CONCAT('ALTER TABLE `topic_favorite` ADD COLUMN `topic_id` ',@topic_id_type,' NULL'), 'SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SET @topic_sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic_favorite' AND INDEX_NAME='idx_topic_favorite_stable')=0, 'ALTER TABLE `topic_favorite` ADD INDEX `idx_topic_favorite_stable` (topic_id,user_id), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SET @topic_sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic_visit' AND COLUMN_NAME='topic_id')=0, CONCAT('ALTER TABLE `topic_visit` ADD COLUMN `topic_id` ',@topic_id_type,' NULL'), 'SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SET @topic_sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic_visit' AND INDEX_NAME='idx_topic_visit_stable')=0, 'ALTER TABLE `topic_visit` ADD INDEX `idx_topic_visit_stable` (topic_id,user_id), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SET @topic_sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic_hot_snapshot' AND COLUMN_NAME='topic_id')=0, CONCAT('ALTER TABLE `topic_hot_snapshot` ADD COLUMN `topic_id` ',@topic_id_type,' NULL'), 'SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SET @topic_sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='topic_hot_snapshot' AND INDEX_NAME='idx_topic_hot_snapshot_stable')=0, 'ALTER TABLE `topic_hot_snapshot` ADD INDEX `idx_topic_hot_snapshot_stable` (topic_id,captured_at), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +-- 已存在列/索引不覆盖;必须核对下面结构,名字相同不能当作定义相同。 +SELECT TABLE_NAME,COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,CHARACTER_SET_NAME,COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('topic','topic_follow','topic_favorite','topic_visit','topic_hot_snapshot') AND COLUMN_NAME IN ('id','topic_id'); +SELECT TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX,COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND INDEX_NAME LIKE 'idx_topic_%_stable' ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX; diff --git a/campus-server/src/main/resources/db/migration/V24__backfill_topic_stable_id.sql b/campus-server/src/main/resources/db/migration/V24__backfill_topic_stable_id.sql new file mode 100644 index 0000000..7eb4599 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V24__backfill_topic_stable_id.sql @@ -0,0 +1,41 @@ +-- V24:V23扩展及双写代码上线后,停写窗口核对并回填。默认预览,不创建永久备份。 +-- 先独立完整备份;同名匹配仅候选,不证明历史归属。复核通过才SET @topic_id_apply=1。 +-- 不自动按别名或merged_into_id猜测,不覆盖任何已有topic_id。 +-- 关系早于候选话题创建时间、找不到名字、已有ID与名称不一致均保持待人工处理。 +SET @topic_id_apply=IFNULL(@topic_id_apply,0); +SELECT 'topic_follow' AS source_table,COUNT(*) total_rows,SUM(r.topic_id IS NULL) null_id_rows,SUM(r.topic_id IS NULL AND t.id IS NOT NULL AND r.created_at>=t.created_at) eligible_rows,SUM(t.id IS NULL OR r.created_att.id)) unresolved_rows FROM `topic_follow` r LEFT JOIN topic t ON BINARY t.name=BINARY r.topic_name; +SET @topic_sql=IF(@topic_id_apply=1,'CREATE TABLE IF NOT EXISTS `topic_follow_bak_v24_id` LIKE `topic_follow`','SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SELECT 'topic_favorite' AS source_table,COUNT(*) total_rows,SUM(r.topic_id IS NULL) null_id_rows,SUM(r.topic_id IS NULL AND t.id IS NOT NULL AND r.created_at>=t.created_at) eligible_rows,SUM(t.id IS NULL OR r.created_att.id)) unresolved_rows FROM `topic_favorite` r LEFT JOIN topic t ON BINARY t.name=BINARY r.topic_name; +SET @topic_sql=IF(@topic_id_apply=1,'CREATE TABLE IF NOT EXISTS `topic_favorite_bak_v24_id` LIKE `topic_favorite`','SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SELECT 'topic_visit' AS source_table,COUNT(*) total_rows,SUM(r.topic_id IS NULL) null_id_rows,SUM(r.topic_id IS NULL AND t.id IS NOT NULL AND r.created_at>=t.created_at) eligible_rows,SUM(t.id IS NULL OR r.created_att.id)) unresolved_rows FROM `topic_visit` r LEFT JOIN topic t ON BINARY t.name=BINARY r.topic_name; +SET @topic_sql=IF(@topic_id_apply=1,'CREATE TABLE IF NOT EXISTS `topic_visit_bak_v24_id` LIKE `topic_visit`','SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +SELECT 'topic_hot_snapshot' AS source_table,COUNT(*) total_rows,SUM(r.topic_id IS NULL) null_id_rows,SUM(r.topic_id IS NULL AND t.id IS NOT NULL AND r.created_at>=t.created_at) eligible_rows,SUM(t.id IS NULL OR r.created_att.id)) unresolved_rows FROM `topic_hot_snapshot` r LEFT JOIN topic t ON BINARY t.name=BINARY r.topic_name; +SET @topic_sql=IF(@topic_id_apply=1,'CREATE TABLE IF NOT EXISTS `topic_hot_snapshot_bak_v24_id` LIKE `topic_hot_snapshot`','SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +START TRANSACTION; +SET @topic_sql=IF(@topic_id_apply=1,'INSERT INTO `topic_follow_bak_v24_id` SELECT r.* FROM `topic_follow` r JOIN topic t ON BINARY t.name=BINARY r.topic_name WHERE r.topic_id IS NULL AND r.created_at>=t.created_at','SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +UPDATE `topic_follow` r JOIN topic t ON BINARY t.name=BINARY r.topic_name SET r.topic_id=t.id WHERE @topic_id_apply=1 AND r.topic_id IS NULL AND r.created_at>=t.created_at; +SET @topic_sql=IF(@topic_id_apply=1,'INSERT INTO `topic_favorite_bak_v24_id` SELECT r.* FROM `topic_favorite` r JOIN topic t ON BINARY t.name=BINARY r.topic_name WHERE r.topic_id IS NULL AND r.created_at>=t.created_at','SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +UPDATE `topic_favorite` r JOIN topic t ON BINARY t.name=BINARY r.topic_name SET r.topic_id=t.id WHERE @topic_id_apply=1 AND r.topic_id IS NULL AND r.created_at>=t.created_at; +SET @topic_sql=IF(@topic_id_apply=1,'INSERT INTO `topic_visit_bak_v24_id` SELECT r.* FROM `topic_visit` r JOIN topic t ON BINARY t.name=BINARY r.topic_name WHERE r.topic_id IS NULL AND r.created_at>=t.created_at','SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +UPDATE `topic_visit` r JOIN topic t ON BINARY t.name=BINARY r.topic_name SET r.topic_id=t.id WHERE @topic_id_apply=1 AND r.topic_id IS NULL AND r.created_at>=t.created_at; +SET @topic_sql=IF(@topic_id_apply=1,'INSERT INTO `topic_hot_snapshot_bak_v24_id` SELECT r.* FROM `topic_hot_snapshot` r JOIN topic t ON BINARY t.name=BINARY r.topic_name WHERE r.topic_id IS NULL AND r.created_at>=t.created_at','SELECT 1'); +PREPARE topic_stmt FROM @topic_sql; EXECUTE topic_stmt; DEALLOCATE PREPARE topic_stmt; +UPDATE `topic_hot_snapshot` r JOIN topic t ON BINARY t.name=BINARY r.topic_name SET r.topic_id=t.id WHERE @topic_id_apply=1 AND r.topic_id IS NULL AND r.created_at>=t.created_at; +COMMIT; +-- 所有null/conflict应核对;不为0不能进入ID读取阶段。 +SELECT 'topic_follow' AS source_table,COUNT(*) unresolved_rows FROM `topic_follow` r LEFT JOIN topic t ON t.id=r.topic_id WHERE r.topic_id IS NULL OR t.id IS NULL OR BINARY r.topic_name<>BINARY t.name; +SELECT 'topic_follow' AS source_table,COUNT(*) duplicate_id_groups FROM (SELECT user_id,topic_id FROM `topic_follow` WHERE topic_id IS NOT NULL GROUP BY user_id,topic_id HAVING COUNT(*)>1) d; +SELECT 'topic_favorite' AS source_table,COUNT(*) unresolved_rows FROM `topic_favorite` r LEFT JOIN topic t ON t.id=r.topic_id WHERE r.topic_id IS NULL OR t.id IS NULL OR BINARY r.topic_name<>BINARY t.name; +SELECT 'topic_favorite' AS source_table,COUNT(*) duplicate_id_groups FROM (SELECT user_id,topic_id FROM `topic_favorite` WHERE topic_id IS NOT NULL GROUP BY user_id,topic_id HAVING COUNT(*)>1) d; +SELECT 'topic_visit' AS source_table,COUNT(*) unresolved_rows FROM `topic_visit` r LEFT JOIN topic t ON t.id=r.topic_id WHERE r.topic_id IS NULL OR t.id IS NULL OR BINARY r.topic_name<>BINARY t.name; +SELECT 'topic_visit' AS source_table,COUNT(*) duplicate_id_groups FROM (SELECT user_id,topic_id FROM `topic_visit` WHERE topic_id IS NOT NULL GROUP BY user_id,topic_id HAVING COUNT(*)>1) d; +SELECT 'topic_hot_snapshot' AS source_table,COUNT(*) unresolved_rows FROM `topic_hot_snapshot` r LEFT JOIN topic t ON t.id=r.topic_id WHERE r.topic_id IS NULL OR t.id IS NULL OR BINARY r.topic_name<>BINARY t.name; +-- 快照允许同一话题多条,不加用户唯一性校验。 +SET @topic_id_apply=0; diff --git a/campus-server/src/main/resources/db/migration/V25__add_achievement_icon_urls.sql b/campus-server/src/main/resources/db/migration/V25__add_achievement_icon_urls.sql new file mode 100644 index 0000000..8867333 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V25__add_achievement_icon_urls.sql @@ -0,0 +1,11 @@ +-- MySQL 5.7兼容,可重跑;只扩展列,不覆盖已有图标配置。DDL隐式提交。 +SET @color_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='achievement_definition' AND COLUMN_NAME='color_icon_url'); +SET @icon_ddl = IF(@color_exists=0, 'ALTER TABLE achievement_definition ADD COLUMN color_icon_url VARCHAR(500) NULL', 'SELECT 1'); +PREPARE icon_stmt FROM @icon_ddl; +EXECUTE icon_stmt; +DEALLOCATE PREPARE icon_stmt; +SET @gray_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='achievement_definition' AND COLUMN_NAME='gray_icon_url'); +SET @icon_ddl = IF(@gray_exists=0, 'ALTER TABLE achievement_definition ADD COLUMN gray_icon_url VARCHAR(500) NULL', 'SELECT 1'); +PREPARE icon_stmt FROM @icon_ddl; +EXECUTE icon_stmt; +DEALLOCATE PREPARE icon_stmt; diff --git a/campus-server/src/main/resources/db/migration/V26__configure_core_achievement_icons.sql b/campus-server/src/main/resources/db/migration/V26__configure_core_achievement_icons.sql new file mode 100644 index 0000000..9a3a300 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V26__configure_core_achievement_icons.sql @@ -0,0 +1,18 @@ +-- 八枚核心徽章图片配置。需先有V20目录和V25图片字段。 +-- 图片已逐张校验;备份首次执行前的图片配置;不修改授予记录。 +-- 建备份表DDL隐式提交;后续更新在同一事务执行。 +CREATE TABLE IF NOT EXISTS achievement_icon_backup_v26 (id VARCHAR(36) NOT NULL PRIMARY KEY, code VARCHAR(40) NOT NULL, icon_url VARCHAR(500), color_icon_url VARCHAR(500), gray_icon_url VARCHAR(500), backed_up_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +START TRANSACTION; +INSERT IGNORE INTO achievement_icon_backup_v26 (id,code,icon_url,color_icon_url,gray_icon_url) SELECT id,code,icon_url,color_icon_url,gray_icon_url FROM achievement_definition WHERE code IN ('ACTIVITY_EXPERT','CAMPUS_EXPERT','CHECKIN_EXPERT','FRIEND_EXPERT','GIFT_EXPERT','POPULAR_EXPERT','MOMENT_EXPERT','SHARE_EXPERT') AND deleted=0; +UPDATE achievement_definition SET color_icon_url='https://oss.yoyoeco.com/achievements/core/activity/color-506c5d5eba7fa679.png', gray_icon_url='https://oss.yoyoeco.com/achievements/core/activity/gray-4ddc6312986a1e98.png' WHERE code='ACTIVITY_EXPERT' AND deleted=0; +UPDATE achievement_definition SET color_icon_url='https://oss.yoyoeco.com/achievements/core/campus/color-d1806e3528a9bd25.png', gray_icon_url='https://oss.yoyoeco.com/achievements/core/campus/gray-53628367e703f231.png' WHERE code='CAMPUS_EXPERT' AND deleted=0; +UPDATE achievement_definition SET color_icon_url='https://oss.yoyoeco.com/achievements/core/checkin/color-daccc5eb3b79b411.png', gray_icon_url='https://oss.yoyoeco.com/achievements/core/checkin/gray-d9b4004fef17ca8b.png' WHERE code='CHECKIN_EXPERT' AND deleted=0; +UPDATE achievement_definition SET color_icon_url='https://oss.yoyoeco.com/achievements/core/friends/color-4a881b377e46f388.png', gray_icon_url='https://oss.yoyoeco.com/achievements/core/friends/gray-13ec6840f9e2e9e7.png' WHERE code='FRIEND_EXPERT' AND deleted=0; +UPDATE achievement_definition SET color_icon_url='https://oss.yoyoeco.com/achievements/core/gift/color-6af0a8b3568ee9d6.png', gray_icon_url='https://oss.yoyoeco.com/achievements/core/gift/gray-50e8319855981deb.png' WHERE code='GIFT_EXPERT' AND deleted=0; +UPDATE achievement_definition SET color_icon_url='https://oss.yoyoeco.com/achievements/core/like/color-e35f28201fb29218.png', gray_icon_url='https://oss.yoyoeco.com/achievements/core/like/gray-1aaefb516e25fd93.png' WHERE code='POPULAR_EXPERT' AND deleted=0; +UPDATE achievement_definition SET color_icon_url='https://oss.yoyoeco.com/achievements/core/moments/color-6f5396bf4174ed8f.png', gray_icon_url='https://oss.yoyoeco.com/achievements/core/moments/gray-f33f573c3aa4b0b3.png' WHERE code='MOMENT_EXPERT' AND deleted=0; +UPDATE achievement_definition SET color_icon_url='https://oss.yoyoeco.com/achievements/core/share/color-53bc1ce25c30a87f.png', gray_icon_url='https://oss.yoyoeco.com/achievements/core/share/gray-7b994c113e5b64fc.png' WHERE code='SHARE_EXPERT' AND deleted=0; +COMMIT; +SELECT code,color_icon_url,gray_icon_url FROM achievement_definition WHERE code IN ('ACTIVITY_EXPERT','CAMPUS_EXPERT','CHECKIN_EXPERT','FRIEND_EXPERT','GIFT_EXPERT','POPULAR_EXPERT','MOMENT_EXPERT','SHARE_EXPERT') AND deleted=0 ORDER BY code; +-- 回退时人工执行: +-- UPDATE achievement_definition d JOIN achievement_icon_backup_v26 b ON d.id=b.id SET d.icon_url=b.icon_url,d.color_icon_url=b.color_icon_url,d.gray_icon_url=b.gray_icon_url; diff --git a/campus-server/src/main/resources/db/migration/V27__account_deletion_batch.sql b/campus-server/src/main/resources/db/migration/V27__account_deletion_batch.sql new file mode 100644 index 0000000..98b0b16 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V27__account_deletion_batch.sql @@ -0,0 +1,24 @@ +-- 上线前备份数据库;此脚本不自动执行。当前执行器仅登记待核实清单,尚不执行销毁。 +CREATE TABLE IF NOT EXISTS account_deletion_batch ( + id varchar(36) NOT NULL PRIMARY KEY, + user_id varchar(36) NOT NULL, + status varchar(32) NOT NULL, + attempts int NOT NULL DEFAULT 0, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + UNIQUE KEY uk_deletion_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS account_deletion_resource ( + id varchar(36) NOT NULL PRIMARY KEY, + batch_id varchar(36) NOT NULL, + type varchar(24) NOT NULL, + resource_id text NOT NULL, + resource_hash char(64) NOT NULL, + status varchar(24) NOT NULL, + attempts int NOT NULL DEFAULT 0, + next_attempt_at datetime NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + UNIQUE KEY uk_deletion_resource (batch_id,type,resource_hash), + CONSTRAINT fk_deletion_resource_batch FOREIGN KEY(batch_id) REFERENCES account_deletion_batch(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/campus-server/src/main/resources/db/migration/V28__account_deletion_receipt.sql b/campus-server/src/main/resources/db/migration/V28__account_deletion_receipt.sql new file mode 100644 index 0000000..2d49419 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V28__account_deletion_receipt.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS account_deletion_receipt ( + id varchar(36) NOT NULL PRIMARY KEY, + completed_at datetime NOT NULL, + resource_count int NOT NULL, + retry_count int NOT NULL, + result varchar(20) NOT NULL, + KEY idx_deletion_receipt_completed (completed_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/campus-server/src/main/resources/db/migration/V29__create_share_request_idempotency.sql b/campus-server/src/main/resources/db/migration/V29__create_share_request_idempotency.sql new file mode 100644 index 0000000..eecc2d5 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V29__create_share_request_idempotency.sql @@ -0,0 +1,53 @@ +-- 分享请求去重记录。执行前备份数据库;与同一连接执行本文件中的动态 DDL 和校验语句。 +-- sender_id 的类型、字符集和排序规则从现有 user.id 读取,保证外键兼容。 +SET @share_user_column = ( + SELECT CONCAT(COLUMN_TYPE, ' CHARACTER SET ', CHARACTER_SET_NAME, ' COLLATE ', COLLATION_NAME) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user' AND COLUMN_NAME = 'id' +); +SET @share_create_sql = CONCAT( + 'CREATE TABLE IF NOT EXISTS `share_request` (', + '`id` VARCHAR(36) NOT NULL PRIMARY KEY,', + '`deleted` TINYINT(1) NOT NULL DEFAULT 0,', + '`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),', + '`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),', + '`sender_id` ', @share_user_column, ' NOT NULL,', + '`request_type` VARCHAR(32) NOT NULL,', + '`request_id` VARCHAR(64) NOT NULL,', + '`request_hash` VARCHAR(64) NOT NULL,', + '`status` VARCHAR(24) NOT NULL,', + '`response_json` MEDIUMTEXT NULL,', + '`error_type` VARCHAR(24) NULL,', + '`error_message` VARCHAR(500) NULL,', + 'UNIQUE KEY `uk_share_request_sender_type_key` (`sender_id`,`request_type`,`request_id`),', + 'KEY `idx_share_request_updated_at` (`updated_at`)', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' +); +PREPARE share_create_statement FROM @share_create_sql; +EXECUTE share_create_statement; +DEALLOCATE PREPARE share_create_statement; + +-- 若 Hibernate 已先建表,CREATE TABLE IF NOT EXISTS 不会补外键,以下语句会按需增加。 +SET @share_fk_exists = ( + SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'share_request' + AND CONSTRAINT_NAME = 'fk_share_request_sender' +); +SET @share_fk_sql = IF(@share_fk_exists > 0, + 'SELECT 1', + 'ALTER TABLE `share_request` ADD CONSTRAINT `fk_share_request_sender` FOREIGN KEY (`sender_id`) REFERENCES `user` (`id`) ON DELETE CASCADE' +); +PREPARE share_fk_statement FROM @share_fk_sql; +EXECUTE share_fk_statement; +DEALLOCATE PREPARE share_fk_statement; + +-- 校验:唯一索引应为 sender_id,request_type,request_id;外键为 fk_share_request_sender。 +SHOW CREATE TABLE `share_request`; +SELECT INDEX_NAME, NON_UNIQUE, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns_in_order +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'share_request' +GROUP BY INDEX_NAME, NON_UNIQUE; +SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME +FROM information_schema.KEY_COLUMN_USAGE +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'share_request' + AND CONSTRAINT_NAME = 'fk_share_request_sender'; diff --git a/campus-server/src/main/resources/db/migration/V2__create_group_announcement.sql b/campus-server/src/main/resources/db/migration/V2__create_group_announcement.sql new file mode 100644 index 0000000..5fda99e --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V2__create_group_announcement.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS group_announcement ( + id VARCHAR(36) NOT NULL, + group_id VARCHAR(36) NOT NULL, + content TEXT NOT NULL, + publisher_id VARCHAR(36) NOT NULL, + publisher_nickname VARCHAR(50) NULL, + publisher_avatar VARCHAR(500) NULL, + published_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + deleted TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_group_announcement_group_id (group_id), + CONSTRAINT fk_group_announcement_group + FOREIGN KEY (group_id) REFERENCES chat_group (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/campus-server/src/main/resources/db/migration/V3__fix_activity_join_duplicates.sql b/campus-server/src/main/resources/db/migration/V3__fix_activity_join_duplicates.sql new file mode 100644 index 0000000..66781f8 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V3__fix_activity_join_duplicates.sql @@ -0,0 +1,133 @@ +-- 修复「组局活动连续点击加入/退出」遗留的脏数据: +-- 1) activity_participant 同一(活动,用户) 存在多行 CONFIRMED(并发重复报名) +-- 2) group_member 同一(群,用户) 存在多行(并发重复加群) +-- 3) activity.current_count 与 chat_group.member_count 漂移 +-- 并补上唯一约束,从数据库层面杜绝再次出现。 +-- +-- 注意:Flyway 当前关闭(spring.flyway.enabled=false),本脚本不会自动执行, +-- 需在目标库手工执行;请先在测试库执行并用文末校验语句确认结果。 +-- 注意:DDL(ALTER TABLE) 会隐式提交,脚本无法整体回滚,务必先做第 0 步备份。 +-- 注意:如果索引已由 Hibernate(ddl-auto=update) 建好,ALTER 会报 Duplicate key name, +-- 该报错可忽略,继续执行后续语句即可。 + +SET SESSION group_concat_max_len = 1000000; + +-- ============ 0. 备份(回滚依据) ============ +CREATE TABLE IF NOT EXISTS activity_participant_bak_20261016 AS SELECT * FROM activity_participant; +CREATE TABLE IF NOT EXISTS group_member_bak_20261016 AS SELECT * FROM group_member; +CREATE TABLE IF NOT EXISTS activity_bak_20261016 AS SELECT * FROM activity; +CREATE TABLE IF NOT EXISTS chat_group_bak_20261016 AS SELECT * FROM chat_group; + +-- ============ 1. activity_participant 去重 ============ +-- 同一 (activity_id, user_id) 只保留一行。 +-- 保留优先级:未删除行 -> CONFIRMED -> PENDING -> CANCELLED -> REJECTED -> REMOVED -> 其它; +-- 同优先级取 joined_at 最早、id 最小的那条。 +DELETE p FROM activity_participant p +JOIN ( + SELECT activity_id, + user_id, + SUBSTRING_INDEX( + GROUP_CONCAT(id ORDER BY deleted, + CASE status + WHEN 'CONFIRMED' THEN 0 + WHEN 'PENDING' THEN 1 + WHEN 'CANCELLED' THEN 2 + WHEN 'REJECTED' THEN 3 + WHEN 'REMOVED' THEN 4 + ELSE 5 + END, + joined_at, id), ',', 1) AS keep_id + FROM activity_participant + GROUP BY activity_id, user_id +) k ON k.activity_id = p.activity_id AND k.user_id = p.user_id +WHERE p.id <> k.keep_id; + +-- ============ 2. group_member 去重 ============ +-- 同一 (group_id, user_id) 只保留一行,优先保留权限最高的角色, +-- 避免误删群主/管理员记录:OWNER -> ADMIN -> MEMBER -> PENDING -> 其它。 +DELETE gm FROM group_member gm +JOIN ( + SELECT group_id, + user_id, + SUBSTRING_INDEX( + GROUP_CONCAT(id ORDER BY deleted, + CASE role + WHEN 'OWNER' THEN 0 + WHEN 'ADMIN' THEN 1 + WHEN 'MEMBER' THEN 2 + WHEN 'PENDING' THEN 3 + ELSE 4 + END, + join_time, id), ',', 1) AS keep_id + FROM group_member + GROUP BY group_id, user_id +) k ON k.group_id = gm.group_id AND k.user_id = gm.user_id +WHERE gm.id <> k.keep_id; + +-- ============ 3. 回算人数 ============ +-- activity.current_count 的口径(与代码保持一致): +-- CONFIRMED 报名行数 + Σ(报名单 quantity - 1),后者覆盖票档一次报多人。 +UPDATE activity a +SET a.current_count = ( + SELECT COUNT(*) + FROM activity_participant p + WHERE p.activity_id = a.id AND p.status = 'CONFIRMED' AND p.deleted = 0 + ) + ( + SELECT COALESCE(SUM(r.quantity - 1), 0) + FROM activity_registration r + WHERE r.activity_id = a.id AND r.status = 'CONFIRMED' AND r.deleted = 0 + ); + +-- 群成员数按成员行数回算。 +UPDATE chat_group g +SET g.member_count = ( + SELECT COUNT(*) + FROM group_member m + WHERE m.group_id = g.id AND m.deleted = 0 + ); + +-- ============ 4. 唯一约束 ============ +ALTER TABLE activity_participant + ADD UNIQUE KEY uk_ap_activity_user (activity_id, user_id); + +ALTER TABLE group_member + ADD UNIQUE KEY uk_gm_group_user (group_id, user_id); + +-- ============ 5. 校验(都应为 0) ============ +-- 5.1 不应再有重复报名组合 +SELECT 'activity_participant_dup' AS chk, COUNT(*) AS should_be_0 FROM ( + SELECT activity_id, user_id FROM activity_participant + GROUP BY activity_id, user_id HAVING COUNT(*) > 1 +) d1; + +-- 5.2 不应再有重复群成员组合 +SELECT 'group_member_dup' AS chk, COUNT(*) AS should_be_0 FROM ( + SELECT group_id, user_id FROM group_member + GROUP BY group_id, user_id HAVING COUNT(*) > 1 +) d2; + +-- 5.3 活动人数应与口径一致 +SELECT 'activity_count_mismatch' AS chk, COUNT(*) AS should_be_0 FROM activity a +WHERE a.deleted = 0 + AND a.current_count <> ( + (SELECT COUNT(*) FROM activity_participant p + WHERE p.activity_id = a.id AND p.status = 'CONFIRMED' AND p.deleted = 0) + + (SELECT COALESCE(SUM(r.quantity - 1), 0) FROM activity_registration r + WHERE r.activity_id = a.id AND r.status = 'CONFIRMED' AND r.deleted = 0) + ); + +-- 5.4 群成员数应与成员行数一致 +SELECT 'group_member_count_mismatch' AS chk, COUNT(*) AS should_be_0 FROM chat_group g +WHERE g.deleted = 0 + AND g.member_count <> ( + SELECT COUNT(*) FROM group_member m WHERE m.group_id = g.id AND m.deleted = 0 + ); + +-- ============ 回滚说明 ============ +-- 如需回滚去重与人数回算(索引需手工 DROP): +-- DROP INDEX uk_ap_activity_user ON activity_participant; +-- DROP INDEX uk_gm_group_user ON group_member; +-- DELETE FROM activity_participant; INSERT INTO activity_participant SELECT * FROM activity_participant_bak_20261016; +-- DELETE FROM group_member; INSERT INTO group_member SELECT * FROM group_member_bak_20261016; +-- UPDATE activity a JOIN activity_bak_20261016 b ON b.id = a.id SET a.current_count = b.current_count; +-- UPDATE chat_group g JOIN chat_group_bak_20261016 b ON b.id = g.id SET g.member_count = b.member_count; diff --git a/campus-server/src/main/resources/db/migration/V4__add_cleared_seq_id_to_chat_conversation.sql b/campus-server/src/main/resources/db/migration/V4__add_cleared_seq_id_to_chat_conversation.sql new file mode 100644 index 0000000..aaf56a1 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V4__add_cleared_seq_id_to_chat_conversation.sql @@ -0,0 +1,39 @@ +-- 「清空聊天记录」按用户隔离(方案 A)—— 为 chat_conversation 增加清空水位线字段。 +-- +-- 背景:原 clearConversationMessages 执行 DELETE FROM chat_message WHERE conversation_id = ?, +-- 而 chat_message 一份记录由私聊双方 / 全群共用(私聊 conversation_id = id1_id2、群聊 = 群ID), +-- 且该表没有 user_id 维度,导致「一个人清空 → 所有人记录消失」且物理删除不可恢复。 +-- +-- 改法:把「我清空到哪条消息」记录在 chat_conversation(本来就是每人一行)上,清空不再删除共享消息。 +-- +-- 语义:cleared_seq_id = N 表示 seq_id <= N 的消息对该用户不可见(只影响本人,不影响其他参与者)。 +-- 默认 0 = 从未清空,与升级前行为完全等价,因此历史数据无需回填。 +-- +-- 注意:Flyway 当前关闭(spring.flyway.enabled=false),本脚本不会自动执行,需在目标库手工执行; +-- 请先在测试库执行并用文末校验语句确认结果。 +-- 注意:DDL(ALTER TABLE) 会隐式提交,脚本无法整体回滚,务必先做第 0 步备份。 +-- 注意:chat_conversation 由 Hibernate(ddl-auto=update) 自动建表,应用启动时可能已自动加上该列, +-- 此时 ALTER 会报 Duplicate column name,该报错可忽略,继续执行后续校验语句即可。 +-- 注意:本脚本只加字段,不做数据变更,因此不会影响任何用户已可见的消息。 + +-- ============ 0. 备份(回滚依据) ============ +CREATE TABLE IF NOT EXISTS chat_conversation_bak_v4_cleared AS SELECT * FROM chat_conversation; + +-- ============ 1. 增加水位线字段 ============ +ALTER TABLE chat_conversation + ADD COLUMN cleared_seq_id BIGINT NOT NULL DEFAULT 0 + COMMENT '该用户在此会话中已清空到的消息水位线:seq_id <= 该值的消息对该用户不可见'; + +-- ============ 2. 校验(都应为 0) ============ +-- 2.1 不应有 NULL(新增列未正确回填默认值) +SELECT 'chat_conversation_cleared_null' AS chk, COUNT(*) AS should_be_0 +FROM chat_conversation WHERE cleared_seq_id IS NULL; + +-- 2.2 水位线不应超过该会话当前最大 seq_id(超过说明水位线被写脏,会导致该用户看不到新消息) +SELECT 'chat_conversation_cleared_ahead' AS chk, COUNT(*) AS should_be_0 +FROM chat_conversation c +WHERE c.cleared_seq_id > COALESCE( + (SELECT MAX(m.seq_id) FROM chat_message m WHERE m.conversation_id = c.peer_id), 0); + +-- ============ 回滚说明 ============ +-- ALTER TABLE chat_conversation DROP COLUMN cleared_seq_id; diff --git a/campus-server/src/main/resources/db/migration/V5__dedup_chat_conversation_and_add_unique_key.sql b/campus-server/src/main/resources/db/migration/V5__dedup_chat_conversation_and_add_unique_key.sql new file mode 100644 index 0000000..635e731 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V5__dedup_chat_conversation_and_add_unique_key.sql @@ -0,0 +1,74 @@ +-- 「清空聊天记录」方案 A 的前置加固:chat_conversation 的 (user_id, peer_id) 唯一约束。 +-- +-- 依赖:请先执行 V4__add_cleared_seq_id_to_chat_conversation.sql(本脚本会读写 cleared_seq_id)。 +-- +-- 为什么必须做:水位线 cleared_seq_id 存在 chat_conversation 的「每人每会话一行」上。 +-- 若同一 (user_id, peer_id) 存在多行(并发插入会话行所致:updateConversationLastMessage 用的是 +-- findByUserIdAndPeerId(...).orElseGet(新建),并发下两边都可能新建), +-- 则水位线会有多份、读到哪一份不确定 → 会出现「清空后仍然看得见」或「误清空别的会话」。 +-- +-- 注意:Flyway 当前关闭(spring.flyway.enabled=false),本脚本不会自动执行,需在目标库手工执行; +-- 请先在测试库执行并用文末校验语句确认结果。 +-- 注意:本脚本含 DELETE 与 DDL,执行前务必先做第 0 步备份。 +-- 注意:如果索引已由 Hibernate(ddl-auto=update) 建好,ALTER 会报 Duplicate key name,可忽略。 +-- 注意:若第 4 节校验 4.1 在执行前就不为 0,请先人工确认重复行的保留策略再执行第 1、2 节。 + +SET SESSION group_concat_max_len = 1000000; + +-- ============ 0. 备份(回滚依据) ============ +CREATE TABLE IF NOT EXISTS chat_conversation_bak_v5_dedup AS SELECT * FROM chat_conversation; + +-- ============ 1. 先把重复行的水位线合并到「保留行」(关键:不能丢掉已清空语义) ============ +-- 保留规则与第 2 节一致:未删除行 -> last_message_time 最新 -> id 最小。 +-- 若不加这一步就直接删重复行,被删行上的 cleared_seq_id 会丢失,用户会「清空后历史又回来了」。 +UPDATE chat_conversation c +JOIN ( + SELECT user_id, peer_id, MAX(cleared_seq_id) AS max_cleared + FROM chat_conversation + GROUP BY user_id, peer_id + HAVING COUNT(*) > 1 +) g ON g.user_id = c.user_id AND g.peer_id = c.peer_id +JOIN ( + SELECT user_id, peer_id, + SUBSTRING_INDEX( + GROUP_CONCAT(id ORDER BY deleted, last_message_time DESC, id), ',', 1) AS keep_id + FROM chat_conversation + GROUP BY user_id, peer_id + HAVING COUNT(*) > 1 +) k ON k.user_id = c.user_id AND k.peer_id = c.peer_id +SET c.cleared_seq_id = g.max_cleared +WHERE c.id = k.keep_id; + +-- ============ 2. 去重 ============ +-- 同一 (user_id, peer_id) 只保留一行。 +DELETE c FROM chat_conversation c +JOIN ( + SELECT user_id, peer_id, + SUBSTRING_INDEX( + GROUP_CONCAT(id ORDER BY deleted, last_message_time DESC, id), ',', 1) AS keep_id + FROM chat_conversation + GROUP BY user_id, peer_id +) k ON k.user_id = c.user_id AND k.peer_id = c.peer_id +WHERE c.id <> k.keep_id; + +-- ============ 3. 唯一约束 ============ +ALTER TABLE chat_conversation + ADD UNIQUE KEY uk_cc_user_peer (user_id, peer_id); + +-- ============ 4. 校验(都应为 0) ============ +-- 4.1 不应再有重复会话组合 +SELECT 'chat_conversation_dup' AS chk, COUNT(*) AS should_be_0 FROM ( + SELECT user_id, peer_id FROM chat_conversation + GROUP BY user_id, peer_id HAVING COUNT(*) > 1 +) d; + +-- 4.2 水位线不应为 NULL +SELECT 'chat_conversation_cleared_null' AS chk, COUNT(*) AS should_be_0 +FROM chat_conversation WHERE cleared_seq_id IS NULL; + +-- 4.3 去重前后总行数对比(人工核对:差值 = 被清理的重复行数) +SELECT 'chat_conversation_rows_after' AS chk, COUNT(*) AS rows_now FROM chat_conversation; + +-- ============ 回滚说明 ============ +-- DROP INDEX uk_cc_user_peer ON chat_conversation; +-- DELETE FROM chat_conversation; INSERT INTO chat_conversation SELECT * FROM chat_conversation_bak_v5_dedup; diff --git a/campus-server/src/main/resources/db/migration/V6__mark_activity_groups.sql b/campus-server/src/main/resources/db/migration/V6__mark_activity_groups.sql new file mode 100644 index 0000000..6532e9c --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V6__mark_activity_groups.sql @@ -0,0 +1,67 @@ +-- 为「群来源」补字段与历史数据:修复「未报名用户可加入活动群聊」的前置数据准备。 +-- 1) chat_group 增加 group_source(NORMAL/ACTIVITY)与 activity_id(活动群所属活动) +-- 2) 回填历史活动群:group_source='ACTIVITY'、activity_id=活动ID +-- 3) 为 activity_id 建索引 +-- +-- 背景:活动群由 ActivityService.createActivityGroup 创建,is_approval_required=false。 +-- 改动前 GroupService.joinGroup 只判断 is_approval_required,导致任何人凭群ID即可入群; +-- 修复后依据 group_source/activity_id 校验「活动创建者或已报名(CONFIRMED)用户」。 +-- +-- 列名说明:Java 实体字段为 source,映射到 group_source,刻意避开 SQL 关键字 source。 +-- +-- 注意:Flyway 当前关闭(spring.flyway.enabled=false),本脚本不会自动执行,需在目标库手工执行。 +-- 注意:本脚本必须在后端新代码上线【之前或同时】执行;若只发代码不跑回填, +-- 历史活动群 group_source 仍是 NORMAL,只能靠 ActivityRepository 反查兜底(新活动群不受影响)。 +-- 注意:DDL(ALTER TABLE) 会隐式提交,脚本无法整体回滚,务必先做第 0 步备份。 +-- 注意:若 chat_group.group_source 列已由 Hibernate(ddl-auto=update) 建好,ALTER 会报 +-- Duplicate column name,该报错可忽略,继续执行后续语句即可。 +-- 注意:本脚本【不清理】任何 group_member 记录。历史上被放行进群的未报名用户仍留在群内, +-- 如需清理须单独评审(环信侧需调 removeGroupMember,SQL 删不掉),见方案文档 §6。 + +-- ============ 0. 备份(回滚依据) ============ +CREATE TABLE IF NOT EXISTS chat_group_bak_v4 AS SELECT * FROM chat_group; + +-- ============ 1. 加列 ============ +-- 已有行的 group_source 补为 NORMAL,避免 NOT NULL 列加到非空表时使用隐式默认值。 +ALTER TABLE chat_group + ADD COLUMN group_source VARCHAR(20) NOT NULL DEFAULT 'NORMAL', + ADD COLUMN activity_id VARCHAR(36) NULL; + +-- ============ 2. 回填历史活动群 ============ +-- 必须用原生 JOIN(不能经 @Where(deleted=false) 的查询),否则软删活动的群会被漏掉: +-- 漏标记的群在修复后会被当作普通群,重新放行未报名用户。 +UPDATE chat_group g +JOIN activity a ON a.group_id = g.id +SET g.group_source = 'ACTIVITY', g.activity_id = a.id +WHERE a.group_id IS NOT NULL; + +-- ============ 3. 索引 ============ +-- 供「标记缺失时按 activity.group_id 反查」与排障使用。 +CREATE INDEX idx_chat_group_activity ON chat_group (activity_id); + +-- ============ 4. 校验语句(期望结果:0) ============ +-- 4.1 应标记为 ACTIVITY 但未被标记的群(回填遗漏) +SELECT COUNT(*) AS unmigrated_activity_groups +FROM activity a JOIN chat_group g ON g.id = a.group_id +WHERE g.group_source IS NULL OR g.group_source <> 'ACTIVITY'; + +-- 4.2 被标记为 ACTIVITY 却没有关联活动ID的群(脏数据,会导致入群一律被拒) +SELECT COUNT(*) AS activity_groups_without_activity_id +FROM chat_group WHERE group_source = 'ACTIVITY' AND (activity_id IS NULL OR activity_id = ''); + +-- 4.3 存量排查(只读,供评估影响面): +-- 在活动群内、非群主、且没有 CONFIRMED 报名记录的用户 —— 这些是历史漏洞放进来的成员。 +-- 本脚本不处理它们;确需清理请走单独评审的清理流程。 +SELECT g.id AS group_id, g.name AS group_name, + a.id AS activity_id, a.title, a.status AS activity_status, a.deleted AS activity_deleted, + gm.user_id, u.nickname, gm.role, gm.join_time +FROM chat_group g +JOIN activity a ON a.group_id = g.id +JOIN group_member gm ON gm.group_id = g.id +LEFT JOIN activity_participant p + ON p.activity_id = a.id AND p.user_id = gm.user_id + AND p.status = 'CONFIRMED' AND p.deleted = false +LEFT JOIN user u ON u.id = gm.user_id +WHERE gm.role <> 'OWNER' + AND p.id IS NULL +ORDER BY a.id, gm.join_time; diff --git a/campus-server/src/main/resources/db/migration/V7__add_apply_count_to_activity_participant.sql b/campus-server/src/main/resources/db/migration/V7__add_apply_count_to_activity_participant.sql new file mode 100644 index 0000000..efafc92 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V7__add_apply_count_to_activity_participant.sql @@ -0,0 +1,49 @@ +-- 活动报名审核(全局审核)—— 为 activity_participant 增加报名申请次数字段。 +-- +-- 背景:原「立即报名」= 直接写 CONFIRMED,没有审核环节;本次改为所有活动统一走报名申请审核: +-- 先写 PENDING → 活动创建者在活动管理页「待审核」tab 通过 / 拒绝 → 报名者收到通知。 +-- 「被拒后仍可重新申请,但必须限制次数」(需求方决策),因此需要把申请次数持久化。 +-- +-- 语义:apply_count = 该用户在这个活动上累计提交过多少次报名申请(含被拒、撤回后重提)。 +-- 通过 / 拒绝 / 撤回都不清零,否则可以靠反复撤回绕过上限。 +-- 上限由 app.activity.max-apply-count 配置(默认 3),超出返回 400「申请次数已达上限,无法再次申请」。 +-- 默认 1 = 已提交过 1 次,与升级前「一行即一次报名」的历史数据等价,因此历史数据只需保证不为 0/NULL。 +-- +-- 注意:Flyway 当前关闭(spring.flyway.enabled=false),本脚本不会自动执行,需在目标库手工执行; +-- 请先在测试库执行并用文末校验语句确认结果。 +-- 注意:DDL(ALTER TABLE) 会隐式提交,脚本无法整体回滚,务必先做第 0 步备份。 +-- 注意:activity_participant 由 Hibernate(ddl-auto=update) 自动维护,应用启动时可能已自动加上该列, +-- 此时 ALTER 会报 Duplicate column name,该报错可忽略,继续执行第 2、3 步即可。 +-- 注意:activity_participant.status 已支持 PENDING/CONFIRMED/REJECTED/REMOVED/CANCELLED(列长 20), +-- 唯一约束 uk_ap_activity_user 也已由 V3 加固,本脚本不需要改这两处。 + +-- ============ 0. 备份(回滚依据) ============ +CREATE TABLE IF NOT EXISTS activity_participant_bak_v7_apply_count AS SELECT * FROM activity_participant; + +-- ============ 1. 增加申请次数字段 ============ +ALTER TABLE activity_participant + ADD COLUMN apply_count INT NOT NULL DEFAULT 1 + COMMENT '该用户在该活动上累计提交的报名申请次数(含被拒/撤回后重提),用于限制反复申请'; + +-- ============ 2. 历史数据回填 ============ +-- 存量行按「至少申请过 1 次」处理:既不能是 NULL,也不能是 0,否则等于额外多送一次申请机会。 +UPDATE activity_participant SET apply_count = 1 WHERE apply_count IS NULL OR apply_count < 1; + +-- ============ 3. 校验(都应为 0) ============ +-- 3.1 不应有 NULL(新增列未正确回填默认值) +SELECT 'activity_participant_apply_count_null' AS chk, COUNT(*) AS should_be_0 +FROM activity_participant WHERE apply_count IS NULL; + +-- 3.2 不应有 0(存量报名记录必须至少算 1 次申请) +SELECT 'activity_participant_apply_count_zero' AS chk, COUNT(*) AS should_be_0 +FROM activity_participant WHERE apply_count < 1; + +-- 3.3 上限校验自查:同一(活动,用户)只应有一行(唯一约束是否生效) +SELECT 'activity_participant_dup' AS chk, COUNT(*) AS should_be_0 FROM ( + SELECT activity_id, user_id FROM activity_participant + GROUP BY activity_id, user_id HAVING COUNT(*) > 1 +) dup; + +-- ============ 回滚说明 ============ +-- ALTER TABLE activity_participant DROP COLUMN apply_count; +-- 需要恢复数据时:DELETE FROM activity_participant; INSERT INTO activity_participant SELECT * FROM activity_participant_bak_v7_apply_count; diff --git a/campus-server/src/main/resources/db/migration/V8__cleanup_system_seed_topics.sql b/campus-server/src/main/resources/db/migration/V8__cleanup_system_seed_topics.sql new file mode 100644 index 0000000..d0d80c3 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V8__cleanup_system_seed_topics.sql @@ -0,0 +1,101 @@ +-- ============================================================================ +-- V8:清理 SYSTEM 灌入的空壳话题(话题广场数据治理) +-- +-- ⚠️ 重要前提:先停掉外部灌数据任务! +-- 生产库 topic 表在两个时间点出现过 SYSTEM 数据: +-- 2026-09-28 12:05:00.465~.639(6 条英文标签) +-- 2026-09-30 17:05:00.395/.519(2 条中文标签) +-- 两批都精确落在「每小时第 5 分 00 秒」,且第二批只补了新增标签, +-- 说明存在一个"每小时 5 分执行、缺则补"的外部任务(直接写库,不走 API)。 +-- 只清理不停用该任务,下一个整点 5 分数据会被重新灌回。 +-- 定位方法见「方案分析与实现(md)文件/实现方案-话题广场数据治理.md」§1.10。 +-- +-- ⚠️ Flyway 当前 enabled: false,本脚本不会自动执行,必须人工在目标库执行。 +-- ⚠️ 备份段是 DDL(CREATE TABLE ... AS SELECT,隐式提交),必须单独先执行并确认成功, +-- 再执行清理段(清理段全是 DML,可包在事务里)。 +-- +-- 为什么必须物理删除而不是软删除: +-- topic 有唯一键 uk_topic_name,实体带 @Where(deleted = false)。 +-- 若置 deleted = 1,findByName 查不到该名字,但唯一键仍被占用, +-- 之后再创建同名话题会直接撞唯一键(500),名字被永久锁死。 +-- 因此这里使用 DELETE。 +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 第一步:备份(单独执行,确认成功后继续) +-- --------------------------------------------------------------------------- +CREATE TABLE topic_bak_20261020 AS SELECT * FROM topic; +CREATE TABLE topic_follow_bak_20261020 AS SELECT * FROM topic_follow; +CREATE TABLE topic_visit_bak_20261020 AS SELECT * FROM topic_visit; +CREATE TABLE topic_alias_bak_20261020 AS SELECT * FROM topic_alias; +CREATE TABLE topic_hot_snapshot_bak_20261020 AS SELECT * FROM topic_hot_snapshot; + +-- --------------------------------------------------------------------------- +-- 第二步:影响面统计(把结果抄到工单/记录里,作为"删了多少"的证据) +-- 这些关注/访问记录会被一并删除,必须留档以便回答用户疑问。 +-- --------------------------------------------------------------------------- +SELECT 'topics' AS affected_table, COUNT(*) AS affected_rows +FROM topic WHERE created_by = 'SYSTEM' AND description IS NULL AND deleted = 0 +UNION ALL +SELECT 'topic_follow', COUNT(*) +FROM topic_follow f JOIN topic t ON t.name = f.topic_name +WHERE t.created_by = 'SYSTEM' AND t.description IS NULL AND t.deleted = 0 +UNION ALL +SELECT 'topic_visit', COUNT(*) +FROM topic_visit v JOIN topic t ON t.name = v.topic_name +WHERE t.created_by = 'SYSTEM' AND t.description IS NULL AND t.deleted = 0 +UNION ALL +SELECT 'topic_hot_snapshot', COUNT(*) +FROM topic_hot_snapshot s JOIN topic t ON t.name = s.topic_name +WHERE t.created_by = 'SYSTEM' AND t.description IS NULL AND t.deleted = 0 +UNION ALL +SELECT 'topic_alias', COUNT(*) +FROM topic_alias a JOIN topic t ON t.id = a.topic_id +WHERE t.created_by = 'SYSTEM' AND t.description IS NULL AND t.deleted = 0; + +-- --------------------------------------------------------------------------- +-- 第三步:清理关联数据(可包事务) +-- 关联方式:topic_follow / topic_visit / topic_hot_snapshot 按 name, +-- topic_alias 按 topic_id。 +-- --------------------------------------------------------------------------- +START TRANSACTION; + +DELETE f FROM topic_follow f +JOIN topic t ON t.name = f.topic_name +WHERE t.created_by = 'SYSTEM' AND t.description IS NULL AND t.deleted = 0; + +DELETE v FROM topic_visit v +JOIN topic t ON t.name = v.topic_name +WHERE t.created_by = 'SYSTEM' AND t.description IS NULL AND t.deleted = 0; + +DELETE s FROM topic_hot_snapshot s +JOIN topic t ON t.name = s.topic_name +WHERE t.created_by = 'SYSTEM' AND t.description IS NULL AND t.deleted = 0; + +DELETE a FROM topic_alias a +JOIN topic t ON t.id = a.topic_id +WHERE t.created_by = 'SYSTEM' AND t.description IS NULL AND t.deleted = 0; + +-- 话题本体(物理删除,释放 uk_topic_name) +DELETE FROM topic +WHERE created_by = 'SYSTEM' AND description IS NULL AND deleted = 0; + +COMMIT; + +-- --------------------------------------------------------------------------- +-- 第四步:校验(三条都必须为 0) +-- --------------------------------------------------------------------------- +SELECT COUNT(*) AS must_be_zero FROM topic +WHERE created_by = 'SYSTEM' AND description IS NULL; + +SELECT COUNT(*) AS orphan_follows FROM topic_follow f +WHERE NOT EXISTS (SELECT 1 FROM topic t WHERE t.name = f.topic_name); + +SELECT COUNT(*) AS orphan_visits FROM topic_visit v +WHERE NOT EXISTS (SELECT 1 FROM topic t WHERE t.name = v.topic_name); + +-- --------------------------------------------------------------------------- +-- 第五步(可选):清理后立刻创建 10~20 个正式话题 +-- 推荐用管理员账号调 POST /api/topics(管理员创建直通 APPROVED), +-- 不要用本脚本插数据,避免绕过校验、又产生新的空壳。 +-- --------------------------------------------------------------------------- diff --git a/campus-server/src/main/resources/db/migration/V9__decouple_topic_heat_from_moment.sql b/campus-server/src/main/resources/db/migration/V9__decouple_topic_heat_from_moment.sql new file mode 100644 index 0000000..87e23c6 --- /dev/null +++ b/campus-server/src/main/resources/db/migration/V9__decouple_topic_heat_from_moment.sql @@ -0,0 +1,59 @@ +-- ============================================================================ +-- V9:话题热度口径切换(话题域指标),清理按旧口径产生的快照 +-- +-- 背景:话题热度原先由动态互动(点赞/评论/分享/浏览)聚合而来,解耦后 +-- 改为纯话题域指标:关注数、访问人数、近 7 天关注/访问增长、推荐/置顶加权。 +-- 若保留旧快照,新热度与旧快照算 delta 会得到巨大偏差(rising 榜失真)。 +-- +-- ⚠️ Flyway 当前 enabled: false,本脚本不会自动执行,必须人工在目标库执行。 +-- ⚠️ 执行顺序:先 V8(清空壳话题),再 V9(清快照)。 +-- ⚠️ 本脚本包含 DDL(CREATE TABLE ... AS SELECT),会隐式提交,无法整体回滚。 +-- ⚠️ 部分客户端默认「批处理模式:遇错继续执行」。这样即使备份失败,后面的 DELETE +-- 仍然会跑。执行前请确认备份语句真的成功(本脚本已改为先备份、再校验备份行数, +-- 校验不通过请立刻停止,不要执行 DELETE)。 +-- +-- 修订记录:2026-10-20 备份表由 topic_hot_snapshot_bak_20261020 改名为 +-- topic_hot_snapshot_bak_v9_20261020。原名字与 V8 撞名,导致 +-- 「Table already exists」而备份失败(V8 已建过同名表)。 +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 第一步:备份(单独执行,必须确认成功) +-- --------------------------------------------------------------------------- +CREATE TABLE topic_hot_snapshot_bak_v9_20261020 AS SELECT * FROM topic_hot_snapshot; + +-- --------------------------------------------------------------------------- +-- 第二步:校验备份(行数必须与备份时一致;为 0 也正常,说明当时快照表是空的) +-- 如果这条报错或行数对不上,请立刻停止,不要执行第三步。 +-- --------------------------------------------------------------------------- +SELECT COUNT(*) AS backup_rows FROM topic_hot_snapshot_bak_v9_20261020; + +-- --------------------------------------------------------------------------- +-- 第三步:清空旧口径快照(旧数据按"动态数/动态互动"计算,不能与新口径混算) +-- 说明:快照是每小时重建的派生数据,清空不影响任何业务数据。 +-- --------------------------------------------------------------------------- +DELETE FROM topic_hot_snapshot; + +-- --------------------------------------------------------------------------- +-- 第四步:快照表新增话题域指标列 visit_count +-- 说明:应用启动时 Hibernate(ddl-auto=update)通常会自动补列; +-- 若不想依赖自动建表,请手工执行下面这行(已幂等化检查)。 +-- +-- 注意:moment_count 列保留但已废弃,代码固定写 0。 +-- 不 DROP 列的原因:该列 NOT NULL,DROP 属有风险 DDL,而本环境 +-- Flyway 关闭、迁移需人工执行,保留一列"永远是 0"更安全。 +-- --------------------------------------------------------------------------- +-- ALTER TABLE topic_hot_snapshot ADD COLUMN visit_count BIGINT NOT NULL DEFAULT 0; + +-- --------------------------------------------------------------------------- +-- 第五步:校验 +-- --------------------------------------------------------------------------- +SELECT COUNT(*) AS snapshots_after_cleanup FROM topic_hot_snapshot; -- 应为 0 +SHOW COLUMNS FROM topic_hot_snapshot LIKE 'visit_count'; -- 应存在 +SELECT COUNT(*) AS moment_count_column_kept FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'topic_hot_snapshot' + AND column_name = 'moment_count'; -- 应为 1 + +-- 回滚(如需): +-- INSERT INTO topic_hot_snapshot SELECT * FROM topic_hot_snapshot_bak_v9_20261020; +-- 若当时 V8 也备份过(topic_hot_snapshot_bak_20261020),那两个备份都可作为恢复源。 diff --git a/campus-server/src/test/java/com/campus/config/AccountDeactivationFilterTest.java b/campus-server/src/test/java/com/campus/config/AccountDeactivationFilterTest.java new file mode 100644 index 0000000..ad471de --- /dev/null +++ b/campus-server/src/test/java/com/campus/config/AccountDeactivationFilterTest.java @@ -0,0 +1,30 @@ +package com.campus.config; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import com.campus.service.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.mock.web.*; +import org.springframework.data.redis.core.*; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; +class AccountDeactivationFilterTest { + @AfterEach void clear(){org.springframework.security.core.context.SecurityContextHolder.clearContext();} + @Test void coolingAccountCannotAccessBusinessButCanRecover() throws Exception { + JwtConfig config=mock(JwtConfig.class);UserRepository users=mock(UserRepository.class); + RedisTemplate redis=mock(RedisTemplate.class);ValueOperations values=mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values);when(values.get("user:token:token")).thenReturn("u"); + when(config.extractUserId("token")).thenReturn("u");when(config.extractPhone("token")).thenReturn("19900000000"); + User user=new User();user.setId("u");user.setPhone("19900000000");user.setAccountStatus("DEACTIVATING");user.setScheduledDeletionAt(new Date(System.currentTimeMillis()+86400000)); + when(users.findById("u")).thenReturn(Optional.of(user)); + JwtAuthenticationFilter filter=new JwtAuthenticationFilter(config,users,redis,new ObjectMapper(),mock(UserRestrictionService.class),new RedisRetryExecutor(),mock(UserSessionService.class)); + MockHttpServletRequest request=new MockHttpServletRequest("GET","/api/campus/moments");request.setServletPath("/api/campus/moments");request.addHeader("access_token","token"); + MockHttpServletResponse response=new MockHttpServletResponse();filter.doFilter(request,response,new MockFilterChain()); + assertEquals(403,response.getStatus());assertTrue(response.getContentAsString().contains("账号处于注销冷静期,请先恢复账号")); + MockHttpServletRequest recovery=new MockHttpServletRequest("POST","/api/users/recover");recovery.setServletPath("/api/users/recover");recovery.addHeader("access_token","token"); + MockHttpServletResponse success=new MockHttpServletResponse();MockFilterChain chain=new MockFilterChain();filter.doFilter(recovery,success,chain); + assertNotNull(chain.getRequest());assertEquals(200,success.getStatus()); + } +} diff --git a/campus-server/src/test/java/com/campus/config/AchievementInitializerTest.java b/campus-server/src/test/java/com/campus/config/AchievementInitializerTest.java new file mode 100644 index 0000000..57f3e6f --- /dev/null +++ b/campus-server/src/test/java/com/campus/config/AchievementInitializerTest.java @@ -0,0 +1,9 @@ +package com.campus.config; +import com.campus.entity.AchievementDefinition; +import com.campus.repository.AchievementDefinitionRepository; +import org.junit.jupiter.api.Test; +import java.util.Optional; +import static org.mockito.Mockito.*; +class AchievementInitializerTest { + @Test void onlyFillsMissingCodesIncludingDeletedRows(){AchievementDefinitionRepository repo=mock(AchievementDefinitionRepository.class);when(repo.findIncludingDeleted("CHECKIN_EXPERT")).thenReturn(Optional.of(new AchievementDefinition()));new AchievementInitializer(repo).run();verify(repo,times(7)).save(any());verify(repo,never()).save(argThat(d->"CHECKIN_EXPERT".equals(d.getCode())));} +} diff --git a/campus-server/src/test/java/com/campus/config/AuthDataIntegrityInitializerTest.java b/campus-server/src/test/java/com/campus/config/AuthDataIntegrityInitializerTest.java new file mode 100644 index 0000000..0f37437 --- /dev/null +++ b/campus-server/src/test/java/com/campus/config/AuthDataIntegrityInitializerTest.java @@ -0,0 +1,42 @@ +package com.campus.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.ApplicationArguments; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthDataIntegrityInitializerTest { + + @Test + void refusesToStartWhenDuplicatePhonesExist() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.queryForObject(contains("HAVING COUNT(*) > 1"), eq(Integer.class))) + .thenReturn(1); + + AuthDataIntegrityInitializer initializer = new AuthDataIntegrityInitializer(jdbcTemplate); + + assertThrows(IllegalStateException.class, () -> initializer.run(mock(ApplicationArguments.class))); + verify(jdbcTemplate, never()).execute(contains("ADD CONSTRAINT")); + } + + @Test + void createsUniquePhoneIndexWhenMissing() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.queryForObject(contains("HAVING COUNT(*) > 1"), eq(Integer.class))) + .thenReturn(0); + when(jdbcTemplate.queryForObject(contains("information_schema.statistics"), eq(Integer.class))) + .thenReturn(0); + + AuthDataIntegrityInitializer initializer = new AuthDataIntegrityInitializer(jdbcTemplate); + initializer.run(mock(ApplicationArguments.class)); + + verify(jdbcTemplate).execute("ALTER TABLE `user` ADD CONSTRAINT `uk_user_phone` UNIQUE (`phone`)"); + } +} diff --git a/campus-server/src/test/java/com/campus/config/EasemobGroupInitializerTest.java b/campus-server/src/test/java/com/campus/config/EasemobGroupInitializerTest.java new file mode 100644 index 0000000..f41301f --- /dev/null +++ b/campus-server/src/test/java/com/campus/config/EasemobGroupInitializerTest.java @@ -0,0 +1,79 @@ +package com.campus.config; + +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.service.EasemobService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EasemobGroupInitializerTest { + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private EasemobService easemobService; + @InjectMocks private EasemobGroupInitializer initializer; + + @Test + void registersActiveUsersBeforeCreatingLegacyGroup() { + Group group = new Group(); + group.setId("group"); + group.setName("Legacy"); + group.setOwnerId("owner"); + when(groupRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(group))); + when(groupMemberRepository.findByGroupId("group")) + .thenReturn(Arrays.asList(member("owner", "OWNER"), member("member", "MEMBER"), + member("applicant", "PENDING"))); + when(easemobService.registerUser("owner", "campus_owner")).thenReturn(true); + when(easemobService.registerUser("member", "campus_member")).thenReturn(true); + when(easemobService.createGroup("Legacy", "", "owner", Collections.singletonList("member"))) + .thenReturn("em-group"); + + initializer.run(); + + assertEquals("em-group", group.getEasemobGroupId()); + verify(groupRepository).save(group); + verify(easemobService, never()).registerUser(eq("applicant"), any()); + InOrder order = inOrder(easemobService); + order.verify(easemobService).registerUser("owner", "campus_owner"); + order.verify(easemobService).registerUser("member", "campus_member"); + order.verify(easemobService).createGroup("Legacy", "", "owner", Collections.singletonList("member")); + } + + @Test + void registrationFailureDoesNotCreateIncompleteGroup() { + Group group = new Group(); + group.setId("group"); + group.setOwnerId("owner"); + when(groupRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(group))); + when(groupMemberRepository.findByGroupId("group")).thenReturn(Collections.emptyList()); + + initializer.run(); + + verify(easemobService, never()).createGroup(any(), any(), any(), any()); + verify(groupRepository, never()).save(group); + } + + private GroupMember member(String userId, String role) { + GroupMember member = new GroupMember(); + member.setUserId(userId); + member.setRole(role); + return member; + } +} diff --git a/campus-server/src/test/java/com/campus/config/JwtAuthenticationFilterTest.java b/campus-server/src/test/java/com/campus/config/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..664fbe1 --- /dev/null +++ b/campus-server/src/test/java/com/campus/config/JwtAuthenticationFilterTest.java @@ -0,0 +1,97 @@ +package com.campus.config; + +import com.campus.repository.UserRepository; +import com.campus.entity.User; +import com.campus.service.UserRestrictionService; +import com.campus.service.RedisRetryExecutor; +import com.campus.service.UserSessionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JwtAuthenticationFilterTest { + + @Test + @SuppressWarnings("unchecked") + void tokenForDeletedUserCannotAuthenticateAReRegisteredPhoneAccount() throws Exception { + JwtConfig jwtConfig = mock(JwtConfig.class); + UserRepository userRepository = mock(UserRepository.class); + RedisTemplate redisTemplate = mock(RedisTemplate.class); + ValueOperations valueOperations = mock(ValueOperations.class); + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + StringWriter body = new StringWriter(); + + when(request.getHeader("access_token")).thenReturn("old-token"); + when(jwtConfig.isTokenExpired("old-token")).thenReturn(false); + when(jwtConfig.extractUserId("old-token")).thenReturn("deleted-user-id"); + when(jwtConfig.extractPhone("old-token")).thenReturn("18800000000"); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("user:token:old-token")).thenReturn("deleted-user-id"); + when(userRepository.findById("deleted-user-id")).thenReturn(Optional.empty()); + when(response.getWriter()).thenReturn(new PrintWriter(body)); + + JwtAuthenticationFilter filter = new JwtAuthenticationFilter( + jwtConfig, userRepository, redisTemplate, new ObjectMapper(), mock(UserRestrictionService.class), + passThroughRetryExecutor(), mock(UserSessionService.class)); + filter.doFilterInternal(request, response, chain); + + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(chain, never()).doFilter(request, response); + assertEquals(true, body.toString().contains("token已失效")); + } + + @Test + @SuppressWarnings("unchecked") + void disabledUserSessionCannotAuthenticate() throws Exception { + JwtConfig jwtConfig = mock(JwtConfig.class); + UserRepository userRepository = mock(UserRepository.class); + RedisTemplate redisTemplate = mock(RedisTemplate.class); + ValueOperations valueOperations = mock(ValueOperations.class); + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + StringWriter body = new StringWriter(); + User disabled = new User(); + disabled.setId("disabled-id"); + disabled.setPhone("18800000000"); + disabled.setDeleted(false); + disabled.setAccountStatus("DISABLED"); + + when(request.getHeader("access_token")).thenReturn("disabled-token"); + when(jwtConfig.isTokenExpired("disabled-token")).thenReturn(false); + when(jwtConfig.extractUserId("disabled-token")).thenReturn("disabled-id"); + when(jwtConfig.extractPhone("disabled-token")).thenReturn("18800000000"); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("user:token:disabled-token")).thenReturn("disabled-id"); + when(userRepository.findById("disabled-id")).thenReturn(Optional.of(disabled)); + when(response.getWriter()).thenReturn(new PrintWriter(body)); + + JwtAuthenticationFilter filter = new JwtAuthenticationFilter( + jwtConfig, userRepository, redisTemplate, new ObjectMapper(), mock(UserRestrictionService.class), + passThroughRetryExecutor(), mock(UserSessionService.class)); + filter.doFilterInternal(request, response, chain); + + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(chain, never()).doFilter(request, response); + } + + private RedisRetryExecutor passThroughRetryExecutor() { + return new RedisRetryExecutor(); + } +} diff --git a/campus-server/src/test/java/com/campus/config/RedisCacheFallbackTest.java b/campus-server/src/test/java/com/campus/config/RedisCacheFallbackTest.java new file mode 100644 index 0000000..dc0d92c --- /dev/null +++ b/campus-server/src/test/java/com/campus/config/RedisCacheFallbackTest.java @@ -0,0 +1,17 @@ +package com.campus.config; + +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; + +class RedisCacheFallbackTest { + @Test + void cacheConnectionFailureIsHandledWithoutBreakingBusinessRead() { + RedisConfig config = new RedisConfig(); + Cache cache = mock(Cache.class); + assertDoesNotThrow(() -> config.errorHandler() + .handleCacheGetError(new RuntimeException("connection reset"), cache, "activity_type")); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/ActivityControllerTest.java b/campus-server/src/test/java/com/campus/controller/ActivityControllerTest.java new file mode 100644 index 0000000..7a31f29 --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/ActivityControllerTest.java @@ -0,0 +1,206 @@ +package com.campus.controller; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.common.Result; +import com.campus.common.TooManyRequestsException; +import com.campus.dto.request.ActivityQueryRequest; +import com.campus.dto.response.ActivityDTO; +import com.campus.dto.response.ActivityParticipantDTO; +import com.campus.service.ActivityService; +import com.campus.service.ClickGuardService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ActivityControllerTest { + + @Mock + private ActivityService activityService; + + @Mock + private ClickGuardService clickGuardService; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void listKeepsPublicApiPageOneBased() { + when(activityService.list("study", null, 1, 20, null)) + .thenReturn(PageResult.empty(1, 20)); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + controller.list("study", null, null, null, + null, null, null, null, null, null, 1, 20); + + verify(activityService).list("study", null, 1, 20, null); + } + + @Test + void listTreatsAllStatusAsNoFilter() { + when(activityService.list("study", null, 1, 20, null)) + .thenReturn(PageResult.empty(1, 20)); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + controller.list("study", null, null, null, + "all", null, null, null, null, null, 1, 20); + + verify(activityService).list("study", null, 1, 20, null); + } + + @Test + void listAcceptsCategoryAsTypeAlias() { + when(activityService.list("study", null, 1, 20, null)) + .thenReturn(PageResult.empty(1, 20)); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + controller.list(null, "study", null, null, + "全部", null, null, null, null, null, 1, 20); + + verify(activityService).list("study", null, 1, 20, null); + } + + @Test + void listAcceptsCamelCaseActivityTypeAlias() { + when(activityService.list("学习", null, 1, 20, null)) + .thenReturn(PageResult.empty(1, 20)); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + controller.list(null, null, "学习", null, + null, null, null, null, null, null, 1, 20); + + verify(activityService).list("学习", null, 1, 20, null); + } + + @Test + void listAcceptsSnakeCaseActivityTypeAlias() { + when(activityService.list("study", null, 1, 20, null)) + .thenReturn(PageResult.empty(1, 20)); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + controller.list(null, null, null, "study", + null, null, null, null, null, null, 1, 20); + + verify(activityService).list("study", null, 1, 20, null); + } + + @Test + void searchKeepsPublicApiPageOneBased() { + ActivityQueryRequest request = new ActivityQueryRequest(); + when(activityService.search(request, null, 1, 20, null)) + .thenReturn(PageResult.empty(1, 20)); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + controller.search(request, 1, 20); + + verify(activityService).search(request, null, 1, 20, null); + } + + @Test + void createdActivitiesKeepPublicApiPageOneBased() { + when(activityService.listByCreatorIdWithPage(null, 1, 20)) + .thenReturn(PageResult.empty(1, 20)); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + controller.listCreated(1, 20); + + verify(activityService).listByCreatorIdWithPage(null, 1, 20); + } + + @Test + void joinedActivitiesKeepPublicApiPageOneBased() { + when(activityService.listJoinedActivities(null, 1, 20)) + .thenReturn(PageResult.empty(1, 20)); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + controller.listJoined(1, 20); + + verify(activityService).listJoinedActivities(null, 1, 20); + } + + @Test + void joinIsRejectedBecauseItWouldBypassCreatorReview() { + ActivityController controller = new ActivityController(activityService, clickGuardService); + + BusinessException error = assertThrows(BusinessException.class, + () -> controller.join("activity", null)); + + assertEquals("报名需发起人审核,请提交报名申请", error.getMessage()); + verify(activityService, never()).joinActivity(any(), any(), any()); + } + + @Test + void applyUsesDedicatedClickGuardScope() { + ActivityParticipantDTO dto = new ActivityParticipantDTO(); + dto.setId("participant"); + dto.setStatus("PENDING"); + when(activityService.applyActivity("activity", null, "想去")).thenReturn(dto); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + Result result = controller.apply("activity", "想去"); + + assertEquals(200, result.getCode().intValue()); + assertSame(dto, result.getData()); + verify(clickGuardService).ensureNotTooFrequent( + ClickGuardService.SCOPE_ACTIVITY_APPLY, null, "activity"); + } + + @Test + void quitReturnsRefreshedActivityState() { + ActivityDTO dto = new ActivityDTO(); + dto.setId("activity"); + dto.setIsJoined(false); + when(activityService.quitActivity("activity", null)).thenReturn(dto); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + Result result = controller.quit("activity"); + + assertEquals(200, result.getCode().intValue()); + assertSame(dto, result.getData()); + verify(activityService).quitActivity("activity", null); + verify(clickGuardService).ensureNotTooFrequent( + ClickGuardService.SCOPE_ACTIVITY_JOIN_QUIT, null, "activity"); + } + + @Test + void applyIsBlockedByClickGuardWithoutCallingService() { + doThrow(new TooManyRequestsException(ClickGuardService.TOO_FREQUENT_MESSAGE)) + .when(clickGuardService) + .ensureNotTooFrequent(ClickGuardService.SCOPE_ACTIVITY_APPLY, null, "activity"); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + + TooManyRequestsException error = assertThrows(TooManyRequestsException.class, + () -> controller.apply("activity", null)); + + assertEquals(ClickGuardService.TOO_FREQUENT_MESSAGE, error.getMessage()); + verify(activityService, never()).applyActivity(any(), any(), any()); + } + + @Test + void quitIsBlockedByClickGuardWithoutCallingService() { + doThrow(new TooManyRequestsException(ClickGuardService.TOO_FREQUENT_MESSAGE)) + .when(clickGuardService) + .ensureNotTooFrequent(ClickGuardService.SCOPE_ACTIVITY_JOIN_QUIT, null, "activity"); + + ActivityController controller = new ActivityController(activityService, clickGuardService); + + assertThrows(TooManyRequestsException.class, () -> controller.quit("activity")); + verify(activityService, never()).quitActivity(any(), any()); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/ActivityLocationControllerTest.java b/campus-server/src/test/java/com/campus/controller/ActivityLocationControllerTest.java new file mode 100644 index 0000000..9495699 --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/ActivityLocationControllerTest.java @@ -0,0 +1,31 @@ +package com.campus.controller; + +import com.campus.common.*; +import com.campus.service.location.ActivityLocationService; +import org.junit.jupiter.api.*; +import org.springframework.test.web.servlet.*; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.http.MediaType; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ActivityLocationControllerTest { + ActivityLocationService service; + MockMvc mvc; + @BeforeEach void setup() { + service = mock(ActivityLocationService.class); + mvc = MockMvcBuilders.standaloneSetup(new ActivityLocationController(service)).setControllerAdvice(new GlobalExceptionHandler()).build(); + } + @Test void businessFailureIsHttp400() throws Exception { + when(service.resolve(any())).thenThrow(new BusinessException("请补充活动所在城市")); + mvc.perform(post("/api/campus/activity-locations/resolve").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(400)).andExpect(jsonPath("$.message").value("请补充活动所在城市")).andExpect(jsonPath("$.data").isEmpty()); + } + @Test void disabledIsHttp503() throws Exception { + when(service.confirm(any())).thenThrow(new ServiceUnavailableException("地点识别服务暂不可用,请稍后重试")); + mvc.perform(post("/api/campus/activity-locations/confirm").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value(503)); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/GroupControllerRoutesTest.java b/campus-server/src/test/java/com/campus/controller/GroupControllerRoutesTest.java new file mode 100644 index 0000000..fdf4440 --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/GroupControllerRoutesTest.java @@ -0,0 +1,68 @@ +package com.campus.controller; + +import com.campus.dto.response.GroupAnnouncementDTO; +import com.campus.service.GroupAnnouncementService; +import com.campus.service.GroupInvitationService; +import com.campus.service.GroupService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class GroupControllerRoutesTest { + + private GroupAnnouncementService announcementService; + private MockMvc mvc; + + @BeforeEach + void setUp() { + GroupService groupService = mock(GroupService.class); + announcementService = mock(GroupAnnouncementService.class); + GroupInvitationService invitationService = mock(GroupInvitationService.class); + mvc = MockMvcBuilders.standaloneSetup( + new GroupController(groupService, announcementService, invitationService)).build(); + } + + @Test + void getAnnouncementReturnsNullWhenGroupHasNoAnnouncement() throws Exception { + when(announcementService.getAnnouncement("group")).thenReturn(null); + + mvc.perform(get("/api/groups/group/announcement")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void putAnnouncementAcceptsJsonAndReturnsAnnouncement() throws Exception { + GroupAnnouncementDTO response = new GroupAnnouncementDTO(); + response.setGroupId("group"); + response.setContent("今晚八点开会"); + when(announcementService.updateAnnouncement(eq("group"), any())).thenReturn(response); + + mvc.perform(put("/api/groups/group/announcement") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"今晚八点开会\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.groupId").value("group")) + .andExpect(jsonPath("$.data.content").value("今晚八点开会")); + } + + @Test + void putAnnouncementRejectsBlankContent() throws Exception { + mvc.perform(put("/api/groups/group/announcement") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\" \"}")) + .andExpect(status().isBadRequest()); + + verify(announcementService, never()).updateAnnouncement(anyString(), any()); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/MomentFavoriteCountControllerTest.java b/campus-server/src/test/java/com/campus/controller/MomentFavoriteCountControllerTest.java new file mode 100644 index 0000000..6afa793 --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/MomentFavoriteCountControllerTest.java @@ -0,0 +1,46 @@ +package com.campus.controller; + +import com.campus.service.MomentLikeCommentService; +import com.campus.service.MomentService; +import com.campus.service.VideoPlayService; +import com.campus.service.MomentShareCountService; +import com.campus.common.Result; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MomentFavoriteCountControllerTest { + @Mock private MomentService momentService; + @Mock private MomentLikeCommentService likeCommentService; + @Mock private VideoPlayService videoPlayService; + @Mock private MomentShareCountService momentShareCountService; + @InjectMocks private MomentController controller; + + @Test + void favoriteReturnsUpdatedCount() { + when(momentService.toggleFavorite("moment")).thenReturn(true); + when(momentService.getFavoriteCount("moment")).thenReturn(4L); + + Result> response = controller.favorite("moment"); + assertEquals(true, response.getData().get("favorited")); + assertEquals(4L, response.getData().get("favoriteCount")); + } + + @Test + void favoriteCountReturnsCountAndCurrentUserState() { + when(momentService.getFavoriteCount("moment")).thenReturn(4L); + when(momentService.isFavorited("moment")).thenReturn(true); + + Result> response = controller.getFavoriteCount("moment"); + assertEquals(4L, response.getData().get("favoriteCount")); + assertEquals(true, response.getData().get("isFavorited")); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/MomentLikeStateControllerTest.java b/campus-server/src/test/java/com/campus/controller/MomentLikeStateControllerTest.java new file mode 100644 index 0000000..564ae97 --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/MomentLikeStateControllerTest.java @@ -0,0 +1,74 @@ +package com.campus.controller; + +import com.campus.common.GlobalExceptionHandler; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class MomentLikeStateControllerTest { + @Mock MomentLikeCommentService likes; + @Mock MomentService moments; + @Mock MomentShareCountService shareCounts; + @Mock VideoPlayService videoPlayService; + @InjectMocks MomentController controller; + MockMvc mvc; + + @BeforeEach void setup() { + mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()).build(); + } + + @Test void explicitStateReturnsOneAtomicSnapshot() throws Exception { + InteractionLikeStateDTO snapshot = new InteractionLikeStateDTO(); + snapshot.setLiked(false); snapshot.setLikeCount(9L); + when(likes.setLike("m", false)).thenReturn(snapshot); + mvc.perform(put("/api/campus/moments/like").param("id", "m") + .contentType(MediaType.APPLICATION_JSON).content("{\"liked\":false}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.liked").value(false)) + .andExpect(jsonPath("$.data.likeCount").value(9)); + verify(likes, never()).getLikeCount(anyString()); + } + + @Test void missingStateIsRejectedWithoutWriting() throws Exception { + mvc.perform(put("/api/campus/moments/like").param("id", "m") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("点赞状态不能为空")); + verifyNoInteractions(likes); + } + + @Test void stringBooleanIsRejectedWithoutWriting() throws Exception { + mvc.perform(put("/api/campus/moments/comments/like").param("commentId", "c") + .contentType(MediaType.APPLICATION_JSON).content("{\"liked\":\"true\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("请求参数格式错误")); + verifyNoInteractions(likes); + } + + @Test void oldPostUsesTransactionalSnapshotWithoutExtraRead() throws Exception { + InteractionLikeStateDTO snapshot = new InteractionLikeStateDTO(); + snapshot.setLiked(true); snapshot.setLikeCount(10L); + when(likes.toggleLikeState("m")).thenReturn(snapshot); + mvc.perform(post("/api/campus/moments/like").param("id", "m")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.liked").value(true)) + .andExpect(jsonPath("$.data.likeCount").value(10)); + verify(likes, never()).getLikeCount(anyString()); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/OcrControllerTest.java b/campus-server/src/test/java/com/campus/controller/OcrControllerTest.java new file mode 100644 index 0000000..85164c6 --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/OcrControllerTest.java @@ -0,0 +1,114 @@ +package com.campus.controller; + +import com.campus.common.GlobalExceptionHandler; +import com.campus.dto.response.ChsiReportDTO; +import com.campus.dto.response.XuexinDedupDTO; +import com.campus.entity.User; +import com.campus.service.AliOcrService; +import com.campus.service.XuexinReportService; +import com.campus.util.OssUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Arrays; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class OcrControllerTest { + + @Mock private AliOcrService aliOcrService; + @Mock private XuexinReportService xuexinReportService; + @Mock private OssUtil ossUtil; + @InjectMocks private OcrController controller; + + private MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + User user = new User(); + user.setId("user-1"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void uploadsPdfAndDoesNotExposeRawText() throws Exception { + ChsiReportDTO dto = validResult(); + when(aliOcrService.recognizeChsiReport(any())).thenReturn(dto); + when(ossUtil.uploadFile(any(), eq("xuexin-reports"))) + .thenReturn("https://oss.example/report.pdf"); + when(xuexinReportService.register("user-1", dto, "https://oss.example/report.pdf")) + .thenAnswer(inv -> { + dto.setReportId("report-1"); + XuexinDedupDTO dedup = new XuexinDedupDTO(); + dedup.setStatus(XuexinDedupDTO.AVAILABLE); + dedup.setMessage(XuexinDedupDTO.MSG_AVAILABLE); + return dedup; + }); + + mvc.perform(multipart("/api/ocr/chsi-report") + .file(new MockMultipartFile("file", "report.pdf", "application/pdf", "%PDF".getBytes()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.reportName").value("教育部学籍在线验证报告")) + .andExpect(jsonPath("$.data.reportId").value("report-1")) + .andExpect(jsonPath("$.data.rawLines").doesNotExist()); + } + + @Test + void returns503WhenPdfStorageIsUnavailable() throws Exception { + ChsiReportDTO dto = validResult(); + when(aliOcrService.recognizeChsiReport(any())).thenReturn(dto); + when(ossUtil.uploadFile(any(), eq("xuexin-reports"))) + .thenThrow(new RuntimeException("OSS unavailable")); + + mvc.perform(multipart("/api/ocr/chsi-report") + .file(new MockMultipartFile("file", "report.pdf", "application/pdf", "%PDF".getBytes()))) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value(503)) + .andExpect(jsonPath("$.message").value("认证材料存储服务暂时不可用,请稍后重试")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void imageUrlEndpointHasBeenRemoved() throws Exception { + mvc.perform(post("/api/ocr/chsi-report/url").param("imageUrl", "https://example.test/a.jpg")) + .andExpect(status().isNotFound()); + } + + private static ChsiReportDTO validResult() { + ChsiReportDTO dto = new ChsiReportDTO(); + dto.setSuccess(true); + dto.setValidChsiReport(true); + dto.setReportName("教育部学籍在线验证报告"); + dto.setName("测试用户"); + dto.setSchool("测试大学"); + dto.setRawLines(Arrays.asList("教育部学籍在线验证报告", "姓名 测试用户")); + return dto; + } +} diff --git a/campus-server/src/test/java/com/campus/controller/ProfileRequestParsingTest.java b/campus-server/src/test/java/com/campus/controller/ProfileRequestParsingTest.java new file mode 100644 index 0000000..8babb70 --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/ProfileRequestParsingTest.java @@ -0,0 +1,63 @@ +package com.campus.controller; + +import com.campus.common.GlobalExceptionHandler; +import com.campus.entity.User; +import com.campus.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ProfileRequestParsingTest { + private UserService userService; + private MockMvc mvc; + private com.campus.service.ProfileService profileService; + + @BeforeEach + void setup() { + userService = mock(UserService.class); + profileService = mock(com.campus.service.ProfileService.class); + mvc = MockMvcBuilders.standaloneSetup(new ProfileController(userService, profileService, null, null, null, null)) + .setControllerAdvice(new GlobalExceptionHandler()).build(); + } + + @Test + void malformedBirthdayReturns400WithoutSaving() throws Exception { + mvc.perform(put("/api/profile").contentType("application/json") + .content("{\"nickname\":\"张三\",\"birthday\":\"2023-61\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("code").value(400)) + .andExpect(jsonPath("message").value("生日格式不正确,请使用 yyyy-MM-dd(例如 2000-06-01)")) + .andExpect(content().json("{\"data\":null}")); + verifyNoInteractions(userService); + } + + @Test + void malformedJsonReturns400WithoutSaving() throws Exception { + mvc.perform(put("/api/profile").contentType("application/json").content("{\"birthday\":")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("code").value(400)) + .andExpect(jsonPath("message").value("请求体格式不正确,请检查参数类型和 JSON 格式")) + .andExpect(content().json("{\"data\":null}")); + verifyNoInteractions(userService); + } + + @Test + void validBirthdayIsDelegatedAndServerProfileReturned() throws Exception { + User saved = new User(); + saved.setNickname("认证姓名"); + when(profileService.updateProfile(any(), any(), any())).thenReturn(saved); + mvc.perform(put("/api/profile").contentType("application/json") + .content("{\"birthday\":\"2000-06-01\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("code").value(200)) + .andExpect(jsonPath("data.nickname").value("认证姓名")); + verify(profileService).updateProfile(any(), any(), any()); + verifyNoInteractions(userService); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/ShareControllerTest.java b/campus-server/src/test/java/com/campus/controller/ShareControllerTest.java new file mode 100644 index 0000000..3271d6d --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/ShareControllerTest.java @@ -0,0 +1,86 @@ +package com.campus.controller; + +import com.campus.common.GlobalExceptionHandler; +import com.campus.dto.response.ShareSendResultDTO; +import com.campus.service.ShareService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 固定 App 内分享的两个正式路由与校验入口。 */ +@ExtendWith(MockitoExtension.class) +class ShareControllerTest { + + @Mock private ShareService shareService; + private MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.standaloneSetup(new ShareController(shareService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void sendsOneCardToMultipleFriends() throws Exception { + ShareSendResultDTO result = new ShareSendResultDTO(); + result.setRequestedCount(2); + result.setSentCount(2); + result.setFailedCount(0); + result.setPartialSuccess(false); + when(shareService.sendToFriends(any())).thenReturn(result); + + mvc.perform(post("/api/campus/shares/friends") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"MOMENT\",\"targetId\":\"moment-1\"," + + "\"receiverIds\":[\"friend-1\",\"friend-2\"]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.requestedCount").value(2)) + .andExpect(jsonPath("$.data.sentCount").value(2)); + + verify(shareService).sendToFriends(any()); + } + + @Test + void sendsOneCardToOneGroup() throws Exception { + ShareSendResultDTO result = new ShareSendResultDTO(); + result.setRequestedCount(1); + result.setSentCount(1); + result.setFailedCount(0); + result.setPartialSuccess(false); + when(shareService.sendToGroup(any())).thenReturn(result); + + mvc.perform(post("/api/campus/shares/group") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"MOMENT\",\"targetId\":\"moment-1\"," + + "\"groupId\":\"group-1\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.requestedCount").value(1)); + + verify(shareService).sendToGroup(any()); + } + + @Test + void rejectsEmptyFriendSelectionBeforeService() throws Exception { + mvc.perform(post("/api/campus/shares/friends") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"MOMENT\",\"targetId\":\"moment-1\"," + + "\"receiverIds\":[]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("请选择要分享的好友")); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/TopicControllerRoutesTest.java b/campus-server/src/test/java/com/campus/controller/TopicControllerRoutesTest.java new file mode 100644 index 0000000..12293ca --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/TopicControllerRoutesTest.java @@ -0,0 +1,166 @@ +package com.campus.controller; + +import com.campus.common.PageResult; +import com.campus.dto.request.TopicReviewRequest; +import com.campus.dto.response.MyTopicsDTO; +import com.campus.dto.response.TopicDTO; +import com.campus.dto.response.TopicFavoriteDTO; +import com.campus.service.TopicDiscussionService; +import com.campus.service.TopicService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class TopicControllerRoutesTest { + private TopicService service; + private TopicDiscussionService discussionService; + private MockMvc mvc; + + @BeforeEach + void setUp() { + service = mock(TopicService.class); + discussionService = mock(TopicDiscussionService.class); + mvc = MockMvcBuilders.standaloneSetup(new TopicController(service, discussionService)).build(); + } + + @Test + void fixedDiscoveryRoutesDoNotResolveAsTopicNames() throws Exception { + when(service.catalog("校园", "校园日常", "latest", 1, 20)) + .thenReturn(PageResult.empty(1, 20)); + when(service.rankings("rising", 5)).thenReturn(Collections.emptyList()); + when(service.myTopics()).thenReturn(new MyTopicsDTO()); + + mvc.perform(get("/api/topics/catalog").param("keyword", "校园") + .param("category", "校园日常").param("sort", "latest")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(0)); + mvc.perform(get("/api/topics/rankings").param("type", "rising").param("limit", "5")) + .andExpect(status().isOk()); + mvc.perform(get("/api/topics/my")).andExpect(status().isOk()); + verify(service, never()).getTopic(anyString()); + } + + @Test + void createReceivesCategoryAndCoverUrl() throws Exception { + when(service.createTopic(eq("期末挑战"), eq("一起复习"), eq("学习成长"), + eq("https://example.com/cover.png"))).thenReturn(new TopicDTO()); + + mvc.perform(post("/api/topics").contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"期末挑战\",\"description\":\"一起复习\"," + + "\"category\":\"学习成长\",\"coverUrl\":\"https://example.com/cover.png\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + verify(service).createTopic("期末挑战", "一起复习", "学习成长", + "https://example.com/cover.png"); + } + + @Test + void reviewAcceptsStatusFieldFromFrontendContract() throws Exception { + when(service.reviewTopic(eq("topic-id"), any(TopicReviewRequest.class))) + .thenReturn(new TopicDTO()); + + mvc.perform(put("/api/topics/topic-id/review").contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"APPROVED\",\"reviewNote\":\"内容合规\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TopicReviewRequest.class); + verify(service).reviewTopic(eq("topic-id"), captor.capture()); + assertEquals("APPROVED", captor.getValue().getStatus()); + assertEquals("内容合规", captor.getValue().getReviewNote()); + } + + @Test + void reviewStillAcceptsLegacyApprovedField() throws Exception { + when(service.reviewTopic(eq("topic-id"), any(TopicReviewRequest.class))) + .thenReturn(new TopicDTO()); + + mvc.perform(put("/api/topics/topic-id/review").contentType(MediaType.APPLICATION_JSON) + .content("{\"approved\":true,\"note\":\"通过\"}")) + .andExpect(status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TopicReviewRequest.class); + verify(service).reviewTopic(eq("topic-id"), captor.capture()); + assertEquals(Boolean.TRUE, captor.getValue().getApproved()); + assertEquals("通过", captor.getValue().getNote()); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedMomentsRouteForwardsToTopicDiscussions() throws Exception { + // 旧路由的行为已改为"按 topic_id 查讨论",与 GET /{topicName}/discussions 一致; + // 路径变量含中文会被 URL 编码,这里不锁定解码后的字面量 + when(discussionService.list(anyString(), eq("latest"), eq(1), eq(20))) + .thenReturn(PageResult.empty(1, 20)); + + mvc.perform(get("/api/topics/{topicName}/moments", "校园日常")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); + verify(discussionService).list(anyString(), eq("latest"), eq(1), eq(20)); + verify(service, never()).getTopic(anyString()); + } + + @Test + void favoriteRouteGoesToToggleFavorite() throws Exception { + TopicFavoriteDTO dto = new TopicFavoriteDTO("期末复习搭子", true, 2L); + // 路径变量含中文,MockMvc 解码结果与字面量可能不一致,这里只校验路由透传 + when(service.toggleFavorite(anyString())).thenReturn(dto); + + mvc.perform(post("/api/topics/{topicName}/favorite", "期末复习搭子")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.favorited").value(true)) + .andExpect(jsonPath("$.data.favoriteCount").value(2)); + + verify(service).toggleFavorite(anyString()); + verify(service, never()).getTopic(anyString()); + } + + @Test + void listSerializesTopicTagsAsArray() throws Exception { + TopicDTO topic = new TopicDTO(); + topic.setName("校园生活"); + topic.setTopicTags(Arrays.asList("热门", "新")); + when(service.listTopics(20)).thenReturn(Collections.singletonList(topic)); + + mvc.perform(get("/api/topics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].topicTags").isArray()) + .andExpect(jsonPath("$.data[0].topicTags[0]").value("热门")) + .andExpect(jsonPath("$.data[0].topicTags[1]").value("新")); + } + + @Test + void topicDtoNoLongerExposesMomentDerivedFields() throws Exception { + TopicDTO topic = new TopicDTO(); + topic.setName("校园生活"); + topic.setFollowerCount(3L); + topic.setVisitCount(5L); + topic.setHeatScore(8L); + when(service.listTopics(20)).thenReturn(Collections.singletonList(topic)); + + mvc.perform(get("/api/topics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].followerCount").value(3)) + .andExpect(jsonPath("$.data[0].visitCount").value(5)) + .andExpect(jsonPath("$.data[0].momentCount").doesNotExist()) + .andExpect(jsonPath("$.data[0].participantCount").doesNotExist()) + .andExpect(jsonPath("$.data[0].unreadMomentCount").doesNotExist()); + } +} diff --git a/campus-server/src/test/java/com/campus/controller/TopicDiscussionControllerTest.java b/campus-server/src/test/java/com/campus/controller/TopicDiscussionControllerTest.java new file mode 100644 index 0000000..f84ac6f --- /dev/null +++ b/campus-server/src/test/java/com/campus/controller/TopicDiscussionControllerTest.java @@ -0,0 +1,165 @@ +package com.campus.controller; + +import com.campus.common.BusinessException; +import com.campus.common.GlobalExceptionHandler; +import com.campus.common.PageResult; +import com.campus.dto.response.MomentDTO; +import com.campus.service.TopicDiscussionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 话题讨论路由:发布 / 列表 / 我的讨论,以及参数校验文案。 */ +class TopicDiscussionControllerTest { + + private TopicDiscussionService service; + private MockMvc mvc; + + @BeforeEach + void setUp() { + service = mock(TopicDiscussionService.class); + mvc = MockMvcBuilders.standaloneSetup(new TopicDiscussionController(service)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void publishRoutesToTopicDiscussionService() throws Exception { + MomentDTO dto = new MomentDTO(); + dto.setId("m1"); + dto.setContentSource("TOPIC"); + dto.setTopicId("t-100"); + dto.setTopicName("宿舍夜谈"); + // 路径变量含中文,MockMvc 会做 URL 编码,这里不锁定解码后的字面量 + when(service.publish(anyString(), any())).thenReturn(dto); + + mvc.perform(post("/api/topics/{topicName}/discussions", "宿舍夜谈") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"讨论测试\"}".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.topicId").value("t-100")) + .andExpect(jsonPath("$.data.contentSource").value("TOPIC")); + + verify(service).publish(anyString(), any()); + } + + /** + * 空白正文的拦截点已从 Bean Validation 移到服务层。 + * + *

原因:讨论允许"纯视频无文案"(决策 2026-10-20),路由层不能再用 {@code @NotBlank} + * 拦空白正文;"文本 + 图片 + 视频三者全空"由 {@code MomentService.createTopicDiscussion} + * 判定。本用例锁的是"服务层拒绝仍映射成 400 + 原始中文文案"这条链路。

+ */ + @Test + void blankContentRejectionFromServiceMapsTo400WithExactMessage() throws Exception { + when(service.publish(anyString(), any())) + .thenThrow(new BusinessException("讨论内容不能为空")); + + String body = mvc.perform(post("/api/topics/{topicName}/discussions", "宿舍夜谈") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\" \"}".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + assertTrue(body.contains("讨论内容不能为空"), "校验失败必须返回原始中文文案,实际: " + body); + } + + @Test + void videoOnlyDiscussionReachesTheServiceWithoutContent() throws Exception { + MomentDTO dto = new MomentDTO(); + dto.setId("m2"); + dto.setContentSource("TOPIC"); + dto.setVideoStatus("PROCESSING"); + when(service.publish(anyString(), any())).thenReturn(dto); + + mvc.perform(post("/api/topics/{topicName}/discussions", "宿舍夜谈") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"vodVideoId\":\"3b1c8f0f5a9e4a0b8c7d6e5f4a3b2c1d\"}" + .getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.videoStatus").value("PROCESSING")); + + verify(service).publish(anyString(), any()); + } + + @Test + void updateRouteDelegatesToService() throws Exception { + MomentDTO dto = new MomentDTO(); + dto.setId("m1"); + dto.setContent("改过的内容"); + when(service.update(eq("m1"), any())).thenReturn(dto); + + mvc.perform(put("/api/topics/discussions/{id}", "m1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"改过的内容\"}".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").value("改过的内容")); + + verify(service).update(eq("m1"), any()); + } + + @Test + void deleteRouteDelegatesToService() throws Exception { + mvc.perform(delete("/api/topics/discussions/{id}", "m1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(service).delete("m1"); + } + + @Test + void listPassesSortAndPaging() throws Exception { + when(service.list(anyString(), eq("hot"), eq(2), eq(10))).thenReturn(PageResult.empty(2, 10)); + + mvc.perform(get("/api/topics/{topicName}/discussions", "宿舍夜谈") + .param("sort", "hot").param("page", "2").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); + + verify(service).list(anyString(), eq("hot"), eq(2), eq(10)); + } + + @Test + void myDiscussionsRouteIsNotSwallowedByPathVariable() throws Exception { + when(service.listMine(null, 1, 20)).thenReturn(PageResult.empty(1, 20)); + + mvc.perform(get("/api/topics/my/discussions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); + + verify(service).listMine(null, 1, 20); + verify(service, never()).list(anyString(), anyString(), anyInt(), anyInt()); + } + + @Test + void myDiscussionsAcceptsTopicFilter() throws Exception { + when(service.listMine("t-100", 1, 20)).thenReturn(PageResult.empty(1, 20)); + + mvc.perform(get("/api/topics/my/discussions").param("topicId", "t-100")) + .andExpect(status().isOk()); + + verify(service).listMine("t-100", 1, 20); + } +} diff --git a/campus-server/src/test/java/com/campus/entity/ChatNotificationIndexTest.java b/campus-server/src/test/java/com/campus/entity/ChatNotificationIndexTest.java new file mode 100644 index 0000000..8616371 --- /dev/null +++ b/campus-server/src/test/java/com/campus/entity/ChatNotificationIndexTest.java @@ -0,0 +1,40 @@ +package com.campus.entity; + +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.junit.jupiter.api.Test; +import javax.persistence.Column; +import javax.persistence.Table; +import java.util.Arrays; +import static org.junit.jupiter.api.Assertions.*; + +class ChatNotificationIndexTest { + @Test void hibernateResolvesPhysicalIndexColumnsWithoutDatabase() { + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .applySetting("hibernate.dialect", "org.hibernate.dialect.MySQL57Dialect") + .applySetting("hibernate.temp.use_jdbc_metadata_defaults", "false") + .applySetting("hibernate.physical_naming_strategy", + "org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy") + .build(); + try { + org.hibernate.boot.Metadata metadata = new MetadataSources(registry) + .addAnnotatedClass(ChatMessage.class).addAnnotatedClass(Notification.class) + .addAnnotatedClass(ActivityPaymentOrder.class).addAnnotatedClass(UserSession.class) + .addAnnotatedClass(ActivityParticipant.class).addAnnotatedClass(MomentCommentLike.class) + .buildMetadata(); + metadata.getEntityBindings().forEach(binding -> binding.getTable().getIndexIterator() + .forEachRemaining(index -> assertTrue(index.getColumnSpan() > 0))); + } finally { StandardServiceRegistryBuilder.destroy(registry); } + } + + @Test void duplicatesAreNotReintroducedByEntityAnnotations() throws Exception { + assertFalse(ActivityPaymentOrder.class.getDeclaredField("registrationId").getAnnotation(Column.class).unique()); + assertFalse(UserSession.class.getDeclaredField("tokenHash").getAnnotation(Column.class).unique()); + for (Class type : new Class[]{ActivityParticipant.class, MomentCommentLike.class, MomentLike.class}) { + Table table = type.getAnnotation(Table.class); + Arrays.stream(table.indexes()).forEach(index -> Arrays.stream(table.uniqueConstraints()).forEach(unique -> + assertNotEquals(String.join(",", unique.columnNames()), index.columnList().replace(" ", "")))); + } + } +} diff --git a/campus-server/src/test/java/com/campus/entity/EntitySoftDeleteTest.java b/campus-server/src/test/java/com/campus/entity/EntitySoftDeleteTest.java new file mode 100644 index 0000000..84f30e4 --- /dev/null +++ b/campus-server/src/test/java/com/campus/entity/EntitySoftDeleteTest.java @@ -0,0 +1,59 @@ +package com.campus.entity; + +import com.campus.common.BaseEntity; +import org.hibernate.annotations.Where; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; + +import javax.persistence.Entity; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * 守护测试:每个继承 {@link BaseEntity} 的实体都必须**自己**声明软删过滤注解。 + * + *

背景:{@code @Where} 在 Hibernate 5.6 里没有 {@code @Inherited},标在 + * {@code @MappedSuperclass BaseEntity} 上不会被子类继承,等同于没写。曾经因此出现 + * "删除动态后刷新又回来"(DB 里 deleted=1,但派生查询照样返回)。 + * 现在每个实体类上都必须显式标注,本测试防止将来新增实体时漏掉。

+ */ +class EntitySoftDeleteTest { + + @Test + void everyEntityDeclaresItsOwnSoftDeleteFilter() { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); + + Set components = scanner.findCandidateComponents("com.campus.entity"); + assertFalse(components.isEmpty(), "未扫描到任何实体类,请检查测试运行目录"); + + List missing = new ArrayList<>(); + for (BeanDefinition component : components) { + Class type; + try { + type = Class.forName(component.getBeanClassName()); + } catch (ClassNotFoundException e) { + fail("无法加载实体类: " + component.getBeanClassName()); + return; + } + if (!BaseEntity.class.isAssignableFrom(type)) { + continue; + } + // 不能用 isAnnotationPresent 之外的手段:@Where 不是 @Inherited,getAnnotation 只认本类 + if (type.getAnnotation(Where.class) == null) { + missing.add(type.getSimpleName()); + } + } + + assertTrue(missing.isEmpty(), + "以下实体缺少 @Where(clause = \"deleted = false\"),软删将失效:" + missing); + } +} diff --git a/campus-server/src/test/java/com/campus/entity/MomentIndexColumnTest.java b/campus-server/src/test/java/com/campus/entity/MomentIndexColumnTest.java new file mode 100644 index 0000000..dacf66b --- /dev/null +++ b/campus-server/src/test/java/com/campus/entity/MomentIndexColumnTest.java @@ -0,0 +1,60 @@ +package com.campus.entity; + +import com.campus.common.BaseEntity; +import org.junit.jupiter.api.Test; + +import javax.persistence.Column; +import javax.persistence.Index; +import javax.persistence.Table; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 守护测试:{@code @Index.columnList} 里写的必须是**真实列名**。 + * + *

踩过的坑:这个字段写了 {@code @Column(name="content_source")},索引却写成 + * {@code columnList = "contentSource"},单元测试全绿,但应用启动时 Hibernate 直接抛 + * 「database column 'contentSource' not found」——索引解析发生在启动期,Mockito 单测根本不会触发。 + * 这条测试把"列名清单"和"索引声明"对一遍,避免再犯。

+ */ +class MomentIndexColumnTest { + + @Test + void everyIndexColumnListEntryIsARealColumnName() { + Table table = Moment.class.getAnnotation(Table.class); + assertNotNull(table, "Moment 必须声明 @Table"); + Index[] indexes = table.indexes(); + assertTrue(indexes.length > 0, "Moment 应声明索引"); + + Set columns = columnNames(); + for (Index index : indexes) { + for (String column : index.columnList().split(",")) { + String name = column.trim(); + assertTrue(columns.contains(name), + "索引 " + index.name() + " 引用了不存在的列 " + name + + ";@Index.columnList 必须写物理列名(有 @Column(name=...) 时写 name)"); + } + } + } + + /** 实体 + 父类所有字段对应的列名:有 @Column(name) 用 name,否则用属性名(Hibernate 的逻辑列名)。 */ + private Set columnNames() { + Set names = new HashSet<>(); + for (Class type : Arrays.asList(Moment.class, BaseEntity.class)) { + for (Field field : type.getDeclaredFields()) { + Column column = field.getAnnotation(Column.class); + if (column != null && column.name() != null && !column.name().isEmpty()) { + names.add(column.name()); + } else if (!java.lang.reflect.Modifier.isStatic(field.getModifiers())) { + names.add(field.getName()); + } + } + } + return names; + } +} diff --git a/campus-server/src/test/java/com/campus/repository/MomentFeedSourceGuardTest.java b/campus-server/src/test/java/com/campus/repository/MomentFeedSourceGuardTest.java new file mode 100644 index 0000000..748ff6e --- /dev/null +++ b/campus-server/src/test/java/com/campus/repository/MomentFeedSourceGuardTest.java @@ -0,0 +1,71 @@ +package com.campus.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.Query; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 守护测试:所有"动态流"查询都必须显式排除话题讨论。 + * + *

为什么需要它:这类过滤条件写在 JPQL 字符串里,编译器看不见、业务测试也只能验证 service 调用。 + * 一旦有人改查询时把 {@code contentSource <> 'TOPIC'} 删掉,讨论就会重新混进动态流, + * 而前端单测/接口测试都不会立刻报错。这里用反射把口径钉死:谁删谁红。

+ */ +class MomentFeedSourceGuardTest { + + /** 这些方法服务于动态流(推荐/校园/关注/热榜/同城/个人主页),必须排除 TOPIC。 */ + private static final List FEED_QUERIES = Arrays.asList( + "findFeedMoments", + "findFeedMomentsByScope", + "findRecommendationCandidates", + "findHotCandidates", + "findFeedMomentsByUser", + "findFeedMomentsByUserAndReviewStatus", + "findApprovedByCity", + "findApprovedByCityAndProvince"); + + @Test + void everyFeedQueryExplicitlyExcludesTopicDiscussions() { + Set checked = new HashSet<>(); + for (Method method : MomentRepository.class.getDeclaredMethods()) { + if (!FEED_QUERIES.contains(method.getName())) { + continue; + } + Query query = method.getAnnotation(Query.class); + assertNotNull(query, method.getName() + " 必须是显式 @Query,并在条件里排除话题讨论"); + String jpql = query.value(); + assertTrue(jpql.contains("contentSource"), + method.getName() + " 的 JPQL 缺少 contentSource 条件,话题讨论会重新混进动态流"); + assertTrue(jpql.contains("TOPIC"), + method.getName() + " 的 JPQL 缺少 TOPIC 判断,话题讨论会重新混进动态流"); + checked.add(method.getName()); + } + assertEquals(new HashSet<>(FEED_QUERIES), checked, "有动态流查询被改名或删除,请同步更新守护清单"); + } + + @Test + void topicPagesQueryOnlyByTopicId() { + // 话题页只认 topic_id,不能再用 tags 反查(tags 是动态的分类标签) + for (Method method : MomentRepository.class.getDeclaredMethods()) { + if (!"findApprovedByTopicId".equals(method.getName()) + && !"findApprovedByTopicIdOrderByHot".equals(method.getName())) { + continue; + } + Query query = method.getAnnotation(Query.class); + assertNotNull(query, method.getName() + " 必须是显式 @Query"); + assertTrue(query.value().contains("m.topicId = :topicId"), + method.getName() + " 必须按 topic_id 查话题内容"); + assertTrue(!query.value().contains("FIND_IN_SET"), + method.getName() + " 不能用 FIND_IN_SET(tag, tags) 判断话题归属"); + } + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeactivationCleanupServiceTest.java b/campus-server/src/test/java/com/campus/service/AccountDeactivationCleanupServiceTest.java new file mode 100644 index 0000000..4880923 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeactivationCleanupServiceTest.java @@ -0,0 +1,31 @@ +package com.campus.service; + +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class AccountDeactivationCleanupServiceTest { + @Test void recoveredAccountNeverCreatesOrExecutesDeletion() { + UserRepository users=mock(UserRepository.class); + JdbcTemplate jdbc=mock(JdbcTemplate.class); + AccountDeletionWorker worker=mock(AccountDeletionWorker.class); + User user=new User(); user.setAccountStatus("ACTIVE"); + when(users.findByIdForUpdate("u")).thenReturn(Optional.of(user)); + assertFalse(new AccountDeactivationCleanupService(users,jdbc,worker).cleanup("u")); + verifyNoInteractions(jdbc,worker); + } + @Test void coolingAccountNeverCreatesOrExecutesDeletion() { + UserRepository users=mock(UserRepository.class); + JdbcTemplate jdbc=mock(JdbcTemplate.class); + AccountDeletionWorker worker=mock(AccountDeletionWorker.class); + User user=new User(); user.setAccountStatus("DEACTIVATING"); + user.setScheduledDeletionAt(new Date(System.currentTimeMillis()+86400000)); + when(users.findByIdForUpdate("u")).thenReturn(Optional.of(user)); + assertFalse(new AccountDeactivationCleanupService(users,jdbc,worker).cleanup("u")); + verifyNoInteractions(jdbc,worker); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionCloudE2ETest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionCloudE2ETest.java new file mode 100644 index 0000000..90b6373 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionCloudE2ETest.java @@ -0,0 +1,72 @@ +package com.campus.service; + +import com.campus.config.*; +import com.campus.entity.*; +import com.campus.util.OssUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.aliyun.oss.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.transaction.interceptor.*; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.yaml.snakeyaml.Yaml; +import java.util.*; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实云端测试仅显式启用;数据库固定隔离实例,资源只读本次专用清单。 */ +@EnabledIfEnvironmentVariable(named="QINGHE_CLOUD_DELETION_E2E",matches="CONFIRMED_SYNTHETIC_ONLY") +class AccountDeletionCloudE2ETest { + @Test void applicationWorkerDeletesCloudAndCertificationData() throws Exception { + AccountDeletionMySqlTest.initialize(); + OSS oss=null; + try { + Map m=new ObjectMapper().readValue(Paths.get("target/cloud-e2e.json").toFile(),Map.class); + Map cfg=new Yaml().load(Files.newInputStream(Paths.get("../application.yml"))); + Map o=(Map)((Map)cfg.get("aliyun")).get("oss"),v=(Map)cfg.get("vod"),i=(Map)cfg.get("easemob"); + OssProperties op=new OssProperties();op.setDomain((String)o.get("domain"));op.setBucketName((String)o.get("bucket-name")); + oss=new OSSClientBuilder().build((String)o.get("endpoint"),(String)o.get("access-key-id"),(String)o.get("access-key-secret")); + OssUtil util=new OssUtil();ReflectionTestUtils.setField(util,"ossClient",oss);ReflectionTestUtils.setField(util,"ossProperties",op); + VodProperties vp=new VodProperties();vp.setRegionId((String)v.get("region-id"));vp.setAccessKeyId((String)v.get("access-key-id"));vp.setAccessKeySecret((String)v.get("access-key-secret")); + VodService vod=new VodService(vp,null,AccountDeletionMySqlTest.moments); + EasemobConfig ep=new EasemobConfig();ep.setAppKey((String)i.get("app-key"));ep.setBaseUrl((String)i.get("base-url"));ep.setClientId((String)i.get("client-id"));ep.setClientSecret((String)i.get("client-secret")); + EasemobService im=new EasemobService();ReflectionTestUtils.setField(im,"easemobConfig",ep);ReflectionTestUtils.setField(im,"emService",ep.emService()); + org.springframework.jdbc.core.JdbcTemplate jdbc=AccountDeletionMySqlTest.jdbc; + String uid=m.get("uid"); + AccountDeletionMySqlTest.tx.execute(t->{ + User user=new User();user.setId(uid);user.setNickname("合成销毁账号");user.setUniversityName("测试学校");user.setMajorName("测试专业");user.setGrade("2026");user.setPhone("19900000999");user.setPassword("synthetic-only");user.setAccountStatus("DEACTIVATING");user.setScheduledDeletionAt(new Date(0));user.setXuexinCertified(true);AccountDeletionMySqlTest.users.saveAndFlush(user); + Moment moment=new Moment();moment.setUserId(uid);moment.setContent("Synthetic video");moment.setScope("CAMPUS");moment.setVodVideoId(m.get("vod_id"));AccountDeletionMySqlTest.moments.saveAndFlush(moment); + jdbc.update("INSERT INTO xuexin_report(id,user_id,status,dedup_key,data_fingerprint,level_rank,valid_chsi_report,report_pdf_url,raw_text,deleted,created_at,updated_at) VALUES(?,?,'CONFIRMED',?,REPEAT('0',64),50,true,?,'SYNTHETIC OCR ONLY',false,NOW(),NOW())",UUID.randomUUID().toString(),uid,"test-"+uid,m.get("pdf_url")); + jdbc.update("INSERT INTO chat_group(id,name,owner_id,max_members,member_count,is_approval_required,is_public,group_source,easemob_group_id,deleted,created_at,updated_at) VALUES(?, 'Synthetic group',?,10,1,false,false,'NORMAL',?,false,NOW(),NOW())",UUID.randomUUID().toString(),uid,m.get("im_group"));return null; + }); + JpaTransactionManager manager=new JpaTransactionManager(AccountDeletionMySqlTest.factory.getObject()); + AccountDeletionFinalizer finalizer=proxy(new AccountDeletionFinalizer(AccountDeletionMySqlTest.users,new UserAccountCleanupService(jdbc),jdbc),manager); + ObjectProvider provider=mock(ObjectProvider.class);when(provider.getIfAvailable()).thenReturn(util); + AccountDeletionWorker worker=new AccountDeletionWorker(jdbc,im,vod,provider,op,new ObjectMapper(),finalizer); + AccountDeactivationCleanupService inventory=proxy(new AccountDeactivationCleanupService(AccountDeletionMySqlTest.users,jdbc,worker),manager); + inventory.cleanup(uid); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_batch WHERE user_id=? AND status='READY'",Integer.class,uid)); + assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource",Integer.class)); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource WHERE type='REVIEW'",Integer.class)); + worker.runPending(); + assertFalse(AccountDeletionMySqlTest.users.findById(uid).isPresent()); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM xuexin_report WHERE user_id=?",Integer.class,uid)); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM moment WHERE user_id=?",Integer.class,uid)); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM chat_group WHERE owner_id=?",Integer.class,uid)); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_batch",Integer.class)); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource",Integer.class)); + assertFalse(oss.doesObjectExist(op.getBucketName(),m.get("oss_key"))); + System.out.println("CLOUD_E2E_PASS: tasks persisted; worker deleted cloud resources and user/certification/OCR/media/group records"); + } finally {if(oss!=null)oss.shutdown();AccountDeletionMySqlTest.close();} + } + private T proxy(T target,JpaTransactionManager manager){ + ProxyFactory factory=new ProxyFactory(target);factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource())); + return (T)factory.getProxy(); + } +} + diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionEasemobTest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionEasemobTest.java new file mode 100644 index 0000000..e706035 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionEasemobTest.java @@ -0,0 +1,27 @@ +package com.campus.service; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import com.campus.config.EasemobConfig; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; +class AccountDeletionEasemobTest { + @Test void unavailableServiceNeverPretendsRemoteDeletionSucceeded(){ + assertFalse(new EasemobService().deleteAccountResource("IM_USER","ignored","11111111-1111-1111-1111-111111111111")); + } + @Test void absentRemoteGroupIsIdempotentSuccess(){ + EasemobService service=new EasemobService();RestTemplate rest=new RestTemplate(); + EasemobConfig config=new EasemobConfig();config.setAppKey("test#app");config.setBaseUrl("https://im.test");config.setClientId("test");config.setClientSecret("test-placeholder"); + ReflectionTestUtils.setField(service,"restTemplate",rest);ReflectionTestUtils.setField(service,"easemobConfig",config); + ReflectionTestUtils.setField(service,"emService",mock(com.easemob.im.server.EMService.class)); + MockRestServiceServer server=MockRestServiceServer.createServer(rest); + server.expect(requestTo("https://im.test/test/app/token")).andRespond(withSuccess("{\"access_token\":\"test-token\"}",MediaType.APPLICATION_JSON)); + server.expect(requestTo("https://im.test/test/app/chatgroups/test-group")).andRespond(withStatus(HttpStatus.NOT_FOUND)); + assertTrue(service.deleteAccountResource("IM_GROUP","test-group","11111111-1111-1111-1111-111111111111"));server.verify(); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionFinalizerTest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionFinalizerTest.java new file mode 100644 index 0000000..a8ad9d0 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionFinalizerTest.java @@ -0,0 +1,47 @@ +package com.campus.service; + +import com.campus.repository.UserRepository; +import com.campus.entity.User; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class AccountDeletionFinalizerTest { + @Test void pendingResourcePreventsLocalDeletion() { + UserRepository users=mock(UserRepository.class); + JdbcTemplate jdbc=mock(JdbcTemplate.class); + UserAccountCleanupService cleanup=mock(UserAccountCleanupService.class); + User user=new User(); user.setAccountStatus("DEACTIVATING"); user.setScheduledDeletionAt(new Date(0)); + when(users.findByIdForUpdate("u")).thenReturn(Optional.of(user)); + when(jdbc.queryForObject(anyString(),eq(Integer.class),eq("b"))).thenReturn(1); + new AccountDeletionFinalizer(users,cleanup,jdbc).finish("u","b"); + verifyNoInteractions(cleanup); + verify(jdbc,never()).update(anyString(),any(Object[].class)); + } + @Test void localFailureRetainsResourceTasksForRetry() { + UserRepository users=mock(UserRepository.class); + JdbcTemplate jdbc=mock(JdbcTemplate.class); + UserAccountCleanupService cleanup=mock(UserAccountCleanupService.class); + User user=new User(); user.setAccountStatus("DEACTIVATING"); user.setScheduledDeletionAt(new Date(0)); + when(users.findByIdForUpdate("u")).thenReturn(Optional.of(user)); + when(jdbc.queryForObject(anyString(),eq(Integer.class),eq("b"))).thenReturn(0); + doThrow(new IllegalStateException("数据库失败")).when(cleanup).deleteUserData("u"); + assertThrows(IllegalStateException.class,()->new AccountDeletionFinalizer(users,cleanup,jdbc).finish("u","b")); + verify(jdbc,never()).update(anyString(),any(Object[].class)); + } + @Test void successfulCleanupWritesReceiptBeforeRemovingTasks() { + UserRepository users=mock(UserRepository.class);JdbcTemplate jdbc=mock(JdbcTemplate.class); + UserAccountCleanupService cleanup=mock(UserAccountCleanupService.class); + User user=new User();user.setAccountStatus("DEACTIVATING");user.setScheduledDeletionAt(new Date(0)); + when(users.findByIdForUpdate("u")).thenReturn(Optional.of(user)); + when(jdbc.queryForObject(anyString(),eq(Integer.class),eq("b"))).thenReturn(0); + new AccountDeletionFinalizer(users,cleanup,jdbc).finish("u","b"); + org.mockito.InOrder order=inOrder(cleanup,jdbc); + order.verify(cleanup).deleteUserData("u"); + order.verify(jdbc).update(startsWith("INSERT INTO account_deletion_receipt"),eq("b"),eq(0),eq(0)); + order.verify(jdbc).update("DELETE FROM account_deletion_resource WHERE batch_id=?","b"); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionLegacyInventoryTest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionLegacyInventoryTest.java new file mode 100644 index 0000000..4685f72 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionLegacyInventoryTest.java @@ -0,0 +1,23 @@ +package com.campus.service; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.beans.factory.ObjectProvider; +import com.campus.config.OssProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +class AccountDeletionLegacyInventoryTest { + @Test void legacyAndDraftResourcesAreRetainedForReviewBeforeDeletion() { + JdbcTemplate jdbc=mock(JdbcTemplate.class); + when(jdbc.queryForObject(contains("information_schema.tables"),eq(Integer.class),anyString())).thenReturn(1); + Map video=new HashMap<>();video.put("video_url","https://legacy.test/video.mp4");video.put("cover_url","https://legacy.test/cover.jpg"); + when(jdbc.queryForList(startsWith("SELECT video_url,cover_url"),eq("u"))).thenReturn(Collections.singletonList(video)); + Map draft=new HashMap<>();draft.put("payload","{\"media\":[{\"url\":\"https://legacy.test/draft.jpg\"}],\"vodVideoId\":\"synthetic-video-id\"}"); + when(jdbc.queryForList(startsWith("SELECT id,payload"),eq("u"))).thenReturn(Collections.singletonList(draft)); + AccountDeletionWorker worker=new AccountDeletionWorker(jdbc,mock(EasemobService.class),mock(VodService.class),mock(ObjectProvider.class),new OssProperties(),new ObjectMapper(),mock(AccountDeletionFinalizer.class)); + worker.inventory("u"); + for(String resource:Arrays.asList("https://legacy.test/video.mp4","https://legacy.test/cover.jpg","https://legacy.test/draft.jpg","synthetic-video-id")) + verify(jdbc).update(startsWith("INSERT IGNORE INTO account_deletion_resource"),anyString(),eq("REVIEW"),eq(resource),eq(resource),eq("u")); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionMySqlTest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionMySqlTest.java new file mode 100644 index 0000000..3a42612 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionMySqlTest.java @@ -0,0 +1,70 @@ +package com.campus.service; + +import com.campus.entity.*; +import com.campus.repository.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.orm.jpa.*; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.core.io.FileSystemResource; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import javax.persistence.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="QINGHE_DELETION_TEST_URL",matches="jdbc:mysql://127.0.0.1:33379/qinghe_deletion_test_local.*") +class AccountDeletionMySqlTest { + static LocalContainerEntityManagerFactoryBean factory; + static JdbcTemplate jdbc; + static TransactionTemplate tx; + static UserRepository users; + static MomentRepository moments; + @BeforeAll static void initialize() { + ((ch.qos.logback.classic.Logger)org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME)).setLevel(ch.qos.logback.classic.Level.WARN); + DriverManagerDataSource ds=new DriverManagerDataSource(System.getenv("QINGHE_DELETION_TEST_URL"),"root",""); + jdbc=new JdbcTemplate(ds); + factory=new LocalContainerEntityManagerFactoryBean();factory.setDataSource(ds); + factory.setPackagesToScan("com.campus.entity");factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); + Map props=new HashMap<>();props.put("hibernate.hbm2ddl.auto","create-drop"); + props.put("hibernate.dialect","org.hibernate.dialect.MySQL57Dialect"); + props.put("hibernate.physical_naming_strategy","org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy"); + factory.setJpaPropertyMap(props);factory.afterPropertiesSet(); + EntityManagerFactory emf=factory.getObject();EntityManager em=SharedEntityManagerCreator.createSharedEntityManager(emf); + tx=new TransactionTemplate(new JpaTransactionManager(emf)); + JpaRepositoryFactory repos=new JpaRepositoryFactory(em);users=repos.getRepository(UserRepository.class);moments=repos.getRepository(MomentRepository.class); + new ResourceDatabasePopulator(new FileSystemResource("src/main/resources/db/migration/V27__account_deletion_batch.sql"),new FileSystemResource("src/main/resources/db/migration/V28__account_deletion_receipt.sql")).execute(ds); + } + @AfterAll static void close(){if(factory!=null)factory.destroy();} + @Test void coolingContentHiddenAndRestoredWithoutChangingContentStatus(){ + String[] ids=tx.execute(t->{User u=new User();u.setNickname("合成测试账号");u.setUniversityName("测试学校");u.setMajorName("测试专业");u.setGrade("2026");u.setPhone("19900000001");u.setPassword("test-only");u.setAccountStatus("DEACTIVATING");users.saveAndFlush(u);Moment m=new Moment();m.setUserId(u.getId());m.setContent("test");m.setScope("CAMPUS");m.setReviewStatus("approved");moments.saveAndFlush(m);return new String[]{u.getId(),m.getId()};}); + assertFalse(moments.findById(ids[1]).isPresent()); + tx.execute(t->{User u=users.findByIdForUpdate(ids[0]).get();u.setAccountStatus("ACTIVE");users.saveAndFlush(u);return null;}); + assertTrue(moments.findById(ids[1]).isPresent()); + } + @Test void migrationIsIdempotentAndResourceTasksCascade(){ + jdbc.update("INSERT INTO account_deletion_batch VALUES ('batch-test','user-test','READY',0,NOW(),NOW())"); + jdbc.update("INSERT INTO account_deletion_resource VALUES ('resource-test','batch-test','IM_USER','user-test',SHA2('user-test',256),'PENDING',0,NOW(),NOW(),NOW())"); + jdbc.update("DELETE FROM account_deletion_batch WHERE id='batch-test'"); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM account_deletion_resource WHERE batch_id='batch-test'",Integer.class)); + } + @Test void concurrentRecoveryAndCleanupSerializeOnUserLock() throws Exception { + String uid=tx.execute(t->{User u=new User();u.setNickname("并发测试");u.setUniversityName("测试学校");u.setMajorName("测试专业");u.setGrade("2026");u.setPhone("19900000003");u.setPassword("test-only");u.setAccountStatus("DEACTIVATING");u.setScheduledDeletionAt(new Date(System.currentTimeMillis()+86400000));return users.saveAndFlush(u).getId();}); + java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newFixedThreadPool(2); + java.util.concurrent.CountDownLatch locked=new java.util.concurrent.CountDownLatch(1),attempted=new java.util.concurrent.CountDownLatch(1); + try { + java.util.concurrent.Future recovery=pool.submit(()->tx.execute(t->{User u=users.findByIdForUpdate(uid).get();locked.countDown();try{assertTrue(attempted.await(5,java.util.concurrent.TimeUnit.SECONDS));}catch(InterruptedException e){throw new RuntimeException(e);}u.setAccountStatus("ACTIVE");u.setScheduledDeletionAt(null);users.saveAndFlush(u);return null;})); + java.util.concurrent.Future cleanup=pool.submit(()->{assertTrue(locked.await(5,java.util.concurrent.TimeUnit.SECONDS));attempted.countDown();return tx.execute(t->{User u=users.findByIdForUpdate(uid).get();return "DEACTIVATING".equals(u.getAccountStatus());});}); + recovery.get(10,java.util.concurrent.TimeUnit.SECONDS);assertFalse(cleanup.get(10,java.util.concurrent.TimeUnit.SECONDS)); + } finally {pool.shutdownNow();} + } + @Test void cleanupFailureRollsBackUserDeletion(){ + String uid=tx.execute(t->{User u=new User();u.setNickname("回滚测试");u.setUniversityName("测试学校");u.setMajorName("测试专业");u.setGrade("2026");u.setPhone("19900000002");u.setPassword("test-only");return users.saveAndFlush(u).getId();}); + RuntimeException marker=new RuntimeException("simulate rollback"); + assertSame(marker,assertThrows(RuntimeException.class,()->tx.execute(t->{new UserAccountCleanupService(jdbc).deleteUserData(uid);throw marker;}))); + assertTrue(users.findById(uid).isPresent()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionOssTest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionOssTest.java new file mode 100644 index 0000000..3ec595f --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionOssTest.java @@ -0,0 +1,21 @@ +package com.campus.service; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import com.campus.config.OssProperties; +import com.campus.util.OssUtil; +import com.aliyun.oss.OSS; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +class AccountDeletionOssTest { + private OssUtil create(OSS client,OssProperties props){OssUtil util=new OssUtil();ReflectionTestUtils.setField(util,"ossClient",client);ReflectionTestUtils.setField(util,"ossProperties",props);return util;} + @Test void signedPdfDeletesExactKeyAndVerifiesAbsence(){ + OSS client=mock(OSS.class);OssProperties props=new OssProperties();props.setDomain("https://files.test/");props.setBucketName("test-bucket"); + OssUtil util=create(client,props); + util.deleteAccountFile("https://files.test/docs/2026/10/23/test.pdf?signature=test"); + verify(client).deleteObject("test-bucket","docs/2026/10/23/test.pdf");verify(client).doesObjectExist("test-bucket","docs/2026/10/23/test.pdf"); + } + @Test void otherHostNeverDeletes(){ + OSS client=mock(OSS.class);OssProperties props=new OssProperties();props.setDomain("https://files.test/"); + assertThrows(IllegalArgumentException.class,()->create(client,props).deleteAccountFile("https://other.test/private.pdf"));verifyNoInteractions(client); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionReviewServiceTest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionReviewServiceTest.java new file mode 100644 index 0000000..cf87365 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionReviewServiceTest.java @@ -0,0 +1,15 @@ +package com.campus.service; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import com.campus.common.ForbiddenException; +class AccountDeletionReviewServiceTest { + @Test void unauthorizedReviewCannotReadOrWrite(){ + JdbcTemplate jdbc=mock(JdbcTemplate.class);AuthorizationService auth=mock(AuthorizationService.class);AuditLogService audit=mock(AuditLogService.class);ClickGuardService clicks=mock(ClickGuardService.class); + doThrow(new ForbiddenException("仅管理员可以执行该操作")).when(auth).requireAdmin(); + AccountDeletionReviewService service=new AccountDeletionReviewService(jdbc,auth,clicks,audit); + ForbiddenException error=assertThrows(ForbiddenException.class,()->service.resolve("task","KEEP_SHARED","测试核实依据")); + assertEquals("仅管理员可以执行该操作",error.getMessage());verifyNoInteractions(jdbc,clicks,audit); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionVodTest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionVodTest.java new file mode 100644 index 0000000..f8bac13 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionVodTest.java @@ -0,0 +1,22 @@ +package com.campus.service; +import org.junit.jupiter.api.Test; +import com.aliyuncs.DefaultAcsClient; +import com.aliyuncs.exceptions.ClientException; +import com.aliyuncs.vod.model.v20170321.DeleteVideoRequest; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; +class AccountDeletionVodTest { + @Test void absentVideoIsSuccessAndClientClosed() throws Exception { + DefaultAcsClient client=mock(DefaultAcsClient.class); + VodService service=spy(new VodService(null,null,null));doReturn(client).when(service).buildVodClient(); + when(client.getAcsResponse(any(DeleteVideoRequest.class))).thenThrow(new ClientException("InvalidVideo.NotFound","absent")); + assertDoesNotThrow(()->service.deleteAccountVideo("test-video"));verify(client).shutdown(); + } + @Test void accessDeniedStillFailsForRetry() throws Exception { + DefaultAcsClient client=mock(DefaultAcsClient.class); + VodService service=spy(new VodService(null,null,null));doReturn(client).when(service).buildVodClient(); + when(client.getAcsResponse(any(DeleteVideoRequest.class))).thenThrow(new ClientException("Forbidden","denied")); + assertThrows(ClientException.class,()->service.deleteAccountVideo("test-video"));verify(client).shutdown(); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AccountDeletionWorkerTest.java b/campus-server/src/test/java/com/campus/service/AccountDeletionWorkerTest.java new file mode 100644 index 0000000..e394cd7 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AccountDeletionWorkerTest.java @@ -0,0 +1,25 @@ +package com.campus.service; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.beans.factory.ObjectProvider; +import com.campus.config.OssProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +class AccountDeletionWorkerTest { + @Test void remoteFailurePreservesLocalDataAndRetriesLater() { + JdbcTemplate jdbc=mock(JdbcTemplate.class); EasemobService im=mock(EasemobService.class); + AccountDeletionFinalizer finalizer=mock(AccountDeletionFinalizer.class); + Map batch=new HashMap<>();batch.put("id","b");batch.put("user_id","u"); + Map task=new HashMap<>();task.put("id","t");task.put("type","IM_GROUP");task.put("resource_id","g"); + when(jdbc.queryForList(startsWith("SELECT id,user_id"))).thenReturn(Collections.singletonList(batch)); + when(jdbc.queryForList(startsWith("SELECT id,type"),eq("b"))).thenReturn(Collections.singletonList(task)); + when(jdbc.queryForObject(anyString(),eq(Integer.class),eq("b"))).thenReturn(1); + when(jdbc.update(startsWith("UPDATE account_deletion_batch SET status='PROCESSING'"),eq("b"))).thenReturn(1); + new AccountDeletionWorker(jdbc,im,mock(VodService.class),mock(ObjectProvider.class),new OssProperties(),new ObjectMapper(),finalizer).runPending(); + verify(im).deleteAccountResource("IM_GROUP","g","u"); + verify(jdbc).update(contains("INTERVAL 1 HOUR"),eq("t")); + verifyNoInteractions(finalizer); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AchievementQueryTest.java b/campus-server/src/test/java/com/campus/service/AchievementQueryTest.java new file mode 100644 index 0000000..f0d3d9b --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AchievementQueryTest.java @@ -0,0 +1,32 @@ +package com.campus.service; +import com.campus.entity.User; +import org.junit.jupiter.api.Test; +import org.hibernate.cfg.Configuration; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.type.classreading.SimpleMetadataReaderFactory; +import javax.persistence.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +/** 无业务数据库连接,使用真实 Hibernate 元数据解析全部统计查询。 */ +class AchievementQueryTest { + @Test void allMetricsQueriesCompileAgainstEntityMappings() throws Exception { + Configuration configuration=new Configuration(); + configuration.setProperty("hibernate.dialect","org.hibernate.dialect.MySQL57Dialect"); + configuration.setProperty("hibernate.temp.use_jdbc_metadata_defaults","false"); + configuration.setProperty("hibernate.hbm2ddl.auto","none"); + SimpleMetadataReaderFactory readers=new SimpleMetadataReaderFactory(); + for(org.springframework.core.io.Resource resource:new PathMatchingResourcePatternResolver().getResources("classpath*:com/campus/entity/*.class")) { + Class type=Class.forName(readers.getMetadataReader(resource).getClassMetadata().getClassName()); + if(type.isAnnotationPresent(Entity.class)) configuration.addAnnotatedClass(type); + } + try(SessionFactoryImplementor factory=(SessionFactoryImplementor)configuration.buildSessionFactory()) { + EntityManager em=mock(EntityManager.class); + when(em.createQuery(anyString())).thenAnswer(inv->{factory.getQueryPlanCache().getHQLQueryPlan(inv.getArgument(0),false,Collections.emptyMap());Query q=mock(Query.class);when(q.setParameter(anyString(),any())).thenReturn(q);when(q.getSingleResult()).thenReturn(0L);return q;}); + when(em.createQuery(anyString(),any(Class.class))).thenAnswer(inv->{factory.getQueryPlanCache().getHQLQueryPlan(inv.getArgument(0),false,Collections.emptyMap());TypedQuery q=mock(TypedQuery.class);when(q.setParameter(anyString(),any())).thenReturn(q);when(q.getResultList()).thenReturn(Collections.emptyList());return q;}); + User user=new User();user.setId("user");Map metrics=new AchievementMetricsService(em).measure(user); + assertEquals(8,metrics.size());assertEquals(0L,metrics.get("CHECKIN_EXPERT"));assertEquals(0L,metrics.get("FRIEND_EXPERT")); + } + } +} diff --git a/campus-server/src/test/java/com/campus/service/AchievementServiceTest.java b/campus-server/src/test/java/com/campus/service/AchievementServiceTest.java new file mode 100644 index 0000000..574dbb9 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AchievementServiceTest.java @@ -0,0 +1,56 @@ +package com.campus.service; +import com.campus.common.BusinessException; +import com.campus.entity.*; +import com.campus.repository.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +@ExtendWith(MockitoExtension.class) +class AchievementServiceTest { + @Mock AchievementDefinitionRepository definitions; + @Mock UserAchievementRepository awards; + @Mock UserRepository users; + @Mock AchievementMetricsService metrics; + @InjectMocks AchievementService service; + User user; + @BeforeEach void login(){user=new User();user.setId("user");SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null));} + @AfterEach void clear(){SecurityContextHolder.clearContext();} + AchievementDefinition definition(){AchievementDefinition d=new AchievementDefinition();d.setCode("CHECKIN_EXPERT");d.setTargetValue(30L);d.setCategoryCode("CORE");return d;} + void catalog(){when(definitions.findByEnabledTrueOrderBySortAscCodeAsc()).thenReturn(Collections.singletonList(definition()));} + com.campus.dto.response.AchievementDTO imageDetail(boolean unlocked, String color, String gray, String legacy) { + AchievementDefinition d=definition();d.setColorIconUrl(color);d.setGrayIconUrl(gray);d.setIconUrl(legacy); + when(definitions.findByEnabledTrueOrderBySortAscCodeAsc()).thenReturn(Collections.singletonList(d)); + when(users.findById("user")).thenReturn(Optional.of(user)); + if(unlocked){UserAchievement a=new UserAchievement();a.setAchievementCode(d.getCode());a.setProgressValue(30L);a.setUnlockedAt(new Date(1000));when(awards.findIncludingDeleted("user")).thenReturn(Collections.singletonList(a));} + com.campus.dto.response.AchievementDTO dto=service.detail(d.getCode()); + assertEquals(unlocked,dto.isUnlocked());assertEquals(unlocked?new Date(1000):null,dto.getUnlockedAt()); + verify(awards,never()).save(any());verify(definitions,never()).save(any());return dto; + } + @Test void unlockedUsesColorInsteadOfLegacyGray(){ + com.campus.dto.response.AchievementDTO dto=imageDetail(true,"https://img/color.png","https://img/gray.png","https://img/old-gray.png"); + assertEquals("https://img/color.png",dto.getIconUrl());assertEquals("https://img/color.png",dto.getColorIconUrl());assertEquals("https://img/gray.png",dto.getGrayIconUrl()); + com.campus.dto.response.AchievementDTO listed=service.overview("ALL",null).getCategories().get(0).getAchievements().get(0); + assertEquals(dto,listed); + } + @Test void lockedUsesGrayInsteadOfLegacyColor(){assertEquals("https://img/gray.png",imageDetail(false,"https://img/color.png","https://img/gray.png","https://img/old-color.png").getIconUrl());} + @Test void missingColorDoesNotReturnGrayForUnlockedBadge(){assertNull(imageDetail(true," ","https://img/gray.png","https://img/old-gray.png").getIconUrl());} + @Test void missingGrayDoesNotReturnColorForLockedBadge(){assertNull(imageDetail(false,"https://img/color.png",null,"https://img/old-color.png").getIconUrl());} + @Test void unconfiguredStateIconsKeepLegacyCompatibility(){assertEquals("https://img/legacy.png",imageDetail(false,null,null,"https://img/legacy.png").getIconUrl());} + @Test void noImagesReturnsNull(){assertNull(imageDetail(false,null,null," ").getIconUrl());} + @Test void requiresLogin(){clear();assertEquals("用户未登录",assertThrows(BusinessException.class,()->service.overview("ALL",null)).getMessage());verifyNoInteractions(definitions,awards,users,metrics);} + @Test void invalidStatusDoesNotWrite(){assertEquals("成就状态参数不合法",assertThrows(BusinessException.class,()->service.overview("bad",null)).getMessage());verifyNoInteractions(definitions,awards,users,metrics);} + @Test void invalidCategoryDoesNotWrite(){catalog();assertEquals("成就分类不存在",assertThrows(BusinessException.class,()->service.overview("ALL","bad")).getMessage());verifyNoInteractions(awards,users,metrics);} + @Test void filteredListKeepsTotalsAndIsReadOnly(){catalog();when(users.findById("user")).thenReturn(Optional.of(user));when(metrics.measure(user)).thenReturn(Collections.singletonMap("CHECKIN_EXPERT",15L));com.campus.dto.response.AchievementOverviewDTO dto=service.overview("UNLOCKED",null);assertEquals(1,dto.getTotalCount());assertEquals(1,dto.getLockedCount());assertTrue(dto.getCategories().get(0).getAchievements().isEmpty());verify(awards,never()).save(any());} + @Test void grantsOnceAndRetainsDate(){catalog();when(users.findByIdForUpdate("user")).thenReturn(Optional.of(user));when(metrics.measure(user)).thenReturn(Collections.singletonMap("CHECKIN_EXPERT",30L));service.evaluate("user");ArgumentCaptor captor=ArgumentCaptor.forClass(UserAchievement.class);verify(awards).save(captor.capture());UserAchievement record=captor.getValue();assertNotNull(record.getUnlockedAt());when(awards.findIncludingDeleted("user")).thenReturn(Collections.singletonList(record));service.evaluate("user");verify(awards,times(1)).save(any());} + @Test void insufficientProgressDoesNotWrite(){catalog();when(users.findByIdForUpdate("user")).thenReturn(Optional.of(user));when(metrics.measure(user)).thenReturn(Collections.singletonMap("CHECKIN_EXPERT",29L));service.evaluate("user");verify(awards,never()).save(any());} + @Test void deletedRecordIsRestored(){catalog();UserAchievement a=new UserAchievement();a.setDeleted(true);a.setAchievementCode("CHECKIN_EXPERT");a.setUnlockedAt(new Date(1000));when(awards.findIncludingDeleted("user")).thenReturn(Collections.singletonList(a));when(users.findByIdForUpdate("user")).thenReturn(Optional.of(user));when(metrics.measure(user)).thenReturn(Collections.singletonMap("CHECKIN_EXPERT",30L));service.evaluate("user");assertFalse(a.getDeleted());assertEquals(new Date(1000),a.getUnlockedAt());verify(awards).save(a);} + @Test void missingCodeIsBusiness400(){when(users.findById("user")).thenReturn(Optional.of(user));assertEquals("成就不存在",assertThrows(BusinessException.class,()->service.detail("bad")).getMessage());verify(awards,never()).save(any());} + @Test void unlockedProgressStaysCompleteAfterSourceRemoved(){catalog();when(users.findById("user")).thenReturn(Optional.of(user));when(metrics.measure(user)).thenReturn(Collections.singletonMap("CHECKIN_EXPERT",1L));UserAchievement a=new UserAchievement();a.setAchievementCode("CHECKIN_EXPERT");a.setProgressValue(30L);a.setUnlockedAt(new Date(1000));when(awards.findIncludingDeleted("user")).thenReturn(Collections.singletonList(a));com.campus.dto.response.AchievementOverviewDTO dto=service.overview("ALL",null);assertEquals(100,dto.getCompletionPercent());assertEquals("徽章大师",dto.getTitle());assertNull(dto.getNextTitle());assertEquals(30,dto.getCategories().get(0).getAchievements().get(0).getProgress());assertEquals(new Date(1000),dto.getCategories().get(0).getAchievements().get(0).getUnlockedAt());verify(awards,never()).save(any());} + @Test void metricFailureDoesNotGrant(){when(users.findByIdForUpdate("user")).thenReturn(Optional.of(user));when(metrics.measure(user)).thenThrow(new IllegalStateException("数据库故障"));assertThrows(IllegalStateException.class,()->service.evaluate("user"));verify(awards,never()).save(any());} +} diff --git a/campus-server/src/test/java/com/campus/service/AchievementStateConsistencyTest.java b/campus-server/src/test/java/com/campus/service/AchievementStateConsistencyTest.java new file mode 100644 index 0000000..89f63e5 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AchievementStateConsistencyTest.java @@ -0,0 +1,133 @@ +package com.campus.service; + +import com.campus.dto.response.AchievementDTO; +import com.campus.dto.response.AchievementOverviewDTO; +import com.campus.entity.*; +import com.campus.repository.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 守护总览、筛选、详情与持久授予之间的同一状态口径。 */ +@ExtendWith(MockitoExtension.class) +class AchievementStateConsistencyTest { + @Mock AchievementDefinitionRepository definitions; + @Mock UserAchievementRepository awards; + @Mock UserRepository users; + @Mock AchievementMetricsService metrics; + @InjectMocks AchievementService service; + UserAchievement award; + Map progress; + List catalog; + + @BeforeEach void setUp() { + User user = new User(); user.setId("user"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null)); + String[] codes = {"ACTIVITY_EXPERT", "MOMENT_EXPERT", "CHECKIN_EXPERT", "FRIEND_EXPERT", + "GIFT_EXPERT", "CAMPUS_EXPERT", "POPULAR_EXPERT", "SHARE_EXPERT"}; + catalog = new ArrayList<>(); + for (String code : codes) { + AchievementDefinition d = new AchievementDefinition(); + d.setCode(code); d.setCategoryCode("CORE"); d.setTargetValue(10L); + catalog.add(d); + } + award = new UserAchievement(); award.setAchievementCode("CAMPUS_EXPERT"); + award.setProgressValue(10L); award.setUnlockedAt(new Date(1000)); award.setDeleted(false); + when(definitions.findByEnabledTrueOrderBySortAscCodeAsc()).thenReturn(catalog); + when(users.findById("user")).thenReturn(Optional.of(user)); + progress = new HashMap<>(); + when(metrics.measure(user)).thenReturn(progress); + when(awards.findIncludingDeleted("user")).thenReturn(Collections.singletonList(award)); + } + + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + List items(AchievementOverviewDTO result) { + return result.getCategories().stream().flatMap(c -> c.getAchievements().stream()).collect(Collectors.toList()); + } + + void assertGlobalCounts(AchievementOverviewDTO result) { + assertEquals(8, result.getTotalCount()); assertEquals(1, result.getUnlockedCount()); + assertEquals(7, result.getLockedCount()); assertEquals(12, result.getCompletionPercent()); + verify(awards, never()).save(any()); + } + + @Test void allItemsAndSummaryUseSameAwardState() { + AchievementOverviewDTO result = service.overview("ALL", null); + assertGlobalCounts(result); + assertEquals(8, items(result).size()); + assertEquals(result.getUnlockedCount(), items(result).stream().filter(AchievementDTO::isUnlocked).count()); + assertEquals(1, result.getCategories().get(0).getUnlockedCount()); + for (AchievementDTO item : items(result)) { + assertEquals("CAMPUS_EXPERT".equals(item.getCode()), item.isUnlocked()); + assertEquals(item.isUnlocked() ? award.getUnlockedAt() : null, item.getUnlockedAt()); + } + } + + @Test void lockedFilterDoesNotChangeGlobalOrCategoryCounts() { + AchievementOverviewDTO result = service.overview("LOCKED", null); + assertGlobalCounts(result); + assertEquals(7, items(result).size()); + assertEquals(8, result.getCategories().get(0).getTotalCount()); + assertEquals(1, result.getCategories().get(0).getUnlockedCount()); + assertTrue(items(result).stream().allMatch(d -> !d.isUnlocked() && d.getUnlockedAt() == null)); + } + + @Test void unlockedFilterReturnsOnlyAwardedItemWithOriginalDate() { + AchievementOverviewDTO result = service.overview("UNLOCKED", null); + assertGlobalCounts(result); + assertEquals(1, items(result).size()); + assertTrue(items(result).get(0).isUnlocked()); + assertEquals(award.getUnlockedAt(), items(result).get(0).getUnlockedAt()); + } + + @Test void categoryFilterCanHideAwardWithoutChangingGlobalCounts() { + catalog.get(5).setCategoryCode("OTHER"); + AchievementOverviewDTO result = service.overview("ALL", "CORE"); + assertGlobalCounts(result); + assertEquals(1, result.getCategories().size()); + assertEquals(7, result.getCategories().get(0).getTotalCount()); + assertEquals(0, result.getCategories().get(0).getUnlockedCount()); + assertTrue(items(result).stream().noneMatch(AchievementDTO::isUnlocked)); + } + + @Test void detailMatchesOverviewIncludingIconAndUnlockDate() { + catalog.get(5).setIconUrl("https://example.invalid/badge.png"); + AchievementDTO listed = items(service.overview("ALL", null)).stream() + .filter(d -> "CAMPUS_EXPERT".equals(d.getCode())).findFirst().get(); + assertEquals(listed, service.detail("CAMPUS_EXPERT")); + verify(awards, never()).save(any()); + } + + @Test void deletedAwardDoesNotUnlockOrExposeDate() { + award.setDeleted(true); + AchievementOverviewDTO result = service.overview("ALL", null); + assertEquals(0, result.getUnlockedCount()); assertEquals(8, result.getLockedCount()); + assertTrue(items(result).stream().allMatch(d -> !d.isUnlocked() && d.getUnlockedAt() == null)); + verify(awards, never()).save(any()); + } + + @Test void awardOutsideEnabledCatalogDoesNotIncreaseCounts() { + catalog.remove(5); + AchievementOverviewDTO result = service.overview("ALL", null); + assertEquals(7, result.getTotalCount()); assertEquals(0, result.getUnlockedCount()); + assertEquals(7, items(result).size()); + verify(awards, never()).save(any()); + } + + @Test void fullProgressDoesNotGrantDuringRead() { + progress.put("ACTIVITY_EXPERT", 10L); + AchievementOverviewDTO result = service.overview("ALL", null); + AchievementDTO item = items(result).get(0); + assertEquals(100, item.getProgressPercent()); assertFalse(item.isUnlocked()); assertNull(item.getUnlockedAt()); + assertGlobalCounts(result); + verify(users, never()).findByIdForUpdate(anyString()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityApplyReviewTest.java b/campus-server/src/test/java/com/campus/service/ActivityApplyReviewTest.java new file mode 100644 index 0000000..499c5ae --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityApplyReviewTest.java @@ -0,0 +1,543 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.response.ActivityDTO; +import com.campus.dto.response.ActivityParticipantDTO; +import com.campus.entity.Activity; +import com.campus.entity.ActivityParticipant; +import com.campus.entity.User; +import com.campus.repository.ActivityParticipantRepository; +import com.campus.repository.ActivityRepository; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.Date; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 活动报名审核(申请 → 待审核 → 通过/拒绝 → 反馈)的语义回归测试。 + * 覆盖:行锁与唯一键兜底、申请次数上限、报名截止、撤回待审核申请、详情页「我的报名状态」回显。 + */ +@ExtendWith(MockitoExtension.class) +class ActivityApplyReviewTest { + + @Mock private ActivityRepository activityRepository; + @Mock private ActivityParticipantRepository participantRepository; + @Mock private ContentReviewService contentReviewService; + @Mock private ObjectMapper objectMapper; + @Mock private PointsService pointsService; + @Mock private PushService pushService; + @Mock private NotificationService notificationService; + @Mock private UserRepository userRepository; + @Mock private EasemobService easemobService; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private GroupAnnouncementRepository groupAnnouncementRepository; + @Mock private ChatConversationRepository chatConversationRepository; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private AuthorizationService authorizationService; + @Mock private UserRestrictionService userRestrictionService; + @Mock private ActivityOperationLogService operationLogService; + + @Mock private com.campus.service.location.ActivityLocationService activityLocationService; + + private ActivityService service() { + return new ActivityService(activityRepository, participantRepository, contentReviewService, objectMapper, + pointsService, pushService, notificationService, userRepository, easemobService, groupRepository, + groupMemberRepository, groupAnnouncementRepository, chatConversationRepository, + privacySettingsService, authorizationService, userRestrictionService, operationLogService, activityLocationService); + } + + @BeforeEach + void loginAsOwner() { + login("owner"); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private static void login(String userId) { + User user = new User(); + user.setId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + private static Activity activity(String creatorId) { + Activity activity = new Activity(); + activity.setId("activity"); + activity.setTitle("测试活动"); + activity.setType("sports"); + activity.setCreatorId(creatorId); + activity.setStatus("ACTIVE"); + activity.setReviewStatus("approved"); + activity.setCurrentCount(0); + activity.setMaxCount(10); + activity.setHeat(0); + return activity; + } + + private static ActivityParticipant participant(String status) { + ActivityParticipant participant = new ActivityParticipant(); + participant.setId("p1"); + participant.setActivityId("activity"); + participant.setUserId("user"); + participant.setStatus(status); + participant.setJoinedAt(new Date()); + return participant; + } + + private static User applicant(String nickname) { + User user = new User(); + user.setId("user"); + user.setNickname(nickname); + return user; + } + + private static void stubActivity(ActivityRepository repository, Activity activity) { + when(repository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + } + + // ==================== 提交报名申请 ==================== + + @Test + void applyRejectsGuestBeforeTouchingActivityRow() { + BusinessException error = assertThrows(BusinessException.class, + () -> service().applyActivity("activity", null, null)); + + assertEquals("请先登录", error.getMessage()); + verify(activityRepository, never()).findByIdForUpdate(any()); + verify(participantRepository, never()).saveAndFlush(any()); + } + + @Test + void applyRejectsCreatorOfTheActivity() { + stubActivity(activityRepository, activity("user")); + + BusinessException error = assertThrows(BusinessException.class, + () -> service().applyActivity("activity", "user", null)); + + assertEquals("不能报名自己创建的活动", error.getMessage()); + verify(participantRepository, never()).saveAndFlush(any()); + } + + @Test + void applyRejectsActivityThatIsNotApproved() { + Activity activity = activity("owner"); + activity.setReviewStatus("pending"); + stubActivity(activityRepository, activity); + + assertEquals("活动当前不可报名", assertThrows(BusinessException.class, + () -> service().applyActivity("activity", "user", null)).getMessage()); + } + + @Test + void applyRejectsActivityThatIsNotActive() { + Activity activity = activity("owner"); + activity.setStatus("ENDED"); + stubActivity(activityRepository, activity); + + assertEquals("活动当前不可报名", assertThrows(BusinessException.class, + () -> service().applyActivity("activity", "user", null)).getMessage()); + } + + @Test + void applyRejectsAfterRegistrationDeadline() { + Activity activity = activity("owner"); + activity.setRegistrationDeadline(new Date(System.currentTimeMillis() - 60_000L)); + stubActivity(activityRepository, activity); + + assertEquals("报名已截止", assertThrows(BusinessException.class, + () -> service().applyActivity("activity", "user", null)).getMessage()); + verify(participantRepository, never()).saveAndFlush(any()); + } + + @Test + void applyRejectsDuplicatePendingApplication() { + stubActivity(activityRepository, activity("owner")); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(participant("PENDING"))); + + assertEquals("你已经提交报名或报名已通过", assertThrows(BusinessException.class, + () -> service().applyActivity("activity", "user", null)).getMessage()); + verify(participantRepository, never()).saveAndFlush(any()); + } + + @Test + void applyTakesActivityRowLock() { + Activity activity = activity("owner"); + stubActivity(activityRepository, activity); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.empty()); + when(participantRepository.saveAndFlush(any(ActivityParticipant.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(userRepository.findById("user")).thenReturn(Optional.empty()); + + service().applyActivity("activity", "user", null); + + verify(activityRepository).findByIdForUpdate("activity"); + } + + @Test + void applyCreatesPendingApplicationWithoutOccupyingQuotaAndNotifiesCreator() { + Activity activity = activity("owner"); + stubActivity(activityRepository, activity); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.empty()); + when(participantRepository.saveAndFlush(any(ActivityParticipant.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(userRepository.findById("user")).thenReturn(Optional.of(applicant("小明"))); + + ActivityParticipantDTO dto = service().applyActivity("activity", "user", "想去"); + + assertEquals("PENDING", dto.getStatus()); + assertEquals(1, dto.getApplyCount().intValue()); + // 待审核不占名额 + assertEquals(0, activity.getCurrentCount()); + verify(pushService).pushActivityApply(eq("owner"), eq("小明"), any(), eq("user"), + eq("activity"), eq("测试活动")); + verify(notificationService).createNotification(eq("owner"), eq("ACTIVITY_JOIN_REQUEST"), + eq("活动报名申请"), contains("申请参加"), any(), eq("/activity/activity"), + eq("activity"), eq("user")); + } + + @Test + void applyRejectsWhenRejectQuotaExhausted() { + stubActivity(activityRepository, activity("owner")); + ActivityParticipant rejected = participant("REJECTED"); + rejected.setRejectCount(3); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(rejected)); + + assertEquals("报名次数已达上限", assertThrows(BusinessException.class, + () -> service().applyActivity("activity", "user", null)).getMessage()); + verify(participantRepository, never()).saveAndFlush(any()); + } + + @Test + void applyRejectsWhenCancelQuotaExhausted() { + stubActivity(activityRepository, activity("owner")); + ActivityParticipant cancelled = participant("CANCELLED"); + cancelled.setCancelCount(3); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(cancelled)); + + assertEquals("报名次数已达上限", assertThrows(BusinessException.class, + () -> service().applyActivity("activity", "user", null)).getMessage()); + verify(participantRepository, never()).saveAndFlush(any()); + } + + @Test + void applyStillAllowedWhenOnlyPartOfEachQuotaIsUsed() { + // 两个额度独立:被拒 2 次 + 撤回 2 次都未用尽,应允许继续申请 + stubActivity(activityRepository, activity("owner")); + ActivityParticipant previous = participant("REJECTED"); + previous.setRejectCount(2); + previous.setCancelCount(2); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(previous)); + when(participantRepository.saveAndFlush(any(ActivityParticipant.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(userRepository.findById("user")).thenReturn(Optional.empty()); + + ActivityParticipantDTO dto = service().applyActivity("activity", "user", null); + + assertEquals("PENDING", dto.getStatus()); + assertEquals(2, dto.getRejectCount().intValue()); + assertEquals(2, dto.getCancelCount().intValue()); + } + + @Test + void applyAllowsReapplyAfterRejectionAndCountsOnceMore() { + Activity activity = activity("owner"); + stubActivity(activityRepository, activity); + ActivityParticipant rejected = participant("REJECTED"); + rejected.setApplyCount(1); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(rejected)); + when(participantRepository.saveAndFlush(any(ActivityParticipant.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(userRepository.findById("user")).thenReturn(Optional.empty()); + + ActivityParticipantDTO dto = service().applyActivity("activity", "user", "再试一次"); + + assertEquals("PENDING", dto.getStatus()); + assertEquals(2, dto.getApplyCount().intValue()); + } + + @Test + void applyConvertsUniqueKeyConflictIntoBusinessErrorInsteadOf500() { + stubActivity(activityRepository, activity("owner")); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.empty()); + when(participantRepository.saveAndFlush(any(ActivityParticipant.class))) + .thenThrow(new DataIntegrityViolationException("uk_ap_activity_user")); + + assertEquals("你已提交过报名申请,请等待审核", assertThrows(BusinessException.class, + () -> service().applyActivity("activity", "user", null)).getMessage()); + } + + // ==================== 创建者审核 ==================== + + @Test + void reviewRejectsNonOwner() { + login("other"); + stubActivity(activityRepository, activity("owner")); + + assertEquals("只有活动创建者可以管理报名", assertThrows(BusinessException.class, + () -> service().reviewParticipant("activity", "p1", "APPROVE", null)).getMessage()); + verify(participantRepository, never()).save(any(ActivityParticipant.class)); + } + + @Test + void reviewApproveRejectsWhenActivityIsFullAndKeepsPending() { + Activity activity = activity("owner"); + activity.setMaxCount(1); + activity.setCurrentCount(1); + stubActivity(activityRepository, activity); + ActivityParticipant pending = participant("PENDING"); + when(participantRepository.findById("p1")).thenReturn(Optional.of(pending)); + + assertEquals("活动报名人数已满", assertThrows(BusinessException.class, + () -> service().reviewParticipant("activity", "p1", "APPROVE", null)).getMessage()); + assertEquals("PENDING", pending.getStatus()); + verify(participantRepository, never()).save(any(ActivityParticipant.class)); + } + + @Test + void reviewApproveConfirmsParticipantAndNotifiesApplicant() { + Activity activity = activity("owner"); + stubActivity(activityRepository, activity); + ActivityParticipant pending = participant("PENDING"); + when(participantRepository.findById("p1")).thenReturn(Optional.of(pending)); + when(participantRepository.save(any(ActivityParticipant.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + ActivityParticipantDTO dto = service().reviewParticipant("activity", "p1", "APPROVE", null); + + assertEquals("CONFIRMED", dto.getStatus()); + assertEquals(1, activity.getCurrentCount()); + verify(pointsService).onJoinActivity("user", "activity", "owner"); + verify(pushService).pushToUser(eq("user"), eq("活动报名已通过"), anyString(), any()); + verify(notificationService).createNotification(eq("user"), eq("ACTIVITY_JOIN_APPROVED"), + eq("活动报名已通过"), anyString(), any(), eq("/activity/activity"), + eq("activity"), eq("owner")); + } + + @Test + void reviewRejectKeepsCountAndCarriesReasonToApplicant() { + Activity activity = activity("owner"); + activity.setCurrentCount(3); + stubActivity(activityRepository, activity); + ActivityParticipant pending = participant("PENDING"); + when(participantRepository.findById("p1")).thenReturn(Optional.of(pending)); + when(participantRepository.save(any(ActivityParticipant.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + ActivityParticipantDTO dto = service().reviewParticipant("activity", "p1", "REJECT", "人数已满"); + + assertEquals("REJECTED", dto.getStatus()); + assertEquals(3, activity.getCurrentCount()); + assertTrue(pending.getRemark().contains("审核意见:人数已满")); + assertEquals("人数已满", dto.getReviewReason()); + // 拒绝待审核申请消耗 1 次拒绝额度,撤回额度不受影响 + assertEquals(1, pending.getRejectCount().intValue()); + assertEquals(0, pending.getCancelCount().intValue()); + verify(notificationService).createNotification(eq("user"), eq("ACTIVITY_JOIN_REJECTED"), + eq("活动报名未通过"), contains("人数已满"), any(), eq("/activity/activity"), + eq("activity"), eq("owner")); + } + + @Test + void reviewRemoveConfirmedParticipantReleasesQuota() { + Activity activity = activity("owner"); + activity.setCurrentCount(2); + stubActivity(activityRepository, activity); + ActivityParticipant confirmed = participant("CONFIRMED"); + when(participantRepository.findById("p1")).thenReturn(Optional.of(confirmed)); + when(participantRepository.save(any(ActivityParticipant.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + ActivityParticipantDTO dto = service().reviewParticipant("activity", "p1", "REMOVE", null); + + assertEquals("REMOVED", dto.getStatus()); + assertEquals(1, activity.getCurrentCount()); + // 移出属非拒绝语义,不消耗拒绝额度 + assertEquals(0, confirmed.getRejectCount().intValue()); + verify(notificationService).createNotification(eq("user"), eq("ACTIVITY_MEMBER_REMOVED"), + eq("已被移出活动"), anyString(), any(), eq("/activity/activity"), + eq("activity"), eq("owner")); + } + + @Test + void reviewRejectOnConfirmedParticipantDoesNotConsumeRejectQuota() { + // 对已通过者执行 REJECT 属「移出」语义,不能算作拒绝了一次报名申请 + Activity activity = activity("owner"); + activity.setCurrentCount(2); + stubActivity(activityRepository, activity); + ActivityParticipant confirmed = participant("CONFIRMED"); + when(participantRepository.findById("p1")).thenReturn(Optional.of(confirmed)); + when(participantRepository.save(any(ActivityParticipant.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + service().reviewParticipant("activity", "p1", "REJECT", null); + + assertEquals("REJECTED", confirmed.getStatus()); + assertEquals(0, confirmed.getRejectCount().intValue()); + } + + @Test + void reviewRejectIsIdempotentAndDoesNotCountTwice() { + Activity activity = activity("owner"); + stubActivity(activityRepository, activity); + ActivityParticipant alreadyRejected = participant("REJECTED"); + alreadyRejected.setRejectCount(1); + when(participantRepository.findById("p1")).thenReturn(Optional.of(alreadyRejected)); + + service().reviewParticipant("activity", "p1", "REJECT", null); + + assertEquals(1, alreadyRejected.getRejectCount().intValue()); + verify(participantRepository, never()).save(any(ActivityParticipant.class)); + } + + // ==================== 撤回待审核申请 ==================== + + @Test + void quitCancelsPendingApplyWithoutTouchingQuotaAndNotifiesCreator() { + Activity activity = activity("owner"); + activity.setCurrentCount(5); + stubActivity(activityRepository, activity); + ActivityParticipant pending = participant("PENDING"); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "PENDING")) + .thenReturn(Collections.singletonList(pending)); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(pending)); + when(participantRepository.existsByActivityIdAndUserIdAndStatus("activity", "user", "CONFIRMED")) + .thenReturn(false); + when(userRepository.findById("user")).thenReturn(Optional.of(applicant("小明"))); + + ActivityDTO dto = service().quitActivity("activity", "user"); + + assertEquals("CANCELLED", pending.getStatus()); + assertEquals(5, activity.getCurrentCount()); + assertEquals("CANCELLED", dto.getMyParticipantStatus()); + // 撤回待审核申请消耗 1 次撤回额度,拒绝额度不受影响 + assertEquals(1, pending.getCancelCount().intValue()); + assertEquals(0, pending.getRejectCount().intValue()); + verify(pushService).pushActivityApplyCancelled(eq("owner"), eq("小明"), any(), eq("user"), + eq("activity"), eq("测试活动")); + verify(notificationService).createNotification(eq("owner"), eq("ACTIVITY_APPLY_CANCELLED"), + eq("报名申请已取消"), contains("取消了"), any(), eq("/activity/activity"), + eq("activity"), eq("user")); + } + + @Test + void quitConfirmedActivityDoesNotConsumeCancelQuota() { + // 退出已通过的活动是正常退出,不是「撤回报名申请」,不能扣撤回额度 + Activity activity = activity("owner"); + activity.setCurrentCount(1); + stubActivity(activityRepository, activity); + ActivityParticipant confirmed = participant("CONFIRMED"); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "PENDING")) + .thenReturn(Collections.emptyList()); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "CONFIRMED")) + .thenReturn(Collections.singletonList(confirmed)); + when(userRepository.findById("user")).thenReturn(Optional.empty()); + + service().quitActivity("activity", "user"); + + assertEquals("CANCELLED", confirmed.getStatus()); + assertEquals(0, confirmed.getCancelCount().intValue()); + } + + @Test + void quitStillRejectsUserWithoutAnyApplication() { + Activity activity = activity("owner"); + stubActivity(activityRepository, activity); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "PENDING")) + .thenReturn(Collections.emptyList()); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "CONFIRMED")) + .thenReturn(Collections.emptyList()); + + assertEquals("您未报名该活动", assertThrows(BusinessException.class, + () -> service().quitActivity("activity", "user")).getMessage()); + } + + // ==================== 详情页「我的报名状态」 ==================== + + @Test + void detailExposesPendingStateSoFrontendCanShowUnderReview() { + Activity activity = activity("owner"); + when(activityRepository.findById("activity")).thenReturn(Optional.of(activity)); + when(participantRepository.existsByActivityIdAndUserIdAndStatus("activity", "user", "CONFIRMED")) + .thenReturn(false); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(participant("PENDING"))); + + ActivityDTO dto = service().getById("activity", "user", null); + + assertEquals("PENDING", dto.getMyParticipantStatus()); + assertEquals("p1", dto.getMyParticipantId()); + assertEquals(Boolean.FALSE, dto.getIsJoined()); + } + + @Test + void detailExposesRejectionReasonForRejectedApplicant() { + Activity activity = activity("owner"); + when(activityRepository.findById("activity")).thenReturn(Optional.of(activity)); + when(participantRepository.existsByActivityIdAndUserIdAndStatus("activity", "user", "CONFIRMED")) + .thenReturn(false); + ActivityParticipant rejected = participant("REJECTED"); + rejected.setRemark("备注\n审核意见:人数已满"); + rejected.setRejectCount(2); + rejected.setCancelCount(1); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(rejected)); + + ActivityDTO dto = service().getById("activity", "user", null); + + assertEquals("REJECTED", dto.getMyParticipantStatus()); + assertEquals("人数已满", dto.getMyReviewReason()); + // 详情页要让前端能展示两个额度各自的剩余次数 + assertEquals(2, dto.getMyRejectCount().intValue()); + assertEquals(1, dto.getMyCancelCount().intValue()); + } + + @Test + void detailLeavesMyStateEmptyForGuest() { + Activity activity = activity("owner"); + when(activityRepository.findById("activity")).thenReturn(Optional.of(activity)); + + ActivityDTO dto = service().getById("activity", null, null); + + assertEquals(null, dto.getMyParticipantStatus()); + assertEquals(null, dto.getMyParticipantId()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityArchiveSchedulerTest.java b/campus-server/src/test/java/com/campus/service/ActivityArchiveSchedulerTest.java new file mode 100644 index 0000000..44c30e9 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityArchiveSchedulerTest.java @@ -0,0 +1,153 @@ +package com.campus.service; + +import com.campus.entity.Activity; +import com.campus.entity.Group; +import com.campus.repository.ActivityRepository; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Date; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ActivityArchiveSchedulerTest { + + @Mock private ActivityRepository activityRepository; + @Mock private ActivityOperationLogService operationLogService; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private GroupAnnouncementRepository groupAnnouncementRepository; + @Mock private ChatConversationRepository chatConversationRepository; + @Mock private EasemobService easemobService; + @InjectMocks private ActivityService activityService; + + @Test + void activityWithFutureEndTimeIsNotArchivedAfterItStarts() { + emptyNewlyEndedActivities(); + when(activityRepository.findByStatusAndGroupIdIsNotNull("ENDED")) + .thenReturn(Collections.emptyList()); + + assertEquals(0, activityService.archiveEndedActivities()); + verify(activityRepository, never()).save(any(Activity.class)); + } + + @Test + void legacyActivityWithoutEndTimeIsArchivedAtItsStartTime() { + Activity legacy = new Activity(); + legacy.setId("legacy-activity"); + legacy.setStatus("ACTIVE"); + when(activityRepository.findByStatusAndEndTimeBefore(eq("ACTIVE"), any(Date.class))) + .thenReturn(Collections.emptyList()); + when(activityRepository.findByStatusAndEndTimeIsNullAndEventTimeBefore(eq("ACTIVE"), any(Date.class))) + .thenReturn(Collections.singletonList(legacy)); + when(activityRepository.findByStatusAndGroupIdIsNotNull("ENDED")) + .thenReturn(Collections.emptyList()); + when(activityRepository.save(legacy)).thenReturn(legacy); + + assertEquals(1, activityService.archiveEndedActivities()); + assertEquals("ENDED", legacy.getStatus()); + verify(activityRepository).save(legacy); + } + + @Test + void endedActivityDissolvesRemoteGroupAndCleansLocalData() { + Activity activity = activity("activity-1", "ACTIVE", "group-1"); + Group group = group("group-1", "em-group-1"); + when(activityRepository.findByStatusAndEndTimeBefore(eq("ACTIVE"), any(Date.class))) + .thenReturn(Collections.singletonList(activity)); + when(activityRepository.findByStatusAndEndTimeIsNullAndEventTimeBefore(eq("ACTIVE"), any(Date.class))) + .thenReturn(Collections.emptyList()); + when(activityRepository.findByStatusAndGroupIdIsNotNull("ENDED")) + .thenReturn(Collections.emptyList()); + when(groupRepository.findById("group-1")).thenReturn(Optional.of(group)); + when(easemobService.deleteGroup("em-group-1")).thenReturn(true); + + assertEquals(1, activityService.archiveEndedActivities()); + + assertEquals("ENDED", activity.getStatus()); + assertNull(activity.getGroupId()); + verify(easemobService).deleteGroup("em-group-1"); + verify(chatConversationRepository).deleteByPeerId("group-1"); + verify(groupMemberRepository).deleteByGroupId("group-1"); + verify(groupRepository).delete(group); + verify(operationLogService).record("activity-1", "SYSTEM", "AUTO_DISSOLVE_GROUP", + "group-1", "活动结束,群聊已自动解散"); + } + + @Test + void remoteDissolutionFailureKeepsLocalGroupForNextRetry() { + Activity activity = activity("activity-2", "ACTIVE", "group-2"); + Group group = group("group-2", "em-group-2"); + when(activityRepository.findByStatusAndEndTimeBefore(eq("ACTIVE"), any(Date.class))) + .thenReturn(Collections.singletonList(activity)); + when(activityRepository.findByStatusAndEndTimeIsNullAndEventTimeBefore(eq("ACTIVE"), any(Date.class))) + .thenReturn(Collections.emptyList()); + when(activityRepository.findByStatusAndGroupIdIsNotNull("ENDED")) + .thenReturn(Collections.emptyList()); + when(groupRepository.findById("group-2")).thenReturn(Optional.of(group)); + when(easemobService.deleteGroup("em-group-2")).thenReturn(false); + + assertEquals(1, activityService.archiveEndedActivities()); + + assertEquals("ENDED", activity.getStatus()); + assertEquals("group-2", activity.getGroupId()); + verify(chatConversationRepository, never()).deleteByPeerId(any()); + verify(groupMemberRepository, never()).deleteByGroupId(any()); + verify(groupRepository, never()).delete(any(Group.class)); + } + + @Test + void retriesDissolutionForActivityThatIsAlreadyEnded() { + Activity activity = activity("activity-3", "ENDED", "group-3"); + Group group = group("group-3", "em-group-3"); + emptyNewlyEndedActivities(); + when(activityRepository.findByStatusAndGroupIdIsNotNull("ENDED")) + .thenReturn(Collections.singletonList(activity)); + when(groupRepository.findById("group-3")).thenReturn(Optional.of(group)); + when(easemobService.deleteGroup("em-group-3")).thenReturn(true); + + assertEquals(0, activityService.archiveEndedActivities()); + + assertNull(activity.getGroupId()); + verify(easemobService).deleteGroup("em-group-3"); + verify(groupRepository).delete(group); + } + + private void emptyNewlyEndedActivities() { + when(activityRepository.findByStatusAndEndTimeBefore(eq("ACTIVE"), any(Date.class))) + .thenReturn(Collections.emptyList()); + when(activityRepository.findByStatusAndEndTimeIsNullAndEventTimeBefore(eq("ACTIVE"), any(Date.class))) + .thenReturn(Collections.emptyList()); + } + + private Activity activity(String activityId, String status, String groupId) { + Activity activity = new Activity(); + activity.setId(activityId); + activity.setStatus(status); + activity.setGroupId(groupId); + return activity; + } + + private Group group(String groupId, String easemobGroupId) { + Group group = new Group(); + group.setId(groupId); + group.setEasemobGroupId(easemobGroupId); + return group; + } +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityEnhancementTest.java b/campus-server/src/test/java/com/campus/service/ActivityEnhancementTest.java new file mode 100644 index 0000000..ed5e12d --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityEnhancementTest.java @@ -0,0 +1,35 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.ActivityRequest; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ActivityEnhancementTest { + @Test + void rejectsRegistrationWindowAfterEvent() { + ActivityRequest request = new ActivityRequest(); + request.setEventTime(new Date(10_000)); + request.setRegistrationDeadline(new Date(20_000)); + assertThrows(BusinessException.class, () -> ActivityService.validateActivityTimes(request)); + } + + @Test + void rejectsOnlyOneCoordinate() { + ActivityRequest request = new ActivityRequest(); + request.setLatitude(31.2); + assertThrows(BusinessException.class, () -> ActivityService.validateActivityTimes(request)); + } + + @Test + void haversineDistanceIsRealMeters() { + double distance = ActivityService.calculateDistanceMeters(31.2304, 121.4737, 31.2304, 121.4837); + assertTrue(distance > 900 && distance < 1000); + assertEquals(0.0, ActivityService.calculateDistanceMeters(31.2, 121.4, 31.2, 121.4)); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityJoinQuitTest.java b/campus-server/src/test/java/com/campus/service/ActivityJoinQuitTest.java new file mode 100644 index 0000000..4018d6b --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityJoinQuitTest.java @@ -0,0 +1,245 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.GlobalExceptionHandler; +import com.campus.common.Result; +import com.campus.common.TooManyRequestsException; +import com.campus.dto.response.ActivityDTO; +import com.campus.entity.Activity; +import com.campus.entity.ActivityParticipant; +import com.campus.repository.ActivityParticipantRepository; +import com.campus.repository.ActivityRepository; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 组局活动「加入/退出」的并发与错误语义回归测试。 + * 覆盖:行锁、重复报名、重复报名行自愈、业务拒绝抛 BusinessException(400 而非 500)。 + */ +@ExtendWith(MockitoExtension.class) +class ActivityJoinQuitTest { + + @Mock private ActivityRepository activityRepository; + @Mock private ActivityParticipantRepository participantRepository; + @Mock private ContentReviewService contentReviewService; + @Mock private ObjectMapper objectMapper; + @Mock private PointsService pointsService; + @Mock private PushService pushService; + @Mock private NotificationService notificationService; + @Mock private UserRepository userRepository; + @Mock private EasemobService easemobService; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private GroupAnnouncementRepository groupAnnouncementRepository; + @Mock private ChatConversationRepository chatConversationRepository; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private AuthorizationService authorizationService; + @Mock private UserRestrictionService userRestrictionService; + @Mock private ActivityOperationLogService operationLogService; + + @Mock private com.campus.service.location.ActivityLocationService activityLocationService; + + private ActivityService service() { + return new ActivityService(activityRepository, participantRepository, contentReviewService, objectMapper, + pointsService, pushService, notificationService, userRepository, easemobService, groupRepository, + groupMemberRepository, groupAnnouncementRepository, chatConversationRepository, + privacySettingsService, authorizationService, userRestrictionService, operationLogService, activityLocationService); + } + + private static Activity activity(String creatorId, String status) { + Activity activity = new Activity(); + activity.setId("activity"); + activity.setTitle("测试活动"); + activity.setType("sports"); + activity.setCreatorId(creatorId); + activity.setStatus(status); + activity.setReviewStatus("approved"); + activity.setCurrentCount(0); + activity.setMaxCount(10); + activity.setHeat(0); + return activity; + } + + private static ActivityParticipant participant(String status) { + ActivityParticipant participant = new ActivityParticipant(); + participant.setId("participant-" + status + "-" + System.nanoTime()); + participant.setActivityId("activity"); + participant.setUserId("user"); + participant.setStatus(status); + participant.setJoinedAt(new Date()); + return participant; + } + + @Test + void joinTakesActivityRowLockAndCreatesParticipant() { + Activity activity = activity("owner", "ACTIVE"); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.empty()); + when(userRepository.findById("user")).thenReturn(Optional.empty()); + when(participantRepository.existsByActivityIdAndUserIdAndStatus("activity", "user", "CONFIRMED")) + .thenReturn(true); + + ActivityDTO dto = service().joinActivity("activity", "user", "备注"); + + verify(activityRepository).findByIdForUpdate("activity"); + verify(participantRepository).save(any(ActivityParticipant.class)); + assertEquals(1, activity.getCurrentCount()); + assertEquals(Boolean.TRUE, dto.getIsJoined()); + assertEquals(1, dto.getCurrentCount()); + } + + @Test + void joinRejectsAlreadyConfirmedParticipationWithBusinessError() { + Activity activity = activity("owner", "ACTIVE"); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(participant("CONFIRMED"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service().joinActivity("activity", "user", null)); + + assertEquals("您已经报名过该活动", error.getMessage()); + verify(participantRepository, never()).save(any(ActivityParticipant.class)); + } + + @Test + void joinReusesExistingRowInsteadOfInsertingSecondOne() { + Activity activity = activity("owner", "ACTIVE"); + ActivityParticipant rejected = participant("REJECTED"); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(rejected)); + when(userRepository.findById("user")).thenReturn(Optional.empty()); + + service().joinActivity("activity", "user", null); + + assertEquals("CONFIRMED", rejected.getStatus()); + verify(participantRepository).save(rejected); + verify(pointsService).onJoinActivity("user", "activity", "owner"); + } + + @Test + void joinRejectsActivityThatIsNotApproved() { + Activity activity = activity("owner", "ACTIVE"); + activity.setReviewStatus("pending"); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + + assertEquals("活动未通过审核,无法报名", assertThrows(BusinessException.class, + () -> service().joinActivity("activity", "user", null)).getMessage()); + } + + @Test + void joinRejectsActivityThatIsNotActive() { + Activity activity = activity("owner", "ENDED"); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + + assertEquals("活动已结束或已关闭,无法报名", assertThrows(BusinessException.class, + () -> service().joinActivity("activity", "user", null)).getMessage()); + } + + @Test + void joinRejectsCreator() { + Activity activity = activity("user", "ACTIVE"); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + + assertEquals("不能加入自己创建的活动", assertThrows(BusinessException.class, + () -> service().joinActivity("activity", "user", null)).getMessage()); + } + + @Test + void joinRejectsFullActivityEvenWhenReusingOldRow() { + Activity activity = activity("owner", "ACTIVE"); + activity.setMaxCount(1); + activity.setCurrentCount(1); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc("activity", "user")) + .thenReturn(Optional.of(participant("CANCELLED"))); + + assertEquals("活动报名人数已满", assertThrows(BusinessException.class, + () -> service().joinActivity("activity", "user", null)).getMessage()); + verify(participantRepository, never()).save(any(ActivityParticipant.class)); + } + + @Test + void joinRejectsMissingActivity() { + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.empty()); + + assertEquals("活动不存在", assertThrows(BusinessException.class, + () -> service().joinActivity("activity", "user", null)).getMessage()); + } + + @Test + void quitRejectsUserWhoNeverJoined() { + Activity activity = activity("owner", "ACTIVE"); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "PENDING")) + .thenReturn(Collections.emptyList()); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "CONFIRMED")) + .thenReturn(Collections.emptyList()); + + assertEquals("您未报名该活动", assertThrows(BusinessException.class, + () -> service().quitActivity("activity", "user")).getMessage()); + } + + @Test + void quitCancelsAllDuplicateConfirmedRowsAndDecrementsOnce() { + Activity activity = activity("owner", "ACTIVE"); + activity.setCurrentCount(5); + List duplicates = Arrays.asList(participant("CONFIRMED"), participant("CONFIRMED")); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "PENDING")) + .thenReturn(Collections.emptyList()); + when(participantRepository.findByActivityIdAndUserIdAndStatus("activity", "user", "CONFIRMED")) + .thenReturn(duplicates); + when(userRepository.findById("user")).thenReturn(Optional.empty()); + + ActivityDTO dto = service().quitActivity("activity", "user"); + + verify(participantRepository).saveAll(duplicates); + assertTrue(duplicates.stream().allMatch(p -> "CANCELLED".equals(p.getStatus()))); + assertEquals(4, activity.getCurrentCount()); + assertEquals(Boolean.FALSE, dto.getIsJoined()); + } + + @Test + void businessRejectionBecomesHttp400InsteadOfServerError() { + Result result = new GlobalExceptionHandler() + .handleBusinessException(new BusinessException("您已经报名过该活动")); + + assertEquals(400, result.getCode()); + assertEquals("您已经报名过该活动", result.getMessage()); + } + + @Test + void tooFrequentClickBecomesHttp429() { + Result result = new GlobalExceptionHandler() + .handleTooManyRequestsException(new TooManyRequestsException( + ClickGuardService.TOO_FREQUENT_MESSAGE)); + + assertEquals(429, result.getCode().intValue()); + assertEquals("点击过于频繁,操作失败", result.getMessage()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityLocationWriteGuardTest.java b/campus-server/src/test/java/com/campus/service/ActivityLocationWriteGuardTest.java new file mode 100644 index 0000000..d5ce9b0 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityLocationWriteGuardTest.java @@ -0,0 +1,39 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.ActivityRequest; +import com.campus.entity.Activity; +import com.campus.repository.ActivityRepository; +import com.campus.service.location.ActivityLocationService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +@ExtendWith(MockitoExtension.class) +class ActivityLocationWriteGuardTest { + @Mock ActivityRepository repository; + @Mock ActivityLocationService locations; + @Mock UserRestrictionService restrictions; + @Mock AuthorizationService authorization; + @Mock EasemobService easemob; + @InjectMocks ActivityService service; + + @Test void invalidTokenDoesNotSaveOrCreateRemoteGroup() { + ActivityRequest request = new ActivityRequest(); request.setLocationToken("expired"); + doThrow(new BusinessException("地点确认已过期,请重新确认")).when(locations).applyConfirmedLocation(request, "user", "create"); + assertEquals("地点确认已过期,请重新确认", assertThrows(BusinessException.class, () -> service.create(request, "user")).getMessage()); + verifyNoInteractions(repository, easemob); + } + @Test void invalidEditDoesNotSave() { + Activity activity = new Activity(); activity.setId("id"); activity.setCreatorId("owner"); + when(repository.findById("id")).thenReturn(java.util.Optional.of(activity)); + ActivityRequest request = new ActivityRequest(); + doThrow(new BusinessException("地点确认已过期,请重新确认")).when(locations).applyConfirmedLocation(eq(request), isNull(), eq("id")); + assertEquals("地点确认已过期,请重新确认", assertThrows(BusinessException.class, () -> service.update("id", request)).getMessage()); + verify(repository, never()).save(any()); verifyNoInteractions(easemob); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityRegistrationEnhancementTest.java b/campus-server/src/test/java/com/campus/service/ActivityRegistrationEnhancementTest.java new file mode 100644 index 0000000..134faf5 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityRegistrationEnhancementTest.java @@ -0,0 +1,95 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.ActivityRegistrationRequest; +import com.campus.entity.Activity; +import com.campus.entity.User; +import com.campus.repository.ActivityParticipantRepository; +import com.campus.repository.ActivityPaymentOrderRepository; +import com.campus.repository.ActivityRegistrationRepository; +import com.campus.repository.ActivityRepository; +import com.campus.repository.ActivityTicketRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.Date; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ActivityRegistrationEnhancementTest { + @Mock private ActivityRepository activityRepository; + @Mock private ActivityService activityService; + @Mock private ActivityTicketRepository ticketRepository; + @Mock private ActivityRegistrationRepository registrationRepository; + @Mock private ActivityPaymentOrderRepository paymentOrderRepository; + @Mock private ActivityParticipantRepository participantRepository; + @Mock private ActivityOperationLogService operationLogService; + private ActivityRegistrationService service; + + @BeforeEach + void setUp() { + service = new ActivityRegistrationService(activityRepository, activityService, ticketRepository, + registrationRepository, paymentOrderRepository, participantRepository, operationLogService); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void registrationBeforeWindowIsRejected() { + authenticate("participant"); + Activity activity = activity("owner"); + activity.setRegistrationStartAt(new Date(System.currentTimeMillis() + 60_000)); + when(activityRepository.findByIdForUpdate("activity")).thenReturn(Optional.of(activity)); + + assertThrows(BusinessException.class, + () -> service.register("activity", new ActivityRegistrationRequest())); + } + + @Test + void ownerCanGenerateHashedCheckInCode() { + authenticate("owner"); + Activity activity = activity("owner"); + when(activityRepository.findById("activity")).thenReturn(Optional.of(activity)); + when(activityRepository.save(activity)).thenReturn(activity); + + String code = service.generateCheckInCode("activity"); + + assertEquals(6, code.length()); + assertEquals(64, activity.getCheckInCodeHash().length()); + verify(operationLogService).record("activity", "owner", "GENERATE_CHECK_IN_CODE", + null, "签到码已更新"); + } + + private Activity activity(String owner) { + Activity activity = new Activity(); + activity.setId("activity"); + activity.setCreatorId(owner); + activity.setStatus("ACTIVE"); + activity.setReviewStatus("approved"); + return activity; + } + + private void authenticate(String id) { + User user = new User(); + user.setId(id); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityScopeNormalizationTest.java b/campus-server/src/test/java/com/campus/service/ActivityScopeNormalizationTest.java new file mode 100644 index 0000000..dd03b04 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityScopeNormalizationTest.java @@ -0,0 +1,23 @@ +package com.campus.service; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ActivityScopeNormalizationTest { + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "ALL", "all", "public", "全部", "公开", "所有人", "全部学校"}) + void normalizesPublicScopeAliases(String input) { + assertEquals("ALL", ActivityService.normalizeActivityScope(input)); + } + + @ParameterizedTest + @ValueSource(strings = {"本校", "浙江大学", "[\"浙江大学\",\"北京林业大学\"]"}) + void preservesRestrictedScopes(String input) { + assertEquals(input, ActivityService.normalizeActivityScope(input)); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityServiceTypeNormalizationTest.java b/campus-server/src/test/java/com/campus/service/ActivityServiceTypeNormalizationTest.java new file mode 100644 index 0000000..57dd515 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityServiceTypeNormalizationTest.java @@ -0,0 +1,46 @@ +package com.campus.service; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ActivityServiceTypeNormalizationTest { + + @ParameterizedTest(name = "{0} -> {1}") + @CsvSource({ + "sports, sports", + "sport, sports", + "运动, sports", + "运动健身, sports", + "study, study", + "学习, study", + "学习交流, study", + "party, party", + "offline, party", + "聚会, party", + "food, food", + "美食, food", + "餐饮美食, food", + "travel, travel", + "trip, travel", + "出行, travel", + "game, game", + "ent, game", + "entertainment, game", + "游戏, game", + "娱乐, game", + "volunteer, volunteer", + "charity, volunteer", + "志愿, volunteer", + "公益志愿, volunteer", + "other, other", + "others, other", + "其他, other", + "TEST, other" + }) + void normalizesAllSupportedActivityTypes(String input, String expected) { + assertEquals(expected, ActivityService.normalizeActivityTypeValue(input)); + } + +} diff --git a/campus-server/src/test/java/com/campus/service/ActivityTimeContractTest.java b/campus-server/src/test/java/com/campus/service/ActivityTimeContractTest.java new file mode 100644 index 0000000..0fb12dd --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ActivityTimeContractTest.java @@ -0,0 +1,89 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.ActivityRequest; +import com.campus.dto.response.ActivityDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.text.SimpleDateFormat; +import java.util.TimeZone; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ActivityTimeContractTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void requestAcceptsFrontendTimeFieldAliases() throws Exception { + ActivityRequest request = objectMapper.readValue("{" + + "\"startTime\":\"2026-10-01 14:00:00\"," + + "\"endTime\":\"2026-10-01 16:00:00\"," + + "\"registrationStartTime\":\"2026-09-28 09:00:00\"," + + "\"registrationEndTime\":\"2026-09-30 18:00:00\"}", ActivityRequest.class); + + assertEquals(parse("2026-10-01 14:00:00"), request.getEventTime()); + assertEquals(parse("2026-10-01 16:00:00"), request.getEndTime()); + assertEquals(parse("2026-09-28 09:00:00"), request.getRegistrationStartAt()); + assertEquals(parse("2026-09-30 18:00:00"), request.getRegistrationDeadline()); + } + + @Test + void responseKeepsCanonicalAndFrontendTimeFields() throws Exception { + ActivityDTO dto = new ActivityDTO(); + dto.setEventTime(parse("2026-10-01 14:00:00")); + dto.setStartTime(dto.getEventTime()); + dto.setEndTime(parse("2026-10-01 16:00:00")); + dto.setRegistrationStartAt(parse("2026-09-28 09:00:00")); + dto.setRegistrationStartTime(dto.getRegistrationStartAt()); + dto.setRegistrationDeadline(parse("2026-09-30 18:00:00")); + dto.setRegistrationEndTime(dto.getRegistrationDeadline()); + + String json = objectMapper.writeValueAsString(dto); + assertTrue(json.contains("\"eventTime\":\"2026-10-01 14:00:00\"")); + assertTrue(json.contains("\"startTime\":\"2026-10-01 14:00:00\"")); + assertTrue(json.contains("\"endTime\":\"2026-10-01 16:00:00\"")); + assertTrue(json.contains("\"registrationStartTime\":\"2026-09-28 09:00:00\"")); + assertTrue(json.contains("\"registrationEndTime\":\"2026-09-30 18:00:00\"")); + } + + @Test + void rejectsActivityEndBeforeStart() throws Exception { + ActivityRequest request = new ActivityRequest(); + request.setEventTime(parse("2026-10-01 16:00:00")); + request.setEndTime(parse("2026-10-01 14:00:00")); + + assertThrows(BusinessException.class, () -> ActivityService.validateActivityTimes(request)); + } + + @Test + void exposesStableChineseLabelsForCanonicalActivityTypes() { + assertEquals("学习", ActivityService.activityTypeLabel("study")); + assertEquals("运动", ActivityService.activityTypeLabel("SPORT")); + assertEquals("游戏", ActivityService.activityTypeLabel("entertainment")); + assertEquals("干饭", ActivityService.activityTypeLabel("food")); + } + + @Test + void exposesCodesForFilteringAndLabelsForDisplay() { + ActivityDTO dto = new ActivityDTO(); + + ActivityService.applyActivityTypeFields(dto, "SPORT"); + + assertEquals("sport", dto.getType()); + assertEquals("sport", dto.getActivityType()); + assertEquals("sports", dto.getCategoryCode()); + assertEquals("sport", dto.getCategory()); + assertEquals("运动", dto.getTypeLabel()); + assertEquals("运动", dto.getCategoryLabel()); + } + + private java.util.Date parse(String value) throws Exception { + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + format.setTimeZone(TimeZone.getTimeZone("GMT+8")); + return format.parse(value); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AdminServiceTest.java b/campus-server/src/test/java/com/campus/service/AdminServiceTest.java new file mode 100644 index 0000000..9e0be9c --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AdminServiceTest.java @@ -0,0 +1,91 @@ +package com.campus.service; + +import com.campus.common.ForbiddenException; +import com.campus.dto.request.AdminAccountStatusRequest; +import com.campus.dto.request.AdminRoleUpdateRequest; +import com.campus.entity.User; +import com.campus.repository.ActivityRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.ReportRepository; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock private UserRepository userRepository; + @Mock private ActivityRepository activityRepository; + @Mock private AnnouncementRepository announcementRepository; + @Mock private ReportRepository reportRepository; + @Mock private UserService userService; + @Mock private AuditLogService auditLogService; + + private AdminService adminService; + + @BeforeEach + void setUp() { + adminService = new AdminService(userRepository, activityRepository, announcementRepository, + reportRepository, userService, new AuthorizationService(), auditLogService); + authenticate(user("admin", true, false)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void adminCannotRevokeOwnAdminRole() { + when(userRepository.findById("admin")).thenReturn(Optional.of(user("admin", true, false))); + AdminRoleUpdateRequest request = new AdminRoleUpdateRequest(); + request.setIsAdmin(false); + request.setIsReviewer(true); + + assertThrows(ForbiddenException.class, () -> adminService.updateRoles("admin", request)); + } + + @Test + void disablingUserPersistsStatusAndInvalidatesSession() { + User target = user("target", false, false); + when(userRepository.findById("target")).thenReturn(Optional.of(target)); + when(userRepository.save(target)).thenReturn(target); + AdminAccountStatusRequest request = new AdminAccountStatusRequest(); + request.setAccountStatus("disabled"); + + assertEquals("DISABLED", adminService.updateAccountStatus("target", request).getAccountStatus()); + verify(userService).kickout("target"); + verify(auditLogService).record("USER_STATUS_UPDATE", "USER", "target", + "accountStatus=ACTIVE", "accountStatus=DISABLED", null); + } + + private void authenticate(User user) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } + + private User user(String id, boolean admin, boolean reviewer) { + User user = new User(); + user.setId(id); + user.setPhone("18800000000"); + user.setNickname(id); + user.setIsAdmin(admin); + user.setIsReviewer(reviewer); + user.setAccountStatus("ACTIVE"); + return user; + } +} diff --git a/campus-server/src/test/java/com/campus/service/AuditLogServiceTest.java b/campus-server/src/test/java/com/campus/service/AuditLogServiceTest.java new file mode 100644 index 0000000..c28c4fd --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AuditLogServiceTest.java @@ -0,0 +1,90 @@ +package com.campus.service; + +import com.campus.common.ForbiddenException; +import com.campus.entity.AdminAuditLog; +import com.campus.entity.User; +import com.campus.repository.AdminAuditLogRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuditLogServiceTest { + + @Mock private AdminAuditLogRepository repository; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void nonAdminCannotListAuditLogs() { + authenticate(false); + AuditLogService service = new AuditLogService(repository, new AuthorizationService()); + assertThrows(ForbiddenException.class, + () -> service.list(null, null, null, null, 1, 20)); + } + + @Test + void recordKeepsOperatorActionTargetAndSummaries() { + authenticate(true); + when(repository.save(any(AdminAuditLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); + AuditLogService service = new AuditLogService(repository, new AuthorizationService()); + + service.record("user_role_update", "user", "target", "isAdmin=false", "isAdmin=true", "approved"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminAuditLog.class); + verify(repository).save(captor.capture()); + AdminAuditLog saved = captor.getValue(); + assertEquals("admin", saved.getOperatorId()); + assertEquals("USER_ROLE_UPDATE", saved.getAction()); + assertEquals("USER", saved.getTargetType()); + assertEquals("target", saved.getTargetId()); + assertEquals("isAdmin=false", saved.getBeforeSummary()); + assertEquals("isAdmin=true", saved.getAfterSummary()); + } + + @Test + void listNormalizesFiltersAndUsesOneBasedPagination() { + authenticate(true); + when(repository.search(any(), any(), any(), any(), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + AuditLogService service = new AuditLogService(repository, new AuthorizationService()); + + service.list("operator", "user_kickout", "user", "target", 2, 30); + + ArgumentCaptor pageable = ArgumentCaptor.forClass(Pageable.class); + verify(repository).search(org.mockito.ArgumentMatchers.eq("operator"), + org.mockito.ArgumentMatchers.eq("USER_KICKOUT"), + org.mockito.ArgumentMatchers.eq("USER"), + org.mockito.ArgumentMatchers.eq("target"), pageable.capture()); + assertEquals(1, pageable.getValue().getPageNumber()); + assertEquals(30, pageable.getValue().getPageSize()); + } + + private void authenticate(boolean admin) { + User user = new User(); + user.setId("admin"); + user.setNickname("Administrator"); + user.setIsAdmin(admin); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } +} diff --git a/campus-server/src/test/java/com/campus/service/AuthorizationServiceTest.java b/campus-server/src/test/java/com/campus/service/AuthorizationServiceTest.java new file mode 100644 index 0000000..f556563 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/AuthorizationServiceTest.java @@ -0,0 +1,64 @@ +package com.campus.service; + +import com.campus.common.ForbiddenException; +import com.campus.entity.User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class AuthorizationServiceTest { + + private final AuthorizationService authorizationService = new AuthorizationService(); + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void ordinaryUserCannotUseAdminPermission() { + authenticate(user("u1", false, false)); + assertThrows(ForbiddenException.class, authorizationService::requireAdmin); + } + + @Test + void adminCanUseAdminAndReviewPermissions() { + authenticate(user("admin", true, false)); + assertDoesNotThrow(authorizationService::requireAdmin); + assertDoesNotThrow(authorizationService::requireReviewerOrAdmin); + } + + @Test + void reviewerCanReviewButCannotManageAdmins() { + authenticate(user("reviewer", false, true)); + assertDoesNotThrow(authorizationService::requireReviewerOrAdmin); + assertThrows(ForbiddenException.class, authorizationService::requireAdmin); + } + + @Test + void ownerOrAdminRuleRejectsUnrelatedUser() { + authenticate(user("u1", false, false)); + assertDoesNotThrow(() -> authorizationService.requireOwnerOrAdmin("u1")); + assertThrows(ForbiddenException.class, () -> authorizationService.requireOwnerOrAdmin("u2")); + } + + private void authenticate(User user) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } + + private User user(String id, boolean admin, boolean reviewer) { + User user = new User(); + user.setId(id); + user.setIsAdmin(admin); + user.setIsReviewer(reviewer); + user.setAccountStatus("ACTIVE"); + return user; + } +} diff --git a/campus-server/src/test/java/com/campus/service/ChatServiceClearHistoryTest.java b/campus-server/src/test/java/com/campus/service/ChatServiceClearHistoryTest.java new file mode 100644 index 0000000..b5787d0 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ChatServiceClearHistoryTest.java @@ -0,0 +1,385 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.response.ChatMessageDTO; +import com.campus.entity.ChatConversation; +import com.campus.entity.ChatMessage; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * 「清空聊天记录」按用户隔离(方案 A)回归测试。 + * + *

覆盖的历史缺陷:clearConversationMessages 曾经直接 + * {@code DELETE FROM chat_message WHERE conversation_id = ?}。由于 + * chat_message 一份记录由私聊双方 / 全群共用(私聊 conversation_id = id1_id2、群聊 = 群ID), + * 一个人清空会让其他所有人的聊天记录一起消失,且是物理删除不可恢复。 + * + *

修复后:清空只抬高「自己那一行」chat_conversation.cleared_seq_id, + * 绝不触碰 chat_message;所有读取路径按该水位线过滤。 + */ +@ExtendWith(MockitoExtension.class) +class ChatServiceClearHistoryTest { + + private static final String USER_ID = "11111111-1111-1111-1111-111111111111"; + private static final String GROUP_ID = "22222222-2222-2222-2222-222222222222"; + private static final String PEER_ID = "99999999-9999-9999-9999-999999999999"; + private static final String PRIVATE_CONVERSATION_ID = USER_ID + "_" + PEER_ID; + + @Mock private EasemobService easemobService; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private ChatConversationRepository chatConversationRepository; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private UserRepository userRepository; + @Mock private FriendshipRepository friendshipRepository; + @Mock private PushService pushService; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private UserRestrictionService userRestrictionService; + @Mock private ChatReportRepository chatReportRepository; + @Mock private AuthorizationService authorizationService; + @Mock private AuditLogService auditLogService; + @InjectMocks private ChatService chatService; + + @BeforeEach + void login() { + User user = new User(); + user.setId(USER_ID); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + // ==================== 核心:绝不删除共享消息 ==================== + + @Test + void clearingGroupHistoryOnlyRaisesOwnWatermarkAndNeverTouchesSharedMessages() { + stubActiveGroupMember(); + when(chatMessageRepository.findMaxSeqIdByConversationId(GROUP_ID)).thenReturn(42L); + ChatConversation mine = conversationWithWatermark(GROUP_ID, "GROUP", 0L, 5); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)) + .thenReturn(Optional.of(mine)); + + chatService.clearConversationMessages(GROUP_ID); + + // 核心断言:对共享消息表只做了「查最大 seqId」这一次读取,没有任何写/删操作 + verify(chatMessageRepository, times(1)).findMaxSeqIdByConversationId(GROUP_ID); + verifyNoMoreInteractions(chatMessageRepository); + + // 水位线抬到自己那一行,并同步会话状态 + assertEquals(42L, mine.getClearedSeqId()); + assertEquals(0, mine.getUnreadCount()); + assertNull(mine.getLastMessage()); + assertNull(mine.getLastMessageType()); + verify(chatConversationRepository).save(mine); + } + + @Test + void clearingDoesNotTouchOtherMembersConversationRow() { + stubActiveGroupMember(); + when(chatMessageRepository.findMaxSeqIdByConversationId(GROUP_ID)).thenReturn(20L); + ChatConversation mine = conversationWithWatermark(GROUP_ID, "GROUP", 0L, 0); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)) + .thenReturn(Optional.of(mine)); + + ChatConversation otherMember = new ChatConversation(); + otherMember.setUserId("33333333-3333-3333-3333-333333333333"); + otherMember.setPeerId(GROUP_ID); + otherMember.setConversationType("GROUP"); + otherMember.setUnreadCount(5); + otherMember.setClearedSeqId(0L); + + chatService.clearConversationMessages(GROUP_ID); + + assertEquals(20L, mine.getClearedSeqId()); + // 他人那一行必须原封不动 + assertEquals(0L, otherMember.getClearedSeqId()); + assertEquals(5, otherMember.getUnreadCount()); + verify(chatConversationRepository, never()).save(otherMember); + } + + // ==================== 权限与入参 ==================== + + @Test + void pendingMemberCannotClearAndNothingIsWritten() { + when(groupRepository.existsById(GROUP_ID)).thenReturn(true); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(member("PENDING", null))); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.clearConversationMessages(GROUP_ID)); + + assertEquals("入群申请尚未通过", error.getMessage()); + verifyNoInteractions(chatMessageRepository); + verifyNoInteractions(chatConversationRepository); + } + + @Test + void nonMemberCannotClearAndNothingIsWritten() { + when(groupRepository.existsById(GROUP_ID)).thenReturn(true); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.clearConversationMessages(GROUP_ID)); + + assertEquals("入群申请尚未通过", error.getMessage()); + verifyNoInteractions(chatMessageRepository); + verifyNoInteractions(chatConversationRepository); + } + + @Test + void rejectsWithExactMessageWhenNotLoggedIn() { + SecurityContextHolder.clearContext(); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.clearConversationMessages(GROUP_ID)); + + assertEquals("用户未登录", error.getMessage()); + verifyNoInteractions(chatConversationRepository); + } + + @Test + void rejectsWithExactMessageWhenConversationIdBlank() { + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.clearConversationMessages(" ")); + + assertEquals("会话ID不能为空", error.getMessage()); + verifyNoInteractions(chatMessageRepository); + verifyNoInteractions(chatConversationRepository); + } + + @Test + void privateClearResolvesPeerUserIdToInternalConversationId() { + // 群不存在 → resolveStoredPeerId 按私聊拼接成 id1_id2 + when(groupRepository.existsById(PEER_ID)).thenReturn(false); + when(userRepository.existsById(PEER_ID)).thenReturn(true); + when(chatMessageRepository.findMaxSeqIdByConversationId(PRIVATE_CONVERSATION_ID)).thenReturn(7L); + ChatConversation mine = conversationWithWatermark(PRIVATE_CONVERSATION_ID, "PRIVATE", 0L, 3); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, PRIVATE_CONVERSATION_ID)) + .thenReturn(Optional.of(mine)); + + chatService.clearConversationMessages(PEER_ID); + + assertEquals(7L, mine.getClearedSeqId()); + // 绝不能把「对方用户ID」本身当会话ID去操作 + verify(chatMessageRepository, never()).findMaxSeqIdByConversationId(PEER_ID); + } + + @Test + void privateClearRejectsWithConversationNotFoundWhenPeerMissing() { + // 群不存在 + 对方用户不存在 → 不能再谎报「入群申请尚未通过」 + when(groupRepository.existsById(PEER_ID)).thenReturn(false); + when(userRepository.existsById(PEER_ID)).thenReturn(false); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.clearConversationMessages(PEER_ID)); + + assertEquals("会话不存在", error.getMessage()); + verifyNoInteractions(chatMessageRepository); + verifyNoInteractions(chatConversationRepository); + } + + // ==================== 会话行缺失的两种情况 ==================== + + @Test + void clearWithoutAnyMessageIsIdempotentAndCreatesNoGhostConversation() { + stubActiveGroupMember(); + when(chatMessageRepository.findMaxSeqIdByConversationId(GROUP_ID)).thenReturn(0L); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)) + .thenReturn(Optional.empty()); + + chatService.clearConversationMessages(GROUP_ID); + + // 没有消息时不补建会话行,避免会话列表里冒出空会话 + verify(chatConversationRepository, never()).save(any(ChatConversation.class)); + } + + @Test + void clearBackfillsConversationRowWhenMessagesExistButRowIsMissing() { + stubActiveGroupMember(); + when(chatMessageRepository.findMaxSeqIdByConversationId(GROUP_ID)).thenReturn(9L); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)) + .thenReturn(Optional.empty()); + + chatService.clearConversationMessages(GROUP_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatConversation.class); + verify(chatConversationRepository).save(captor.capture()); + ChatConversation saved = captor.getValue(); + assertEquals(USER_ID, saved.getUserId()); + assertEquals(GROUP_ID, saved.getPeerId()); + assertEquals("GROUP", saved.getConversationType()); + assertEquals(9L, saved.getClearedSeqId()); + assertEquals(0, saved.getUnreadCount()); + } + + @Test + void secondClearAdvancesWatermarkToLatestMessage() { + stubActiveGroupMember(); + when(chatMessageRepository.findMaxSeqIdByConversationId(GROUP_ID)).thenReturn(100L); + ChatConversation mine = conversationWithWatermark(GROUP_ID, "GROUP", 42L, 0); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)) + .thenReturn(Optional.of(mine)); + + chatService.clearConversationMessages(GROUP_ID); + + assertEquals(100L, mine.getClearedSeqId()); + } + + // ==================== 读取路径必须遵守水位线 ==================== + + @Test + void groupHistoryIsFilteredByClearedWatermark() { + Group group = new Group(); + group.setId(GROUP_ID); + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(member("MEMBER", null))); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)) + .thenReturn(Optional.of(conversationWithWatermark(GROUP_ID, "GROUP", 15L, 0))); + when(chatMessageRepository.findByConversationIdAndSeqIdGreaterThan( + eq(GROUP_ID), eq(15L), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + PageResult result = chatService.getGroupChatHistory(GROUP_ID, 1, 20); + + assertTrue(result.getItems().isEmpty()); + verify(chatMessageRepository).findByConversationIdAndSeqIdGreaterThan( + eq(GROUP_ID), eq(15L), any(Pageable.class)); + } + + @Test + void syncMessagesLiftsSinceUpToClearedWatermark() { + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, PRIVATE_CONVERSATION_ID)) + .thenReturn(Optional.of(conversationWithWatermark(PRIVATE_CONVERSATION_ID, "PRIVATE", 30L, 0))); + when(chatMessageRepository.findByConversationIdAndSeqIdGreaterThanOrderBySeqIdAsc( + PRIVATE_CONVERSATION_ID, 30L)) + .thenReturn(Collections.emptyList()); + + List result = chatService.syncMessages(PEER_ID, 0L); + + assertTrue(result.isEmpty()); + // since=0 必须被抬到水位线 30,否则清空后旧消息会被原样拉回来 + verify(chatMessageRepository) + .findByConversationIdAndSeqIdGreaterThanOrderBySeqIdAsc(PRIVATE_CONVERSATION_ID, 30L); + } + + @Test + void globalSyncFiltersEachConversationByItsOwnWatermark() { + when(groupMemberRepository.findByUserId(USER_ID)) + .thenReturn(Collections.singletonList(member("MEMBER", null))); + when(chatConversationRepository.findByUserId(USER_ID)).thenReturn(Arrays.asList( + conversationWithWatermark(GROUP_ID, "GROUP", 5L, 0), + conversationWithWatermark(PRIVATE_CONVERSATION_ID, "PRIVATE", 0L, 0))); + when(chatMessageRepository.findBySeqIdGreaterThanOrderBySeqIdAsc(0L)).thenReturn(Arrays.asList( + message(GROUP_ID, USER_ID, 3L), + message(PRIVATE_CONVERSATION_ID, PEER_ID, 3L))); + + List result = chatService.syncAllMessages(0L); + + // 群会话水位线=5:seqId=3 被滤掉;私聊水位线=0:seqId=3 保留 + assertEquals(1, result.size()); + assertEquals(PEER_ID, result.get(0).getConversationId()); + } + + @Test + void searchAndExportRespectClearedWatermark() { + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, PRIVATE_CONVERSATION_ID)) + .thenReturn(Optional.of(conversationWithWatermark(PRIVATE_CONVERSATION_ID, "PRIVATE", 8L, 0))); + when(chatMessageRepository.search( + eq(PRIVATE_CONVERSATION_ID), eq("hi"), eq(8L), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + when(chatMessageRepository.findByConversationIdAndSeqIdGreaterThanOrderByCreatedAtDesc( + eq(PRIVATE_CONVERSATION_ID), eq(8L), any(Pageable.class))) + .thenReturn(Collections.emptyList()); + + assertTrue(chatService.searchPrivateMessages(PEER_ID, "hi", 1, 20).getItems().isEmpty()); + assertNotNull(chatService.exportPrivateMessages(PEER_ID)); + + verify(chatMessageRepository).search( + eq(PRIVATE_CONVERSATION_ID), eq("hi"), eq(8L), any(Pageable.class)); + verify(chatMessageRepository).findByConversationIdAndSeqIdGreaterThanOrderByCreatedAtDesc( + eq(PRIVATE_CONVERSATION_ID), eq(8L), any(Pageable.class)); + } + + // ==================== 测试数据构造 ==================== + + /** 群存在 + 本人是正常成员(两条 stub 都会被 clearConversationMessages 用到) */ + private void stubActiveGroupMember() { + when(groupRepository.existsById(GROUP_ID)).thenReturn(true); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(member("MEMBER", null))); + } + + private GroupMember member(String role, String nickname) { + GroupMember member = new GroupMember(); + member.setGroupId(GROUP_ID); + member.setUserId(USER_ID); + member.setRole(role); + member.setNickname(nickname); + return member; + } + + private ChatConversation conversationWithWatermark(String peerId, String type, + Long clearedSeqId, int unreadCount) { + ChatConversation conversation = new ChatConversation(); + conversation.setUserId(USER_ID); + conversation.setPeerId(peerId); + conversation.setConversationType(type); + conversation.setUnreadCount(unreadCount); + conversation.setClearedSeqId(clearedSeqId); + conversation.setLastMessage("旧摘要"); + return conversation; + } + + private ChatMessage message(String conversationId, String senderId, Long seqId) { + ChatMessage message = new ChatMessage(); + message.setSeqId(seqId); + message.setConversationId(conversationId); + message.setSenderId(senderId); + message.setContent("hello"); + message.setIsRead(false); + return message; + } +} diff --git a/campus-server/src/test/java/com/campus/service/ChatServiceReadStateTest.java b/campus-server/src/test/java/com/campus/service/ChatServiceReadStateTest.java new file mode 100644 index 0000000..d790a8c --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ChatServiceReadStateTest.java @@ -0,0 +1,280 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.response.ChatMessageDTO; +import com.campus.entity.ChatConversation; +import com.campus.entity.ChatMessage; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 会话已读 / 未读数回归测试。 + *

+ * 覆盖历史上的核心缺陷:群聊已读接口把群ID当成私聊对端ID拼接,导致 + * chat_conversation.unread_count 永远无法归零,刷新消息列表后红点复活。 + */ +@ExtendWith(MockitoExtension.class) +class ChatServiceReadStateTest { + + private static final String USER_ID = "11111111-1111-1111-1111-111111111111"; + private static final String GROUP_ID = "22222222-2222-2222-2222-222222222222"; + private static final String PEER_ID = "99999999-9999-9999-9999-999999999999"; + private static final String PRIVATE_CONVERSATION_ID = USER_ID + "_" + PEER_ID; + + @Mock private EasemobService easemobService; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private ChatConversationRepository chatConversationRepository; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private UserRepository userRepository; + @Mock private FriendshipRepository friendshipRepository; + @Mock private PushService pushService; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private UserRestrictionService userRestrictionService; + @Mock private ChatReportRepository chatReportRepository; + @Mock private AuthorizationService authorizationService; + @Mock private AuditLogService auditLogService; + @InjectMocks private ChatService chatService; + + @BeforeEach + void login() { + User user = new User(); + user.setId(USER_ID); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + // ==================== 群聊已读 ==================== + + @Test + void groupReadUsesGroupIdAsPeerIdAndClearsUnread() { + when(groupRepository.existsById(GROUP_ID)).thenReturn(true); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(member("MEMBER"))); + when(chatMessageRepository.findByConversationIdAndIsReadFalse(GROUP_ID)) + .thenReturn(Arrays.asList(otherMessage(GROUP_ID), otherMessage(GROUP_ID))); + ChatConversation conversation = conversation(GROUP_ID, "GROUP", 6); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)) + .thenReturn(Optional.of(conversation)); + + chatService.markMessagesAsRead(GROUP_ID); + + // 核心断言:必须按 peer_id = 群ID 命中会话并把未读数清零 + assertEquals(0, conversation.getUnreadCount()); + verify(chatConversationRepository).save(conversation); + verify(chatMessageRepository).saveAll(any()); + // 绝不能再用私聊拼接出的假会话ID去查 + verify(chatConversationRepository, never()).findByUserIdAndPeerId(USER_ID, GROUP_ID + "_" + USER_ID); + } + + @Test + void groupReadMarksMessageDetailAsRead() { + when(groupRepository.existsById(GROUP_ID)).thenReturn(true); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(member("MEMBER"))); + when(chatMessageRepository.findByConversationIdAndIsReadFalse(GROUP_ID)) + .thenReturn(Collections.singletonList(otherMessage(GROUP_ID))); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)) + .thenReturn(Optional.of(conversation(GROUP_ID, "GROUP", 1))); + + chatService.markMessagesAsRead(GROUP_ID); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(chatMessageRepository).saveAll(captor.capture()); + assertEquals(1, captor.getValue().size()); + assertTrue(captor.getValue().get(0).getIsRead()); + assertNotNull(captor.getValue().get(0).getReadAt()); + } + + @Test + void pendingMemberCannotClearGroupUnread() { + when(groupRepository.existsById(GROUP_ID)).thenReturn(true); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(member("PENDING"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.markMessagesAsRead(GROUP_ID)); + + assertEquals("入群申请尚未通过", error.getMessage()); + // PENDING 成员不按群会话处理:不能误清群会话红点,也不能退化成私聊会话 + verify(chatConversationRepository, never()).findByUserIdAndPeerId(anyString(), anyString()); + verifyNoInteractions(chatMessageRepository); + } + + @Test + void nonMemberCannotClearGroupUnread() { + when(groupRepository.existsById(GROUP_ID)).thenReturn(true); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.markMessagesAsRead(GROUP_ID)); + + assertEquals("入群申请尚未通过", error.getMessage()); + verifyNoInteractions(chatMessageRepository); + } + + // ==================== 私聊已读 ==================== + + @Test + void privateReadStillUsesPrivateConversationId() { + when(userRepository.existsById(PEER_ID)).thenReturn(true); + when(chatMessageRepository.findByConversationIdAndIsReadFalse(PRIVATE_CONVERSATION_ID)) + .thenReturn(Collections.singletonList(otherMessage(PRIVATE_CONVERSATION_ID))); + ChatConversation conversation = conversation(PRIVATE_CONVERSATION_ID, "PRIVATE", 3); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, PRIVATE_CONVERSATION_ID)) + .thenReturn(Optional.of(conversation)); + + chatService.markMessagesAsRead(PEER_ID); + + assertEquals(0, conversation.getUnreadCount()); + verify(chatConversationRepository).save(conversation); + } + + @Test + void privateReadRejectsWithConversationNotFoundWhenPeerMissing() { + // 群不存在 + 对方用户不存在 → 不能再谎报「入群申请尚未通过」 + when(groupRepository.existsById(PEER_ID)).thenReturn(false); + when(userRepository.existsById(PEER_ID)).thenReturn(false); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.markMessagesAsRead(PEER_ID)); + + assertEquals("会话不存在", error.getMessage()); + verifyNoInteractions(chatMessageRepository); + } + + // ==================== 异常语义 ==================== + + @Test + void rejectsWithExactMessageWhenNotLoggedIn() { + SecurityContextHolder.clearContext(); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.markMessagesAsRead(GROUP_ID)); + + assertEquals("用户未登录", error.getMessage()); + verifyNoInteractions(chatConversationRepository); + } + + @Test + void rejectsWithExactMessageWhenConversationIdBlank() { + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.markMessagesAsRead(" ")); + + assertEquals("会话ID不能为空", error.getMessage()); + verifyNoInteractions(chatMessageRepository); + } + + // ==================== 发送方未读归零 ==================== + + @Test + void senderSideUnreadIsResetWhenReceivingGroupMessage() { + Group group = new Group(); + group.setId(GROUP_ID); + group.setEasemobGroupId("em-group"); + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(member("MEMBER"))); + when(chatMessageRepository.save(any(ChatMessage.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(groupMemberRepository.findByGroupId(GROUP_ID)).thenReturn(Collections.singletonList(member("MEMBER"))); + ChatConversation stale = conversation(GROUP_ID, "GROUP", 2); + when(chatConversationRepository.findByUserIdAndPeerId(USER_ID, GROUP_ID)).thenReturn(Optional.of(stale)); + + ChatMessageDTO result = chatService.sendGroupMessage(GROUP_ID, "hello"); + + assertEquals("hello", result.getContent()); + // 自己发的消息,自己这边的红点必须清零(历史上只做 +1 不做清零) + assertEquals(0, stale.getUnreadCount()); + } + + // ==================== 全部标为已读 ==================== + + @Test + void markAllAsReadClearsCountersAndMessageDetails() { + ChatConversation groupConversation = conversation(GROUP_ID, "GROUP", 6); + ChatConversation privateConversation = conversation(PRIVATE_CONVERSATION_ID, "PRIVATE", 2); + when(chatConversationRepository.findByUserId(USER_ID)) + .thenReturn(Arrays.asList(groupConversation, privateConversation)); + + chatService.markAllAsRead(); + + assertEquals(0, groupConversation.getUnreadCount()); + assertEquals(0, privateConversation.getUnreadCount()); + verify(chatMessageRepository).markAsReadByConversationIdAndUserId(GROUP_ID, USER_ID); + verify(chatMessageRepository).markAsReadByConversationIdAndUserId(PRIVATE_CONVERSATION_ID, USER_ID); + verify(chatConversationRepository).saveAll(any()); + } + + @Test + void markAllAsReadRejectsWithExactMessageWhenNotLoggedIn() { + SecurityContextHolder.clearContext(); + + BusinessException error = assertThrows(BusinessException.class, () -> chatService.markAllAsRead()); + + assertEquals("用户未登录", error.getMessage()); + verifyNoInteractions(chatConversationRepository); + } + + // ==================== 测试数据构造 ==================== + + private GroupMember member(String role) { + GroupMember member = new GroupMember(); + member.setGroupId(GROUP_ID); + member.setUserId(USER_ID); + member.setRole(role); + return member; + } + + private ChatMessage otherMessage(String conversationId) { + ChatMessage message = new ChatMessage(); + message.setConversationId(conversationId); + message.setSenderId(PEER_ID); + message.setContent("hello"); + message.setIsRead(false); + return message; + } + + private ChatConversation conversation(String peerId, String type, int unreadCount) { + ChatConversation conversation = new ChatConversation(); + conversation.setUserId(USER_ID); + conversation.setPeerId(peerId); + conversation.setConversationType(type); + conversation.setUnreadCount(unreadCount); + return conversation; + } +} diff --git a/campus-server/src/test/java/com/campus/service/ChatServiceShareTest.java b/campus-server/src/test/java/com/campus/service/ChatServiceShareTest.java new file mode 100644 index 0000000..bdde5ba --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ChatServiceShareTest.java @@ -0,0 +1,330 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.common.ServiceUnavailableException; +import com.campus.dto.request.ChatMessageRequest; +import com.campus.dto.response.ChatMessageDTO; +import com.campus.entity.ChatMessage; +import com.campus.entity.Friendship; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.ChatMessageRepository; +import com.campus.repository.ChatReportRepository; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Arrays; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** 分享卡片消息测试:远端成功后落库、权限、群未读与 DTO 卡片解析。 */ +@ExtendWith(MockitoExtension.class) +class ChatServiceShareTest { + + private static final String USER_ID = "11111111-1111-1111-1111-111111111111"; + private static final String PEER_ID = "99999999-9999-9999-9999-999999999999"; + private static final String GROUP_ID = "22222222-2222-2222-2222-222222222222"; + private static final String CARD_JSON = "{\"targetType\":\"MOMENT\",\"targetId\":\"m-1\",\"title\":\"今晚图书馆自习组队\"}"; + private static final String DISPLAY_TEXT = "[分享] 今晚图书馆自习组队"; + + @Mock private EasemobService easemobService; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private ChatConversationRepository chatConversationRepository; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private UserRepository userRepository; + @Mock private FriendshipRepository friendshipRepository; + @Mock private PushService pushService; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private UserRestrictionService userRestrictionService; + @Mock private ChatReportRepository chatReportRepository; + @Mock private AuthorizationService authorizationService; + @Mock private AuditLogService auditLogService; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks private ChatService chatService; + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"MOMENT", "TOPIC", "ACTIVITY", "ANNOUNCEMENT", "BADGE", "USER"}) + void historyAndIncrementalSyncRetainAllCardFields(String type) throws Exception { + com.campus.dto.response.ShareCardDTO card = new com.campus.dto.response.ShareCardDTO(); + card.setTargetType(type); card.setTargetId("target"); card.setTitle("标题"); + ChatMessage stored = new ChatMessage(); + stored.setMessageId("message"); stored.setSeqId(1L); stored.setSenderId(USER_ID); + stored.setConversationId(USER_ID + "_" + PEER_ID); + stored.setMessageType("SHARE_CARD"); stored.setContent(objectMapper.writeValueAsString(card)); + when(chatMessageRepository.findByConversationIdAndSeqIdGreaterThan(anyString(), eq(0L), any(org.springframework.data.domain.Pageable.class))) + .thenReturn(new org.springframework.data.domain.PageImpl<>(java.util.Collections.singletonList(stored))); + when(chatMessageRepository.findByConversationIdAndSeqIdGreaterThanOrderBySeqIdAsc(anyString(), eq(0L))) + .thenReturn(java.util.Collections.singletonList(stored)); + when(chatMessageRepository.findBySeqIdGreaterThanOrderBySeqIdAsc(0L)) + .thenReturn(java.util.Collections.singletonList(stored)); + java.util.List messages = new java.util.ArrayList<>(); + messages.addAll(chatService.getPrivateChatHistory(PEER_ID, 1, 20).getItems()); + messages.addAll(chatService.syncMessages(PEER_ID, 0L)); + messages.addAll(chatService.syncAllMessages(0L)); + assertEquals(3, messages.size()); + for (ChatMessageDTO message : messages) { + assertEquals(card, message.getShareCard()); + com.fasterxml.jackson.databind.JsonNode json = objectMapper.valueToTree(message.getShareCard()); + assertEquals(5, json.size()); + org.junit.jupiter.api.Assertions.assertTrue(json.get("coverUrl").isNull()); + org.junit.jupiter.api.Assertions.assertTrue(json.get("summary").isNull()); + } + verify(chatMessageRepository, never()).save(any()); + } + + @BeforeEach + void login() { + User user = new User(); + user.setId(USER_ID); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void privateShareSendsRemoteBeforeLocalWriteAndReturnsCard() { + stubAcceptedFriend(); + when(userRepository.existsById(PEER_ID)).thenReturn(true); + when(easemobService.sendShareCardMessage(eq(USER_ID), eq(PEER_ID), eq(DISPLAY_TEXT), + anyString(), eq(CARD_JSON))).thenReturn(true); + when(chatMessageRepository.save(any(ChatMessage.class))).thenAnswer(inv -> inv.getArgument(0)); + User sender = new User(); + sender.setId(USER_ID); + sender.setNickname("小核"); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(sender)); + + ChatMessageDTO result = chatService.sendSharePrivateMessage(PEER_ID, CARD_JSON, DISPLAY_TEXT); + + InOrder order = inOrder(easemobService, chatMessageRepository); + order.verify(easemobService).sendShareCardMessage(eq(USER_ID), eq(PEER_ID), eq(DISPLAY_TEXT), + anyString(), eq(CARD_JSON)); + order.verify(chatMessageRepository).save(any(ChatMessage.class)); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).save(messageCaptor.capture()); + assertEquals("SHARE_CARD", messageCaptor.getValue().getMessageType()); + assertEquals(CARD_JSON, messageCaptor.getValue().getContent()); + assertEquals("SHARE_CARD", result.getMessageType()); + assertEquals("MOMENT", result.getShareCard().getTargetType()); + assertEquals(DISPLAY_TEXT, result.getContent()); + verify(chatConversationRepository, org.mockito.Mockito.times(2)).save(any()); + verify(pushService).pushChatMessage(eq(PEER_ID), eq(USER_ID), eq("小核"), eq(DISPLAY_TEXT)); + } + + @Test + void privateShareRejectsNonFriendBeforeRemoteOrWrite() { + when(userRepository.existsById(PEER_ID)).thenReturn(true); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.sendSharePrivateMessage(PEER_ID, CARD_JSON, DISPLAY_TEXT)); + + assertEquals("只能分享给好友", error.getMessage()); + verifyNoInteractions(easemobService, chatMessageRepository); + } + + @Test + void privateShareRemoteFailureReturns503AndDoesNotWrite() { + stubAcceptedFriend(); + when(userRepository.existsById(PEER_ID)).thenReturn(true); + when(easemobService.sendShareCardMessage(eq(USER_ID), eq(PEER_ID), eq(DISPLAY_TEXT), + anyString(), eq(CARD_JSON))).thenReturn(false); + + ServiceUnavailableException error = assertThrows(ServiceUnavailableException.class, + () -> chatService.sendSharePrivateMessage(PEER_ID, CARD_JSON, DISPLAY_TEXT)); + + assertEquals("分享服务暂不可用,请稍后重试", error.getMessage()); + verify(chatMessageRepository, never()).save(any(ChatMessage.class)); + verifyNoInteractions(chatConversationRepository); + } + + @Test + void genericPrivateEndpointCannotForgeShareCard() { + ChatMessageRequest request = new ChatMessageRequest(); + request.setPeerId(PEER_ID); + request.setContent(CARD_JSON); + request.setMessageType("SHARE_CARD"); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.sendPrivateMessage(request)); + + assertEquals("不支持的消息类型", error.getMessage()); + verifyNoInteractions(easemobService, chatMessageRepository); + } + + @Test + void malformedCardJsonFallsBackWithoutBreakingMessage() { + stubAcceptedFriend(); + when(userRepository.existsById(PEER_ID)).thenReturn(true); + when(easemobService.sendShareCardMessage(eq(USER_ID), eq(PEER_ID), eq(DISPLAY_TEXT), + anyString(), eq("not-json"))).thenReturn(true); + when(chatMessageRepository.save(any(ChatMessage.class))).thenAnswer(inv -> inv.getArgument(0)); + + ChatMessageDTO result = chatService.sendSharePrivateMessage(PEER_ID, "not-json", DISPLAY_TEXT); + + assertEquals("[分享] 内容暂不可查看", result.getContent()); + assertNull(result.getShareCard()); + } + + @Test + void groupShareRejectsNonMemberWith403() { + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group())); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.empty()); + + ForbiddenException error = assertThrows(ForbiddenException.class, + () -> chatService.sendGroupShareMessage(GROUP_ID, CARD_JSON, DISPLAY_TEXT)); + + assertEquals("不在群内", error.getMessage()); + verifyNoInteractions(easemobService, chatMessageRepository); + } + + @Test + void groupShareRejectsPendingMemberWithExactMessageAndNoWrite() { + GroupMember sender = member(USER_ID, "PENDING"); + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group())); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(sender)); + + BusinessException error = assertThrows(BusinessException.class, + () -> chatService.sendGroupShareMessage(GROUP_ID, CARD_JSON, DISPLAY_TEXT)); + + assertEquals("入群申请尚未通过", error.getMessage()); + verifyNoInteractions(easemobService, chatMessageRepository); + } + + @Test + void groupShareRejectsMutedMemberWith403() { + GroupMember sender = member(USER_ID, "MEMBER"); + sender.setIsMuted(true); + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group())); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(sender)); + + ForbiddenException error = assertThrows(ForbiddenException.class, + () -> chatService.sendGroupShareMessage(GROUP_ID, CARD_JSON, DISPLAY_TEXT)); + + assertEquals("你已被群禁言", error.getMessage()); + verifyNoInteractions(easemobService, chatMessageRepository); + } + + @Test + void groupShareWithoutEasemobGroupReturns503AndDoesNotWrite() { + Group group = group(); + group.setEasemobGroupId(null); + GroupMember sender = member(USER_ID, "MEMBER"); + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(sender)); + + ServiceUnavailableException error = assertThrows(ServiceUnavailableException.class, + () -> chatService.sendGroupShareMessage(GROUP_ID, CARD_JSON, DISPLAY_TEXT)); + + assertEquals("群聊服务尚未就绪,请稍后重试", error.getMessage()); + verifyNoInteractions(easemobService, chatMessageRepository); + } + + @Test + void groupShareRemoteFailureDoesNotWriteOrUpdateGroup() { + Group group = group(); + GroupMember sender = member(USER_ID, "MEMBER"); + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(sender)); + when(easemobService.sendGroupShareCardMessage(eq(USER_ID), eq("eg-1"), eq(DISPLAY_TEXT), + anyString(), eq(CARD_JSON))).thenReturn(false); + + ServiceUnavailableException error = assertThrows(ServiceUnavailableException.class, + () -> chatService.sendGroupShareMessage(GROUP_ID, CARD_JSON, DISPLAY_TEXT)); + + assertEquals("分享服务暂不可用,请稍后重试", error.getMessage()); + verify(chatMessageRepository, never()).save(any(ChatMessage.class)); + verify(groupRepository, never()).save(any(Group.class)); + } + + @Test + void groupShareUpdatesActiveMembersAndSkipsPendingMember() { + Group group = group(); + GroupMember sender = member(USER_ID, "MEMBER"); + GroupMember other = member("33333333-3333-3333-3333-333333333333", "MEMBER"); + GroupMember pending = member("44444444-4444-4444-4444-444444444444", "PENDING"); + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, USER_ID)) + .thenReturn(Optional.of(sender)); + when(easemobService.sendGroupShareCardMessage(eq(USER_ID), eq("eg-1"), eq(DISPLAY_TEXT), + anyString(), eq(CARD_JSON))).thenReturn(true); + when(chatMessageRepository.save(any(ChatMessage.class))).thenAnswer(inv -> inv.getArgument(0)); + when(groupMemberRepository.findByGroupId(GROUP_ID)).thenReturn(Arrays.asList(sender, other, pending)); + + ChatMessageDTO result = chatService.sendGroupShareMessage(GROUP_ID, CARD_JSON, DISPLAY_TEXT); + + assertEquals(DISPLAY_TEXT, group.getLastMessage()); + assertEquals("SHARE_CARD", result.getMessageType()); + verify(groupRepository).save(group); + verify(chatConversationRepository, org.mockito.Mockito.times(2)).save(any()); + verify(pushService).pushGroupChatMessage( + eq(other.getUserId()), eq(USER_ID), anyString(), eq(GROUP_ID), eq(DISPLAY_TEXT)); + verify(pushService, never()).pushGroupChatMessage( + eq(pending.getUserId()), anyString(), anyString(), anyString(), anyString()); + } + + private void stubAcceptedFriend() { + Friendship friendship = new Friendship(); + friendship.setUserId(USER_ID); + friendship.setFriendId(PEER_ID); + friendship.setStatus("ACCEPTED"); + when(friendshipRepository.findByUserIdAndFriendId(USER_ID, PEER_ID)) + .thenReturn(Optional.of(friendship)); + } + + private Group group() { + Group group = new Group(); + group.setId(GROUP_ID); + group.setName("羽毛球群"); + group.setEasemobGroupId("eg-1"); + return group; + } + + private GroupMember member(String userId, String role) { + GroupMember member = new GroupMember(); + member.setGroupId(GROUP_ID); + member.setUserId(userId); + member.setRole(role); + member.setIsMuted(false); + return member; + } +} diff --git a/campus-server/src/test/java/com/campus/service/ChsiPdfServiceTest.java b/campus-server/src/test/java/com/campus/service/ChsiPdfServiceTest.java new file mode 100644 index 0000000..bcb63df --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ChsiPdfServiceTest.java @@ -0,0 +1,118 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChsiPdfServiceTest { + + private final ChsiPdfService service = service(); + + @Test + void readsSinglePagePdfTextAndCanRenderFallbackImage() throws Exception { + byte[] pdf = pdf(1, "CHSI student report"); + MockMultipartFile file = new MockMultipartFile( + "file", "report.pdf", "application/pdf", pdf); + + ChsiPdfService.PdfContent content = service.read(file); + byte[] image = service.renderFirstPage(content); + + assertTrue(content.getTextLines().contains("CHSI student report")); + assertTrue(image.length > 8); + assertEquals((byte) 0x89, image[0]); + assertEquals((byte) 'P', image[1]); + assertEquals((byte) 'N', image[2]); + assertEquals((byte) 'G', image[3]); + } + + @Test + void rejectsEmptyFileWithExactMessage() { + MockMultipartFile file = new MockMultipartFile( + "file", "report.pdf", "application/pdf", new byte[0]); + + BusinessException e = assertThrows(BusinessException.class, () -> service.read(file)); + + assertEquals("PDF 文件不能为空", e.getMessage()); + } + + @Test + void rejectsExtensionOrMimeMismatchWithExactMessage() throws Exception { + byte[] pdf = pdf(1, "test"); + MockMultipartFile file = new MockMultipartFile( + "file", "report.jpg", "image/jpeg", pdf); + + BusinessException e = assertThrows(BusinessException.class, () -> service.read(file)); + + assertEquals("请上传从学信网下载的 PDF 文件", e.getMessage()); + } + + @Test + void rejectsFakePdfHeaderWithExactMessage() { + MockMultipartFile file = new MockMultipartFile( + "file", "report.pdf", "application/pdf", "not-pdf".getBytes()); + + BusinessException e = assertThrows(BusinessException.class, () -> service.read(file)); + + assertEquals("请上传从学信网下载的 PDF 文件", e.getMessage()); + } + + @Test + void rejectsMultiplePagesWithExactMessage() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "report.pdf", "application/pdf", pdf(2, "test")); + + BusinessException e = assertThrows(BusinessException.class, () -> service.read(file)); + + assertEquals("仅支持单页学信网验证报告 PDF", e.getMessage()); + } + + @Test + void rejectsOversizedPdfBeforeParsing() { + ReflectionTestUtils.setField(service, "maxSizeMb", 0L); + MockMultipartFile file = new MockMultipartFile( + "file", "report.pdf", "application/pdf", "%PDF-1.4".getBytes()); + + BusinessException e = assertThrows(BusinessException.class, () -> service.read(file)); + + assertEquals("PDF 文件不能超过 0MB", e.getMessage()); + assertFalse(file.isEmpty()); + } + + private static ChsiPdfService service() { + ChsiPdfService service = new ChsiPdfService(); + ReflectionTestUtils.setField(service, "maxSizeMb", 10L); + ReflectionTestUtils.setField(service, "renderDpi", 72F); + return service; + } + + private static byte[] pdf(int pages, String text) throws Exception { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + PDPage page = new PDPage(); + document.addPage(page); + try (PDPageContentStream stream = new PDPageContentStream(document, page)) { + stream.beginText(); + stream.setFont(PDType1Font.HELVETICA, 12); + stream.newLineAtOffset(72, 720); + stream.showText(text); + stream.endText(); + } + } + document.save(output); + return output.toByteArray(); + } + } +} diff --git a/campus-server/src/test/java/com/campus/service/ChsiReportParserTest.java b/campus-server/src/test/java/com/campus/service/ChsiReportParserTest.java new file mode 100644 index 0000000..f8b940f --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ChsiReportParserTest.java @@ -0,0 +1,164 @@ +package com.campus.service; + +import com.campus.config.OcrProperties; +import com.campus.dto.response.ChsiReportDTO; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChsiReportParserTest { + + private final ChsiReportParser parser = new ChsiReportParser(); + + @Test + void parsesAllFieldsFromLatestStudentStatusReport() { + ChsiReportDTO result = parser.parse(Arrays.asList( + "教育部学籍在线验证报告", + "更新日期:2026年09月17日", + "姓名 赵子越", + "性别 男", + "出生日期 2004年01月23日", + "民族 汉族", + "学校名称 华北理工大学", + "层次 本科", + "专业 计算机科学与技术", + "学制 4年", + "学历类别 普通高等教育", + "学习形式 普通全日制", + "分院 曹妃甸校区", + "系所 人工智能学院", + "入学日期 2022年09月01日", + "学籍状态 不在籍(毕业)", + "离校日期 2026年06月17日", + "在线验证码 AJ3218N50MPXWGEG")); + + assertEquals("教育部学籍在线验证报告", result.getReportName()); + assertEquals("2026-09-17", result.getReportUpdatedDate()); + assertEquals("赵子越", result.getName()); + assertEquals("男", result.getGender()); + assertEquals("2004-01-23", result.getBirthDate()); + assertEquals("汉族", result.getEthnicity()); + assertEquals("华北理工大学", result.getSchool()); + assertEquals("本科", result.getLevel()); + assertEquals("计算机科学与技术", result.getMajor()); + assertEquals("4年", result.getStudyDuration()); + assertEquals("普通高等教育", result.getEducationCategory()); + assertEquals("普通全日制", result.getStudyForm()); + assertEquals("曹妃甸校区", result.getBranchCampus()); + assertEquals("人工智能学院", result.getDepartment()); + assertEquals("2022-09-01", result.getEnrollmentDate()); + assertEquals("不在籍(毕业)", result.getStatus()); + assertEquals("2026-06-17", result.getDepartureDate()); + assertEquals("AJ3218N50MPXWGEG", result.getOnlineVerificationCode()); + } + + @Test + void updateDateIsNotMistakenForBirthDate() { + ChsiReportDTO result = parser.parse(Arrays.asList( + "教育部学籍在线验证报告", + "更新日期:2026年09月17日", + "姓名 赵子越")); + + assertEquals("2026-09-17", result.getReportUpdatedDate()); + assertNull(result.getBirthDate()); + } + + @Test + void branchCampusAndDepartmentRemainSeparate() { + ChsiReportDTO result = parser.parse(Arrays.asList( + "分院 曹妃甸校区", + "系所 人工智能学院")); + + assertEquals("曹妃甸校区", result.getBranchCampus()); + assertEquals("人工智能学院", result.getDepartment()); + } + + @Test + void certificateNumberIsNotAcceptedAsOnlineVerificationCode() { + ChsiReportDTO result = parser.parse(Arrays.asList( + "教育部学籍在线验证报告", + "证书编号 ABCDEFGHIJKLMNOP")); + + assertNull(result.getOnlineVerificationCode()); + } + + @Test + void tableCoordinatesAreStillMergedBeforeParsing() { + String ocr = "{\"tableHeadTail\":[{\"head\":[\"教育部学籍在线验证报告\"," + + "\"姓名 测试甲\",\"学校名称 测试大学\",\"学制 3年\"],\"tail\":[\"注意事项:\"]}]," + + "\"prism_wordsInfo\":[" + + word("专业", 100, 400) + "," + word("商品花卉", 260, 402) + "," + + word("学制", 100, 438) + "," + word("3年", 260, 438) + "," + + word("本科", 260, 477) + "," + word("层次", 100, 476) + + "]}"; + AliOcrService service = service(); + + List lines = service.parseOcrResultToLines(ocr); + ChsiReportDTO result = parser.parse(lines); + + assertTrue(lines.contains("专业 商品花卉")); + assertTrue(lines.contains("层次 本科")); + assertEquals("商品花卉", result.getMajor()); + assertEquals("本科", result.getLevel()); + } + + @Test + void validationAcceptsOnlyLatestReportAndRequiresCoreFields() { + ChsiReportDTO result = parser.parse(Arrays.asList( + "教育部学籍在线验证报告", + "姓名 赵子越", + "学校名称 华北理工大学", + "层次 本科", + "专业 计算机科学与技术", + "入学日期 2022年09月01日", + "学籍状态 不在籍(毕业)", + "在线验证码 AJ3218N50MPXWGEG")); + + service().validateChsiReport(result, Arrays.asList("教育部学籍在线验证报告")); + + assertTrue(result.isValidChsiReport()); + } + + @Test + void validationReportsExactMissingFields() { + ChsiReportDTO result = parser.parse(Arrays.asList( + "教育部学籍在线验证报告", + "姓名 赵子越", + "学校名称 华北理工大学")); + + service().validateChsiReport(result, Arrays.asList("教育部学籍在线验证报告")); + + assertFalse(result.isValidChsiReport()); + assertEquals("报告识别不完整,缺少:层次 专业 入学日期 学籍状态 在线验证码。请重新从学信网下载原始 PDF 后上传", + result.getErrorMsg()); + } + + @Test + void oldCertificateRegistrationReportIsRejected() { + ChsiReportDTO result = parser.parse(Arrays.asList( + "教育部学历证书电子注册备案表", + "姓名 测试甲", + "学校名称 测试大学")); + + service().validateChsiReport(result, Arrays.asList("教育部学历证书电子注册备案表")); + + assertFalse(result.isValidChsiReport()); + assertEquals("上传的 PDF 不是有效的学信网验证报告", result.getErrorMsg()); + } + + private AliOcrService service() { + return new AliOcrService(new OcrProperties(), parser, new ChsiPdfService()); + } + + private String word(String value, int x, int y) { + return "{\"word\":\"" + value + "\",\"pos\":[{\"x\":" + x + ",\"y\":" + y + + "},{\"x\":" + (x + 70) + ",\"y\":" + y + "},{\"x\":" + (x + 70) + + ",\"y\":" + (y + 20) + "},{\"x\":" + x + ",\"y\":" + (y + 20) + "}]}"; + } +} diff --git a/campus-server/src/test/java/com/campus/service/ClickGuardServiceTest.java b/campus-server/src/test/java/com/campus/service/ClickGuardServiceTest.java new file mode 100644 index 0000000..4efbd9c --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ClickGuardServiceTest.java @@ -0,0 +1,83 @@ +package com.campus.service; + +import com.campus.common.TooManyRequestsException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** 连点守卫:首次放行、窗口内第二次拒绝(429 文案)、Redis 故障放行、未登录跳过。 */ +@ExtendWith(MockitoExtension.class) +class ClickGuardServiceTest { + + private static final String SCOPE = ClickGuardService.SCOPE_ACTIVITY_JOIN_QUIT; + private static final long WINDOW_MS = 2000L; + + @Mock private RedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + + private ClickGuardService service; + + @BeforeEach + void setUp() { + service = new ClickGuardService(redisTemplate, new RedisRetryExecutor()); + ReflectionTestUtils.setField(service, "windowMs", WINDOW_MS); + } + + private void useValueOperations() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + void firstClickIsAllowedAndTakesTheWindow() { + useValueOperations(); + when(valueOperations.setIfAbsent( + "click-guard:" + SCOPE + ":user-1:activity-1", "1", WINDOW_MS, TimeUnit.MILLISECONDS)) + .thenReturn(true); + + assertDoesNotThrow(() -> service.ensureNotTooFrequent(SCOPE, "user-1", "activity-1")); + } + + @Test + void secondClickInsideWindowIsRejected() { + useValueOperations(); + when(valueOperations.setIfAbsent(anyString(), any(), anyLong(), any(TimeUnit.class))).thenReturn(false); + + TooManyRequestsException error = assertThrows(TooManyRequestsException.class, + () -> service.ensureNotTooFrequent(SCOPE, "user-1", "activity-1")); + + assertEquals("点击过于频繁,操作失败", error.getMessage()); + } + + @Test + void redisFailureFailsOpen() { + useValueOperations(); + when(valueOperations.setIfAbsent(anyString(), any(), anyLong(), any(TimeUnit.class))) + .thenThrow(new RedisConnectionFailureException("redis down")); + + assertDoesNotThrow(() -> service.ensureNotTooFrequent(SCOPE, "user-1", "activity-1")); + } + + @Test + void anonymousRequestSkipsTheGuardEntirely() { + assertDoesNotThrow(() -> service.ensureNotTooFrequent(SCOPE, null, "activity-1")); + + verifyNoInteractions(redisTemplate); + } +} diff --git a/campus-server/src/test/java/com/campus/service/CommerceServiceTest.java b/campus-server/src/test/java/com/campus/service/CommerceServiceTest.java new file mode 100644 index 0000000..db82fb6 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/CommerceServiceTest.java @@ -0,0 +1,358 @@ +package com.campus.service; + +import com.campus.entity.CommerceOrder; +import com.campus.entity.LotteryPrize; +import com.campus.entity.IdempotencyRecord; +import com.campus.entity.MallProduct; +import com.campus.entity.TaskDefinition; +import com.campus.entity.TaskClaim; +import com.campus.entity.UserTaskProgress; +import com.campus.entity.User; +import com.campus.repository.CommerceOrderRepository; +import com.campus.repository.LotteryDrawRepository; +import com.campus.repository.IdempotencyRecordRepository; +import com.campus.repository.LotteryPrizeRepository; +import com.campus.repository.MallProductRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.RewardIssuanceLogRepository; +import com.campus.repository.TaskClaimRepository; +import com.campus.repository.TaskDefinitionRepository; +import com.campus.repository.UserEntitlementRepository; +import com.campus.repository.UserRepository; +import com.campus.repository.UserTaskProgressRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CommerceServiceTest { + + @Mock private PointsService pointsService; + @Mock private MallProductRepository productRepository; + @Mock private CommerceOrderRepository orderRepository; + @Mock private UserEntitlementRepository entitlementRepository; + @Mock private RewardIssuanceLogRepository rewardLogRepository; + @Mock private TaskDefinitionRepository taskRepository; + @Mock private UserTaskProgressRepository progressRepository; + @Mock private TaskClaimRepository taskClaimRepository; + @Mock private LotteryPrizeRepository prizeRepository; + @Mock private LotteryDrawRepository drawRepository; + @Mock private UserRepository userRepository; + @Mock private MomentRepository momentRepository; + @Mock private IdempotencyRecordRepository idempotencyRepository; + @Mock private ObjectMapper objectMapper; + @InjectMocks private CommerceService service; + + @Test + void weightedSelectionUsesConfiguredWeightRanges() { + LotteryPrize common = prize("COMMON", 3); + LotteryPrize rare = prize("RARE", 1); + assertEquals("COMMON", service.selectPrize(Arrays.asList(common, rare), 0).getCode()); + assertEquals("COMMON", service.selectPrize(Arrays.asList(common, rare), 2).getCode()); + assertEquals("RARE", service.selectPrize(Arrays.asList(common, rare), 3).getCode()); + } + + @Test + void purchaseDeductsPointsDecrementsStockAndIssuesEntitlement() { + User user = new User(); + user.setId("user-1"); + MallProduct product = new MallProduct(); + product.setId("theme"); + product.setCode("theme"); + product.setName("主题"); + product.setPricePoints(30); + product.setStock(1); + product.setEnabled(true); + product.setEntitlementType("THEME"); + product.setDurationDays(30); + when(userRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); + when(productRepository.findByCodeForUpdate("theme")).thenReturn(Optional.of(product)); + when(orderRepository.save(any(CommerceOrder.class))).thenAnswer(invocation -> { + CommerceOrder order = invocation.getArgument(0); + order.setId("order-1"); + return order; + }); + + service.purchase("user-1", "theme"); + + assertEquals(0, product.getStock()); + verify(pointsService).spendPoints("user-1", PointsService.RULE_REDEEM_PRIZE, + 30, "MALL", "order-1", "兑换商城商品:主题"); + verify(productRepository).save(product); + verify(entitlementRepository).save(any()); + verify(rewardLogRepository).save(any()); + } + + @Test + @SuppressWarnings("unchecked") + void repeatedIdempotencyKeyReplaysWithoutSecondPurchase() throws Exception { + User user = new User(); + user.setId("user-1"); + IdempotencyRecord record = new IdempotencyRecord(); + record.setRequestFingerprint("theme"); + record.setResponseJson("{\"orderId\":\"order-1\"}"); + Map saved = new LinkedHashMap<>(); + saved.put("orderId", "order-1"); + when(userRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); + when(idempotencyRepository.findByUserIdAndOperationAndIdempotencyKey( + "user-1", "MALL_PURCHASE", "request-1")).thenReturn(Optional.of(record)); + when(objectMapper.readValue(any(String.class), any(TypeReference.class))).thenReturn(saved); + + Map replay = service.purchase("user-1", "theme", "request-1"); + + assertEquals("order-1", replay.get("orderId")); + verify(productRepository, never()).findByCodeForUpdate(any()); + verify(pointsService, never()).spendPoints(any(), any(), any(), any(), any(), any()); + } + + @Test + void completeProfileTaskRequiresHometownBioAndNonBlankInterestButIgnoresNameAndAvatar() { + User user = new User(); + user.setId("user-1"); + user.setNickname(null); + user.setAvatar(null); + user.setHometown(" 成都 "); + user.setBio(" 喜欢摄影和跑步 "); + user.setInterests(Arrays.asList(" ", " 摄影 ")); + + TaskDefinition task = new TaskDefinition(); + task.setCode("complete-profile"); + task.setName("完善个人资料"); + task.setDescription("完善家乡、个人介绍和兴趣标签"); + task.setRewardPoints(10); + task.setCycle("ONCE"); + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(taskRepository.findByEnabledTrueOrderBySortAsc()).thenReturn(Collections.singletonList(task)); + when(progressRepository.findByUserIdAndTaskCodeAndCycleKey("user-1", "complete-profile", "ONCE")) + .thenReturn(Optional.empty()); + when(progressRepository.save(any(UserTaskProgress.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(taskClaimRepository.findByUserIdAndTaskId("user-1", "complete-profile")) + .thenReturn(Optional.empty()); + + List> tasks = service.listTasks("user-1"); + + assertEquals(1, tasks.get(0).get("progress")); + assertEquals(true, tasks.get(0).get("completed")); + assertEquals(10, tasks.get(0).get("rewardPoints")); + + user.setHometown(" "); + assertEquals(0, service.listTasks("user-1").get(0).get("progress")); + user.setHometown("成都"); + user.setBio(" \t "); + assertEquals(0, service.listTasks("user-1").get(0).get("progress")); + user.setBio("喜欢摄影"); + user.setInterests(Arrays.asList(null, " ")); + assertEquals(0, service.listTasks("user-1").get(0).get("progress")); + } + + @Test + void completeProfileTaskRecalculatesLegacyUnclaimedProgress() { + User user = new User(); + user.setId("user-1"); + user.setHometown(null); + user.setBio("喜欢摄影"); + user.setInterests(Arrays.asList("摄影")); + + TaskDefinition task = new TaskDefinition(); + task.setCode("complete-profile"); + task.setName("完善个人资料"); + task.setTarget(1); + task.setRewardPoints(10); + task.setCycle("ONCE"); + + UserTaskProgress legacyProgress = new UserTaskProgress(); + legacyProgress.setProgress(1); + legacyProgress.setCompleted(true); + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(taskRepository.findByEnabledTrueOrderBySortAsc()).thenReturn(Collections.singletonList(task)); + when(progressRepository.findByUserIdAndTaskCodeAndCycleKey("user-1", "complete-profile", "ONCE")) + .thenReturn(Optional.of(legacyProgress)); + when(progressRepository.save(any(UserTaskProgress.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(taskClaimRepository.findByUserIdAndTaskId("user-1", "complete-profile")) + .thenReturn(Optional.empty()); + + Map taskItem = service.listTasks("user-1").get(0); + + assertEquals(0, taskItem.get("progress")); + assertEquals(false, taskItem.get("completed")); + } + + @Test + void realNameAuthTaskIsCompletedOnlyAfterXuexinCertification() { + User user = new User(); + user.setId("user-1"); + user.setXuexinCertified(true); + + TaskDefinition task = task("real-name-auth", "实名认证", 100); + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(taskRepository.findByEnabledTrueOrderBySortAsc()).thenReturn(Collections.singletonList(task)); + when(progressRepository.findByUserIdAndTaskCodeAndCycleKey("user-1", "real-name-auth", "ONCE")) + .thenReturn(Optional.empty()); + when(progressRepository.save(any(UserTaskProgress.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(taskClaimRepository.findByUserIdAndTaskId("user-1", "real-name-auth")) + .thenReturn(Optional.empty()); + + Map item = service.listTasks("user-1").get(0); + + assertEquals(1, item.get("progress")); + assertEquals(true, item.get("completed")); + assertEquals(100, item.get("rewardPoints")); + } + + @Test + void claimingRealNameAuthTaskAwardsOneHundredPointsWithRealNameRule() { + User user = new User(); + user.setId("user-1"); + user.setXuexinCertified(true); + TaskDefinition task = task("real-name-auth", "实名认证", 100); + when(userRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); + when(taskRepository.findByCode("real-name-auth")).thenReturn(Optional.of(task)); + when(progressRepository.findForUpdate("user-1", "real-name-auth", "ONCE")) + .thenReturn(Optional.empty()); + when(progressRepository.findByUserIdAndTaskCodeAndCycleKey("user-1", "real-name-auth", "ONCE")) + .thenReturn(Optional.empty()); + when(progressRepository.save(any(UserTaskProgress.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(taskClaimRepository.findByUserIdAndTaskId("user-1", "real-name-auth")) + .thenReturn(Optional.empty()); + when(taskClaimRepository.save(any(TaskClaim.class))).thenAnswer(invocation -> { + TaskClaim claim = invocation.getArgument(0); + claim.setId("claim-1"); + return claim; + }); + when(pointsService.earnPointsWithAmount(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(new com.campus.entity.PointTransaction()); + + Map result = service.claimTask("user-1", "real-name-auth"); + + assertEquals("100 青豆", result.get("reward")); + assertEquals(true, result.get("rewardGranted")); + verify(pointsService).earnPointsWithAmount("user-1", PointsService.RULE_REAL_NAME_AUTH, + 100, "TASK", "claim-1", null, "完成任务:实名认证"); + } + + private TaskDefinition task(String code, String name, int rewardPoints) { + TaskDefinition task = new TaskDefinition(); + task.setCode(code); + task.setName(name); + task.setDescription(name); + task.setTarget(1); + task.setRewardPoints(rewardPoints); + task.setCycle("ONCE"); + return task; + } + + @Test + void automaticRewardsSkipIncompleteTasksAndFirstLoginOutsideLogin() { + User user = new User(); + user.setId("user-1"); + when(userRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); + when(taskRepository.findByCode("complete-profile")) + .thenReturn(Optional.of(task("complete-profile", "完善资料", 10))); + when(taskRepository.findByCode("real-name-auth")) + .thenReturn(Optional.of(task("real-name-auth", "实名认证", 100))); + + service.grantNewcomerRewards("user-1", false); + + verify(taskRepository, never()).findByCode("first-login"); + verify(taskClaimRepository, never()).save(any()); + org.mockito.Mockito.verifyNoInteractions(pointsService); + } + + @Test + void successfulLoginAutomaticallyClaimsAllEligibleNewcomerTasks() { + User user = new User(); + user.setId("user-1"); + user.setHometown("成都"); + user.setBio("喜欢摄影"); + user.setInterests(Collections.singletonList("摄影")); + user.setXuexinCertified(true); + when(userRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); + when(taskRepository.findByCode(anyString())).thenAnswer(i -> + Optional.of(task(i.getArgument(0), "新手任务", 10))); + when(progressRepository.save(any(UserTaskProgress.class))).thenAnswer(i -> i.getArgument(0)); + when(taskClaimRepository.save(any(TaskClaim.class))).thenAnswer(i -> { + TaskClaim claim = i.getArgument(0); + claim.setId("claim-" + claim.getTaskId()); + return claim; + }); + when(pointsService.earnPointsWithAmount(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(new com.campus.entity.PointTransaction()); + + service.grantNewcomerRewards("user-1", true); + + verify(taskClaimRepository, org.mockito.Mockito.times(3)).save(any()); + verify(pointsService).earnPointsWithAmount("user-1", PointsService.RULE_FIRST_LOGIN, + 10, "TASK", "claim-first-login", null, "完成任务:新手任务"); + } + + @Test + void uncertifiedUserCannotClaimRealNameReward() { + User user = new User(); + user.setId("user-1"); + when(userRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); + when(taskRepository.findByCode("real-name-auth")) + .thenReturn(Optional.of(task("real-name-auth", "实名认证", 100))); + when(progressRepository.findForUpdate("user-1", "real-name-auth", "ONCE")) + .thenReturn(Optional.of(new UserTaskProgress())); + when(progressRepository.save(any(UserTaskProgress.class))).thenAnswer(i -> i.getArgument(0)); + + com.campus.common.BusinessException error = org.junit.jupiter.api.Assertions.assertThrows( + com.campus.common.BusinessException.class, + () -> service.claimTask("user-1", "real-name-auth")); + + assertEquals("任务尚未完成", error.getMessage()); + verify(taskClaimRepository, never()).save(any()); + org.mockito.Mockito.verifyNoInteractions(pointsService, rewardLogRepository); + } + + @Test + void repeatedRealNameClaimDoesNotAwardAgain() { + User user = new User(); + user.setId("user-1"); + user.setXuexinCertified(true); + UserTaskProgress progress = new UserTaskProgress(); + progress.setClaimed(true); + when(userRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); + when(taskRepository.findByCode("real-name-auth")) + .thenReturn(Optional.of(task("real-name-auth", "实名认证", 100))); + when(progressRepository.findForUpdate("user-1", "real-name-auth", "ONCE")) + .thenReturn(Optional.of(progress)); + when(progressRepository.save(any(UserTaskProgress.class))).thenAnswer(i -> i.getArgument(0)); + + Map result = service.claimTask("user-1", "real-name-auth"); + + assertEquals(false, result.get("rewardGranted")); + assertEquals("该任务已领取", result.get("message")); + verify(taskClaimRepository, never()).save(any()); + org.mockito.Mockito.verifyNoInteractions(pointsService, rewardLogRepository); + } + + private LotteryPrize prize(String code, int weight) { + LotteryPrize prize = new LotteryPrize(); + prize.setCode(code); + prize.setWeight(weight); + return prize; + } +} diff --git a/campus-server/src/test/java/com/campus/service/ContentAppealServiceTest.java b/campus-server/src/test/java/com/campus/service/ContentAppealServiceTest.java new file mode 100644 index 0000000..b378431 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ContentAppealServiceTest.java @@ -0,0 +1,151 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.dto.request.ContentAppealRequest; +import com.campus.dto.request.ContentAppealReviewRequest; +import com.campus.entity.ContentAppeal; +import com.campus.entity.Moment; +import com.campus.entity.User; +import com.campus.repository.ActivityRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.ContentAppealRepository; +import com.campus.repository.MomentRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContentAppealServiceTest { + @Mock private ContentAppealRepository repository; + @Mock private MomentRepository momentRepository; + @Mock private ActivityRepository activityRepository; + @Mock private AnnouncementRepository announcementRepository; + @Mock private NotificationService notificationService; + @Mock private AuditLogService auditLogService; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void onlyOwnerCanAppeal() { + authenticate("other", false, false); + Moment moment = rejectedMoment("owner"); + when(momentRepository.findIncludingDeleted("moment")).thenReturn(Optional.of(moment)); + + assertThrows(BusinessException.class, () -> service().create(appealRequest())); + verify(repository, never()).save(any()); + } + + @Test + void duplicatePendingAppealIsRejected() { + authenticate("owner", false, false); + when(momentRepository.findIncludingDeleted("moment")) + .thenReturn(Optional.of(rejectedMoment("owner"))); + when(repository.existsByUserIdAndContentTypeAndContentIdAndStatus( + "owner", "MOMENT", "moment", "PENDING")).thenReturn(true); + + assertThrows(BusinessException.class, () -> service().create(appealRequest())); + } + + @Test + void normalUserCannotReviewAppeal() { + authenticate("user", false, false); + ContentAppealReviewRequest request = reviewRequest("APPROVED"); + assertThrows(ForbiddenException.class, () -> service().review("appeal", request)); + } + + @Test + void reviewerApprovalRestoresContentAndEmitsNotificationAndAudit() { + authenticate("reviewer", false, true); + ContentAppeal appeal = pendingAppeal(); + when(repository.findById("appeal")).thenReturn(Optional.of(appeal)); + when(momentRepository.restoreApproved("moment")).thenReturn(1); + when(repository.save(appeal)).thenReturn(appeal); + + assertEquals("APPROVED", service().review("appeal", reviewRequest("approved")).getStatus()); + + verify(momentRepository).restoreApproved("moment"); + verify(notificationService).createNotification(eq("owner"), eq("CONTENT_APPEAL_RESULT"), + eq("内容申诉通过"), any(), any(), any(), eq("appeal"), eq("reviewer")); + verify(auditLogService).record(eq("CONTENT_APPEAL_REVIEW"), eq("CONTENT_APPEAL"), + eq("appeal"), eq("status=PENDING"), eq("status=APPROVED"), any()); + } + + @Test + void rejectionDoesNotRestoreContent() { + authenticate("admin", true, false); + ContentAppeal appeal = pendingAppeal(); + when(repository.findById("appeal")).thenReturn(Optional.of(appeal)); + when(repository.save(appeal)).thenReturn(appeal); + + assertEquals("REJECTED", service().review("appeal", reviewRequest("REJECTED")).getStatus()); + verify(momentRepository, never()).restoreApproved(any()); + } + + private ContentAppealService service() { + return new ContentAppealService(repository, momentRepository, activityRepository, + announcementRepository, new AuthorizationService(), notificationService, auditLogService); + } + + private ContentAppealRequest appealRequest() { + ContentAppealRequest request = new ContentAppealRequest(); + request.setContentType("MOMENT"); + request.setContentId("moment"); + request.setReason("误判"); + return request; + } + + private ContentAppealReviewRequest reviewRequest(String status) { + ContentAppealReviewRequest request = new ContentAppealReviewRequest(); + request.setStatus(status); + request.setNote("已复核"); + return request; + } + + private Moment rejectedMoment(String ownerId) { + Moment moment = new Moment(); + moment.setId("moment"); + moment.setUserId(ownerId); + moment.setReviewStatus("rejected"); + moment.setDeleted(false); + return moment; + } + + private ContentAppeal pendingAppeal() { + ContentAppeal appeal = new ContentAppeal(); + appeal.setId("appeal"); + appeal.setUserId("owner"); + appeal.setContentType("MOMENT"); + appeal.setContentId("moment"); + appeal.setReason("误判"); + appeal.setStatus("PENDING"); + return appeal; + } + + private void authenticate(String id, boolean admin, boolean reviewer) { + User user = new User(); + user.setId(id); + user.setIsAdmin(admin); + user.setIsReviewer(reviewer); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ContentDraftServiceTest.java b/campus-server/src/test/java/com/campus/service/ContentDraftServiceTest.java new file mode 100644 index 0000000..8795596 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ContentDraftServiceTest.java @@ -0,0 +1,39 @@ +package com.campus.service; + +import com.campus.entity.ContentDraftVersion; +import com.campus.repository.ContentDraftVersionRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContentDraftServiceTest { + @Mock private ContentDraftVersionRepository repository; + + @Test + void savingExistingDraftCreatesNextImmutableVersion() { + when(repository.maxVersion("user", "draft")).thenReturn(2); + ContentDraftService service = new ContentDraftService(repository, new ObjectMapper()); + Map payload = new HashMap<>(); + payload.put("draftId", "draft"); + payload.put("contentType", "MOMENT"); + payload.put("content", "third version"); + + assertEquals(3, service.save("user", payload).get("version")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContentDraftVersion.class); + verify(repository).save(captor.capture()); + assertEquals(3, captor.getValue().getVersion()); + assertEquals(true, captor.getValue().getPayload().contains("third version")); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ContentReviewServiceTest.java b/campus-server/src/test/java/com/campus/service/ContentReviewServiceTest.java new file mode 100644 index 0000000..5a5b614 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ContentReviewServiceTest.java @@ -0,0 +1,205 @@ +package com.campus.service; + +import com.campus.entity.ContentReview; +import com.campus.entity.Moment; +import com.campus.entity.Topic; +import com.campus.repository.ActivityRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.ContentReviewRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.TopicRepository; +import com.campus.service.review.ImageReviewStrategy; +import com.campus.service.review.ReviewResult; +import com.campus.service.review.TextReviewStrategy; +import com.campus.service.review.VideoReviewStrategy; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 动态自动审核链路回归测试:确认「敏感词命中 → rejected → 落审核记录 → 推送并通知作者」这条链是通的。 + * + *

现场排查结论:这条链本身正常(线上那条动态确实是 rejected),真正漏的是读取侧可见性; + * 本测试用于防止后续改审核策略时把判定/通知改坏。

+ */ +@ExtendWith(MockitoExtension.class) +class ContentReviewServiceTest { + + @Mock private TextReviewStrategy textReviewStrategy; + @Mock private ImageReviewStrategy imageReviewStrategy; + @Mock private VideoReviewStrategy videoReviewStrategy; + @Mock private ContentReviewRepository contentReviewRepository; + @Mock private AnnouncementRepository announcementRepository; + @Mock private MomentRepository momentRepository; + @Mock private ActivityRepository activityRepository; + @Mock private TopicRepository topicRepository; + @Mock private PushService pushService; + @Mock private NotificationService notificationService; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks private ContentReviewService service; + + @Test + void sensitiveWordMarksMomentRejectedAndNotifiesAuthor() { + Moment moment = moment("m1", "author"); + when(textReviewStrategy.review("法轮功")).thenReturn(ReviewResult.rejected("包含敏感词:法轮功")); + when(imageReviewStrategy.review(null)).thenReturn(ReviewResult.passed()); + when(momentRepository.findById("m1")).thenReturn(Optional.of(moment)); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.reviewMomentSync("m1", "法轮功", null); + + assertEquals("rejected", moment.getReviewStatus()); + assertEquals("包含敏感词:法轮功", moment.getReviewReason()); + + ArgumentCaptor reviewCaptor = ArgumentCaptor.forClass(ContentReview.class); + verify(contentReviewRepository).save(reviewCaptor.capture()); + ContentReview record = reviewCaptor.getValue(); + assertEquals("MOMENT", record.getContentType()); + assertEquals("m1", record.getContentId()); + assertEquals("rejected", record.getStatus()); + assertEquals("AUTO", record.getReviewType()); + assertEquals("包含敏感词:法轮功", record.getReason()); + + verify(pushService).pushMomentReview("author", false, "m1", "包含敏感词:法轮功"); + verify(notificationService).createNotification( + eq("author"), eq("MOMENT_REVIEW_REJECTED"), eq("动态审核未通过"), + eq("你的动态审核未通过:包含敏感词:法轮功"), + isNull(), eq("/moment/m1"), eq("m1"), isNull()); + } + + @Test + void cleanMomentIsApprovedAndNotified() { + Moment moment = moment("m2", "author"); + when(textReviewStrategy.review("今天食堂真好吃")).thenReturn(ReviewResult.passed()); + when(imageReviewStrategy.review("https://cdn/a.jpg")).thenReturn(ReviewResult.passed()); + when(momentRepository.findById("m2")).thenReturn(Optional.of(moment)); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.reviewMomentSync("m2", "今天食堂真好吃", "https://cdn/a.jpg"); + + assertEquals("approved", moment.getReviewStatus()); + verify(pushService).pushMomentReview("author", true, "m2", null); + verify(notificationService).createNotification( + eq("author"), eq("MOMENT_REVIEW_APPROVED"), eq("动态审核通过"), + eq("你的动态已通过审核"), isNull(), eq("/moment/m2"), eq("m2"), isNull()); + } + + // ==================== 话题自动审核 ==================== + + @Test + void sensitiveTopicMarksRejectedAndNotifiesAuthor() { + Topic topic = topic("t1", "author", "法轮功", "一起自习"); + when(textReviewStrategy.review(eq("法轮功 一起自习"), anySet())) + .thenReturn(ReviewResult.rejected("包含敏感词:法轮功")); + when(imageReviewStrategy.review(null)).thenReturn(ReviewResult.passed()); + when(topicRepository.findById("t1")).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(Topic.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.reviewTopicSync("t1", "法轮功", "一起自习", null); + + assertEquals("REJECTED", topic.getStatus()); + assertEquals("包含敏感词:法轮功", topic.getReviewNote()); + assertNull(topic.getReviewedBy(), "自动审核没有人工审核人"); + verify(pushService).pushTopicReview("author", false, "t1", "法轮功", "包含敏感词:法轮功"); + verify(notificationService).createNotification( + eq("author"), eq("TOPIC_REVIEW_REJECTED"), eq("话题审核未通过"), + eq("你创建的话题「法轮功」未通过审核:包含敏感词:法轮功"), + isNull(), eq("/topic/t1"), eq("t1"), isNull()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContentReview.class); + verify(contentReviewRepository).save(captor.capture()); + assertEquals("TOPIC", captor.getValue().getContentType()); + assertEquals("rejected", captor.getValue().getStatus()); + assertEquals("AUTO", captor.getValue().getReviewType()); + } + + @Test + void lowRiskWordDoesNotBlockTopicAndIsRecordedInDetails() { + Topic topic = topic("t2", "author", "垃圾", "一起自习"); + ReviewResult lowRisk = ReviewResult.passed(); + lowRisk.setDetails("LOW:包含敏感词:垃圾"); + when(textReviewStrategy.review(eq("垃圾 一起自习"), anySet())).thenReturn(lowRisk); + when(imageReviewStrategy.review(null)).thenReturn(ReviewResult.passed()); + when(topicRepository.findById("t2")).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(Topic.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.reviewTopicSync("t2", "垃圾", "一起自习", null); + + assertEquals("APPROVED", topic.getStatus()); + assertNull(topic.getReviewNote()); + verify(pushService).pushTopicReview("author", true, "t2", "垃圾", null); + verify(notificationService).createNotification( + eq("author"), eq("TOPIC_REVIEW_APPROVED"), eq("话题审核通过"), + eq("你创建的话题「垃圾」已通过审核"), isNull(), eq("/topic/t2"), eq("t2"), isNull()); + } + + @Test + void previewTopicReviewDoesNotWriteAnything() { + when(textReviewStrategy.review(eq("学习交流 一起自习"), anySet())).thenReturn(ReviewResult.passed()); + when(imageReviewStrategy.review(null)).thenReturn(ReviewResult.passed()); + + ReviewResult result = service.previewTopicReview("学习交流", "一起自习", null); + + assertEquals(true, result.isPassed()); + verify(topicRepository, never()).save(any(Topic.class)); + verify(topicRepository, never()).findById(any()); + verify(contentReviewRepository, never()).save(any(ContentReview.class)); + } + + @Test + void recordTopicApprovalWritesRecordWithoutNotification() { + Topic topic = topic("t3", "author", "学习交流", "一起自习"); + ReviewResult passed = ReviewResult.passed(); + when(topicRepository.findById("t3")).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(Topic.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.recordTopicApproval("t3", passed); + + assertEquals("APPROVED", topic.getStatus()); + ArgumentCaptor captor = ArgumentCaptor.forClass(ContentReview.class); + verify(contentReviewRepository).save(captor.capture()); + assertEquals("TOPIC", captor.getValue().getContentType()); + assertEquals("approved", captor.getValue().getStatus()); + assertEquals("AUTO", captor.getValue().getReviewType()); + // 创建即上线,不再发"审核通过"通知/推送 + verify(notificationService, never()).createNotification( + any(), any(), any(), any(), any(), any(), any(), any()); + verify(pushService, never()).pushTopicReview(any(), anyBoolean(), any(), any(), any()); + } + + private Topic topic(String id, String createdBy, String name, String description) { + Topic topic = new Topic(); + topic.setId(id); + topic.setCreatedBy(createdBy); + topic.setName(name); + topic.setDescription(description); + return topic; + } + + private Moment moment(String id, String userId) { + Moment moment = new Moment(); + moment.setId(id); + moment.setUserId(userId); + moment.setContent("内容"); + return moment; + } +} diff --git a/campus-server/src/test/java/com/campus/service/EasemobGroupDissolutionTest.java b/campus-server/src/test/java/com/campus/service/EasemobGroupDissolutionTest.java new file mode 100644 index 0000000..cf78f39 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/EasemobGroupDissolutionTest.java @@ -0,0 +1,42 @@ +package com.campus.service; + +import com.campus.config.EasemobConfig; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class EasemobGroupDissolutionTest { + + @Test + void deletingAnAlreadyMissingRemoteGroupIsIdempotent() { + EasemobService service = new EasemobService(); + EasemobConfig config = new EasemobConfig(); + config.setAppKey("test-org#test-app"); + config.setClientId("client-id"); + config.setClientSecret("client-secret"); + config.setBaseUrl("https://easemob.example"); + + RestTemplate restTemplate = new RestTemplate(); + ReflectionTestUtils.setField(service, "easemobConfig", config); + ReflectionTestUtils.setField(service, "restTemplate", restTemplate); + MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate); + server.expect(requestTo("https://easemob.example/test-org/test-app/token")) + .andRespond(withSuccess("{\"access_token\":\"app-token\"}", MediaType.APPLICATION_JSON)); + server.expect(requestTo("https://easemob.example/test-org/test-app/chatgroups/missing-group")) + .andExpect(method(HttpMethod.DELETE)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + + assertTrue(service.deleteGroup("missing-group")); + server.verify(); + } +} diff --git a/campus-server/src/test/java/com/campus/service/EasemobShareCardTest.java b/campus-server/src/test/java/com/campus/service/EasemobShareCardTest.java new file mode 100644 index 0000000..482c674 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/EasemobShareCardTest.java @@ -0,0 +1,82 @@ +package com.campus.service; + +import com.easemob.im.server.EMService; +import com.easemob.im.server.api.message.MessageApi; +import com.easemob.im.server.model.EMKeyValue; +import com.easemob.im.server.model.EMMessage; +import com.easemob.im.server.model.EMSentMessageIds; +import com.easemob.im.server.model.EMTextMessage; +import com.easemob.im.shaded.reactor.core.publisher.Mono; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 验证真正交给环信的消息包含文本兜底与结构化分享扩展字段。 */ +@ExtendWith(MockitoExtension.class) +class EasemobShareCardTest { + + private static final String USER_ID = "11111111-1111-1111-1111-111111111111"; + private static final String PEER_ID = "99999999-9999-9999-9999-999999999999"; + private static final String CARD_JSON = "{\"targetType\":\"MOMENT\",\"targetId\":\"moment-1\"}"; + + @Mock private EMService emService; + @Mock private MessageApi messageApi; + private EasemobService easemobService; + + @BeforeEach + void setUp() { + easemobService = new EasemobService(); + ReflectionTestUtils.setField(easemobService, "emService", emService); + when(emService.message()).thenReturn(messageApi); + when(messageApi.send(anyString(), anyString(), anySet(), any(EMMessage.class), anySet())) + .thenReturn(Mono.just(new EMSentMessageIds(Collections.emptyMap()))); + } + + @Test + @SuppressWarnings("unchecked") + void privateShareCarriesCardExtensionsAndReadableText() { + boolean sent = easemobService.sendShareCardMessage( + USER_ID, PEER_ID, "[分享] 自习组队", "message-1", CARD_JSON); + + assertTrue(sent); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(EMMessage.class); + ArgumentCaptor> extensionCaptor = ArgumentCaptor.forClass(Set.class); + verify(messageApi).send(anyString(), eq("users"), anySet(), + messageCaptor.capture(), extensionCaptor.capture()); + + assertEquals("[分享] 自习组队", ((EMTextMessage) messageCaptor.getValue()).text()); + Map extensions = extensionCaptor.getValue().stream() + .collect(Collectors.toMap(EMKeyValue::key, EMKeyValue::asString)); + assertEquals("message-1", extensions.get("messageId")); + assertEquals("SHARE_CARD", extensions.get("messageType")); + assertEquals(CARD_JSON, extensions.get("card")); + } + + @Test + void groupShareUsesChatgroupsTarget() { + boolean sent = easemobService.sendGroupShareCardMessage( + USER_ID, "easemob-group-1", "[分享] 自习组队", "message-2", CARD_JSON); + + assertTrue(sent); + verify(messageApi).send(anyString(), eq("chatgroups"), eq(Collections.singleton("easemob-group-1")), + any(EMMessage.class), anySet()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/FriendServiceSearchTest.java b/campus-server/src/test/java/com/campus/service/FriendServiceSearchTest.java new file mode 100644 index 0000000..bf41fbb --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/FriendServiceSearchTest.java @@ -0,0 +1,81 @@ +package com.campus.service; + +import com.campus.common.PageResult; +import com.campus.dto.request.FriendSearchRequest; +import com.campus.dto.response.FriendDTO; +import com.campus.entity.Friendship; +import com.campus.entity.User; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.GreetingRepository; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FriendServiceSearchTest { + + @Mock private FriendshipRepository friendshipRepository; + @Mock private UserRepository userRepository; + @Mock private GreetingRepository greetingRepository; + @Mock private NotificationService notificationService; + @Mock private RecommendService recommendService; + @Mock private DictService dictService; + @Mock private FollowService followService; + @Mock private org.springframework.data.redis.core.StringRedisTemplate redisTemplate; + @Mock private PushService pushService; + @Mock private SseService sseService; + @Mock private PrivacySettingsService privacySettingsService; + @InjectMocks private FriendService friendService; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void exactAccountSearchKeepsExistingRelationVisible() { + User me = new User(); + me.setId("me"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(me, null)); + + User target = new User(); + target.setId("target"); + target.setNumber("41110100010000001"); + target.setNickname("测试用户"); + Friendship relation = new Friendship(); + relation.setStatus("ACCEPTED"); + when(userRepository.searchByKeyword(eq(target.getNumber()), eq("me"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(target))); + when(friendshipRepository.existsByUserIdAndFriendId("me", "target")).thenReturn(true); + when(friendshipRepository.findByUserIdAndFriendId("me", "target")) + .thenReturn(Optional.of(relation)); + when(greetingRepository.findByUserIdAndTargetUserIdIn(eq("me"), any())) + .thenReturn(Collections.emptyList()); + + FriendSearchRequest request = new FriendSearchRequest(); + request.setKeyword(" " + target.getNumber() + " "); + PageResult result = friendService.search(request); + + assertEquals(1, result.getItems().size()); + assertEquals("ACCEPTED", result.getItems().get(0).getRelationStatus()); + verify(userRepository).searchByKeyword(eq(target.getNumber()), eq("me"), any(Pageable.class)); + } +} diff --git a/campus-server/src/test/java/com/campus/service/GroupActivityJoinGuardTest.java b/campus-server/src/test/java/com/campus/service/GroupActivityJoinGuardTest.java new file mode 100644 index 0000000..6ea337b --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/GroupActivityJoinGuardTest.java @@ -0,0 +1,388 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.Activity; +import com.campus.entity.ActivityParticipant; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.ActivityParticipantRepository; +import com.campus.repository.ActivityRepository; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 「未加入组局活动的用户可以加入活动群聊」修复的准入矩阵测试。 + * + *

修复前:活动群 is_approval_required=false,任何人凭群 ID 调 POST /api/groups/{id}/join 即可入群。 + * 修复后:活动群只允许「活动创建者」或「已报名(CONFIRMED)用户」加入。 + */ +@ExtendWith(MockitoExtension.class) +class GroupActivityJoinGuardTest { + + private static final String GROUP_ID = "group"; + private static final String ACTIVITY_ID = "activity"; + private static final String EM_GROUP_ID = "em-group"; + + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private GroupAnnouncementRepository groupAnnouncementRepository; + @Mock private ActivityRepository activityRepository; + @Mock private ActivityParticipantRepository participantRepository; + @Mock private EasemobService easemobService; + @Mock private NotificationService notificationService; + @Mock private PushService pushService; + @Mock private UserRepository userRepository; + @InjectMocks private GroupService groupService; + + @BeforeEach + void setUp() { + login("member"); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void login(String userId) { + User user = new User(); + user.setId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + private Group group(String source, boolean isPublic, boolean approvalRequired) { + Group group = new Group(); + group.setId(GROUP_ID); + group.setName("测试群"); + group.setOwnerId("owner"); + group.setEasemobGroupId(EM_GROUP_ID); + group.setMemberCount(1); + group.setMaxMembers(200); + group.setSource(source); + group.setIsPublic(isPublic); + group.setIsApprovalRequired(approvalRequired); + when(groupRepository.findById(GROUP_ID)).thenReturn(Optional.of(group)); + return group; + } + + private Group activityGroup() { + Group group = group(Group.SOURCE_ACTIVITY, false, false); + group.setActivityId(ACTIVITY_ID); + return group; + } + + private Activity activity(String creatorId, boolean deleted, String status) { + Activity activity = new Activity(); + activity.setId(ACTIVITY_ID); + activity.setCreatorId(creatorId); + activity.setStatus(status); + activity.setDeleted(deleted); + return activity; + } + + private ActivityParticipant participant(String status) { + ActivityParticipant participant = new ActivityParticipant(); + participant.setActivityId(ACTIVITY_ID); + participant.setUserId("member"); + participant.setStatus(status); + return participant; + } + + private void stubExistingMember(Optional existing) { + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, "member")) + .thenReturn(existing); + } + + // ===================== 活动群:必须已报名 ===================== + + @Test + void unregisteredUserIsRejectedAndNothingIsWritten() { + activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", false, "ACTIVE"))); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(ACTIVITY_ID, "member")) + .thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("请先报名活动,报名成功后才能加入活动群聊", error.getMessage()); + verifyNoInteractions(easemobService); + verify(groupMemberRepository, never()).save(any(GroupMember.class)); + verify(groupRepository, never()).save(any(Group.class)); + } + + @Test + void pendingParticipantIsRejectedWithItsOwnMessage() { + activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", false, "ACTIVE"))); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(ACTIVITY_ID, "member")) + .thenReturn(Optional.of(participant("PENDING"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("报名审核通过后才能加入活动群聊", error.getMessage()); + verifyNoInteractions(easemobService); + verify(groupMemberRepository, never()).save(any(GroupMember.class)); + } + + @Test + void cancelledParticipantIsRejected() { + activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", false, "ACTIVE"))); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(ACTIVITY_ID, "member")) + .thenReturn(Optional.of(participant("CANCELLED"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("请先报名活动,报名成功后才能加入活动群聊", error.getMessage()); + verifyNoInteractions(easemobService); + } + + @Test + void confirmedParticipantJoinsAndCountIncreases() { + Group group = activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", false, "ACTIVE"))); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(ACTIVITY_ID, "member")) + .thenReturn(Optional.of(participant("CONFIRMED"))); + stubExistingMember(Optional.empty()); + when(easemobService.addGroupMembers(EM_GROUP_ID, Collections.singletonList("member"))).thenReturn(true); + when(groupMemberRepository.save(any(GroupMember.class))).thenAnswer(inv -> inv.getArgument(0)); + + GroupMember saved = groupService.joinGroup(GROUP_ID); + + assertEquals("MEMBER", saved.getRole()); + assertEquals(GROUP_ID, saved.getGroupId()); + assertEquals("member", saved.getUserId()); + assertEquals(2, group.getMemberCount()); + verify(groupRepository).save(group); + } + + @Test + void remoteFailureLeavesNoMemberRecordAndKeepsCount() { + Group group = activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", false, "ACTIVE"))); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(ACTIVITY_ID, "member")) + .thenReturn(Optional.of(participant("CONFIRMED"))); + stubExistingMember(Optional.empty()); + when(easemobService.addGroupMembers(EM_GROUP_ID, Collections.singletonList("member"))).thenReturn(false); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("环信加群失败,请稍后重试", error.getMessage()); + verify(groupMemberRepository, never()).save(any(GroupMember.class)); + verify(groupRepository, never()).save(any(Group.class)); + assertEquals(1, group.getMemberCount()); + } + + @Test + void confirmedParticipantAlreadyInGroupIsIdempotent() { + activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", false, "ACTIVE"))); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(ACTIVITY_ID, "member")) + .thenReturn(Optional.of(participant("CONFIRMED"))); + GroupMember existing = new GroupMember(); + existing.setGroupId(GROUP_ID); + existing.setUserId("member"); + existing.setRole("MEMBER"); + stubExistingMember(Optional.of(existing)); + + assertSame(existing, groupService.joinGroup(GROUP_ID)); + + verify(easemobService, never()).addGroupMembers(any(), anyList()); + verify(groupRepository, never()).save(any(Group.class)); + } + + @Test + void activityCreatorIsAllowedWithoutParticipantRecord() { + login("owner"); + Group group = activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", false, "ACTIVE"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc(GROUP_ID, "owner")) + .thenReturn(Optional.empty()); + when(easemobService.addGroupMembers(EM_GROUP_ID, Collections.singletonList("owner"))).thenReturn(true); + when(groupMemberRepository.save(any(GroupMember.class))).thenAnswer(inv -> inv.getArgument(0)); + + GroupMember saved = groupService.joinGroup(GROUP_ID); + + assertEquals("MEMBER", saved.getRole()); + assertEquals(2, group.getMemberCount()); + verify(participantRepository, never()) + .findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(any(), any()); + } + + @Test + void softDeletedActivityClosesTheGroup() { + activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", true, "ACTIVE"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("活动群聊已关闭", error.getMessage()); + verifyNoInteractions(easemobService); + } + + @Test + void cancelledActivityClosesTheGroup() { + activityGroup(); + when(activityRepository.findIncludingDeleted(ACTIVITY_ID)) + .thenReturn(Optional.of(activity("owner", false, "CANCELLED"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("活动群聊已关闭", error.getMessage()); + verifyNoInteractions(easemobService); + } + + @Test + void activityGroupWithoutActivityIdIsClosedAndNeverFallsBackToOpenJoin() { + group(Group.SOURCE_ACTIVITY, false, false); // activityId 为空(脏数据) + when(activityRepository.findFirstByGroupId(GROUP_ID)).thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("活动群聊已关闭", error.getMessage()); + verifyNoInteractions(easemobService); + verify(groupMemberRepository, never()).save(any(GroupMember.class)); + } + + /** 历史群漏标记 source 时,靠 activity.group_id 反查兜底 —— 必须仍然拦住未报名用户。 */ + @Test + void unmarkedActivityGroupIsStillGuardedByReverseLookup() { + group(Group.SOURCE_NORMAL, false, false); + when(activityRepository.findFirstByGroupId(GROUP_ID)) + .thenReturn(Optional.of(activity("owner", false, "ACTIVE"))); + when(participantRepository.findFirstByActivityIdAndUserIdOrderByJoinedAtDesc(ACTIVITY_ID, "member")) + .thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("请先报名活动,报名成功后才能加入活动群聊", error.getMessage()); + verifyNoInteractions(easemobService); + } + + // ===================== 普通群:行为回归 ===================== + + @Test + void privateGroupWithoutApprovalCannotBeSelfJoined() { + group(Group.SOURCE_NORMAL, false, false); + when(activityRepository.findFirstByGroupId(GROUP_ID)).thenReturn(Optional.empty()); + when(groupMemberRepository.existsByGroupIdAndUserId(GROUP_ID, "member")).thenReturn(false); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("该群不支持自助加入", error.getMessage()); + verifyNoInteractions(easemobService); + } + + @Test + void publicGroupWithoutApprovalStillJoinsDirectly() { + Group group = group(Group.SOURCE_NORMAL, true, false); + when(activityRepository.findFirstByGroupId(GROUP_ID)).thenReturn(Optional.empty()); + when(groupMemberRepository.existsByGroupIdAndUserId(GROUP_ID, "member")).thenReturn(false); + when(easemobService.addGroupMembers(EM_GROUP_ID, Collections.singletonList("member"))).thenReturn(true); + when(groupMemberRepository.save(any(GroupMember.class))).thenAnswer(inv -> inv.getArgument(0)); + + GroupMember saved = groupService.joinGroup(GROUP_ID); + + assertEquals("MEMBER", saved.getRole()); + assertEquals(2, group.getMemberCount()); + } + + @Test + void approvalRequiredGroupStillCreatesPendingRequest() { + group(Group.SOURCE_NORMAL, false, true); + when(activityRepository.findFirstByGroupId(GROUP_ID)).thenReturn(Optional.empty()); + when(groupMemberRepository.existsByGroupIdAndUserId(GROUP_ID, "member")).thenReturn(false); + when(groupMemberRepository.save(any(GroupMember.class))).thenAnswer(inv -> inv.getArgument(0)); + + GroupMember saved = groupService.joinGroup(GROUP_ID); + + assertEquals("PENDING", saved.getRole()); + verifyNoInteractions(easemobService); + verify(notificationService).createNotification( + any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void alreadyMemberStillRejectedForNormalGroup() { + group(Group.SOURCE_NORMAL, false, true); + when(activityRepository.findFirstByGroupId(GROUP_ID)).thenReturn(Optional.empty()); + when(groupMemberRepository.existsByGroupIdAndUserId(GROUP_ID, "member")).thenReturn(true); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.joinGroup(GROUP_ID)); + + assertEquals("已在群内", error.getMessage()); + verifyNoInteractions(easemobService); + } +} + +/** + * 建群侧断言:活动群必须带上来源标记,否则入群侧只能靠反查兜底。 + */ +@ExtendWith(MockitoExtension.class) +class ActivityGroupSourceMarkingTest { + + @Mock private EasemobService easemobService; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @InjectMocks private ActivityService activityService; + + @Test + void activityGroupIsMarkedWithSourceAndActivityId() { + Activity activity = new Activity(); + activity.setId("activity"); + activity.setTitle("群聊测试活动"); + activity.setMaxCount(10); + + when(easemobService.createGroup(any(), any(), any(), anyList())).thenReturn("em-group"); + when(groupRepository.save(any(Group.class))).thenAnswer(inv -> inv.getArgument(0)); + + Group group = ReflectionTestUtils.invokeMethod( + activityService, "createActivityGroup", activity, "creator"); + + assertNotNull(group); + assertEquals(Group.SOURCE_ACTIVITY, group.getSource()); + assertEquals("activity", group.getActivityId()); + assertTrue(group.isActivityGroup()); + // 活动群仍不走「申请-审批」,入群资格由 GroupService 依据 source + activityId 校验 + assertFalse(group.getIsApprovalRequired()); + assertFalse(group.getIsPublic()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/GroupAnnouncementNotificationListenerTest.java b/campus-server/src/test/java/com/campus/service/GroupAnnouncementNotificationListenerTest.java new file mode 100644 index 0000000..a1dc62d --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/GroupAnnouncementNotificationListenerTest.java @@ -0,0 +1,38 @@ +package com.campus.service; + +import com.campus.service.event.GroupAnnouncementPublishedEvent; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class GroupAnnouncementNotificationListenerTest { + + @Test + void delegatesToStructuredGroupAnnouncementMessage() { + ChatService chatService = mock(ChatService.class); + GroupAnnouncementNotificationListener listener = + new GroupAnnouncementNotificationListener(chatService); + + listener.notifyGroup(new GroupAnnouncementPublishedEvent( + "group", "owner", "公告正文")); + + verify(chatService).sendGroupAnnouncementNotification( + "group", "owner", "公告正文"); + } + + @Test + void notificationFailureDoesNotEscapeAfterAnnouncementWasCommitted() { + ChatService chatService = mock(ChatService.class); + doThrow(new RuntimeException("环信不可用")) + .when(chatService) + .sendGroupAnnouncementNotification("group", "owner", "公告正文"); + GroupAnnouncementNotificationListener listener = + new GroupAnnouncementNotificationListener(chatService); + + assertDoesNotThrow(() -> listener.notifyGroup( + new GroupAnnouncementPublishedEvent("group", "owner", "公告正文"))); + } +} diff --git a/campus-server/src/test/java/com/campus/service/GroupAnnouncementServiceTest.java b/campus-server/src/test/java/com/campus/service/GroupAnnouncementServiceTest.java new file mode 100644 index 0000000..50eb604 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/GroupAnnouncementServiceTest.java @@ -0,0 +1,170 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.UpdateGroupAnnouncementRequest; +import com.campus.dto.response.GroupAnnouncementDTO; +import com.campus.entity.Group; +import com.campus.entity.GroupAnnouncement; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import com.campus.service.event.GroupAnnouncementPublishedEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GroupAnnouncementServiceTest { + + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private GroupAnnouncementRepository groupAnnouncementRepository; + @Mock private UserRepository userRepository; + @Mock private ApplicationEventPublisher eventPublisher; + @InjectMocks private GroupAnnouncementService service; + + @BeforeEach + void setUp() { + Group group = new Group(); + group.setId("group"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void activeMemberGetsNullWhenAnnouncementHasNotBeenPublished() { + login("member"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "member")) + .thenReturn(Optional.of(member("member", "MEMBER", false))); + when(groupAnnouncementRepository.findByGroupId("group")).thenReturn(Optional.empty()); + + assertNull(service.getAnnouncement("group")); + } + + @Test + void ownerPublishesAnnouncementWithPublisherSnapshotAndEvent() { + login("owner"); + GroupMember owner = member("owner", "OWNER", false); + User user = new User(); + user.setId("owner"); + user.setNickname("群主"); + user.setAvatar("avatar.png"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "owner")) + .thenReturn(Optional.of(owner)); + when(userRepository.findById("owner")).thenReturn(Optional.of(user)); + when(groupAnnouncementRepository.findByGroupId("group")).thenReturn(Optional.empty()); + when(groupAnnouncementRepository.save(any(GroupAnnouncement.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + GroupAnnouncementDTO result = service.updateAnnouncement("group", request(" 今晚八点开会 ")); + + assertEquals("今晚八点开会", result.getContent()); + assertEquals("owner", result.getPublisher().getId()); + assertEquals("群主", result.getPublisher().getNickname()); + assertEquals("avatar.png", result.getPublisher().getAvatar()); + assertNotNull(result.getPublishedAt()); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(GroupAnnouncementPublishedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertEquals("今晚八点开会", eventCaptor.getValue().getContent()); + } + + @Test + void unmutedAdminCanReplaceExistingAnnouncement() { + login("admin"); + GroupAnnouncement existing = new GroupAnnouncement(); + existing.setGroupId("group"); + existing.setContent("旧公告"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(member("admin", "ADMIN", false))); + when(userRepository.findById("admin")).thenReturn(Optional.empty()); + when(groupAnnouncementRepository.findByGroupId("group")).thenReturn(Optional.of(existing)); + when(groupAnnouncementRepository.save(existing)).thenReturn(existing); + + GroupAnnouncementDTO result = service.updateAnnouncement("group", request("新公告")); + + assertEquals("新公告", result.getContent()); + assertEquals("群管理员", result.getPublisher().getNickname()); + verify(groupAnnouncementRepository).save(existing); + } + + @Test + void mutedAdminCannotPublishAnnouncement() { + login("admin"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(member("admin", "ADMIN", true))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.updateAnnouncement("group", request("公告"))); + + assertEquals("你已被群禁言", error.getMessage()); + verifyNoInteractions(groupAnnouncementRepository, eventPublisher); + } + + @Test + void ordinaryMemberCannotPublishAnnouncement() { + login("member"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "member")) + .thenReturn(Optional.of(member("member", "MEMBER", false))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.updateAnnouncement("group", request("公告"))); + + assertEquals("只有群主或管理员可以操作", error.getMessage()); + } + + @Test + void pendingMemberCannotReadAnnouncement() { + login("pending"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "pending")) + .thenReturn(Optional.of(member("pending", "PENDING", false))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.getAnnouncement("group")); + + assertEquals("入群申请尚未通过", error.getMessage()); + } + + private void login(String userId) { + User user = new User(); + user.setId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + private GroupMember member(String userId, String role, boolean muted) { + GroupMember member = new GroupMember(); + member.setGroupId("group"); + member.setUserId(userId); + member.setRole(role); + member.setIsMuted(muted); + return member; + } + + private UpdateGroupAnnouncementRequest request(String content) { + UpdateGroupAnnouncementRequest request = new UpdateGroupAnnouncementRequest(); + request.setContent(content); + return request; + } +} diff --git a/campus-server/src/test/java/com/campus/service/GroupChatAccessTest.java b/campus-server/src/test/java/com/campus/service/GroupChatAccessTest.java new file mode 100644 index 0000000..2f9fb8c --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/GroupChatAccessTest.java @@ -0,0 +1,163 @@ +package com.campus.service; + +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.ChatMessage; +import com.campus.entity.User; +import com.campus.dto.response.ChatMessageDTO; +import com.campus.repository.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GroupChatAccessTest { + @Mock private EasemobService easemobService; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private ChatConversationRepository chatConversationRepository; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private UserRepository userRepository; + @Mock private FriendshipRepository friendshipRepository; + @Mock private PushService pushService; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private UserRestrictionService userRestrictionService; + @Mock private ChatReportRepository chatReportRepository; + @Mock private AuthorizationService authorizationService; + @Mock private AuditLogService auditLogService; + @InjectMocks private ChatService chatService; + + @BeforeEach + void login() { + User user = new User(); + user.setId("applicant"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void setMember(String role, boolean muted) { + Group group = new Group(); + group.setId("group"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + GroupMember member = new GroupMember(); + member.setRole(role); + member.setIsMuted(muted); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "applicant")) + .thenReturn(Optional.of(member)); + } + + @Test + void pendingMemberCannotSendOrReadHistory() { + setMember("PENDING", false); + assertThrows(RuntimeException.class, () -> chatService.sendGroupMessage("group", "hello")); + assertThrows(RuntimeException.class, () -> chatService.getGroupChatHistory("group", 1, 20)); + verifyNoInteractions(chatMessageRepository); + } + + @Test + void mutedMemberCannotSend() { + setMember("MEMBER", true); + assertThrows(RuntimeException.class, () -> chatService.sendGroupMessage("group", "hello")); + verifyNoInteractions(chatMessageRepository); + } + + @Test + void groupSendReturnsGroupNickname() { + Group group = new Group(); + group.setId("group"); + group.setEasemobGroupId("em-group"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + GroupMember sender = member("group", "applicant", "MEMBER", "群内昵称"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "applicant")) + .thenReturn(Optional.of(sender)); + when(chatMessageRepository.save(any(ChatMessage.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(groupMemberRepository.findByGroupId("group")).thenReturn(Collections.singletonList(sender)); + + ChatMessageDTO result = chatService.sendGroupMessage("group", "hello"); + + assertEquals("群内昵称", result.getSenderGroupNickname()); + verify(userRepository, never()).findById("applicant"); + } + + @Test + void groupHistoryUsesGroupNicknameThenProfileFallback() { + setMember("MEMBER", false); + ChatMessage first = message("group", "sender1"); + ChatMessage second = message("group", "sender2"); + when(chatMessageRepository.findByConversationIdAndSeqIdGreaterThan(eq("group"), anyLong(), any(Pageable.class))) + .thenReturn(new PageImpl<>(Arrays.asList(first, second))); + when(groupMemberRepository.findByGroupIdIn(any())) + .thenReturn(Arrays.asList(member("group", "sender1", "MEMBER", "群内昵称"), + member("group", "sender2", "MEMBER", null))); + User user = new User(); + user.setId("sender2"); + user.setNickname("资料昵称"); + when(userRepository.findAllById(any())).thenReturn(Collections.singletonList(user)); + + java.util.List items = chatService.getGroupChatHistory("group", 1, 20).getItems(); + + assertEquals("群内昵称", items.get(0).getSenderGroupNickname()); + assertEquals("资料昵称", items.get(1).getSenderGroupNickname()); + } + + @Test + void globalSyncEnrichesOnlyGroupMessages() { + GroupMember myMembership = member("group", "applicant", "MEMBER", null); + when(groupMemberRepository.findByUserId("applicant")) + .thenReturn(Collections.singletonList(myMembership)); + when(chatMessageRepository.findBySeqIdGreaterThanOrderBySeqIdAsc(0L)) + .thenReturn(Arrays.asList(message("group", "sender"), message("applicant_friend", "friend"))); + when(groupMemberRepository.findByGroupIdIn(any())) + .thenReturn(Collections.singletonList(member("group", "sender", "MEMBER", "群内昵称"))); + + java.util.List result = chatService.syncAllMessages(0L); + + assertEquals("群内昵称", result.get(0).getSenderGroupNickname()); + assertNull(result.get(1).getSenderGroupNickname()); + } + + private GroupMember member(String groupId, String userId, String role, String nickname) { + GroupMember member = new GroupMember(); + member.setGroupId(groupId); + member.setUserId(userId); + member.setRole(role); + member.setNickname(nickname); + return member; + } + + private long nextSeqId = 1L; + + private ChatMessage message(String conversationId, String senderId) { + ChatMessage message = new ChatMessage(); + // seq_id 参与「按用户清空水位线」过滤,必须给值(真实数据里它是自增主键,不可能为空) + message.setSeqId(nextSeqId++); + message.setConversationId(conversationId); + message.setSenderId(senderId); + message.setContent("hello"); + return message; + } +} diff --git a/campus-server/src/test/java/com/campus/service/GroupInvitationServiceTest.java b/campus-server/src/test/java/com/campus/service/GroupInvitationServiceTest.java new file mode 100644 index 0000000..9d07bd1 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/GroupInvitationServiceTest.java @@ -0,0 +1,479 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.common.PageResult; +import com.campus.dto.response.GroupInvitationDTO; +import com.campus.dto.response.GroupInviteResult; +import com.campus.entity.Group; +import com.campus.entity.GroupInvitation; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.GroupInvitationRepository; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 群聊邀请入群的核心规则测试。 + * + *

覆盖:权限、部分成功、待处理邀请去重、活动群拒绝、环信失败不落库、 + * 懒过期、幂等、越权 403、撤回权限、通知文案。 + * + *

用 LENIENT:{@code invite} 内部会先按「邀请人 ID」调用一次 userRepository.findById, + * 再按「被邀请人 ID」调用,同一 mock 方法被不同参数调用,严格模式会误报 PotentialStubbingProblem。 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class GroupInvitationServiceTest { + + @Mock private GroupInvitationRepository invitationRepository; + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private UserRepository userRepository; + @Mock private EasemobService easemobService; + @Mock private NotificationService notificationService; + @Mock private PushService pushService; + @Mock private ClickGuardService clickGuardService; + @Mock private GroupService groupService; + + @InjectMocks private GroupInvitationService service; + + private Group group; + + @BeforeEach + void setUp() { + group = new Group(); + group.setId("group"); + group.setName("21级计科1班"); + group.setOwnerId("owner"); + group.setEasemobGroupId("em-group"); + group.setMemberCount(5); + group.setMaxMembers(200); + group.setSource(Group.SOURCE_NORMAL); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void login(String userId) { + User user = new User(); + user.setId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + private User user(String id) { + User user = new User(); + user.setId(id); + user.setAccountStatus("ACTIVE"); + user.setDeleted(false); + return user; + } + + private GroupMember member(String userId, String role) { + GroupMember member = new GroupMember(); + member.setGroupId("group"); + member.setUserId(userId); + member.setRole(role); + return member; + } + + private GroupInvitation pendingInvitation(String id, String inviterId, String inviteeId) { + GroupInvitation invitation = new GroupInvitation(); + invitation.setId(id); + invitation.setGroupId("group"); + invitation.setInviterId(inviterId); + invitation.setInviteeId(inviteeId); + invitation.setStatus(GroupInvitation.STATUS_PENDING); + invitation.setPendingKey(GroupInvitation.PENDING_KEY); + invitation.setExpireAt(new Date(System.currentTimeMillis() + 86_400_000L)); + return invitation; + } + + // ===================== 发起邀请 ===================== + + @Test + void ordinaryMemberCannotInvite() { + login("member"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + doThrow(new BusinessException("只有群主或管理员可以操作")) + .when(groupService).requireManager("group", "member"); + + BusinessException e = assertThrows(BusinessException.class, + () -> service.invite("group", Collections.singletonList("new"), null)); + + assertEquals("只有群主或管理员可以操作", e.getMessage()); + verifyNoInteractions(invitationRepository, easemobService); + } + + @Test + void activityGroupCannotInvite() { + login("owner"); + group.setSource(Group.SOURCE_ACTIVITY); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + + BusinessException e = assertThrows(BusinessException.class, + () -> service.invite("group", Collections.singletonList("new"), null)); + + assertEquals("活动群不支持邀请入群", e.getMessage()); + verifyNoInteractions(invitationRepository, easemobService); + } + + @Test + void inviteRejectsDuplicateUserIds() { + login("owner"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + + BusinessException e = assertThrows(BusinessException.class, + () -> service.invite("group", Arrays.asList("new", "new"), null)); + + assertEquals("用户ID不能为空或重复", e.getMessage()); + verifyNoInteractions(invitationRepository); + } + + @Test + void inviteRejectsWhenGroupIsFull() { + login("owner"); + group.setMemberCount(200); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + + BusinessException e = assertThrows(BusinessException.class, + () -> service.invite("group", Collections.singletonList("new"), null)); + + assertEquals("群成员已满", e.getMessage()); + verifyNoInteractions(invitationRepository); + } + + @Test + void cannotInviteSelfCollectedAsFailureWithoutWriting() { + login("owner"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + + GroupInviteResult result = service.invite("group", Collections.singletonList("owner"), null); + + assertEquals(0, result.getInvited().size()); + assertEquals(1, result.getFailed().size()); + assertEquals("不能邀请自己", result.getFailed().get(0).getReason()); + verify(invitationRepository, never()).save(any(GroupInvitation.class)); + } + + @Test + void userAlreadyInGroupCollectedAsFailure() { + login("owner"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(userRepository.findById("occupied")).thenReturn(Optional.of(user("occupied"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "occupied")) + .thenReturn(Optional.of(member("occupied", "MEMBER"))); + + GroupInviteResult result = service.invite("group", Collections.singletonList("occupied"), null); + + assertEquals(1, result.getFailed().size()); + assertEquals("用户已在群内: occupied", result.getFailed().get(0).getReason()); + verify(invitationRepository, never()).save(any(GroupInvitation.class)); + } + + @Test + void pendingApplicantCollectedAsFailureSoOwnerApprovesInstead() { + login("owner"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(userRepository.findById("applicant")).thenReturn(Optional.of(user("applicant"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "applicant")) + .thenReturn(Optional.of(member("applicant", "PENDING"))); + + GroupInviteResult result = service.invite("group", Collections.singletonList("applicant"), null); + + assertEquals(1, result.getFailed().size()); + assertEquals("该用户已提交入群申请,请直接审批", result.getFailed().get(0).getReason()); + verify(invitationRepository, never()).save(any(GroupInvitation.class)); + } + + @Test + void duplicatePendingInvitationCollectedAsFailure() { + login("owner"); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(userRepository.findById("new")).thenReturn(Optional.of(user("new"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "new")) + .thenReturn(Optional.empty()); + when(invitationRepository.findByGroupIdAndInviteeIdAndPendingKey( + "group", "new", GroupInvitation.PENDING_KEY)) + .thenReturn(Optional.of(pendingInvitation("inv-old", "owner", "new"))); + + GroupInviteResult result = service.invite("group", Collections.singletonList("new"), null); + + assertEquals(1, result.getFailed().size()); + assertEquals("已向该用户发送过邀请", result.getFailed().get(0).getReason()); + verify(invitationRepository, never()).save(any(GroupInvitation.class)); + } + + @Test + void inviteCreatesPendingInvitationAndNotifiesInvitee() { + login("owner"); + User inviter = user("owner"); + inviter.setNickname("班长"); + User invitee = user("new"); + invitee.setNickname("小明"); + + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(userRepository.findById("owner")).thenReturn(Optional.of(inviter)); + when(userRepository.findById("new")).thenReturn(Optional.of(invitee)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "new")) + .thenReturn(Optional.empty()); + when(invitationRepository.findByGroupIdAndInviteeIdAndPendingKey( + "group", "new", GroupInvitation.PENDING_KEY)).thenReturn(Optional.empty()); + when(invitationRepository.save(any(GroupInvitation.class))).thenAnswer(invocation -> { + GroupInvitation saved = invocation.getArgument(0); + saved.setId("inv-1"); + saved.setCreatedAt(new Date()); + return saved; + }); + + GroupInviteResult result = service.invite("group", Collections.singletonList("new"), "来我们班群"); + + assertEquals(1, result.getInvited().size()); + GroupInvitationDTO dto = result.getInvited().get(0); + assertEquals("inv-1", dto.getId()); + assertEquals("小明", dto.getInviteeNickname()); + assertEquals("21级计科1班", dto.getGroupName()); + assertEquals(GroupInvitation.STATUS_PENDING, dto.getStatus()); + assertEquals("来我们班群", dto.getMessage()); + assertNotNull(dto.getExpireAt()); + + // 先推送、再建站内通知(NotificationService.createNotification 不再自动推送) + verify(pushService).pushGroupInvite("new", "班长", null, "group", "21级计科1班", "inv-1"); + ArgumentCaptor content = ArgumentCaptor.forClass(String.class); + verify(notificationService).createNotification( + eq("new"), eq("GROUP_INVITE"), eq("群聊邀请"), content.capture(), + any(), eq("/group-invite/inv-1"), eq("inv-1"), eq("owner")); + assertEquals("班长 邀请你加入群聊「21级计科1班」", content.getValue()); + } + + // ===================== 允许入群 ===================== + + @Test + void acceptKeepsInvitationPendingWhenRemoteFails() { + login("new"); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(groupService.requireEasemobGroupId(group)).thenReturn("em-group"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "new")) + .thenReturn(Optional.empty()); + when(easemobService.addGroupMembers("em-group", Collections.singletonList("new"))).thenReturn(false); + + BusinessException e = assertThrows(BusinessException.class, () -> service.accept("inv-1")); + + assertEquals("环信加群失败,请稍后重试", e.getMessage()); + // 邀请仍是待处理,用户可重试 + assertEquals(GroupInvitation.STATUS_PENDING, invitation.getStatus()); + assertEquals(GroupInvitation.PENDING_KEY, invitation.getPendingKey()); + verify(groupMemberRepository, never()).save(any(GroupMember.class)); + assertEquals(5, group.getMemberCount()); + } + + @Test + void acceptAddsMemberIncrementsCountAndNotifiesInviter() { + login("new"); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + User invitee = user("new"); + invitee.setNickname("小明"); + + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(groupService.requireEasemobGroupId(group)).thenReturn("em-group"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "new")) + .thenReturn(Optional.empty()); + when(easemobService.addGroupMembers("em-group", Collections.singletonList("new"))).thenReturn(true); + when(groupMemberRepository.save(any(GroupMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(invitationRepository.save(any(GroupInvitation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(userRepository.findById("new")).thenReturn(Optional.of(invitee)); + + GroupMember member = service.accept("inv-1"); + + assertEquals("new", member.getUserId()); + assertEquals("MEMBER", member.getRole()); + assertNotNull(member.getJoinTime()); + // 计数与明细同事务 + assertEquals(6, group.getMemberCount()); + verify(groupRepository).save(group); + // 邀请收口为 ACCEPTED 并释放唯一键占位 + assertEquals(GroupInvitation.STATUS_ACCEPTED, invitation.getStatus()); + assertNotNull(invitation.getRespondedAt()); + + verify(pushService).pushToUser(eq("owner"), eq("邀请已通过"), eq("小明 已同意加入群聊「21级计科1班」"), any()); + verify(notificationService).createNotification( + eq("owner"), eq("GROUP_INVITE_ACCEPTED"), eq("邀请已通过"), + eq("小明 已同意加入群聊「21级计科1班」"), any(), eq("/group/group"), eq("group"), eq("new")); + } + + @Test + void acceptRejectsExpiredInvitationAndSettlesIt() { + login("new"); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + invitation.setExpireAt(new Date(System.currentTimeMillis() - 1000L)); + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + when(invitationRepository.save(any(GroupInvitation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + BusinessException e = assertThrows(BusinessException.class, () -> service.accept("inv-1")); + + assertEquals("邀请已过期", e.getMessage()); + assertEquals(GroupInvitation.STATUS_EXPIRED, invitation.getStatus()); + assertNull(invitation.getPendingKey()); + verify(easemobService, never()).addGroupMembers(any(), anyList()); + } + + @Test + void acceptRejectsAnotherUsersInvitationWith403() { + login("other"); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + + ForbiddenException e = assertThrows(ForbiddenException.class, () -> service.accept("inv-1")); + + assertEquals("无权处理该邀请", e.getMessage()); + verify(easemobService, never()).addGroupMembers(any(), anyList()); + } + + @Test + void acceptIsIdempotentWhenAlreadyAccepted() { + login("new"); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + invitation.settleTo(GroupInvitation.STATUS_ACCEPTED, new Date()); + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "new")) + .thenReturn(Optional.of(member("new", "MEMBER"))); + + GroupMember member = service.accept("inv-1"); + + assertEquals("new", member.getUserId()); + // 不重复加计数、不重复调环信 + assertEquals(5, group.getMemberCount()); + verify(easemobService, never()).addGroupMembers(any(), anyList()); + verify(groupMemberRepository, never()).save(any(GroupMember.class)); + } + + @Test + void acceptSettlesInvitationWhenUserAlreadyJoinedThroughAnotherPath() { + login("new"); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "new")) + .thenReturn(Optional.of(member("new", "MEMBER"))); + when(invitationRepository.save(any(GroupInvitation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + GroupMember member = service.accept("inv-1"); + + assertEquals("MEMBER", member.getRole()); + assertEquals(GroupInvitation.STATUS_ACCEPTED, invitation.getStatus()); + assertEquals(5, group.getMemberCount()); + verify(easemobService, never()).addGroupMembers(any(), anyList()); + } + + @Test + void acceptRejectsWhenGroupIsFull() { + login("new"); + group.setMemberCount(200); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "new")) + .thenReturn(Optional.empty()); + + BusinessException e = assertThrows(BusinessException.class, () -> service.accept("inv-1")); + + assertEquals("群成员已满", e.getMessage()); + assertEquals(GroupInvitation.STATUS_PENDING, invitation.getStatus()); + verify(easemobService, never()).addGroupMembers(any(), anyList()); + } + + // ===================== 撤回 ===================== + + @Test + void cancelRejectsStrangerWith403() { + login("stranger"); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + + ForbiddenException e = assertThrows(ForbiddenException.class, () -> service.cancel("inv-1")); + + assertEquals("无权撤回该邀请", e.getMessage()); + verify(invitationRepository, never()).save(any(GroupInvitation.class)); + } + + @Test + void inviterCanCancelPendingInvitation() { + login("owner"); + GroupInvitation invitation = pendingInvitation("inv-1", "owner", "new"); + when(invitationRepository.findById("inv-1")).thenReturn(Optional.of(invitation)); + when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + when(invitationRepository.save(any(GroupInvitation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + service.cancel("inv-1"); + + assertEquals(GroupInvitation.STATUS_CANCELLED, invitation.getStatus()); + assertNull(invitation.getPendingKey()); + } + + // ===================== 列表与懒过期 ===================== + + @Test + void listMineRefreshesOverdueInvitationsFirst() { + login("new"); + when(invitationRepository.findByInviteeIdAndStatusOrderByCreatedAtDesc( + eq("new"), eq(GroupInvitation.STATUS_PENDING), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + PageResult result = service.listMine(null, 1, 20); + + // 不点「允许」就停在 PENDING,超过 7 天后由这次懒判定刷成 EXPIRED + verify(invitationRepository).expireOverdueByInvitee(eq("new"), any(Date.class)); + assertTrue(result.getItems().isEmpty()); + assertEquals(0L, result.getTotal().longValue()); + } + + @Test + void listMineRequiresLogin() { + BusinessException e = assertThrows(BusinessException.class, () -> service.listMine(null, 1, 20)); + + assertEquals("用户未登录", e.getMessage()); + verifyNoInteractions(invitationRepository); + } +} diff --git a/campus-server/src/test/java/com/campus/service/GroupManagementTest.java b/campus-server/src/test/java/com/campus/service/GroupManagementTest.java new file mode 100644 index 0000000..7edbf4b --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/GroupManagementTest.java @@ -0,0 +1,496 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.config.EasemobConfig; +import com.campus.entity.Group; +import com.campus.entity.GroupMember; +import com.campus.entity.User; +import com.campus.repository.GroupMemberRepository; +import com.campus.repository.GroupAnnouncementRepository; +import com.campus.repository.GroupInvitationRepository; +import com.campus.repository.GroupRepository; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +@ExtendWith(MockitoExtension.class) +class GroupManagementTest { + @Mock private GroupRepository groupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private GroupAnnouncementRepository groupAnnouncementRepository; + @Mock private GroupInvitationRepository groupInvitationRepository; + @Mock private EasemobService easemobService; + @Mock private NotificationService notificationService; + @Mock private PushService pushService; + @Mock private UserRepository userRepository; + @InjectMocks private GroupService groupService; + + private Group group; + + @BeforeEach + void setUp() { + group = new Group(); + group.setId("group"); + group.setOwnerId("owner"); + group.setEasemobGroupId("em-group"); + group.setMemberCount(3); + // lenient:建群相关用例不会走到群查询,不放开会被 Mockito 严格模式判为多余的 stub + lenient().when(groupRepository.findById("group")).thenReturn(Optional.of(group)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void login(String userId) { + User user = new User(); + user.setId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + private GroupMember member(String userId, String role) { + GroupMember member = new GroupMember(); + member.setGroupId("group"); + member.setUserId(userId); + member.setRole(role); + return member; + } + + private User activeUser(String userId) { + User user = new User(); + user.setId(userId); + user.setAccountStatus("ACTIVE"); + return user; + } + + // 说明:原「已建群直接拉人」的 addMembers 已下线,改为发群邀请(GroupInvitationService), + // 对应的三个用例随之删除,新的邀请用例见 GroupInvitationServiceTest。 + // 这里保留并新增的是「建群时带人」这条路径的校验用例。 + + @Test + void createGroupSkipsOwnerAndDuplicatesBeforeRemoteCall() { + login("owner"); + when(userRepository.findById("new")).thenReturn(Optional.of(activeUser("new"))); + when(easemobService.createGroup(eq("群"), eq(""), eq("owner"), anyList())).thenReturn("em-group-2"); + when(groupRepository.save(any(Group.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(groupMemberRepository.saveAll(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + + groupService.createGroup("群", null, null, 10, Arrays.asList("owner", "new", "new", "")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(easemobService).createGroup(eq("群"), eq(""), eq("owner"), captor.capture()); + // 创建者自己、重复 ID、空白 ID 都必须被剔除,否则落库时会撞 uk_gm_group_user + assertEquals(Collections.singletonList("new"), captor.getValue()); + } + + @Test + void createGroupRejectsUnknownMemberBeforeRemoteCall() { + login("owner"); + when(userRepository.findById("ghost")).thenReturn(Optional.empty()); + + BusinessException e = assertThrows(BusinessException.class, + () -> groupService.createGroup("群", null, null, 10, Collections.singletonList("ghost"))); + assertEquals("用户不存在: ghost", e.getMessage()); + verifyNoInteractions(easemobService); + } + + @Test + void createGroupRejectsOverCapacityBeforeRemoteCall() { + login("owner"); + when(userRepository.findById("new")).thenReturn(Optional.of(activeUser("new"))); + + BusinessException e = assertThrows(BusinessException.class, + () -> groupService.createGroup("群", null, null, 1, Collections.singletonList("new"))); + assertEquals("群成员数量超过上限", e.getMessage()); + verifyNoInteractions(easemobService); + } + + @Test + void deleteGroupAlsoClearsGroupInvitations() { + login("owner"); + when(easemobService.deleteGroup("em-group")).thenReturn(true); + + groupService.deleteGroup("group"); + + verify(groupInvitationRepository).deleteAllByGroupId("group"); + } + + @Test + void removeMemberRequiresRemoteSuccessBeforeLocalDeletion() { + login("owner"); + GroupMember target = member("member", "MEMBER"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "owner")) + .thenReturn(Optional.of(member("owner", "OWNER"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "member")) + .thenReturn(Optional.of(target)); + + assertThrows(BusinessException.class, () -> groupService.removeMember("group", "member")); + verify(groupMemberRepository, never()).delete(target); + assertEquals(3, group.getMemberCount()); + + when(easemobService.removeGroupMemberDetectingOwnerConflict("em-group", "member")).thenReturn(true); + groupService.removeMember("group", "member"); + verify(groupMemberRepository).delete(target); + assertEquals(2, group.getMemberCount()); + } + + @Test + void removeMemberRepairsRemoteOwnerDriftBeforeRetrying() { + login("owner"); + GroupMember target = member("admin", "ADMIN"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "owner")) + .thenReturn(Optional.of(member("owner", "OWNER"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(target)); + when(easemobService.removeGroupMemberDetectingOwnerConflict("em-group", "admin")) + .thenThrow(new EasemobService.GroupOwnerConflictException("目标用户是环信群主", null)) + .thenReturn(true); + when(easemobService.transferGroupOwner("em-group", "owner")).thenReturn(true); + + groupService.removeMember("group", "admin"); + + InOrder order = inOrder(easemobService, groupMemberRepository); + order.verify(easemobService).removeGroupMemberDetectingOwnerConflict("em-group", "admin"); + order.verify(easemobService).transferGroupOwner("em-group", "owner"); + order.verify(easemobService).removeGroupMemberDetectingOwnerConflict("em-group", "admin"); + order.verify(groupMemberRepository).delete(target); + assertEquals(2, group.getMemberCount()); + } + + @Test + void failedOwnerRepairKeepsMemberLocally() { + login("owner"); + GroupMember target = member("admin", "ADMIN"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "owner")) + .thenReturn(Optional.of(member("owner", "OWNER"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(target)); + when(easemobService.removeGroupMemberDetectingOwnerConflict("em-group", "admin")) + .thenThrow(new EasemobService.GroupOwnerConflictException("目标用户是环信群主", null)); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.removeMember("group", "admin")); + + assertTrue(error.getMessage().contains("自动修复失败")); + verify(groupMemberRepository, never()).delete(target); + verify(groupRepository, never()).save(group); + assertEquals(3, group.getMemberCount()); + } + + @Test + void onlyOwnerCanAppointAdminAndRemoteFailureKeepsLocalRole() { + login("admin"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "member")) + .thenReturn(Optional.of(member("member", "MEMBER"))); + assertThrows(BusinessException.class, () -> groupService.appointAdmin("group", "member")); + verifyNoInteractions(easemobService); + + login("owner"); + GroupMember target = member("member", "MEMBER"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "member")) + .thenReturn(Optional.of(target)); + assertThrows(BusinessException.class, () -> groupService.appointAdmin("group", "member")); + assertEquals("MEMBER", target.getRole()); + verify(groupMemberRepository, never()).save(target); + + when(easemobService.addGroupAdmin("em-group", "member")).thenReturn(true); + groupService.appointAdmin("group", "member"); + assertEquals("ADMIN", target.getRole()); + verify(groupMemberRepository).save(target); + } + + @Test + void revokeAdminRepairsRemoteOwnerDriftAndDemotesLocally() { + login("owner"); + GroupMember target = member("admin", "ADMIN"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(target)); + when(easemobService.removeGroupAdminDetectingOwnerConflict("em-group", "admin")) + .thenThrow(new EasemobService.GroupOwnerConflictException("目标用户是环信群主", null)); + when(easemobService.transferGroupOwner("em-group", "owner")).thenReturn(true); + + groupService.revokeAdmin("group", "admin"); + + InOrder order = inOrder(easemobService, groupMemberRepository); + order.verify(easemobService).removeGroupAdminDetectingOwnerConflict("em-group", "admin"); + order.verify(easemobService).transferGroupOwner("em-group", "owner"); + order.verify(groupMemberRepository).save(target); + assertEquals("MEMBER", target.getRole()); + verify(easemobService, times(1)).removeGroupAdminDetectingOwnerConflict("em-group", "admin"); + } + + @Test + void adminCannotRemoveOtherAdmin() { + login("admin"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(member("admin", "ADMIN"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "other")) + .thenReturn(Optional.of(member("other", "ADMIN"))); + assertThrows(BusinessException.class, () -> groupService.removeMember("group", "other")); + verifyNoInteractions(easemobService); + } + + @Test + void adminCanApproveButRemoteFailureLeavesRequestPending() { + login("admin"); + GroupMember pending = member("applicant", "PENDING"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(member("admin", "ADMIN"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "applicant")) + .thenReturn(Optional.of(pending)); + assertThrows(BusinessException.class, () -> groupService.approveJoinRequest("group", "applicant")); + assertEquals("PENDING", pending.getRole()); + assertEquals(3, group.getMemberCount()); + verify(groupMemberRepository, never()).save(pending); + + when(easemobService.addGroupMembers("em-group", Collections.singletonList("applicant"))) + .thenReturn(true); + groupService.approveJoinRequest("group", "applicant"); + assertEquals("MEMBER", pending.getRole()); + assertEquals(4, group.getMemberCount()); + } + + @Test + void pendingUserIsExcludedFromMembersAndCannotSeeRequests() { + login("owner"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "owner")) + .thenReturn(Optional.of(member("owner", "OWNER"))); + when(groupMemberRepository.findByGroupIdOrderByJoinTimeAsc("group")) + .thenReturn(Arrays.asList(member("owner", "OWNER"), member("applicant", "PENDING"))); + assertEquals(1, groupService.getGroupMembers("group").size()); + + login("applicant"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "applicant")) + .thenReturn(Optional.of(member("applicant", "PENDING"))); + assertThrows(BusinessException.class, () -> groupService.getGroupMembers("group")); + assertThrows(BusinessException.class, () -> groupService.getJoinRequests("group")); + } + + @Test + void muteFailureDoesNotPersistAndSuccessDoes() { + login("admin"); + GroupMember target = member("member", "MEMBER"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(member("admin", "ADMIN"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "member")) + .thenReturn(Optional.of(target)); + assertThrows(BusinessException.class, () -> groupService.setMemberMute("group", "member", true)); + assertFalse(target.getIsMuted()); + when(easemobService.setGroupMemberMute("em-group", "member", true)).thenReturn(true); + groupService.setMemberMute("group", "member", true); + assertTrue(target.getIsMuted()); + } + + @Test + void ownerCanMuteAdmin() { + login("owner"); + GroupMember target = member("admin", "ADMIN"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "owner")) + .thenReturn(Optional.of(member("owner", "OWNER"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(target)); + when(easemobService.setGroupMemberMute("em-group", "admin", true)).thenReturn(true); + + groupService.setMemberMute("group", "admin", true); + + assertTrue(target.getIsMuted()); + verify(groupMemberRepository).save(target); + } + + @Test + void adminCannotMuteAnotherAdmin() { + login("admin-one"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin-one")) + .thenReturn(Optional.of(member("admin-one", "ADMIN"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin-two")) + .thenReturn(Optional.of(member("admin-two", "ADMIN"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.setMemberMute("group", "admin-two", true)); + + assertEquals("只有群主可以禁言管理员", error.getMessage()); + verifyNoInteractions(easemobService); + } + + @Test + void ownerCannotBeMuted() { + login("admin"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "admin")) + .thenReturn(Optional.of(member("admin", "ADMIN"))); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "owner")) + .thenReturn(Optional.of(member("owner", "OWNER"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> groupService.setMemberMute("group", "owner", true)); + + assertEquals("不能禁言群主", error.getMessage()); + verifyNoInteractions(easemobService); + } + + @Test + void pendingLeaveDoesNotCallRemoteOrDecreaseCount() { + login("applicant"); + GroupMember pending = member("applicant", "PENDING"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "applicant")) + .thenReturn(Optional.of(pending)); + groupService.leaveGroup("group"); + verify(groupMemberRepository).delete(pending); + verifyNoInteractions(easemobService); + assertEquals(3, group.getMemberCount()); + } + + @Test + void failedRemoteDeletionPreservesGroup() { + login("owner"); + assertThrows(BusinessException.class, () -> groupService.deleteGroup("group")); + verify(groupRepository, never()).delete(group); + verify(groupMemberRepository, never()).deleteByGroupId("group"); + } + + @Test + void memberCanSaveAndClearOwnGroupNickname() { + login("member"); + GroupMember member = member("member", "MEMBER"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "member")) + .thenReturn(Optional.of(member)); + when(groupMemberRepository.save(member)).thenReturn(member); + + assertEquals("群内昵称", groupService.updateMyGroupNickname("group", " 群内昵称 ").getNickname()); + assertNull(groupService.updateMyGroupNickname("group", " ").getNickname()); + verify(groupMemberRepository, times(2)).save(member); + verifyNoInteractions(easemobService); + } + + @Test + void pendingUserCannotSetGroupNickname() { + login("applicant"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "applicant")) + .thenReturn(Optional.of(member("applicant", "PENDING"))); + + assertThrows(BusinessException.class, + () -> groupService.updateMyGroupNickname("group", "群内昵称")); + verify(groupMemberRepository, never()).save(any(GroupMember.class)); + } + + @Test + void rejectsNicknameLongerThanDatabaseColumn() { + login("member"); + when(groupMemberRepository.findFirstByGroupIdAndUserIdOrderByJoinTimeAsc("group", "member")) + .thenReturn(Optional.of(member("member", "MEMBER"))); + + assertThrows(BusinessException.class, + () -> groupService.updateMyGroupNickname("group", String.join("", Collections.nCopies(51, "a")))); + verify(groupMemberRepository, never()).save(any(GroupMember.class)); + } +} + +class EasemobServiceOwnerConflictTest { + + @Test + void transferGroupOwnerUsesDocumentedGroupEndpoint() { + EasemobService service = new EasemobService(); + EasemobConfig config = new EasemobConfig(); + config.setAppKey("test-org#test-app"); + config.setClientId("client-id"); + config.setClientSecret("client-secret"); + config.setBaseUrl("https://easemob.example"); + + RestTemplate restTemplate = new RestTemplate(); + ReflectionTestUtils.setField(service, "easemobConfig", config); + ReflectionTestUtils.setField(service, "restTemplate", restTemplate); + + MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate); + server.expect(requestTo("https://easemob.example/test-org/test-app/token")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andRespond(withSuccess("{\"access_token\":\"app-token\"}", MediaType.APPLICATION_JSON)); + server.expect(requestTo("https://easemob.example/test-org/test-app/chatgroups/327824719282177")) + .andExpect(method(org.springframework.http.HttpMethod.PUT)) + .andExpect(header("Authorization", "Bearer app-token")) + .andExpect(content().json("{\"newowner\":\"u07bc21d495894488a8394a9aec629b2\"}")) + .andRespond(withSuccess()); + + assertTrue(service.transferGroupOwner( + "327824719282177", "07bc21d4-9589-4488-a839-4a9aec629b29")); + server.verify(); + } + + @Test + void transferGroupOwnerIsIdempotentWhenRemoteOwnerAlreadyChanged() { + EasemobService service = new EasemobService(); + EasemobConfig config = new EasemobConfig(); + config.setAppKey("test-org#test-app"); + config.setClientId("client-id"); + config.setClientSecret("client-secret"); + config.setBaseUrl("https://easemob.example"); + + RestTemplate restTemplate = new RestTemplate(); + ReflectionTestUtils.setField(service, "easemobConfig", config); + ReflectionTestUtils.setField(service, "restTemplate", restTemplate); + + MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate); + server.expect(requestTo("https://easemob.example/test-org/test-app/token")) + .andRespond(withSuccess("{\"access_token\":\"app-token\"}", MediaType.APPLICATION_JSON)); + server.expect(requestTo("https://easemob.example/test-org/test-app/chatgroups/327824719282177")) + .andRespond(withStatus(HttpStatus.FORBIDDEN) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"error\":\"forbidden_op\"," + + "\"error_description\":\"new owner and old owner are the same\"}")); + + assertTrue(service.transferGroupOwner( + "327824719282177", "07bc21d4-9589-4488-a839-4a9aec629b29")); + server.verify(); + } + + @Test + void onlyMatchesSpecificForbiddenResponse() { + EasemobService service = new EasemobService(); + HttpClientErrorException ownerConflict = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, + "Forbidden", + HttpHeaders.EMPTY, + ("{\"exception\":\"com.easemob.group.exception.ForbiddenOpException\"," + + "\"error_description\":\"forbidden operation on group owner!\"}") + .getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8); + HttpClientErrorException unrelatedForbidden = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, + "Forbidden", + HttpHeaders.EMPTY, + "{\"error_description\":\"permission denied\"}".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8); + + assertTrue(service.isGroupOwnerConflict(ownerConflict)); + assertFalse(service.isGroupOwnerConflict(unrelatedForbidden)); + } +} diff --git a/campus-server/src/test/java/com/campus/service/InteractionGuardTest.java b/campus-server/src/test/java/com/campus/service/InteractionGuardTest.java new file mode 100644 index 0000000..cd8d01c --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/InteractionGuardTest.java @@ -0,0 +1,263 @@ +package com.campus.service; + +import com.campus.common.*; +import com.campus.dto.request.CommentRequest; +import com.campus.entity.*; +import com.campus.repository.*; +import com.campus.service.event.InteractionCommittedAction; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +@ExtendWith(MockitoExtension.class) +class InteractionGuardTest { + @Mock MomentRepository momentRepository; + @Mock MomentLikeRepository momentLikeRepository; + @Mock MomentFavoriteRepository momentFavoriteRepository; + @Mock MomentCommentRepository momentCommentRepository; + @Mock MomentCommentLikeRepository momentCommentLikeRepository; + @Mock UserRepository userRepository; + @Mock FollowRepository followRepository; + @Mock FriendshipRepository friendshipRepository; + @Mock GreetingRepository greetingRepository; + @Mock NotificationRepository notificationRepository; + @Mock PointsService pointsService; + @Mock PushService pushService; + @Mock NotificationService notificationService; + @Mock UserRestrictionService userRestrictionService; + @Spy AuthorizationService authorizationService = new AuthorizationService(); + @Mock ClickGuardService clickGuardService; + @Mock InteractionLockService interactionLockService; + @Mock ApplicationEventPublisher eventPublisher; + @InjectMocks MomentLikeCommentService likes; + @InjectMocks MomentService moments; + @InjectMocks FollowService follows; + @InjectMocks FriendService friends; + + @BeforeEach void login() { + User u = new User(); u.setId("user"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(u, null)); + } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + private Moment moment() { + Moment m = new Moment(); m.setId("moment"); m.setUserId("owner"); + m.setReviewStatus("approved"); m.setLikeCount(4); m.setCommentCount(2); return m; + } + private MomentComment comment() { + MomentComment c = new MomentComment(); c.setId("comment"); c.setMomentId("moment"); + c.setUserId("user"); c.setStatus("ACTIVE"); c.setLikeCount(4); return c; + } + private void block(String scope, String target) { + doThrow(new TooManyRequestsException("点击过于频繁,操作失败")).when(clickGuardService) + .ensureNotTooFrequent(scope, "user", target); + } + @Test void anonymousLikeFailsBeforeDatabase() { + SecurityContextHolder.clearContext(); + assertEquals("用户未登录", assertThrows(BusinessException.class, () -> likes.toggleLike("moment")).getMessage()); + verifyNoInteractions(momentRepository, momentLikeRepository, clickGuardService); + } + @Test void rapidLikeReversalIsAllowedAndUsesBroadRateLimit() { + Moment m = moment(); + MomentLike existing = new MomentLike(); + when(momentRepository.findByIdForInteraction("moment")).thenReturn(Optional.of(m)); + when(momentLikeRepository.findByMomentIdAndUserId("moment", "user")) + .thenReturn(Optional.empty(), Optional.of(existing)); + + assertTrue(likes.toggleLike("moment")); + assertFalse(likes.toggleLike("moment")); + + assertEquals(4, m.getLikeCount()); + verify(clickGuardService, times(2)).ensureWithinRate( + eq(ClickGuardService.SCOPE_MOMENT_LIKE), eq("user"), anyLong(), anyLong()); + verify(clickGuardService, never()).ensureNotTooFrequent( + ClickGuardService.SCOPE_MOMENT_LIKE, "user", "moment"); + } + @Test void locksBeforeLikeAndDefersAuxiliaryWork() { + Moment m = moment(); when(momentRepository.findByIdForInteraction("moment")).thenReturn(Optional.of(m)); + assertTrue(likes.toggleLike("moment")); assertEquals(5, m.getLikeCount()); + InOrder order = inOrder(momentRepository, momentLikeRepository); + order.verify(momentRepository).findByIdForInteraction("moment"); + order.verify(momentLikeRepository).findByMomentIdAndUserId("moment", "user"); + order.verify(momentLikeRepository).upsertActive(anyString(), eq("moment"), eq("user")); + order.verify(momentRepository).save(m); + verify(eventPublisher).publishEvent(any(InteractionCommittedAction.class)); + verifyNoInteractions(pointsService, pushService, notificationService); + } + @Test void rejectedMomentWritesNothing() { + Moment m = moment(); m.setReviewStatus("rejected"); + when(momentRepository.findByIdForInteraction("moment")).thenReturn(Optional.of(m)); + assertEquals("动态未发布,无法点赞", assertThrows(BusinessException.class, + () -> likes.toggleLike("moment")).getMessage()); + verify(momentLikeRepository, never()).upsertActive(anyString(), anyString(), anyString()); + verify(momentRepository, never()).save(any()); verifyNoInteractions(eventPublisher); + } + @Test void unlikeRemovesDetailAndDecrementsOnce() { + Moment m = moment(); MomentLike l = new MomentLike(); + when(momentRepository.findByIdForInteraction("moment")).thenReturn(Optional.of(m)); + when(momentLikeRepository.findByMomentIdAndUserId("moment", "user")).thenReturn(Optional.of(l)); + assertFalse(likes.toggleLike("moment")); assertEquals(3, m.getLikeCount()); + verify(momentLikeRepository).delete(l); verifyNoInteractions(eventPublisher); + } + @Test void rapidCommentLikeCanBeImmediatelyUndone() { + MomentComment c = comment(); + MomentCommentLike existing = new MomentCommentLike(); + when(momentCommentRepository.findByIdForInteraction("comment")).thenReturn(Optional.of(c)); + when(momentCommentLikeRepository.findByCommentIdAndUserId("comment", "user")) + .thenReturn(Optional.empty(), Optional.of(existing)); + + assertTrue(likes.toggleCommentLike("comment")); + assertFalse(likes.toggleCommentLike("comment")); + + assertEquals(4, c.getLikeCount()); + verify(clickGuardService, times(2)).ensureWithinRate( + eq(ClickGuardService.SCOPE_COMMENT_LIKE), eq("user"), anyLong(), anyLong()); + verify(clickGuardService, never()).ensureNotTooFrequent( + ClickGuardService.SCOPE_COMMENT_LIKE, "user", "comment"); + } + + @Test void settingExistingLikeToTrueIsIdempotent() { + Moment m = moment(); + when(momentRepository.findByIdForInteraction("moment")).thenReturn(Optional.of(m)); + when(momentLikeRepository.findByMomentIdAndUserId("moment", "user")) + .thenReturn(Optional.of(new MomentLike())); + + com.campus.dto.response.InteractionLikeStateDTO result = likes.setLike("moment", true); + + assertEquals(true, result.getLiked()); + assertEquals(4L, result.getLikeCount()); + verify(momentLikeRepository, never()).delete(any()); + verify(momentLikeRepository, never()).upsertActive(anyString(), anyString(), anyString()); + verify(momentRepository, never()).save(any()); + verifyNoInteractions(eventPublisher, pointsService); + } + + @Test void explicitLikeCancelLikeCancelConvergesToOriginalCount() { + Moment m = moment(); + when(momentRepository.findByIdForInteraction("moment")).thenReturn(Optional.of(m)); + MomentLike relation = new MomentLike(); + when(momentLikeRepository.findByMomentIdAndUserId("moment", "user")) + .thenReturn(Optional.empty(), Optional.of(relation), Optional.empty(), Optional.of(relation)); + assertTrue(likes.setLike("moment", true).getLiked()); + assertFalse(likes.setLike("moment", false).getLiked()); + assertTrue(likes.setLike("moment", true).getLiked()); + assertFalse(likes.setLike("moment", false).getLiked()); + assertEquals(4, m.getLikeCount()); + verify(eventPublisher, times(2)).publishEvent(any(InteractionCommittedAction.class)); + verify(momentLikeRepository, times(2)).delete(relation); + } + + @Test void settingCommentLikeToFalseWhenAlreadyFalseDoesNotWrite() { + MomentComment c = comment(); + when(momentCommentRepository.findByIdForInteraction("comment")).thenReturn(Optional.of(c)); + when(momentCommentLikeRepository.findByCommentIdAndUserId("comment", "user")) + .thenReturn(Optional.empty()); + + com.campus.dto.response.InteractionLikeStateDTO result = likes.setCommentLike("comment", false); + + assertEquals(false, result.getLiked()); + assertEquals(4L, result.getLikeCount()); + verify(momentCommentLikeRepository, never()).delete(any()); + verify(momentCommentLikeRepository, never()).upsertActive(anyString(), anyString(), anyString()); + verify(momentCommentRepository, never()).save(any()); + } + @Test void commentLikeUsesLockedTarget() { + MomentComment c = comment(); when(momentCommentRepository.findByIdForInteraction("comment")).thenReturn(Optional.of(c)); + assertTrue(likes.toggleCommentLike("comment")); assertEquals(5, c.getLikeCount()); + verify(momentCommentLikeRepository).upsertActive(anyString(), eq("comment"), eq("user")); + verifyNoInteractions(pushService, notificationService); + } + @Test void rapidCommentCreationNeverWrites() { + block(ClickGuardService.SCOPE_MOMENT_COMMENT, "moment"); + assertThrows(TooManyRequestsException.class, () -> likes.addComment("moment", new CommentRequest())); + verifyNoInteractions(momentRepository, momentCommentRepository, userRestrictionService); + } + @Test void deletingInactiveCommentDoesNotDecrementAgain() { + Moment m = moment(); MomentComment c = comment(); c.setStatus("INACTIVE"); + when(momentRepository.findByIdForInteraction("moment")).thenReturn(Optional.of(m)); + when(momentCommentRepository.findByIdForInteraction("comment")).thenReturn(Optional.of(c)); + likes.deleteComment("moment", "comment"); assertEquals(2, m.getCommentCount()); + verify(momentRepository, never()).save(any()); verify(momentCommentRepository, never()).save(any()); + } + @Test void refusesToDeleteAnotherUsersComment() { + MomentComment c = comment(); c.setUserId("other"); + when(momentRepository.findByIdForInteraction("moment")).thenReturn(Optional.of(moment())); + when(momentCommentRepository.findByIdForInteraction("comment")).thenReturn(Optional.of(c)); + assertEquals("无权删除此评论", assertThrows(ForbiddenException.class, + () -> likes.deleteComment("moment", "comment")).getMessage()); + verify(momentRepository, never()).save(any()); verify(momentCommentRepository, never()).save(any()); + } + @Test void rapidFavoriteNeverWrites() { + block(ClickGuardService.SCOPE_MOMENT_FAVORITE, "moment"); + assertThrows(TooManyRequestsException.class, () -> moments.toggleFavorite("moment")); + verifyNoInteractions(momentRepository, momentFavoriteRepository); + } + @Test void cannotFollowSelfIsBusinessError() { + assertEquals("不能关注自己", assertThrows(BusinessException.class, + () -> follows.toggleFollow("user")).getMessage()); + verifyNoInteractions(followRepository, interactionLockService); + } + @Test void rapidFollowFailsBeforeDatabase() { + block(ClickGuardService.SCOPE_USER_FOLLOW, "target"); + assertEquals("点击过于频繁,操作失败", assertThrows(TooManyRequestsException.class, + () -> follows.toggleFollow("target")).getMessage()); + verifyNoInteractions(followRepository, userRepository, interactionLockService); + } + @Test void followLocksBothUsersBeforeReadingAndInserting() { + assertEquals(true, follows.toggleFollow("target").get("followed")); + InOrder order = inOrder(interactionLockService, followRepository); + order.verify(interactionLockService).lockUsers("user", "target"); + order.verify(followRepository).findForInteraction("user", "target"); + order.verify(followRepository).upsertActive(anyString(), eq("user"), eq("target")); + verifyNoInteractions(pushService, notificationService); + } + @Test void rapidGreetingCannotCreateGreetingOrFriendship() { + block(ClickGuardService.SCOPE_FRIEND_REQUEST, "target"); + com.campus.dto.request.GreetRequest r = new com.campus.dto.request.GreetRequest(); r.setFriendId("target"); + assertThrows(TooManyRequestsException.class, () -> friends.greetUser(r)); + verifyNoInteractions(greetingRepository, friendshipRepository, interactionLockService); + } + @Test void rapidFriendRequestCannotWrite() { + block(ClickGuardService.SCOPE_FRIEND_REQUEST, "target"); + com.campus.dto.request.AddFriendRequest r = new com.campus.dto.request.AddFriendRequest(); r.setFriendId("target"); + assertThrows(TooManyRequestsException.class, () -> friends.addFriend(r)); + verifyNoInteractions(friendshipRepository, interactionLockService); + } + @Test void auxiliaryFailureNeverEscapesCommittedListener() { + InteractionSideEffectService effects = mock(InteractionSideEffectService.class); + Runnable action = mock(Runnable.class); + doThrow(new IllegalStateException("模拟通知事务失败")).when(effects).execute(action); + assertDoesNotThrow(() -> new InteractionCommittedListener(effects).onCommitted(new InteractionCommittedAction(action))); + verify(effects).execute(action); + } + @Test void oppositeDirectionsAlwaysUseSameLockOrder() { + when(userRepository.findByIdForUpdate("a")).thenReturn(Optional.of(new User())); + when(userRepository.findByIdForUpdate("z")).thenReturn(Optional.of(new User())); + InteractionLockService locks = new InteractionLockService(userRepository); + locks.lockUsers("z", "a"); locks.lockUsers("a", "z"); + InOrder order = inOrder(userRepository); + order.verify(userRepository).findByIdForUpdate("a"); order.verify(userRepository).findByIdForUpdate("z"); + order.verify(userRepository).findByIdForUpdate("a"); order.verify(userRepository).findByIdForUpdate("z"); + } + @Test void unauthorizedFriendResponseIsForbiddenAndDoesNotWrite() { + when(friendshipRepository.findParties("request")) + .thenReturn(java.util.Collections.singletonList(new Object[]{"sender", "receiver"})); + assertEquals("无权处理该好友请求", assertThrows(ForbiddenException.class, + () -> friends.acceptFriendRequest("request")).getMessage()); + verifyNoInteractions(interactionLockService, eventPublisher); + verify(friendshipRepository, never()).save(any()); + } + @Test void missingCommentIsBusinessErrorAndDoesNotWrite() { + assertEquals("评论不存在", assertThrows(BusinessException.class, + () -> likes.toggleCommentLike("missing")).getMessage()); + verifyNoInteractions(momentCommentLikeRepository, eventPublisher); + } + +} diff --git a/campus-server/src/test/java/com/campus/service/InteractionMySqlTest.java b/campus-server/src/test/java/com/campus/service/InteractionMySqlTest.java new file mode 100644 index 0000000..5fc01dc --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/InteractionMySqlTest.java @@ -0,0 +1,336 @@ +package com.campus.service; + +import com.campus.entity.*; +import com.campus.repository.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.orm.jpa.*; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.support.TransactionTemplate; +import javax.persistence.*; +import java.util.*; +import java.util.concurrent.*; +import java.lang.reflect.Constructor; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 必须指定独立 MySQL 测试库;绝不使用应用的业务数据库配置。 */ +@EnabledIfEnvironmentVariable(named = "QINGHE_INTERACTION_TEST_URL", matches = ".*/qinghe_interaction_test_[a-z0-9_]+.*") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class InteractionMySqlTest { + static LocalContainerEntityManagerFactoryBean factory; + static EntityManager em; + static DriverManagerDataSource dataSource; + static TransactionTemplate tx; + static final Map, Object> dependencies = new HashMap<>(); + static MomentRepository moments; + static MomentLikeRepository likes; + static MomentCommentRepository comments; + static MomentCommentLikeRepository commentLikes; + static MomentFavoriteRepository favorites; + static UserRepository users; + static FollowRepository follows; + static MomentLikeCommentService service; + static FollowService followService; + static MomentService momentService; + static FriendService friendService; + static FriendshipRepository friendships; + static GreetingRepository greetings; + + @BeforeAll static void initialize() throws Exception { + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME)).setLevel(ch.qos.logback.classic.Level.WARN); + String url = System.getenv("QINGHE_INTERACTION_TEST_URL"); + assertTrue(url.matches("jdbc:mysql://[^/]+/qinghe_interaction_test_[a-z0-9_]+(?:\\?.*)?")); + DriverManagerDataSource ds = new DriverManagerDataSource(url, + System.getenv("QINGHE_INTERACTION_TEST_USER"), System.getenv("QINGHE_INTERACTION_TEST_PASSWORD")); + dataSource = ds; + factory = new LocalContainerEntityManagerFactoryBean(); factory.setDataSource(ds); + factory.setPackagesToScan("com.campus.entity"); factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); + Map properties = new HashMap<>(); + properties.put("hibernate.hbm2ddl.auto", "create-only"); + properties.put("hibernate.dialect", "org.hibernate.dialect.MySQL57Dialect"); + properties.put("hibernate.physical_naming_strategy", "org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy"); + properties.put("hibernate.show_sql", "false"); + factory.setJpaPropertyMap(properties); factory.afterPropertiesSet(); + EntityManagerFactory emf = factory.getObject(); + em = SharedEntityManagerCreator.createSharedEntityManager(emf); + tx = new TransactionTemplate(new JpaTransactionManager(emf)); tx.setTimeout(30); + JpaRepositoryFactory repos = new JpaRepositoryFactory(em); + moments = repository(repos, MomentRepository.class); likes = repository(repos, MomentLikeRepository.class); + comments = repository(repos, MomentCommentRepository.class); commentLikes = repository(repos, MomentCommentLikeRepository.class); + favorites = repository(repos, MomentFavoriteRepository.class); users = repository(repos, UserRepository.class); + follows = repository(repos, FollowRepository.class); + dependencies.put(InteractionLockService.class, new InteractionLockService(users)); + dependencies.put(ClickGuardService.class, mock(ClickGuardService.class)); // 模拟 Redis 不可用放行 + service = create(MomentLikeCommentService.class); followService = create(FollowService.class); + momentService = create(MomentService.class); + friendships = repository(repos, FriendshipRepository.class); greetings = repository(repos, GreetingRepository.class); + PrivacySettingsService privacy = mock(PrivacySettingsService.class); + when(privacy.allowsStrangerFriendRequest(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + dependencies.put(PrivacySettingsService.class, privacy); + dependencies.put(AuthorizationService.class, new AuthorizationService()); + friendService = create(FriendService.class); + } + static T repository(JpaRepositoryFactory f, Class type) { + T repo = f.getRepository(type); dependencies.put(type, repo); return repo; + } + static T create(Class type) throws Exception { + Constructor c = type.getConstructors()[0]; + Object[] args = Arrays.stream(c.getParameterTypes()).map(t -> dependencies.computeIfAbsent(t, k -> mock(k))).toArray(); + return type.cast(c.newInstance(args)); + } + static void login(String id) { + User u = new User(); u.setId(id); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(u, null)); + } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + @AfterAll static void close() { if (factory != null) factory.destroy(); } + String seedMoment() { + return tx.execute(status -> { + Moment m = new Moment(); m.setUserId("owner"); m.setReviewStatus("approved"); + m.setContent("并发测试"); m.setScope("CAMPUS"); m.setLikeCount(0); m.setCommentCount(0); + return moments.saveAndFlush(m).getId(); + }); + } + void parallel(int n, java.util.function.IntConsumer work) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(n); + CountDownLatch ready = new CountDownLatch(n), start = new CountDownLatch(1); + try { + List> results = new ArrayList<>(); + for (int i=0;i { + ready.countDown(); try { assertTrue(start.await(10, TimeUnit.SECONDS)); work.accept(index); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } + finally { SecurityContextHolder.clearContext(); } + })); } + assertTrue(ready.await(10, TimeUnit.SECONDS)); start.countDown(); + for (Future result : results) result.get(40, TimeUnit.SECONDS); + } finally { pool.shutdownNow(); } + } + @Test void differentUsersLikeWithoutLosingCounts() throws Exception { + String id = seedMoment(); + parallel(8, i -> { login("liker-"+i); tx.execute(s -> service.toggleLike(id)); }); + tx.execute(s -> { assertEquals(8, moments.findById(id).get().getLikeCount()); + assertEquals(8L, likes.countByMomentId(id)); return null; }); + } + @Test void sameUserConcurrentToggleNeverDuplicatesOrReturns500() throws Exception { + String id = seedMoment(); + parallel(8, i -> { login("same-user"); tx.execute(s -> service.toggleLike(id)); }); + tx.execute(s -> { assertEquals(0, moments.findById(id).get().getLikeCount()); + assertEquals(0L, likes.countByMomentId(id)); return null; }); + } + @Test void concurrentExplicitLikesAreIdempotentAndCanBeImmediatelyCancelled() throws Exception { + String id = seedMoment(); + parallel(8, i -> { login("same-explicit-user"); tx.execute(s -> service.setLike(id, true)); }); + tx.execute(s -> { + assertEquals(1, moments.findById(id).get().getLikeCount()); + assertEquals(1L, likes.countByMomentId(id)); return null; + }); + parallel(8, i -> { login("same-explicit-user"); tx.execute(s -> service.setLike(id, false)); }); + tx.execute(s -> { + assertEquals(0, moments.findById(id).get().getLikeCount()); + assertEquals(0L, likes.countByMomentId(id)); return null; + }); + } + @Test void durableShareClaimsAllowOnlyOneSenderAndReplayCommittedResult() throws Exception { + ShareRequestRecordRepository records = new JpaRepositoryFactory(em).getRepository(ShareRequestRecordRepository.class); + ShareRequestIdempotencyService requests = new ShareRequestIdempotencyService(records, + new com.fasterxml.jackson.databind.ObjectMapper()); + String key = UUID.randomUUID().toString(); + java.util.concurrent.atomic.AtomicInteger owners = new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicReference recordId = new java.util.concurrent.atomic.AtomicReference<>(); + parallel(8, i -> { + try { + ShareRequestIdempotencyService.Claim claim = tx.execute(s -> requests.claim("share-user", + "SHARE_SEND", key, "hash")); + assertTrue(claim.isNewRequest()); + owners.incrementAndGet(); recordId.set(claim.getRecordId()); + } catch (com.campus.common.TooManyRequestsException ex) { + assertEquals("分享请求正在处理中,请勿重复提交", ex.getMessage()); + } + }); + assertEquals(1, owners.get()); + tx.execute(s -> { requests.complete(recordId.get(), Collections.singletonMap("sentCount", 1)); return null; }); + tx.execute(s -> { + ShareRequestIdempotencyService.Claim replay = requests.claim("share-user", "SHARE_SEND", key, "hash"); + assertTrue(replay.isCompleted()); + assertTrue(replay.getResponseJson().contains("sentCount")); + return null; + }); + } + @Test void commentLikesAreUniqueAndCountersAgree() throws Exception { + String mid = seedMoment(); + String cid = tx.execute(s -> { MomentComment c = new MomentComment(); c.setMomentId(mid); + c.setUserId("owner"); c.setStatus("ACTIVE"); c.setContent("测试评论"); c.setLikeCount(0); + return comments.saveAndFlush(c).getId(); }); + parallel(8, i -> { login("comment-liker-"+i); tx.execute(s -> service.toggleCommentLike(cid)); }); + tx.execute(s -> { assertEquals(8, comments.findById(cid).get().getLikeCount()); + assertEquals(8L, commentLikes.countByCommentId(cid)); return null; }); + parallel(8, i -> { login("same-comment-user"); tx.execute(s -> service.toggleCommentLike(cid)); }); + tx.execute(s -> { assertEquals(8, comments.findById(cid).get().getLikeCount()); + assertEquals(8L, commentLikes.countByCommentId(cid)); return null; }); + } + @Test void deletedLikeAndFavoriteCanBeRestoredWithoutUniqueFailure() { + String id = seedMoment(); login("restore-user"); + tx.execute(s -> { MomentLike l = new MomentLike(); l.setMomentId(id); l.setUserId("restore-user"); l.setDeleted(true); likes.save(l); + MomentFavorite f = new MomentFavorite(); f.setMomentId(id); f.setUserId("restore-user"); f.setDeleted(true); favorites.save(f); return null; }); + assertEquals(Boolean.TRUE, tx.execute(s -> service.toggleLike(id))); + assertEquals(Boolean.TRUE, tx.execute(s -> momentService.toggleFavorite(id))); + tx.execute(s -> { assertEquals(1L, likes.countByMomentId(id)); assertEquals(1L, favorites.countByMomentIdAndDeletedFalse(id)); return null; }); + } + @Test void differentFollowersPreserveTargetCounter() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0,8); + String target = "target-"+suffix; + tx.execute(s -> { for (int i=0;i<9;i++) { User u = new User(); u.setId(i==8 ? target : "follower-"+suffix+"-"+i); + u.setNickname("并发测试用户"); u.setUniversityName("测试大学"); u.setMajorName("测试专业"); u.setGrade("2026"); + u.setPhone(UUID.randomUUID().toString().substring(0,20)); u.setPassword("test-only"); u.setFollowersCount(0); u.setFollowingCount(0); users.save(u); } return null; }); + parallel(8, i -> { login("follower-"+suffix+"-"+i); tx.execute(s -> followService.toggleFollow(target)); }); + tx.execute(s -> { assertEquals(8, users.findById(target).get().getFollowersCount()); + assertEquals(8L, follows.countByFollowingId(target)); return null; }); + } + @org.springframework.context.annotation.Configuration + @org.springframework.transaction.annotation.EnableTransactionManagement + static class ListenerConfig { } + + @Test void committedActionUsesIndependentTransactionAndCannotUndoMainWrite() { + org.springframework.context.annotation.AnnotationConfigApplicationContext context = + new org.springframework.context.annotation.AnnotationConfigApplicationContext(); + context.register(ListenerConfig.class); + context.registerBean("transactionManager", org.springframework.transaction.PlatformTransactionManager.class, + () -> new JpaTransactionManager(factory.getObject())); + context.registerBean(InteractionSideEffectService.class); + context.registerBean(InteractionCommittedListener.class); + context.refresh(); + String id = seedMoment(); + java.util.concurrent.atomic.AtomicBoolean invoked = new java.util.concurrent.atomic.AtomicBoolean(); + try { + tx.execute(s -> { + Moment m = moments.findById(id).get(); m.setContent("主操作已提交"); moments.save(m); + context.publishEvent(new com.campus.service.event.InteractionCommittedAction(() -> { + invoked.set(true); + Moment auxiliary = moments.findById(id).get(); auxiliary.setLikeCount(999); moments.saveAndFlush(auxiliary); + throw new IllegalStateException("模拟辅助事务失败"); + })); + assertFalse(invoked.get()); + return null; + }); + assertTrue(invoked.get()); + tx.execute(s -> { assertEquals("主操作已提交", moments.findById(id).get().getContent()); + assertEquals(0, moments.findById(id).get().getLikeCount()); return null; }); + } finally { context.close(); } + } + + @Test void favoriteConcurrentToggleRemainsUnique() throws Exception { + String id = seedMoment(); + parallel(8, i -> { login("same-favorite-user"); tx.execute(s -> momentService.toggleFavorite(id)); }); + tx.execute(s -> { assertEquals(0L, favorites.countByMomentIdAndDeletedFalse(id)); return null; }); + } + + + String[] seedUserPair() { + String suffix = UUID.randomUUID().toString().substring(0,8); + String[] ids = {"sender-"+suffix, "receiver-"+suffix}; + tx.execute(s -> { for (String id : ids) { User u = new User(); u.setId(id); u.setNickname("好友并发测试"); + u.setUniversityName("测试大学"); u.setMajorName("测试专业"); u.setGrade("2026"); + u.setPhone(UUID.randomUUID().toString().substring(0,20)); u.setPassword("test-only"); users.save(u); } + return null; }); + return ids; + } + @Test void repeatedFriendRequestAndGreetingDoNotDuplicateRelations() throws Exception { + String[] ids = seedUserPair(); + java.util.concurrent.atomic.AtomicInteger success = new java.util.concurrent.atomic.AtomicInteger(); + parallel(8, i -> { login(ids[0]); try { + tx.execute(s -> { com.campus.dto.request.GreetRequest r = new com.campus.dto.request.GreetRequest(); + r.setFriendId(ids[1]); friendService.greetUser(r); return null; }); success.incrementAndGet(); + } catch (com.campus.common.BusinessException e) { assertEquals("已经打过招呼了", e.getMessage()); } }); + assertEquals(1, success.get()); + tx.execute(s -> { assertEquals(1, friendships.findByUserIdAndStatus(ids[0], "PENDING").size()); + assertTrue(greetings.existsByUserIdAndTargetUserId(ids[0], ids[1])); return null; }); + } + @Test void concurrentAcceptThroughBothRoutesOnlyCreatesOneReverseRelation() throws Exception { + String[] ids = seedUserPair(); login(ids[0]); + String requestId = tx.execute(s -> { com.campus.dto.request.AddFriendRequest r = new com.campus.dto.request.AddFriendRequest(); + r.setFriendId(ids[1]); return friendService.addFriend(r).getId(); }); + java.util.concurrent.atomic.AtomicInteger accepted = new java.util.concurrent.atomic.AtomicInteger(); + parallel(8, i -> { login(ids[1]); try { + tx.execute(s -> i%2==0 ? friendService.acceptFriendRequest(requestId) : friendService.acceptFriendRequestByUserId(ids[0])); + accepted.incrementAndGet(); + } catch (com.campus.common.BusinessException e) { assertEquals("该好友请求已被处理", e.getMessage()); } }); + assertEquals(1, accepted.get()); + login(ids[1]); + assertEquals("该好友请求已被处理", assertThrows(com.campus.common.BusinessException.class, + () -> tx.execute(s -> { friendService.rejectFriendRequest(requestId); return null; })).getMessage()); + tx.execute(s -> { assertEquals(1, friendships.findByUserIdAndStatus(ids[0], "ACCEPTED").size()); + assertEquals(1, friendships.findByUserIdAndStatus(ids[1], "ACCEPTED").size()); return null; }); + } + + + @Test void unrelatedStaleEditCannotOverwriteNewLikeCounter() throws Exception { + String id = seedMoment(); + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + tx.execute(s -> { + Moment stale = moments.findById(id).get(); + try { pool.submit(() -> { login("another-user"); try { tx.execute(t -> service.toggleLike(id)); } + finally { SecurityContextHolder.clearContext(); } }).get(20, TimeUnit.SECONDS); } + catch (Exception e) { throw new RuntimeException(e); } + stale.setContent("并发资料编辑"); moments.save(stale); return null; + }); + tx.execute(s -> { assertEquals(1, moments.findById(id).get().getLikeCount()); return null; }); + } finally { pool.shutdownNow(); } + } + @Test void concurrentCommentsPreserveDetailAndCounterTogether() throws Exception { + String id = seedMoment(); + parallel(8, i -> { login("commenter-"+i); tx.execute(s -> { + com.campus.dto.request.CommentRequest r = new com.campus.dto.request.CommentRequest(); + r.setMomentId(id); r.setContent("并发评论"+i); return service.addComment(id, r); + }); }); + tx.execute(s -> { assertEquals(8, moments.findById(id).get().getCommentCount()); + assertEquals(8L, comments.countByMomentIdAndStatus(id, "ACTIVE")); return null; }); + } + + @Test @Order(100) void migrationDeduplicatesRepairsCountersAndCanBeRepeated() throws Exception { + String mid = seedMoment(); + String cid = tx.execute(s -> { MomentComment c = new MomentComment(); c.setMomentId(mid); c.setUserId("owner"); + c.setContent("迁移测试"); c.setStatus("ACTIVE"); c.setLikeCount(99); return comments.saveAndFlush(c).getId(); }); + try (java.sql.Connection connection = dataSource.getConnection(); java.sql.Statement statement = connection.createStatement()) { + statement.execute("ALTER TABLE moment_comment_like DROP INDEX uk_clike_comment_user"); + for (int i=0;i<2;i++) { + statement.execute("INSERT INTO moment_comment_like (id,comment_id,user_id,created_at,updated_at,deleted) VALUES ('" + +UUID.randomUUID()+"','"+cid+"','migration-user',NOW(),NOW(),0)"); + } + String sql; + try (java.io.InputStream input = getClass().getResourceAsStream("/db/migration/V19__fix_interaction_duplicates_and_counters.sql")) { + assertNotNull(input); + sql = new String(org.springframework.util.StreamUtils.copyToByteArray(input), java.nio.charset.StandardCharsets.UTF_8); + } + sql = sql.replaceAll("--[^\n]*", ""); + for (int run=0;run<2;run++) for (String command : sql.split(";")) { + if (!command.trim().isEmpty()) statement.execute(command.trim()); + } + tx.execute(s -> { assertEquals(1L, commentLikes.countByCommentId(cid)); + assertEquals(1, comments.findById(cid).get().getLikeCount()); + assertEquals(1, moments.findById(mid).get().getCommentCount()); return null; }); + // 状态冲突必须中止,不能悄悄选一条好友关系删除。 + statement.execute("ALTER TABLE friendship DROP INDEX idx_friendship_unique"); + statement.execute("INSERT INTO friendship(id,user_id,friend_id,status,created_at,updated_at,deleted) VALUES " + +"('conflict-a','a','b','PENDING',NOW(),NOW(),0),('conflict-b','a','b','ACCEPTED',NOW(),NOW(),0)"); + assertThrows(java.sql.SQLException.class, () -> { + for (String command : sqlCommands()) if (!command.trim().isEmpty()) statement.execute(command.trim()); + }); + try (java.sql.ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM friendship WHERE user_id='a' AND friend_id='b'")) { + assertTrue(result.next()); assertEquals(2, result.getInt(1)); + } + } + } + private String[] sqlCommands() throws java.io.IOException { + try (java.io.InputStream input = getClass().getResourceAsStream("/db/migration/V19__fix_interaction_duplicates_and_counters.sql")) { + return new String(org.springframework.util.StreamUtils.copyToByteArray(input), java.nio.charset.StandardCharsets.UTF_8) + .replaceAll("--[^\n]*", "").split(";"); + } + } + +} diff --git a/campus-server/src/test/java/com/campus/service/InteractionRateLimitTest.java b/campus-server/src/test/java/com/campus/service/InteractionRateLimitTest.java new file mode 100644 index 0000000..4bf399b --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/InteractionRateLimitTest.java @@ -0,0 +1,36 @@ +package com.campus.service; + +import com.campus.common.TooManyRequestsException; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.data.redis.serializer.RedisSerializer; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class InteractionRateLimitTest { + @Test void normalRapidRequestsAreAllowedButAbnormalRateIsRejected() { + RedisTemplate redis = mock(RedisTemplate.class); + RedisRetryExecutor retries = mock(RedisRetryExecutor.class); + when(redis.execute(any(RedisScript.class), any(RedisSerializer.class), any(RedisSerializer.class), + eq(Collections.singletonList("click-guard:rate:moment-like:u")), eq("5000"))) + .thenReturn(1L, 2L, 20L, 21L); + ClickGuardService service = new ClickGuardService(redis, retries); + for (int i=0; i<3; i++) assertDoesNotThrow(() -> service.ensureWithinRate("moment-like", "u", 5000, 20)); + assertEquals("点击过于频繁,操作失败", assertThrows(TooManyRequestsException.class, + () -> service.ensureWithinRate("moment-like", "u", 5000, 20)).getMessage()); + verifyNoInteractions(retries); + } + + @Test void redisFailureIsFailOpenWithoutReplayingIncrement() { + RedisTemplate redis = mock(RedisTemplate.class); + RedisRetryExecutor retries = mock(RedisRetryExecutor.class); + when(redis.execute(any(RedisScript.class), any(RedisSerializer.class), any(RedisSerializer.class), + anyList(), eq("5000"))).thenThrow(new DataAccessResourceFailureException("测试断线")); + assertDoesNotThrow(() -> new ClickGuardService(redis, retries).ensureWithinRate("moment-like", "u", 5000, 20)); + verifyNoInteractions(retries); + } +} diff --git a/campus-server/src/test/java/com/campus/service/LoginSecurityServiceTest.java b/campus-server/src/test/java/com/campus/service/LoginSecurityServiceTest.java new file mode 100644 index 0000000..fb142b7 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/LoginSecurityServiceTest.java @@ -0,0 +1,53 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LoginSecurityServiceTest { + @Mock private RedisTemplate redisTemplate; + @Mock private ValueOperations values; + private LoginSecurityService service; + + @BeforeEach + void setUp() { + service = new LoginSecurityService(redisTemplate, new RedisRetryExecutor()); + } + + @Test + void fifthFailureLocksLoginForFifteenMinutes() { + when(redisTemplate.opsForValue()).thenReturn(values); + when(values.increment("login:failed:18800000000")).thenReturn(5L); + + assertEquals(true, service.recordFailure("18800000000")); + + verify(values).set("login:locked:18800000000", true, 15, TimeUnit.MINUTES); + verify(redisTemplate).delete("login:failed:18800000000"); + } + + @Test + void lockedPhoneIsRejectedBeforePasswordCheck() { + when(redisTemplate.hasKey("login:locked:18800000000")).thenReturn(true); + assertThrows(BusinessException.class, () -> service.checkNotLocked("18800000000")); + } + + @Test + void successfulLoginClearsFailureAndLockKeys() { + service.clearFailures("18800000000"); + verify(redisTemplate).delete("login:failed:18800000000"); + verify(redisTemplate).delete("login:locked:18800000000"); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ModerationServiceTest.java b/campus-server/src/test/java/com/campus/service/ModerationServiceTest.java new file mode 100644 index 0000000..db17bbf --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ModerationServiceTest.java @@ -0,0 +1,89 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.BatchContentReviewRequest; +import com.campus.entity.ContentReview; +import com.campus.entity.Moment; +import com.campus.entity.User; +import com.campus.repository.ActivityRepository; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.ContentReviewRepository; +import com.campus.repository.MomentRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ModerationServiceTest { + @Mock private ContentReviewRepository reviewRepository; + @Mock private MomentRepository momentRepository; + @Mock private ActivityRepository activityRepository; + @Mock private AnnouncementRepository announcementRepository; + @Mock private NotificationService notificationService; + @Mock private AuditLogService auditLogService; + private ModerationService service; + + @BeforeEach + void setUp() { + authenticateReviewer(); + service = new ModerationService(reviewRepository, momentRepository, activityRepository, + announcementRepository, new AuthorizationService(), notificationService, auditLogService); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void manualReviewUpdatesContentAndCreatesHistoryAuditAndNotification() { + Moment moment = new Moment(); + moment.setId("moment"); + moment.setUserId("owner"); + moment.setDeleted(true); + moment.setReviewStatus("rejected"); + when(momentRepository.findIncludingDeleted("moment")).thenReturn(Optional.of(moment)); + when(momentRepository.save(moment)).thenReturn(moment); + when(reviewRepository.save(any(ContentReview.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + assertEquals("approved", service.review("moment", "moment", true, null).getStatus()); + assertEquals(false, moment.getDeleted()); + verify(notificationService).createNotification(eq("owner"), eq("MOMENT_REVIEW_APPROVED"), + any(), any(), any(), any(), eq("moment"), eq("reviewer")); + verify(auditLogService).record("CONTENT_REVIEW", "MOMENT", "moment", + null, "status=approved", null); + } + + @Test + void batchLimitPreventsOversizedTransaction() { + BatchContentReviewRequest request = new BatchContentReviewRequest(); + request.setItems(new ArrayList<>()); + for (int i = 0; i < 101; i++) request.getItems().add(new BatchContentReviewRequest.Item()); + assertThrows(BusinessException.class, () -> service.batchReview(request)); + } + + private void authenticateReviewer() { + User user = new User(); + user.setId("reviewer"); + user.setIsReviewer(true); + user.setIsAdmin(false); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } +} diff --git a/campus-server/src/test/java/com/campus/service/MomentAnonymousTest.java b/campus-server/src/test/java/com/campus/service/MomentAnonymousTest.java new file mode 100644 index 0000000..6ac9b19 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/MomentAnonymousTest.java @@ -0,0 +1,68 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.MomentRequest; +import com.campus.dto.response.MomentCommentDTO; +import com.campus.dto.response.MomentDTO; +import com.campus.entity.Moment; +import com.campus.entity.MomentComment; +import com.campus.entity.User; +import com.campus.repository.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MomentAnonymousTest { + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + private void login() { + User u = new User(); u.setId("owner"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(u, null)); + } + private Moment moment() { + Moment m = new Moment(); m.setId("m"); m.setUserId("owner"); m.setAnonymous(true); return m; + } + @Test void anonymousDetailHidesIdentityButRetainsOwnership() { + login(); + MomentService service = mock(MomentService.class, CALLS_REAL_METHODS); + ReflectionTestUtils.setField(service, "momentFavoriteRepository", mock(MomentFavoriteRepository.class)); + ReflectionTestUtils.setField(service, "momentLikeRepository", mock(MomentLikeRepository.class)); + ReflectionTestUtils.setField(service, "videoPlayRecordRepository", mock(VideoPlayRecordRepository.class)); + MomentDTO dto = ReflectionTestUtils.invokeMethod(service, "toDTO", moment()); + assertNull(dto.getUserId()); assertNull(dto.getUserAvatar()); + assertEquals("匿名用户", dto.getUserName()); assertTrue(dto.getOwnedByCurrentUser()); + } + @Test void anonymousAuthorReplyAndParentAreMasked() { + MomentLikeCommentService service = mock(MomentLikeCommentService.class, CALLS_REAL_METHODS); + MomentRepository moments = mock(MomentRepository.class); + MomentCommentRepository comments = mock(MomentCommentRepository.class); + UserRepository users = mock(UserRepository.class); + ReflectionTestUtils.setField(service, "momentRepository", moments); + ReflectionTestUtils.setField(service, "momentCommentRepository", comments); + ReflectionTestUtils.setField(service, "userRepository", users); + ReflectionTestUtils.setField(service, "momentCommentLikeRepository", mock(MomentCommentLikeRepository.class)); + when(moments.findById("m")).thenReturn(Optional.of(moment())); + User owner = new User(); owner.setNickname("真实昵称"); owner.setAvatar("real-avatar"); + when(users.findById("owner")).thenReturn(Optional.of(owner)); + MomentComment c = new MomentComment(); c.setId("c"); c.setMomentId("m"); c.setUserId("owner"); c.setParentId("parent"); + when(comments.findById("parent")).thenReturn(Optional.of(c)); + MomentCommentDTO dto = ReflectionTestUtils.invokeMethod(service, "toCommentDTO", c, "owner", true); + assertNull(dto.getUserId()); assertNull(dto.getUserAvatar()); + assertEquals("匿名楼主", dto.getUserName()); assertEquals("匿名楼主", dto.getParentNickname()); + assertTrue(dto.getIsAnonymousAuthor()); assertTrue(dto.getOwnedByCurrentUser()); + } + @Test void changingAnonymousStateFailsBeforeWrite() { + MomentService service = mock(MomentService.class, CALLS_REAL_METHODS); + MomentRepository repo = mock(MomentRepository.class); + ReflectionTestUtils.setField(service, "momentRepository", repo); + ReflectionTestUtils.setField(service, "authorizationService", mock(AuthorizationService.class)); + when(repo.findById("m")).thenReturn(Optional.of(moment())); + MomentRequest request = new MomentRequest(); request.setAnonymous(false); + BusinessException e = assertThrows(BusinessException.class, () -> service.update("m", request)); + assertEquals("发布后不支持修改匿名状态", e.getMessage()); verify(repo, never()).save(any()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/MomentFavoriteCountTest.java b/campus-server/src/test/java/com/campus/service/MomentFavoriteCountTest.java new file mode 100644 index 0000000..67d4a2c --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/MomentFavoriteCountTest.java @@ -0,0 +1,137 @@ +package com.campus.service; + +import com.campus.common.PageResult; +import com.campus.dto.response.MomentDTO; +import com.campus.entity.Moment; +import com.campus.entity.MomentViewRecord; +import com.campus.entity.User; +import com.campus.repository.MomentFavoriteRepository; +import com.campus.repository.MomentLikeRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.MomentViewRecordRepository; +import com.campus.repository.VideoPlayRecordRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MomentFavoriteCountTest { + @Mock private MomentRepository momentRepository; + @Mock private MomentFavoriteRepository momentFavoriteRepository; + @Mock private MomentLikeRepository momentLikeRepository; + @Mock private MomentViewRecordRepository momentViewRecordRepository; + @Mock private VideoPlayRecordRepository videoPlayRecordRepository; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private VideoPlayService videoPlayService; + @InjectMocks private MomentService momentService; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void listIncludesCountsAndDefaultsMissingRowsToZero() { + Moment first = moment("first"); + Moment second = moment("second"); + when(privacySettingsService.blockedUserIds(null)) + .thenReturn(Collections.emptySet()); + when(momentRepository.findFeedMoments(any(), any(Pageable.class))) + .thenReturn(new PageImpl<>(Arrays.asList(first, second))); + when(momentFavoriteRepository.countByMomentIds(anyCollection())) + .thenReturn(Collections.singletonList(new Object[]{"first", 3L})); + + PageResult page = momentService.list(null, 1, 20); + + assertEquals(3L, page.getItems().get(0).getFavoriteCount()); + assertEquals(0L, page.getItems().get(1).getFavoriteCount()); + verify(momentFavoriteRepository).countByMomentIds(anyCollection()); + } + + @Test + void detailIncludesFavoriteCount() { + Moment moment = moment("first"); + when(momentRepository.findById("first")).thenReturn(Optional.of(moment)); + when(momentRepository.findByIdForView("first")).thenReturn(Optional.of(moment)); + when(momentFavoriteRepository.countByMomentIdAndDeletedFalse("first")).thenReturn(3L); + + assertEquals(3L, momentService.getById("first").getFavoriteCount()); + } + + @Test + void regularDetailCountsOnceWithinThirtyMinutes() { + authenticate(); + Moment moment = moment("first"); + moment.setViewCount(1); + when(momentRepository.findById("first")).thenReturn(Optional.of(moment)); + when(momentRepository.findByIdForView("first")).thenReturn(Optional.of(moment)); + MomentViewRecord record = new MomentViewRecord(); + record.setLastViewTime(new Date()); + when(momentViewRecordRepository.findByMomentIdAndUserId("first", "viewer")) + .thenReturn(Optional.empty(), Optional.of(record), Optional.of(record)); + + assertEquals(2, momentService.getById("first").getViewCount()); + assertEquals(2, momentService.getById("first").getViewCount()); + record.setLastViewTime(new Date(System.currentTimeMillis() - 31 * 60 * 1000L)); + assertEquals(3, momentService.getById("first").getViewCount()); + verify(momentViewRecordRepository, org.mockito.Mockito.times(2)).save(any(MomentViewRecord.class)); + } + + @Test + void videoDetailUsesVideoPlaybackCounter() { + Moment video = moment("video"); + video.setIsYouthVideo(true); + when(momentRepository.findById("video")).thenReturn(Optional.of(video)); + when(videoPlayService.recordVideoOpen("video")).thenReturn(video); + + assertEquals(0, momentService.getById("video").getViewCount()); + verify(videoPlayService).recordVideoOpen("video"); + } + + @Test + void legacyVideoWithPlaybackUrlUsesVideoCounter() { + Moment video = moment("legacy-video"); + video.setPlayUrl("https://example.test/video.m3u8"); + when(momentRepository.findById("legacy-video")).thenReturn(Optional.of(video)); + when(videoPlayService.recordVideoOpen("legacy-video")).thenReturn(video); + + momentService.getById("legacy-video"); + + verify(videoPlayService).recordVideoOpen("legacy-video"); + } + + private Moment moment(String id) { + Moment moment = new Moment(); + moment.setId(id); + moment.setUserId("owner"); + moment.setAnonymous(true); + // 本测试只验证收藏/浏览计数;公开可见性要求已过审(未过审对非作者不可见,见 MomentReviewVisibilityTest) + moment.setReviewStatus("approved"); + return moment; + } + + private void authenticate() { + User user = new User(); + user.setId("viewer"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } +} diff --git a/campus-server/src/test/java/com/campus/service/MomentReviewVisibilityTest.java b/campus-server/src/test/java/com/campus/service/MomentReviewVisibilityTest.java new file mode 100644 index 0000000..c4f558c --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/MomentReviewVisibilityTest.java @@ -0,0 +1,334 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.MomentRequest; +import com.campus.dto.response.MomentDTO; +import com.campus.entity.Moment; +import com.campus.entity.MomentFavorite; +import com.campus.entity.User; +import com.campus.repository.MomentFavoriteRepository; +import com.campus.repository.MomentCommentRepository; +import com.campus.repository.MomentLikeRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.MomentViewRecordRepository; +import com.campus.repository.UniversityRepository; +import com.campus.repository.UserBlockedMomentRepository; +import com.campus.repository.UserRepository; +import com.campus.repository.VideoLikeRepository; +import com.campus.repository.VideoPlayRecordRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 动态审核结果的「可见性」回归测试。 + * + *

背景:自动审核早已把含敏感词的动态判为 rejected(content_review / moment.review_status 都正确), + * 但公开列表走 findAll/findByScope 不看审核状态,导致被驳回的动态仍然出现在推荐/校园/关注流里。 + * 本测试锁定三条口径:公开列表只出 approved、他人不可直接看未过审详情、编辑后必须重新审核。

+ */ +@ExtendWith(MockitoExtension.class) +class MomentReviewVisibilityTest { + + @Mock private MomentRepository momentRepository; + @Mock private MomentLikeRepository momentLikeRepository; + @Mock private MomentFavoriteRepository momentFavoriteRepository; + @Mock private MomentCommentRepository momentCommentRepository; + @Mock private MomentViewRecordRepository momentViewRecordRepository; + @Mock private VideoPlayService videoPlayService; + @Mock private ContentReviewService contentReviewService; + @Mock private PointsService pointsService; + @Mock private UserRepository userRepository; + @Mock private UniversityRepository universityRepository; + @Mock private VideoLikeRepository videoLikeRepository; + @Mock private VideoPlayRecordRepository videoPlayRecordRepository; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private FollowService followService; + @Mock private AuthorizationService authorizationService; + @Mock private NotificationService notificationService; + @Mock private UserRestrictionService userRestrictionService; + @Mock private UserBlockedMomentRepository blockedMomentRepository; + + @InjectMocks private MomentService service; + + @BeforeEach + void stubShared() { + lenient().when(privacySettingsService.blockedUserIds(any())).thenReturn(Collections.emptySet()); + lenient().when(followService.getFollowedUserIds(any())).thenReturn(Collections.emptySet()); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + // ==================== 公开列表只出 approved ==================== + + @Test + void publicListQueriesApprovedMomentsForScope() { + Moment approved = moment("m1", "author", "approved"); + when(momentRepository.findFeedMomentsByScope(eq("approved"), eq("CAMPUS"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(approved))); + + PageResult result = service.list("CAMPUS", 1, 20); + + assertEquals(1, result.getItems().size()); + assertEquals("m1", result.getItems().get(0).getId()); + verify(momentRepository).findFeedMomentsByScope(eq("approved"), eq("CAMPUS"), any(Pageable.class)); + verify(momentRepository, never()).findAll(any(Pageable.class)); + verify(momentRepository, never()).findByScope(anyString(), any(Pageable.class)); + } + + @Test + void publicListWithoutScopeQueriesApprovedMoments() { + Moment approved = moment("m1", "author", "approved"); + when(momentRepository.findFeedMoments(eq("approved"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(approved))); + + PageResult result = service.list(null, 1, 20); + + assertEquals(1, result.getItems().size()); + verify(momentRepository).findFeedMoments(eq("approved"), any(Pageable.class)); + verify(momentRepository, never()).findAll(any(Pageable.class)); + } + + @Test + void recommendationRanksInterestMatchAheadOfNewerNonMatchingMoment() { + login("me"); + User me = new User(); + me.setInterests(Collections.singletonList("学习交流")); + when(userRepository.findById("me")).thenReturn(Optional.of(me)); + when(followService.getFollowedUserIds("me")).thenReturn(Collections.emptySet()); + when(momentLikeRepository.findRecentMomentIdsByUser(eq("me"), any())).thenReturn(Collections.emptyList()); + when(momentFavoriteRepository.findRecentMomentIdsByUser(eq("me"), any())).thenReturn(Collections.emptyList()); + when(momentCommentRepository.findRecentMomentIdsByUser(eq("me"), any())).thenReturn(Collections.emptyList()); + when(blockedMomentRepository.findByUserId("me")).thenReturn(Collections.emptyList()); + Moment matched = moment("matched", "author1", "approved"); + matched.setCategory("学习交流"); + matched.setCreatedAt(new java.util.Date(System.currentTimeMillis() - 2L * 24 * 60 * 60 * 1000)); + Moment newer = moment("newer", "author2", "approved"); + newer.setCategory("二手市场"); + newer.setCreatedAt(new java.util.Date()); + when(momentRepository.findRecommendationCandidates(any(), any())).thenReturn(Arrays.asList(newer, matched)); + stubBatchDtoDependencies(); + + PageResult result = service.list("CAMPUS", "recommend", 1, 20); + + assertEquals("matched", result.getItems().get(0).getId()); + assertEquals(2L, result.getTotal()); + } + + @Test + void rejectsUnknownMomentSortWithExactMessageAndNoRepositoryRead() { + BusinessException error = assertThrows(BusinessException.class, + () -> service.list("CAMPUS", "unknown", 1, 20)); + + assertEquals("排序方式无效", error.getMessage()); + verifyNoInteractions(momentRepository); + } + + @Test + void hotListUsesRecentLikesAndCommentsInsteadOfLifetimeCounters() { + login("me"); + Moment moreRecentLikes = moment("liked", "author1", "approved"); + moreRecentLikes.setLikeCount(0); + moreRecentLikes.setCreatedAt(new java.util.Date(System.currentTimeMillis() - 5L * 60 * 1000)); + Moment moreRecentComments = moment("commented", "author2", "approved"); + moreRecentComments.setLikeCount(1000); + moreRecentComments.setCreatedAt(new java.util.Date()); + when(momentRepository.findHotCandidates(isNull(), any())).thenReturn(Arrays.asList(moreRecentComments, moreRecentLikes)); + when(momentLikeRepository.sumRecentHeatByMomentIds(anyList(), any(), any())).thenReturn( + Collections.singletonList(new Object[]{"liked", 4.0})); + when(momentCommentRepository.sumRecentHeatByMomentIds(anyList(), any(), any())).thenReturn( + Collections.singletonList(new Object[]{"commented", 1.0})); + when(blockedMomentRepository.findByUserId("me")).thenReturn(Collections.emptyList()); + stubBatchDtoDependencies(); + + PageResult result = service.listHot(null, 1, 20); + + assertEquals("liked", result.getItems().get(0).getId()); + assertEquals(2L, result.getTotal()); + } + + private void stubBatchDtoDependencies() { + lenient().when(momentLikeRepository.findByMomentIdInAndUserId(anyList(), anyString())).thenReturn(Collections.emptyList()); + lenient().when(momentFavoriteRepository.findByMomentIdInAndUserId(anyList(), anyString())).thenReturn(Collections.emptyList()); + lenient().when(momentFavoriteRepository.countByMomentIds(anyList())).thenReturn(Collections.emptyList()); + lenient().when(videoPlayRecordRepository.findByVideoIdInAndUserId(anyList(), anyString())).thenReturn(Collections.emptyList()); + lenient().when(userRepository.findAllById(anyList())).thenReturn(Collections.emptyList()); + } + + // ==================== 详情页不可越权查看未过审内容 ==================== + + @Test + void rejectedMomentDetailHiddenFromOtherUsers() { + login("other"); + Moment rejected = moment("m1", "author", "rejected"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(rejected)); + when(authorizationService.isReviewerOrAdmin(any())).thenReturn(false); + + BusinessException error = assertThrows(BusinessException.class, () -> service.getById("m1")); + + assertEquals("动态不存在", error.getMessage()); + verifyNoInteractions(videoPlayService); + verify(momentRepository, never()).findByIdForView(anyString()); + } + + @Test + void rejectedMomentDetailVisibleToAuthor() { + login("author"); + Moment rejected = moment("m1", "author", "rejected"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(rejected)); + when(momentRepository.findByIdForView("m1")).thenReturn(Optional.of(rejected)); + + MomentDTO dto = service.getById("m1"); + + assertEquals("rejected", dto.getReviewStatus()); + } + + // ==================== 编辑必须重新审核 ==================== + + @Test + void updateResetsReviewStatusAndReRunsAutoReview() { + login("author"); + Moment existing = moment("m1", "author", "approved"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(existing)); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> inv.getArgument(0)); + MomentRequest request = request("法轮功"); + + MomentDTO dto = service.update("m1", request); + + assertEquals("pending", dto.getReviewStatus()); + verify(momentRepository).save(existing); + verify(contentReviewService).reviewMomentSync(eq("m1"), eq("法轮功"), isNull()); + } + + // ==================== 发布:被驳回不算发布成功,不发积分 ==================== + + @Test + void rejectedMomentKeepsRejectedStatusAndAwardsNoPoints() { + login("author"); + AtomicReference saved = new AtomicReference<>(); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> { + Moment value = inv.getArgument(0); + if (value.getId() == null) { + // 真实 JPA save 会分配 UUID 主键,这里模拟同样的行为 + value.setId("m1"); + } + saved.set(value); + return value; + }); + when(momentRepository.findById(anyString())).thenAnswer(inv -> Optional.ofNullable(saved.get())); + doAnswer(inv -> { + saved.get().setReviewStatus("rejected"); + saved.get().setReviewReason("包含敏感词:法轮功"); + return null; + }).when(contentReviewService).reviewMomentSync(anyString(), eq("法轮功"), any()); + + MomentDTO dto = service.create(request("法轮功")); + + assertEquals("rejected", dto.getReviewStatus()); + assertEquals("包含敏感词:法轮功", dto.getReviewReason()); + verify(pointsService, never()).onPublishMoment(anyString(), anyString(), anyBoolean()); + verify(userRestrictionService).ensureAllowed("author", "PUBLISH"); + } + + // ==================== 收藏与主页:不露出被驳回内容,但本人可见 ==================== + + @Test + void favoriteListHidesRejectedMoments() { + login("me"); + MomentFavorite first = favorite("m1"); + MomentFavorite second = favorite("m2"); + when(momentFavoriteRepository.findActiveByUserIdOrderByCreatedAtDesc(eq("me"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Arrays.asList(first, second))); + when(momentRepository.findAllById(anyList())).thenReturn(Arrays.asList( + moment("m1", "u1", "approved"), + moment("m2", "u2", "rejected"))); + + PageResult result = service.listMyFavorites(1, 20); + + assertEquals(1, result.getItems().size()); + assertEquals("m1", result.getItems().get(0).getId()); + } + + @Test + void myOwnMomentsStillShowRejected() { + login("me"); + when(momentRepository.findFeedMomentsByUser(eq("me"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(moment("m1", "me", "rejected")))); + + PageResult result = service.listMyMoments(1, 20); + + assertEquals(1, result.getItems().size()); + assertEquals("rejected", result.getItems().get(0).getReviewStatus()); + } + + // ==================== 工具方法 ==================== + + private void login(String userId) { + User user = new User(); + user.setId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + private MomentRequest request(String content) { + MomentRequest request = new MomentRequest(); + request.setContent(content); + request.setCategory("校园求助"); + request.setScope("CAMPUS"); + return request; + } + + private MomentFavorite favorite(String momentId) { + MomentFavorite favorite = new MomentFavorite(); + favorite.setMomentId(momentId); + return favorite; + } + + private Moment moment(String id, String userId, String reviewStatus) { + Moment moment = new Moment(); + moment.setId(id); + moment.setUserId(userId); + moment.setContent("动态内容"); + moment.setReviewStatus(reviewStatus); + moment.setAnonymous(false); + moment.setIsYouthVideo(false); + moment.setViewCount(0); + moment.setLikeCount(0); + moment.setCommentCount(0); + moment.setShareCount(0); + moment.setDeleted(false); + return moment; + } +} diff --git a/campus-server/src/test/java/com/campus/service/MomentShareCountServiceTest.java b/campus-server/src/test/java/com/campus/service/MomentShareCountServiceTest.java new file mode 100644 index 0000000..ef28ba2 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/MomentShareCountServiceTest.java @@ -0,0 +1,50 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.User; +import com.campus.repository.MomentRepository; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MomentShareCountServiceTest { + @Mock MomentRepository moments; + @Mock ShareRequestIdempotencyService requests; + @InjectMocks MomentShareCountService service; + + @BeforeEach void login() { + User u = new User(); u.setId("u"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(u, null)); + } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + @Test void completedRequestDoesNotIncrementAgain() { + when(requests.claim(eq("u"), eq("MOMENT_COUNT"), eq("key"), anyString())) + .thenReturn(new ShareRequestIdempotencyService.Claim(false, true, "r", "{}")); + service.incrementShare("m", "key"); + verifyNoInteractions(moments); + } + + @Test void newRequestIncrementsAndCompletesOnce() { + when(requests.claim(eq("u"), eq("MOMENT_COUNT"), eq("key"), anyString())) + .thenReturn(new ShareRequestIdempotencyService.Claim(true, false, "r", null)); + when(moments.incrementShareCount("m")).thenReturn(1); + service.incrementShare("m", "key"); + verify(moments).incrementShareCount("m"); + verify(requests).complete("r", "{}"); + } + + @Test void missingMomentDoesNotCompleteRequest() { + assertEquals("动态不存在", assertThrows(BusinessException.class, + () -> service.incrementShare("missing", null)).getMessage()); + verifyNoInteractions(requests); + } +} diff --git a/campus-server/src/test/java/com/campus/service/MomentTagQueryTest.java b/campus-server/src/test/java/com/campus/service/MomentTagQueryTest.java new file mode 100644 index 0000000..4789d7f --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/MomentTagQueryTest.java @@ -0,0 +1,111 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.response.MomentDTO; +import com.campus.entity.Moment; +import com.campus.repository.MomentFavoriteRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** 动态域按标签筛选(话题详情页的数据入口)。 */ +@ExtendWith(MockitoExtension.class) +class MomentTagQueryTest { + + @Mock private MomentRepository momentRepository; + @Mock private com.campus.service.PrivacySettingsService privacySettingsService; + @Mock private UserRepository userRepository; + @Mock private MomentFavoriteRepository momentFavoriteRepository; + + @InjectMocks private MomentService momentService; + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void blankTagIsRejectedWithoutQuery() { + BusinessException error = assertThrows(BusinessException.class, + () -> momentService.listByTag(" ", 1, 20)); + + assertEquals("标签不能为空", error.getMessage()); + verifyNoInteractions(momentRepository); + } + + @Test + void multiTagIsRejectedBecauseOnlyExactMatchIsSupported() { + BusinessException error = assertThrows(BusinessException.class, + () -> momentService.listByTag("校园日常,学习成长", 1, 20)); + + assertEquals("一次只能按一个标签查询", error.getMessage()); + verifyNoInteractions(momentRepository); + } + + @Test + void overlongTagIsRejected() { + String tag = String.join("", Collections.nCopies(101, "话")); + + BusinessException error = assertThrows(BusinessException.class, + () -> momentService.listByTag(tag, 1, 20)); + + assertEquals("标签长度不能超过100个字符", error.getMessage()); + verifyNoInteractions(momentRepository); + } + + @Test + void exactTagQueryReturnsPagedMoments() { + when(momentRepository.findApprovedByExactTag(eq("校园日常"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(moment()))); + + PageResult result = momentService.listByTag("校园日常", 1, 20); + + assertEquals(1, result.getTotal()); + assertEquals(1, result.getItems().size()); + assertEquals(2, result.getItems().get(0).getTags().size()); + verify(momentRepository).findApprovedByExactTag(eq("校园日常"), any(Pageable.class)); + } + + @Test + void leadingHashIsStrippedBeforeQuery() { + when(momentRepository.findApprovedByExactTag(eq("校园日常"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(moment()))); + + momentService.listByTag("#校园日常", 1, 20); + + verify(momentRepository).findApprovedByExactTag(eq("校园日常"), any(Pageable.class)); + } + + private Moment moment() { + Moment moment = new Moment(); + moment.setId("m1"); + moment.setUserId("u1"); + moment.setAnonymous(false); + moment.setTags("校园日常,学习成长"); + moment.setReviewStatus("approved"); + moment.setLikeCount(0); + moment.setCommentCount(0); + moment.setShareCount(0); + moment.setViewCount(0); + return moment; + } +} diff --git a/campus-server/src/test/java/com/campus/service/MomentTopicDiscussionTest.java b/campus-server/src/test/java/com/campus/service/MomentTopicDiscussionTest.java new file mode 100644 index 0000000..ca99850 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/MomentTopicDiscussionTest.java @@ -0,0 +1,671 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.common.PageResult; +import com.campus.dto.request.MomentRequest; +import com.campus.dto.response.MomentDTO; +import com.campus.entity.Moment; +import com.campus.entity.User; +import com.campus.repository.MomentFavoriteRepository; +import com.campus.repository.MomentLikeRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.MomentViewRecordRepository; +import com.campus.repository.UniversityRepository; +import com.campus.repository.UserBlockedMomentRepository; +import com.campus.repository.UserRepository; +import com.campus.repository.VideoLikeRepository; +import com.campus.repository.VideoPlayRecordRepository; +import com.campus.service.review.ReviewResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 话题讨论的落库与查询口径(动态侧存储)。 + * + *

锁这些事:① 讨论不占用动态的分类体系(tags / category 为空);② 先审后存,命中违禁词不落库; + * ③ 讨论不计"发布动态"积分;④ 话题页列表只按 topic_id 查、排序参数受控; + * ⑤ 图片/视频的形态与结构校验(互斥、9 张上限、域名白名单、扩展名、videoId 唯一); + * ⑥ 视频只落 vodVideoId、播放地址等回调,{@code videoStatus} 派生正确;⑦ 编辑/删除的归属校验。

+ */ +@ExtendWith(MockitoExtension.class) +class MomentTopicDiscussionTest { + + /** 格式合法的 VOD videoId(阿里云为字母数字串)。 */ + private static final String VIDEO_ID = "3b1c8f0f5a9e4a0b8c7d6e5f4a3b2c1d"; + + @Mock private MomentRepository momentRepository; + @Mock private MomentLikeRepository momentLikeRepository; + @Mock private MomentFavoriteRepository momentFavoriteRepository; + @Mock private MomentViewRecordRepository momentViewRecordRepository; + @Mock private VideoPlayService videoPlayService; + @Mock private ContentReviewService contentReviewService; + @Mock private PointsService pointsService; + @Mock private UserRepository userRepository; + @Mock private UniversityRepository universityRepository; + @Mock private VideoLikeRepository videoLikeRepository; + @Mock private VideoPlayRecordRepository videoPlayRecordRepository; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private FollowService followService; + /** 用真实实现:403 的文案由 AuthorizationService 产出,不要在测试里自己造。 */ + @Spy private AuthorizationService authorizationService = new AuthorizationService(); + @Mock private NotificationService notificationService; + @Mock private UserRestrictionService userRestrictionService; + @Mock private UserBlockedMomentRepository blockedMomentRepository; + @Mock private VodService vodService; + + @InjectMocks private MomentService service; + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + // ==================== 基础口径(原有) ==================== + + @Test + void rejectsUnauthenticatedWithoutWriting() { + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "讨论测试", null, null, false)); + + assertEquals("用户未登录", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + verifyNoInteractions(contentReviewService); + } + + @Test + void rejectsBlankContentWithoutWriting() { + login("u1"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", " ", null, null, false)); + + assertEquals("讨论内容不能为空", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void rejectsOverlongContentWithoutWriting() { + login("u1"); + String content = String.join("", Collections.nCopies(2001, "话")); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", content, null, null, false)); + + assertEquals("讨论内容需要1到2000个字符", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void bannedWordsFailThePublishAndNothingIsSaved() { + login("u1"); + when(contentReviewService.previewMomentReview(eq("赌博"), any())) + .thenReturn(ReviewResult.rejected("包含敏感词:赌博")); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "赌博", null, null, false)); + + assertEquals("包含违禁词:赌博", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + verify(contentReviewService, never()).recordMomentApproval(anyString(), any()); + } + + @Test + void createsDiscussionWithoutDynamicClassification() { + login("u1"); + AtomicReference saved = new AtomicReference<>(); + when(contentReviewService.previewMomentReview(eq("讨论测试"), any())) + .thenReturn(ReviewResult.passed()); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> { + Moment value = inv.getArgument(0); + value.setId("m1"); + saved.set(value); + return value; + }); + + MomentDTO dto = service.createTopicDiscussion("t-100", "宿舍夜谈", "讨论测试", null, null, false); + + Moment stored = saved.get(); + assertEquals(Moment.SOURCE_TOPIC, stored.getContentSource()); + assertEquals("t-100", stored.getTopicId()); + assertNull(stored.getTags()); + assertNull(stored.getCategory()); + assertEquals("CAMPUS", stored.getScope()); + assertEquals("approved", stored.getReviewStatus()); + assertNull(stored.getVodVideoId()); + + assertEquals(Moment.SOURCE_TOPIC, dto.getContentSource()); + assertEquals("t-100", dto.getTopicId()); + assertEquals("TOPIC", dto.getContentSource()); + assertTrue(dto.getTags() == null || dto.getTags().isEmpty()); + assertNull(dto.getVideoStatus()); + + verify(contentReviewService).recordMomentApproval(eq("m1"), any(ReviewResult.class)); + verify(pointsService, never()).onPublishMoment(anyString(), anyString(), anyBoolean()); + verify(userRestrictionService).ensureAllowed("u1", "PUBLISH"); + verify(vodService, never()).asyncPullPlayInfo(anyString()); + } + + // ==================== 图片:结构校验(补上"文档承诺 9 张但代码不管"的缺口) ==================== + + @Test + void rejectsMoreThanNineImagesWithoutWriting() { + login("u1"); + List urls = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + urls.add(image(i)); + } + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "九张以上", urls, null, false)); + + assertEquals("图片最多只能上传9张", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + verifyNoInteractions(contentReviewService); + } + + @Test + void rejectsImageFromHostOutsideWhitelist() { + login("u1"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "外链图", + Collections.singletonList("https://evil.example.com/a.jpg"), null, false)); + + assertEquals("图片地址不合法", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void rejectsNonHttpImageUrl() { + login("u1"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "协议不对", + Collections.singletonList("javascript:alert(1)"), null, false)); + + assertEquals("图片地址不合法", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void rejectsUnsupportedImageExtension() { + login("u1"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "svg 图", + Collections.singletonList("https://oss.yoyoeco.com/images/2026/11/01/a.svg"), + null, false)); + + assertEquals("不支持的图片格式", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void acceptsNineImagesAndStoresThemCommaSeparated() { + login("u1"); + List urls = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + urls.add(image(i)); + } + AtomicReference saved = new AtomicReference<>(); + when(contentReviewService.previewMomentReview(anyString(), any())).thenReturn(ReviewResult.passed()); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> { + Moment value = inv.getArgument(0); + value.setId("m1"); + saved.set(value); + return value; + }); + + service.createTopicDiscussion("t-100", "宿舍夜谈", "九张图", urls, null, false); + + String[] stored = saved.get().getMediaUrls().split(","); + assertEquals(9, stored.length); + assertEquals(image(0), stored[0]); + assertNull(saved.get().getVodVideoId()); + } + + @Test + void duplicateImageUrlsAreDeduped() { + login("u1"); + AtomicReference saved = new AtomicReference<>(); + when(contentReviewService.previewMomentReview(anyString(), any())).thenReturn(ReviewResult.passed()); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> { + Moment value = inv.getArgument(0); + value.setId("m1"); + saved.set(value); + return value; + }); + + service.createTopicDiscussion("t-100", "宿舍夜谈", "重复图", + new ArrayList<>(Collections.nCopies(9, image(0))), null, false); + + assertEquals(image(0), saved.get().getMediaUrls()); + } + + // ==================== 视频:形态互斥 + ID 校验 + 只落 ID ==================== + + @Test + void rejectsImagesAndVideoTogether() { + login("u1"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "又要图又要视频", + Collections.singletonList(image(0)), VIDEO_ID, false)); + + assertEquals("图片和视频不能同时发布", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void rejectsMalformedVideoId() { + login("u1"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "坏 ID", null, "abc", false)); + + assertEquals("视频信息无效", error.getMessage()); + verify(momentRepository, never()).existsByVodVideoId(anyString()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void rejectsVideoAlreadyBoundToAnotherContent() { + login("u1"); + when(momentRepository.existsByVodVideoId(VIDEO_ID)).thenReturn(true); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopicDiscussion("t-100", "宿舍夜谈", "复用视频", null, VIDEO_ID, false)); + + assertEquals("该视频已被其他内容使用", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void createsVideoDiscussionWithoutAnyText() { + login("u1"); + AtomicReference saved = new AtomicReference<>(); + when(momentRepository.existsByVodVideoId(VIDEO_ID)).thenReturn(false); + when(contentReviewService.previewMomentReview(eq(""), any())).thenReturn(ReviewResult.passed()); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> { + Moment value = inv.getArgument(0); + value.setId("m2"); + saved.set(value); + return value; + }); + + MomentDTO dto = service.createTopicDiscussion("t-100", "宿舍夜谈", null, null, VIDEO_ID, false); + + Moment stored = saved.get(); + assertEquals("", stored.getContent(), "纯视频讨论正文落空串,DTO 也返回空串"); + assertEquals(VIDEO_ID, stored.getVodVideoId()); + assertNull(stored.getMediaUrls()); + assertNull(stored.getPlayUrl(), "播放地址必须由 VOD 回调回填,发布时不写"); + assertNull(stored.getVideoCoverUrl()); + assertEquals(Moment.SOURCE_TOPIC, stored.getContentSource()); + assertEquals(Boolean.FALSE, stored.getIsYouthVideo()); + assertNull(stored.getTags()); + assertNull(stored.getCategory()); + assertEquals("approved", stored.getReviewStatus()); + + assertEquals("PROCESSING", dto.getVideoStatus()); + verify(vodService).asyncPullPlayInfo(VIDEO_ID); + verify(pointsService, never()).onPublishMoment(anyString(), anyString(), anyBoolean()); + } + + // ==================== videoStatus 派生(不落库) ==================== + + @Test + void videoStatusIsReadyWhenPlayUrlFilled() { + Moment moment = discussion("m1", VIDEO_ID); + moment.setPlayUrl("https://video.yoyoeco.com/1.m3u8"); + assertEquals("READY", updateAndGetStatus(moment)); + } + + @Test + void videoStatusIsRejectedWhenAuditBlocks() { + Moment moment = discussion("m1", VIDEO_ID); + moment.setPlayUrl("https://video.yoyoeco.com/1.m3u8"); + moment.setAuditStatus("REJECTED"); + assertEquals("REJECTED", updateAndGetStatus(moment)); + } + + @Test + void videoStatusIsProcessingBeforeTimeout() { + Moment moment = discussion("m1", VIDEO_ID); + moment.setCreatedAt(new Date()); + assertEquals("PROCESSING", updateAndGetStatus(moment)); + } + + @Test + void videoStatusIsFailedAfterTimeout() { + Moment moment = discussion("m1", VIDEO_ID); + moment.setCreatedAt(new Date(System.currentTimeMillis() - 40L * 60 * 1000)); + assertEquals("FAILED", updateAndGetStatus(moment)); + } + + @Test + void videoStatusIsNullForNonVideoContent() { + Moment moment = discussion("m1", null); + moment.setMediaUrls(image(0)); + assertEquals(null, updateAndGetStatus(moment)); + } + + // ==================== 编辑 / 删除 ==================== + + @Test + void rejectsEditingOthersDiscussionWithForbidden() { + login("u1"); + Moment moment = discussion("m1", null); + moment.setUserId("u2"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(moment)); + + ForbiddenException error = assertThrows(ForbiddenException.class, + () -> service.updateTopicDiscussion("m1", "改", null, null, false)); + + assertEquals("无权操作他人的内容", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + @Test + void rejectsEditingNonTopicContent() { + login("u1"); + Moment feed = new Moment(); + feed.setId("m2"); + feed.setUserId("u1"); + feed.setContentSource(Moment.SOURCE_FEED); + when(momentRepository.findById("m2")).thenReturn(Optional.of(feed)); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.updateTopicDiscussion("m2", "改", null, null, false)); + + assertEquals("讨论不存在", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + /** + * 通用编辑接口必须拒收讨论:{@code MomentRequest.category} 是 {@code @NotBlank}, + * 放行等于逼前端编造分类,写进去就破坏"讨论不占动态分类体系"。 + */ + @Test + void genericMomentEditRejectsDiscussionToProtectClassificationInvariant() { + login("u1"); + Moment moment = discussion("m1", null); + moment.setUserId("u1"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(moment)); + + MomentRequest request = new MomentRequest(); + request.setContent("偷偷用动态接口改"); + request.setCategory("学习交流"); + request.setScope("CAMPUS"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.update("m1", request)); + + assertEquals("话题讨论请在话题页编辑", error.getMessage()); + assertNull(moment.getCategory(), "被拒绝的请求不能把假分类留在讨论上"); + verify(momentRepository, never()).save(any(Moment.class)); + verify(contentReviewService, never()).reviewMomentSync(anyString(), any(), any()); + } + + /** 历史脏值(旧通用接口写进来的假分类/假标签)在作者编辑一次后自愈。 */ + @Test + void editingDiscussionClearsStaleDynamicClassification() { + login("u1"); + Moment moment = discussion("m1", null); + moment.setUserId("u1"); + moment.setCategory("学习交流"); + moment.setTags("宿舍夜谈,二手"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(moment)); + when(contentReviewService.previewMomentReview(anyString(), any())).thenReturn(ReviewResult.passed()); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.updateTopicDiscussion("m1", "改过的内容", null, null, false); + + assertNull(moment.getCategory()); + assertNull(moment.getTags()); + assertEquals(Moment.SOURCE_TOPIC, moment.getContentSource()); + assertEquals("t-100", moment.getTopicId()); + assertEquals("approved", moment.getReviewStatus()); + } + + @Test + void editingOwnDiscussionKeepsTopicAndReapproves() { + login("u1"); + Moment moment = discussion("m1", null); + moment.setUserId("u1"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(moment)); + when(contentReviewService.previewMomentReview(anyString(), any())).thenReturn(ReviewResult.passed()); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> inv.getArgument(0)); + + MomentDTO dto = service.updateTopicDiscussion("m1", "改过的内容", null, null, false); + + assertEquals("改过的内容", moment.getContent()); + assertEquals("t-100", moment.getTopicId()); + assertEquals("approved", moment.getReviewStatus()); + assertEquals("改过的内容", dto.getContent()); + } + + @Test + void switchingFromVideoToImagesClearsVideoDerivedFields() { + login("u1"); + Moment moment = discussion("m1", VIDEO_ID); + moment.setUserId("u1"); + moment.setPlayUrl("https://video.yoyoeco.com/1.m3u8"); + moment.setVideoCoverUrl("https://video.yoyoeco.com/1.jpg"); + moment.setVideoDuration(12); + moment.setAuditStatus("APPROVED"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(moment)); + when(contentReviewService.previewMomentReview(anyString(), any())).thenReturn(ReviewResult.passed()); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.updateTopicDiscussion("m1", "换成图", Collections.singletonList(image(0)), null, false); + + assertNull(moment.getVodVideoId()); + assertNull(moment.getPlayUrl()); + assertNull(moment.getVideoCoverUrl()); + assertNull(moment.getVideoDuration()); + assertNull(moment.getAuditStatus()); + assertEquals(image(0), moment.getMediaUrls()); + } + + @Test + void deletingOwnDiscussionSoftDeletesIt() { + login("u1"); + Moment moment = discussion("m1", null); + moment.setUserId("u1"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(moment)); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.deleteTopicDiscussion("m1"); + + assertTrue(moment.getDeleted()); + verify(momentRepository).save(moment); + } + + @Test + void deletingOthersDiscussionIsForbidden() { + login("u1"); + Moment moment = discussion("m1", null); + moment.setUserId("u2"); + when(momentRepository.findById("m1")).thenReturn(Optional.of(moment)); + + ForbiddenException error = assertThrows(ForbiddenException.class, + () -> service.deleteTopicDiscussion("m1")); + + assertEquals("无权操作他人的内容", error.getMessage()); + verify(momentRepository, never()).save(any(Moment.class)); + } + + // ==================== 列表与迁移(原有) ==================== + + @Test + void normalMomentsKeepFeedSource() { + login("u1"); + Moment feed = new Moment(); + feed.setId("m2"); + feed.setUserId("u1"); + feed.setReviewStatus("approved"); + feed.setAnonymous(false); + feed.setDeleted(false); + feed.setLikeCount(0); + feed.setCommentCount(0); + feed.setShareCount(0); + feed.setViewCount(0); + when(momentRepository.findById("m2")).thenReturn(Optional.of(feed)); + when(momentRepository.findByIdForView("m2")).thenReturn(Optional.of(feed)); + + MomentDTO dto = service.getById("m2"); + + assertEquals(Moment.SOURCE_FEED, dto.getContentSource()); + assertNull(dto.getTopicId()); + } + + @Test + void topicDiscussionListRejectsUnknownSort() { + BusinessException error = assertThrows(BusinessException.class, + () -> service.listTopicDiscussions("t-100", "rising", 1, 20)); + + assertEquals("排序方式无效", error.getMessage()); + verifyNoInteractions(momentRepository); + } + + @Test + void topicDiscussionListDefaultsToLatestOrdering() { + when(momentRepository.findApprovedByTopicId(eq("t-100"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + PageResult result = service.listTopicDiscussions("t-100", null, 1, 20); + + assertEquals(0, result.getTotal()); + verify(momentRepository).findApprovedByTopicId(eq("t-100"), any(Pageable.class)); + verify(momentRepository, never()).findApprovedByTopicIdOrderByHot(anyString(), any(Pageable.class)); + } + + @Test + void topicDiscussionListHotSortUsesHotQuery() { + when(momentRepository.findApprovedByTopicIdOrderByHot(eq("t-100"), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + service.listTopicDiscussions("t-100", "HOT", 1, 20); + + verify(momentRepository).findApprovedByTopicIdOrderByHot(eq("t-100"), any(Pageable.class)); + verify(momentRepository, never()).findApprovedByTopicId(anyString(), any(Pageable.class)); + } + + @Test + void myDiscussionsRequireLogin() { + BusinessException error = assertThrows(BusinessException.class, + () -> service.listMyDiscussions(null, 1, 20)); + + assertEquals("用户未登录", error.getMessage()); + verifyNoInteractions(momentRepository); + } + + @Test + void myDiscussionsOnlyQueryOwnTopicContent() { + login("u1"); + when(momentRepository.findMyDiscussions(eq("u1"), isNull(), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + service.listMyDiscussions(null, 1, 20); + + verify(momentRepository).findMyDiscussions(eq("u1"), isNull(), any(Pageable.class)); + } + + @Test + void topicMergeMigrationIgnoresSameOrMissingIds() { + assertEquals(0, service.migrateTopicDiscussions("t-1", "t-1")); + assertEquals(0, service.migrateTopicDiscussions(null, "t-2")); + verifyNoInteractions(momentRepository); + } + + @Test + void topicMergeMigrationReassignsRows() { + when(momentRepository.reassignTopic("t-source", "t-target")).thenReturn(3); + + assertEquals(3, service.migrateTopicDiscussions("t-source", "t-target")); + verify(momentRepository).reassignTopic("t-source", "t-target"); + } + + // ==================== 辅助 ==================== + + /** + * 用「编辑自己的讨论」这条链路拿一次 DTO:{@code toDTO} 是私有的,而 videoStatus 的派生 + * 只在转换时发生,所以借一个不改变视频状态的最小更新来观测它。 + */ + private String updateAndGetStatus(Moment moment) { + login("u1"); + moment.setUserId("u1"); + if (moment.getReviewStatus() == null) { + moment.setReviewStatus("approved"); + } + String videoId = moment.getVodVideoId(); + when(momentRepository.findById(moment.getId())).thenReturn(Optional.of(moment)); + when(contentReviewService.previewMomentReview(anyString(), any())).thenReturn(ReviewResult.passed()); + when(momentRepository.save(any(Moment.class))).thenAnswer(inv -> inv.getArgument(0)); + + MomentDTO dto = service.updateTopicDiscussion(moment.getId(), "内容不变", null, videoId, false); + return dto.getVideoStatus(); + } + + private Moment discussion(String id, String vodVideoId) { + Moment moment = new Moment(); + moment.setId(id); + moment.setContentSource(Moment.SOURCE_TOPIC); + moment.setTopicId("t-100"); + moment.setUserId("u1"); + moment.setContent("原内容"); + moment.setVodVideoId(vodVideoId); + moment.setReviewStatus("approved"); + moment.setAnonymous(false); + moment.setDeleted(false); + moment.setIsYouthVideo(false); + moment.setLikeCount(0); + moment.setCommentCount(0); + moment.setShareCount(0); + moment.setViewCount(0); + return moment; + } + + private String image(int index) { + return "https://oss.yoyoeco.com/images/2026/11/01/" + index + ".jpg"; + } + + private void login(String userId) { + User user = new User(); + user.setId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } +} diff --git a/campus-server/src/test/java/com/campus/service/NotificationEnhancementTest.java b/campus-server/src/test/java/com/campus/service/NotificationEnhancementTest.java new file mode 100644 index 0000000..f7501b4 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/NotificationEnhancementTest.java @@ -0,0 +1,81 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.NotificationRequest; +import com.campus.entity.Notification; +import com.campus.entity.NotificationPreference; +import com.campus.repository.ChatConversationRepository; +import com.campus.repository.NotificationPreferenceRepository; +import com.campus.repository.NotificationRepository; +import com.campus.repository.NotificationTemplateRepository; +import com.campus.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationEnhancementTest { + @Mock private NotificationRepository notificationRepository; + @Mock private ChatConversationRepository conversationRepository; + @Mock private PushService pushService; + @Mock private SseService sseService; + @Mock private NotificationPreferenceRepository preferenceRepository; + @Mock private NotificationTemplateRepository templateRepository; + @Mock private UserRepository userRepository; + + @Test + void rejectsUnknownNotificationType() { + NotificationRequest request = new NotificationRequest(); + request.setType("TYPO_TYPE"); + request.setTitle("title"); + assertThrows(BusinessException.class, () -> service().create(request, "user", "sender")); + } + + @Test + void quietHoursKeepInboxRecordButSuppressRealtimeDelivery() { + NotificationPreference preference = new NotificationPreference(); + preference.setUserId("user"); + preference.setPushEnabled(true); + preference.setSystemEnabled(true); + preference.setQuietStart("00:00"); + preference.setQuietEnd("00:00"); + when(preferenceRepository.findByUserId("user")).thenReturn(Optional.of(preference)); + when(notificationRepository.save(any(Notification.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + service().createNotification("user", "SYSTEM", "title", "content", null, null, null, null); + + verify(notificationRepository).save(any(Notification.class)); + verify(sseService, never()).sendToUser(any(), any(), any()); + verify(pushService, never()).pushToUser(any(), any(), any(), any()); + } + + @Test + void listUsesOneBasedPublicPagination() { + when(notificationRepository.findByUserId(any(), any(Pageable.class))).thenReturn(Page.empty()); + service().list("user", null, null, 1, 20); + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + verify(notificationRepository).findByUserId(any(), captor.capture()); + assertEquals(0, captor.getValue().getPageNumber()); + assertEquals(20, captor.getValue().getPageSize()); + } + + private NotificationService service() { + return new NotificationService(notificationRepository, conversationRepository, + new AuthorizationService(), pushService, sseService, new ObjectMapper(), + preferenceRepository, templateRepository, userRepository); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ProfileServiceTest.java b/campus-server/src/test/java/com/campus/service/ProfileServiceTest.java new file mode 100644 index 0000000..9d89b4c --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ProfileServiceTest.java @@ -0,0 +1,72 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProfileServiceTest { + @Mock UserRepository userRepository; + @Mock UserService userService; + @Mock ClickGuardService clickGuardService; + @InjectMocks ProfileService service; + + @Test void rejectsAnonymousWithoutWrite() { + assertEquals("用户未登录", assertThrows(BusinessException.class, + () -> service.updateProfile(null, "u", new User())).getMessage()); + verifyNoInteractions(userRepository, userService, clickGuardService); + } + + @Test void protectsIdentityBeforeAndAfterCertification() { + for (boolean certified : new boolean[]{false, true}) { + User user = new User(); user.setId("u"); user.setNickname("认证姓名"); + user.setGender("M"); user.setBirthday(new Date(0)); user.setUniversityName("认证学校"); + user.setEducation("本科"); user.setXuexinCertified(certified); + User input = new User(); input.setNickname("伪造"); input.setGender("F"); + input.setBirthday(new Date()); input.setUniversityName("伪造学校"); + input.setUniversityId("fake"); input.setCollegeName("fake"); input.setCollegeId("fake"); + input.setMajorName("fake"); input.setMajorId("fake"); input.setGrade("2026级"); + input.setEducation("博士"); input.setEducationType("fake"); input.setXuexinCertified(true); + when(userRepository.findById("u")).thenReturn(Optional.of(user)); + assertSame(user, service.updateProfile("u", "u", input)); + assertEquals("认证姓名", user.getNickname()); assertEquals("M", user.getGender()); + assertEquals(new Date(0), user.getBirthday()); assertEquals("认证学校", user.getUniversityName()); + assertEquals("本科", user.getEducation()); assertNull(user.getUniversityId()); + assertNull(user.getCollegeName()); assertNull(user.getCollegeId()); assertNull(user.getMajorName()); + assertNull(user.getMajorId()); assertNull(user.getGrade()); assertNull(user.getEducationType()); + assertEquals(certified, user.getXuexinCertified()); + } + verifyNoInteractions(userService); + } + + @Test void allowsBioAndIgnoresForgedName() { + User user = new User(); user.setNickname("认证姓名"); + User input = new User(); input.setNickname("伪造"); input.setBio("新简介"); + when(userRepository.findById("u")).thenReturn(Optional.of(user)); + when(userService.saveUser(user)).thenReturn(user); + assertSame(user, service.updateProfile("u", "u", input)); + assertEquals("认证姓名", user.getNickname()); assertEquals("新简介", user.getBio()); + verify(userService).saveUser(user); + } + + @Test void rejectsMissingUserWithoutWrite() { + assertEquals("用户不存在", assertThrows(BusinessException.class, + () -> service.updateProfile("u", "missing", new User())).getMessage()); + verifyNoInteractions(userService); + } + + @Test void clickGuardFailureDoesNotReadOrWrite() { + doThrow(new com.campus.common.TooManyRequestsException("点击过于频繁,操作失败")) + .when(clickGuardService).ensureNotTooFrequent("profile-update", "u", "u"); + assertEquals("点击过于频繁,操作失败", assertThrows(com.campus.common.TooManyRequestsException.class, + () -> service.updateProfile("u", "u", new User())).getMessage()); + verifyNoInteractions(userRepository, userService); + } +} diff --git a/campus-server/src/test/java/com/campus/service/RadarVenueLocationTest.java b/campus-server/src/test/java/com/campus/service/RadarVenueLocationTest.java new file mode 100644 index 0000000..00c3e41 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/RadarVenueLocationTest.java @@ -0,0 +1,46 @@ +package com.campus.service; + +import com.campus.dto.response.RadarDTO; +import com.campus.entity.*; +import com.campus.repository.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +@ExtendWith(MockitoExtension.class) +class RadarVenueLocationTest { + @Mock UserRepository users; + @Mock ActivityRepository activities; + @Mock FriendshipRepository friends; + @Mock GreetingRepository greetings; + @Mock PrivacySettingsService privacy; + @InjectMocks RadarService radar; + @BeforeEach void login() { + User current = new User(); current.setId("user"); current.setLatitude(30d); current.setLongitude(104d); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(current, null)); + when(users.findById("user")).thenReturn(Optional.of(current)); + } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + @Test void venueUsesOwnCoordinatesWithoutLoadingCreator() { + ReflectionTestUtils.setField(radar, "radarCoordinatesVerified", true); + Activity activity = new Activity(); activity.setId("activity"); activity.setCreatorId("creator"); activity.setLatitude(30d); activity.setLongitude(104d); + when(activities.findNearbyActivitiesAtVenue("user",30d,104d,500)).thenReturn(Collections.singletonList(activity)); + RadarDTO.RadarActivityDTO dto = radar.scan("nearby",500).getNearbyActivities().get(0); + assertEquals(0,dto.getDistance()); assertEquals(30d,dto.getLatitude()); assertEquals("GCJ02",dto.getCoordinateSystem()); + verify(users,never()).findById("creator"); + } + @Test void verifiedQueryExcludesEmptyVenueResults() { + ReflectionTestUtils.setField(radar, "radarCoordinatesVerified", true); + assertTrue(radar.scan("nearby",500).getNearbyActivities().isEmpty()); + verify(activities).findNearbyActivitiesAtVenue("user",30d,104d,500); + verify(activities,never()).findNearbyActivities(anyString(),anyDouble(),anyDouble(),anyInt()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/RadarVenueMySqlTest.java b/campus-server/src/test/java/com/campus/service/RadarVenueMySqlTest.java new file mode 100644 index 0000000..cb05847 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/RadarVenueMySqlTest.java @@ -0,0 +1,33 @@ +package com.campus.service; + +import com.campus.repository.ActivityRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.SingleConnectionDataSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** 仅在显式指定的独立测试库运行,临时表只存活于本连接,不触碰业务数据。 */ +@EnabledIfEnvironmentVariable(named = "QINGHE_LOCATION_TEST_URL", matches = "jdbc:mysql://[^/]+/qinghe_location_test_[a-z0-9_]+(?:\\?.*)?") +class RadarVenueMySqlTest { + @Test void nativeQueriesUseVenueAndExcludeMissingCoordinates() throws Exception { + SingleConnectionDataSource source = new SingleConnectionDataSource(System.getenv("QINGHE_LOCATION_TEST_URL"), + System.getenv("QINGHE_LOCATION_TEST_USER"), System.getenv("QINGHE_LOCATION_TEST_PASSWORD"), true); + try { + NamedParameterJdbcTemplate jdbc = new NamedParameterJdbcTemplate(source); + jdbc.getJdbcTemplate().execute("CREATE TEMPORARY TABLE user (id varchar(36), deleted boolean, university_name varchar(100), latitude double, longitude double)"); + jdbc.getJdbcTemplate().execute("CREATE TEMPORARY TABLE activity (id varchar(36), creator_id varchar(36), deleted boolean, status varchar(20), latitude double, longitude double, created_at datetime)"); + jdbc.getJdbcTemplate().execute("INSERT INTO user VALUES ('creator',false,'学校',0,0)"); + jdbc.getJdbcTemplate().execute("INSERT INTO activity VALUES ('near','creator',false,'ACTIVE',30,104,NOW()),('missing','creator',false,'ACTIVE',NULL,NULL,NOW()),('far','creator',false,'ACTIVE',31,104,NOW())"); + Map args = new HashMap<>(); args.put("excludeId","viewer"); args.put("lat",30d); args.put("lng",104d); args.put("radius",500); args.put("school","学校"); + String nearby = ActivityRepository.class.getMethod("findNearbyActivitiesAtVenue",String.class,double.class,double.class,int.class).getAnnotation(Query.class).value(); + String school = ActivityRepository.class.getMethod("findNearbyActivitiesAtVenueBySchool",String.class,String.class,double.class,double.class,int.class).getAnnotation(Query.class).value(); + assertEquals(Collections.singletonList("near"),jdbc.queryForList(nearby,args).stream().map(r -> r.get("id")).collect(java.util.stream.Collectors.toList())); + assertEquals(1,jdbc.queryForList(school,args).size()); + jdbc.getJdbcTemplate().execute("UPDATE user SET latitude=50,longitude=120"); + assertEquals(1,jdbc.queryForList(nearby,args).size()); + } finally { source.destroy(); } + } +} diff --git a/campus-server/src/test/java/com/campus/service/RedisRetryExecutorTest.java b/campus-server/src/test/java/com/campus/service/RedisRetryExecutorTest.java new file mode 100644 index 0000000..263078b --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/RedisRetryExecutorTest.java @@ -0,0 +1,36 @@ +package com.campus.service; + +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessResourceFailureException; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RedisRetryExecutorTest { + private final RedisRetryExecutor executor = new RedisRetryExecutor(); + + @Test + void retriesOnceAfterConnectionFailure() { + AtomicInteger attempts = new AtomicInteger(); + String result = executor.execute("test", () -> { + if (attempts.incrementAndGet() == 1) { + throw new DataAccessResourceFailureException("connection reset"); + } + return "ok"; + }); + assertEquals("ok", result); + assertEquals(2, attempts.get()); + } + + @Test + void propagatesSecondFailure() { + AtomicInteger attempts = new AtomicInteger(); + assertThrows(DataAccessResourceFailureException.class, () -> executor.execute("test", () -> { + attempts.incrementAndGet(); + throw new DataAccessResourceFailureException("connection reset"); + })); + assertEquals(2, attempts.get()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ScheduledPublishingServiceTest.java b/campus-server/src/test/java/com/campus/service/ScheduledPublishingServiceTest.java new file mode 100644 index 0000000..a411119 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ScheduledPublishingServiceTest.java @@ -0,0 +1,47 @@ +package com.campus.service; + +import com.campus.entity.Announcement; +import com.campus.entity.Moment; +import com.campus.repository.AnnouncementRepository; +import com.campus.repository.MomentRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScheduledPublishingServiceTest { + @Mock private MomentRepository momentRepository; + @Mock private AnnouncementRepository announcementRepository; + + @Test + void publishesOnlyDueScheduledContent() { + Moment moment = new Moment(); + moment.setReviewStatus("scheduled"); + Announcement announcement = new Announcement(); + announcement.setStatus("scheduled"); + when(momentRepository.findByReviewStatusAndScheduledPublishAtBefore(eq("scheduled"), any(Date.class))) + .thenReturn(Collections.singletonList(moment)); + when(announcementRepository.findByStatusAndScheduledPublishAtBefore(eq("scheduled"), any(Date.class))) + .thenReturn(Collections.singletonList(announcement)); + + new ScheduledPublishingService(momentRepository, announcementRepository).publishDueContent(); + + assertEquals("approved", moment.getReviewStatus()); + assertEquals("approved", announcement.getStatus()); + verify(momentRepository).save(moment); + verify(announcementRepository).save(announcement); + } + + private static T eq(T value) { + return org.mockito.ArgumentMatchers.eq(value); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ShareRequestIdempotencyServiceTest.java b/campus-server/src/test/java/com/campus/service/ShareRequestIdempotencyServiceTest.java new file mode 100644 index 0000000..73f0474 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ShareRequestIdempotencyServiceTest.java @@ -0,0 +1,66 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.TooManyRequestsException; +import com.campus.entity.ShareRequestRecord; +import com.campus.repository.ShareRequestRecordRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ShareRequestIdempotencyServiceTest { + @Mock ShareRequestRecordRepository repository; + @Spy ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks ShareRequestIdempotencyService service; + + private ShareRequestRecord record(String status) { + ShareRequestRecord r = new ShareRequestRecord(); + r.setId("record"); r.setSenderId("u"); r.setRequestType("FRIENDS"); + r.setRequestId("key"); r.setRequestHash("hash"); r.setStatus(status); + return r; + } + + @Test void duplicateProcessingRequestIsNotAllowedToResend() { + when(repository.findForUpdate("u", "FRIENDS", "key")) + .thenReturn(Optional.of(record("PROCESSING"))); + assertEquals("分享请求正在处理中,请勿重复提交", assertThrows(TooManyRequestsException.class, + () -> service.claim("u", "FRIENDS", "key", "hash")).getMessage()); + verify(repository, never()).save(any()); + } + + @Test void sameKeyDifferentPayloadIsRejected() { + when(repository.findForUpdate("u", "FRIENDS", "key")) + .thenReturn(Optional.of(record("COMPLETED"))); + assertEquals("分享请求标识已被其他请求使用", assertThrows(BusinessException.class, + () -> service.claim("u", "FRIENDS", "key", "other-hash")).getMessage()); + verify(repository, never()).save(any()); + } + + @Test void completedRequestReturnsStoredResult() { + ShareRequestRecord r = record("COMPLETED"); r.setResponseJson("{\"sentCount\":1}"); + when(repository.findForUpdate("u", "FRIENDS", "key")).thenReturn(Optional.of(r)); + ShareRequestIdempotencyService.Claim claim = service.claim("u", "FRIENDS", "key", "hash"); + assertFalse(claim.isNewRequest()); assertTrue(claim.isCompleted()); + assertEquals("{\"sentCount\":1}", claim.getResponseJson()); + verify(repository, never()).save(any()); + } + + @Test void newRecordCanBeProcessedOnlyByItsCreator() { + when(repository.insertIfAbsent(anyString(), eq("u"), eq("FRIENDS"), eq("key"), eq("hash"))) + .thenReturn(1); + when(repository.findForUpdate("u", "FRIENDS", "key")) + .thenReturn(Optional.of(record("PROCESSING"))); + assertTrue(service.claim("u", "FRIENDS", "key", "hash").isNewRequest()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/ShareServiceTest.java b/campus-server/src/test/java/com/campus/service/ShareServiceTest.java new file mode 100644 index 0000000..cfd6625 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ShareServiceTest.java @@ -0,0 +1,397 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ServiceUnavailableException; +import com.campus.common.TooManyRequestsException; +import com.campus.dto.request.ShareToFriendsRequest; +import com.campus.dto.request.ShareToGroupRequest; +import com.campus.dto.response.ChatMessageDTO; +import com.campus.dto.response.ShareSendResultDTO; +import com.campus.entity.Friendship; +import com.campus.entity.Moment; +import com.campus.entity.User; +import com.campus.repository.FriendshipRepository; +import com.campus.repository.MomentRepository; +import com.campus.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** App 内动态分享编排测试:整批预校验、部分成功、群聊与连点防护。 */ +@ExtendWith(MockitoExtension.class) +class ShareServiceTest { + + private static final String CURRENT_USER_ID = "user-1"; + private static final String AUTHOR_ID = "author-1"; + private static final String MOMENT_ID = "moment-1"; + private static final String FRIEND_1 = "friend-1"; + private static final String FRIEND_2 = "friend-2"; + + @Mock private ShareTargetResolver shareTargetResolver; + @Mock private MomentRepository momentRepository; + @Mock private FriendshipRepository friendshipRepository; + @Mock private UserRepository userRepository; + @Mock private PrivacySettingsService privacySettingsService; + @Mock private ClickGuardService clickGuardService; + @Mock private ChatService chatService; + @Mock private ShareRequestIdempotencyService requestIdempotencyService; + @Mock private MomentShareCountService momentShareCountService; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks private ShareService shareService; + + @Test + void recipientRejectionStopsBothEntrypointsBeforeSending() { + com.campus.dto.response.ShareCardDTO card = new com.campus.dto.response.ShareCardDTO(); + card.setTargetType("USER"); card.setTargetId("target"); + when(shareTargetResolver.resolve("USER", "target", CURRENT_USER_ID)).thenReturn(card); + stubAcceptedFriend(FRIEND_1); + doThrow(new BusinessException("对方设置了隐私保护,无法分享")).when(shareTargetResolver) + .validateRecipients(card, Collections.singletonList(FRIEND_1)); + ShareToFriendsRequest friends = friendRequest(FRIEND_1); friends.setTargetType("USER"); friends.setTargetId("target"); + assertEquals("对方设置了隐私保护,无法分享", assertThrows(BusinessException.class, + () -> shareService.sendToFriends(friends)).getMessage()); + doThrow(new BusinessException("群内存在无权查看该内容的成员,无法分享")).when(shareTargetResolver).validateGroup(card,"group"); + ShareToGroupRequest group = groupRequest("group"); group.setTargetType("USER"); group.setTargetId("target"); + assertEquals("群内存在无权查看该内容的成员,无法分享", assertThrows(BusinessException.class, + () -> shareService.sendToGroup(group)).getMessage()); + verifyNoInteractions(chatService, clickGuardService); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"TOPIC", "ACTIVITY", "ANNOUNCEMENT", "BADGE", "USER"}) + void newTypesUseSameCardForFriendsAndGroups(String type) throws Exception { + com.campus.dto.response.ShareCardDTO card = new com.campus.dto.response.ShareCardDTO(); + card.setTargetType(type); card.setTargetId("target"); card.setTitle("标题"); + when(shareTargetResolver.resolve(type, "target", CURRENT_USER_ID)).thenReturn(card); + stubAcceptedFriend(FRIEND_1); + when(chatService.sendSharePrivateMessage(eq(FRIEND_1), anyString(), anyString())).thenReturn(message("private")); + when(chatService.sendGroupShareMessage(eq("group"), anyString(), anyString())).thenReturn(message("group")); + ShareToFriendsRequest friends = friendRequest(FRIEND_1); friends.setTargetType(type); friends.setTargetId("target"); + ShareToGroupRequest group = groupRequest("group"); group.setTargetType(type); group.setTargetId("target"); + assertEquals(type, shareService.sendToFriends(friends).getCard().getTargetType()); + assertEquals(type, shareService.sendToGroup(group).getCard().getTargetType()); + verify(shareTargetResolver).validateRecipients(card, Collections.singletonList(FRIEND_1)); + verify(shareTargetResolver).validateGroup(card, "group"); + verify(clickGuardService).ensureNotTooFrequent(ShareService.SCOPE_SHARE_GROUP, CURRENT_USER_ID, type + ":target:GROUP:group"); + org.mockito.ArgumentCaptor json = org.mockito.ArgumentCaptor.forClass(String.class); + verify(chatService).sendGroupShareMessage(eq("group"), json.capture(), anyString()); + assertTrue(objectMapper.readTree(json.getValue()).has("coverUrl")); + } + + @BeforeEach + void login() { + User user = new User(); + user.setId(CURRENT_USER_ID); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void rejectsWhenNotLoggedInAndDoesNotReadOrSend() { + SecurityContextHolder.clearContext(); + + BusinessException error = assertThrows(BusinessException.class, + () -> shareService.sendToFriends(friendRequest(FRIEND_1))); + + assertEquals("用户未登录", error.getMessage()); + verifyNoInteractions(momentRepository, chatService); + } + + @Test + void rejectsEmptyReceiverListWithoutSending() { + ShareToFriendsRequest request = friendRequest(); + request.setReceiverIds(Arrays.asList(" ", null)); + + BusinessException error = assertThrows(BusinessException.class, + () -> shareService.sendToFriends(request)); + + assertEquals("请选择要分享的好友", error.getMessage()); + verifyNoInteractions(momentRepository, chatService); + } + + @Test + void rejectsMoreThanTwentyDistinctFriendsWithoutSending() { + List receiverIds = new ArrayList<>(); + for (int i = 0; i < 21; i++) { + receiverIds.add("friend-" + i); + } + ShareToFriendsRequest request = friendRequest(); + request.setReceiverIds(receiverIds); + + BusinessException error = assertThrows(BusinessException.class, + () -> shareService.sendToFriends(request)); + + assertEquals("一次最多分享给20位好友", error.getMessage()); + verifyNoInteractions(momentRepository, chatService); + } + + @Test + void rejectsUnapprovedMomentWithExactMessageAndNoSend() { + Moment moment = feedMoment(); + moment.setReviewStatus("pending"); + when(momentRepository.findById(MOMENT_ID)).thenReturn(Optional.of(moment)); + + BusinessException error = assertThrows(BusinessException.class, + () -> shareService.sendToFriends(friendRequest(FRIEND_1))); + + assertEquals("分享内容不存在或不可见", error.getMessage()); + verifyNoInteractions(chatService); + verify(momentRepository, never()).incrementShareCount(anyString()); + } + + @Test + void rejectsWholeBatchWhenAnyReceiverIsNotFriend() { + stubShareableMoment(); + stubAcceptedFriend(FRIEND_1); + when(userRepository.existsById(FRIEND_2)).thenReturn(true); + when(friendshipRepository.findByUserIdAndFriendId(CURRENT_USER_ID, FRIEND_2)) + .thenReturn(Optional.empty()); + when(friendshipRepository.findByUserIdAndFriendId(FRIEND_2, CURRENT_USER_ID)) + .thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> shareService.sendToFriends(friendRequest(FRIEND_1, FRIEND_2))); + + assertEquals("只能分享给好友", error.getMessage()); + verifyNoInteractions(chatService); + } + + @Test + void rejectsWholeBatchWhenReceiverCannotViewMoment() { + stubShareableMoment(); + stubAcceptedFriend(FRIEND_1); + when(privacySettingsService.isBlocked(CURRENT_USER_ID, AUTHOR_ID)).thenReturn(false); + when(privacySettingsService.isBlocked(FRIEND_1, AUTHOR_ID)).thenReturn(true); + + BusinessException error = assertThrows(BusinessException.class, + () -> shareService.sendToFriends(friendRequest(FRIEND_1))); + + assertEquals("分享内容不存在或不可见", error.getMessage()); + verifyNoInteractions(chatService); + } + + @Test + void sendsToDistinctFriendsAndReturnsAllSuccess() { + stubShareableMoment(); + stubAcceptedFriend(FRIEND_1); + stubAcceptedFriend(FRIEND_2); + when(chatService.sendSharePrivateMessage(eq(FRIEND_1), anyString(), anyString())) + .thenReturn(message("message-1")); + when(chatService.sendSharePrivateMessage(eq(FRIEND_2), anyString(), anyString())) + .thenReturn(message("message-2")); + + ShareSendResultDTO result = shareService.sendToFriends( + friendRequest(FRIEND_1, FRIEND_2, FRIEND_1)); + + assertEquals(2, result.getRequestedCount()); + assertEquals(2, result.getSentCount()); + assertEquals(0, result.getFailedCount()); + assertFalse(result.getPartialSuccess()); + assertEquals("MOMENT", result.getCard().getTargetType()); + assertEquals("今晚图书馆自习组队", result.getCard().getTitle()); + assertEquals("message-1", result.getResults().get(0).getMessageId()); + assertEquals("SENT", result.getResults().get(1).getStatus()); + verify(momentRepository, never()).incrementShareCount(anyString()); + } + + @Test + void returnsPerReceiverResultWhenOnlyPartOfBatchSucceeds() { + stubShareableMoment(); + stubAcceptedFriend(FRIEND_1); + stubAcceptedFriend(FRIEND_2); + when(chatService.sendSharePrivateMessage(eq(FRIEND_1), anyString(), anyString())) + .thenReturn(message("message-1")); + when(chatService.sendSharePrivateMessage(eq(FRIEND_2), anyString(), anyString())) + .thenThrow(new ServiceUnavailableException("分享服务暂不可用,请稍后重试")); + + ShareSendResultDTO result = shareService.sendToFriends(friendRequest(FRIEND_1, FRIEND_2)); + + assertEquals(1, result.getSentCount()); + assertEquals(1, result.getFailedCount()); + assertTrue(result.getPartialSuccess()); + assertEquals("FAILED", result.getResults().get(1).getStatus()); + assertEquals("分享服务暂不可用,请稍后重试", result.getResults().get(1).getMessage()); + } + + @Test + void throws503WhenEveryFriendDeliveryFails() { + stubShareableMoment(); + stubAcceptedFriend(FRIEND_1); + when(chatService.sendSharePrivateMessage(eq(FRIEND_1), anyString(), anyString())) + .thenThrow(new ServiceUnavailableException("分享服务暂不可用,请稍后重试")); + + ServiceUnavailableException error = assertThrows(ServiceUnavailableException.class, + () -> shareService.sendToFriends(friendRequest(FRIEND_1))); + + assertEquals("分享服务暂不可用,请稍后重试", error.getMessage()); + } + + @Test + void sendsToOneGroupAndReturnsGroupDestination() { + stubShareableMoment(); + when(chatService.sendGroupShareMessage(eq("group-1"), anyString(), anyString())) + .thenReturn(message("group-message")); + + ShareSendResultDTO result = shareService.sendToGroup(groupRequest("group-1")); + + assertEquals(1, result.getRequestedCount()); + assertEquals(1, result.getSentCount()); + assertEquals("GROUP", result.getResults().get(0).getDestinationType()); + assertEquals("group-1", result.getResults().get(0).getDestinationId()); + verify(momentRepository, never()).incrementShareCount(anyString()); + } + + @Test + void clickGuardRejectsRepeatedFriendBatchWithoutSending() { + stubShareableMoment(); + stubAcceptedFriend(FRIEND_1); + doThrow(new TooManyRequestsException("点击过于频繁,操作失败")) + .when(clickGuardService) + .ensureNotTooFrequent(eq(ShareService.SCOPE_SHARE_FRIENDS), eq(CURRENT_USER_ID), anyString()); + + TooManyRequestsException error = assertThrows(TooManyRequestsException.class, + () -> shareService.sendToFriends(friendRequest(FRIEND_1))); + + assertEquals("点击过于频繁,操作失败", error.getMessage()); + verifyNoInteractions(chatService); + } + + @Test + void requestIdReplayReturnsOriginalResultWithoutSendingAgain() throws Exception { + stubShareableMoment(); + stubAcceptedFriend(FRIEND_1); + ShareSendResultDTO previous = new ShareSendResultDTO(); + previous.setRequestedCount(1); + previous.setSentCount(1); + previous.setFailedCount(0); + previous.setPartialSuccess(false); + previous.setCard(new com.campus.dto.response.ShareCardDTO()); + when(requestIdempotencyService.claim(eq(CURRENT_USER_ID), eq(ShareRequestIdempotencyService.TYPE_FRIENDS), + eq("share-op-1"), anyString())) + .thenReturn(new ShareRequestIdempotencyService.Claim(false, true, "record-1", + new ObjectMapper().writeValueAsString(previous))); + ShareToFriendsRequest request = friendRequest(FRIEND_1); + request.setRequestId("share-op-1"); + + ShareSendResultDTO replay = shareService.sendToFriends(request); + + assertEquals(1, replay.getSentCount()); + verifyNoInteractions(chatService); + verify(clickGuardService, never()).ensureNotTooFrequent( + eq(ShareService.SCOPE_SHARE_FRIENDS), eq(CURRENT_USER_ID), anyString()); + } + + @Test + void newRequestIdSendsAndStoresSuccessfulResult() { + stubShareableMoment(); + stubAcceptedFriend(FRIEND_1); + when(requestIdempotencyService.claim(eq(CURRENT_USER_ID), eq(ShareRequestIdempotencyService.TYPE_FRIENDS), + eq("share-op-2"), anyString())) + .thenReturn(new ShareRequestIdempotencyService.Claim(true, false, "record-2", null)); + when(chatService.sendSharePrivateMessage(eq(FRIEND_1), anyString(), anyString())) + .thenReturn(message("sent-1")); + ShareToFriendsRequest request = friendRequest(FRIEND_1); + request.setRequestId("share-op-2"); + + ShareSendResultDTO result = shareService.sendToFriends(request); + + assertEquals(1, result.getSentCount()); + verify(requestIdempotencyService).complete(eq("record-2"), any(ShareSendResultDTO.class)); + verify(clickGuardService, never()).ensureNotTooFrequent( + eq(ShareService.SCOPE_SHARE_FRIENDS), eq(CURRENT_USER_ID), anyString()); + } + + @Test + void rejectsUnsupportedTargetType() { + ShareToFriendsRequest request = friendRequest(FRIEND_1); + request.setTargetType("IMAGE"); + when(shareTargetResolver.resolve("IMAGE", MOMENT_ID, CURRENT_USER_ID)) + .thenThrow(new BusinessException("不支持的分享类型")); + + BusinessException error = assertThrows(BusinessException.class, + () -> shareService.sendToFriends(request)); + + assertEquals("不支持的分享类型", error.getMessage()); + verifyNoInteractions(momentRepository, chatService); + } + + private ShareToFriendsRequest friendRequest(String... receiverIds) { + ShareToFriendsRequest request = new ShareToFriendsRequest(); + request.setTargetType(ShareService.TYPE_MOMENT); + request.setTargetId(MOMENT_ID); + request.setReceiverIds(new ArrayList<>(Arrays.asList(receiverIds))); + return request; + } + + private ShareToGroupRequest groupRequest(String groupId) { + ShareToGroupRequest request = new ShareToGroupRequest(); + request.setTargetType(ShareService.TYPE_MOMENT); + request.setTargetId(MOMENT_ID); + request.setGroupId(groupId); + return request; + } + + private void stubShareableMoment() { + when(momentRepository.findById(MOMENT_ID)).thenReturn(Optional.of(feedMoment())); + } + + private void stubAcceptedFriend(String receiverId) { + Friendship friendship = new Friendship(); + friendship.setUserId(CURRENT_USER_ID); + friendship.setFriendId(receiverId); + friendship.setStatus("ACCEPTED"); + when(friendshipRepository.findByUserIdAndFriendId(CURRENT_USER_ID, receiverId)) + .thenReturn(Optional.of(friendship)); + when(userRepository.existsById(receiverId)).thenReturn(true); + } + + private Moment feedMoment() { + Moment moment = new Moment(); + moment.setId(MOMENT_ID); + moment.setUserId(AUTHOR_ID); + moment.setContent("今晚图书馆自习组队"); + moment.setReviewStatus("approved"); + moment.setContentSource(Moment.SOURCE_FEED); + moment.setDeleted(false); + return moment; + } + + private ChatMessageDTO message(String id) { + ChatMessageDTO message = new ChatMessageDTO(); + message.setId(id); + return message; + } +} diff --git a/campus-server/src/test/java/com/campus/service/ShareTargetResolverTest.java b/campus-server/src/test/java/com/campus/service/ShareTargetResolverTest.java new file mode 100644 index 0000000..3cd07fa --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/ShareTargetResolverTest.java @@ -0,0 +1,132 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.response.ShareCardDTO; +import com.campus.entity.*; +import com.campus.repository.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.annotation.JsonInclude; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ShareTargetResolverTest { + @Mock TopicRepository topicRepository; + @Mock ActivityRepository activityRepository; + @Mock AnnouncementRepository announcementRepository; + @Mock AchievementDefinitionRepository achievementDefinitionRepository; + @Mock UserAchievementRepository userAchievementRepository; + @Mock UserRepository userRepository; + @Mock GroupMemberRepository groupMemberRepository; + @Mock PrivacySettingsService privacySettingsService; + @Spy ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks ShareTargetResolver resolver; + + @ParameterizedTest + @ValueSource(strings={"topic", "activity", "announcement", "badge", "user"}) + void buildsAllNewTypesWithExplicitNullFields(String type) throws Exception { + switch(type) { + case "topic": { + Topic t = new Topic(); t.setStatus("APPROVED"); t.setName("学习"); + when(topicRepository.findById("id")).thenReturn(Optional.of(t)); break; + } + case "activity": { + Activity a = new Activity(); a.setReviewStatus("approved"); a.setTitle("自习"); + when(activityRepository.findById("id")).thenReturn(Optional.of(a)); break; + } + case "announcement": { + Announcement a = new Announcement(); a.setStatus("approved"); a.setTitle("公告"); + when(announcementRepository.findById("id")).thenReturn(Optional.of(a)); break; + } + case "badge": { + AchievementDefinition a = new AchievementDefinition(); a.setName("徽章"); + when(achievementDefinitionRepository.findByCode("id")).thenReturn(Optional.of(a)); + when(userAchievementRepository.existsByUserIdAndAchievementCode("sender", "id")).thenReturn(true); break; + } + default: { + User u = new User(); u.setNickname("小核"); + when(userRepository.findById("id")).thenReturn(Optional.of(u)); + when(privacySettingsService.isProfileVisible("id")).thenReturn(true); + } + } + ShareCardDTO card = resolver.resolve(" " + type + " ", " id ", "sender"); + assertEquals(type.toUpperCase(Locale.ROOT), card.getTargetType()); + assertEquals("id", card.getTargetId()); + assertNotNull(card.getTitle()); + ObjectMapper mapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + com.fasterxml.jackson.databind.JsonNode json = mapper.valueToTree(card); + assertEquals(5, json.size()); assertTrue(json.get("coverUrl").isNull()); + assertTrue(json.has("summary")); + } + + @ParameterizedTest + @ValueSource(strings={"TOPIC", "ACTIVITY", "ANNOUNCEMENT", "BADGE", "USER"}) + void missingTargetsReturnExactBusinessMessage(String type) { + String message = "USER".equals(type) ? "用户不存在或不可用" : + "TOPIC".equals(type) ? "话题不存在或尚未上线" : + "ACTIVITY".equals(type) ? "活动不存在或已取消" : + "ANNOUNCEMENT".equals(type) ? "公告不存在或未发布" : "该徽章尚未获得,无法分享"; + assertEquals(message, assertThrows(BusinessException.class, + () -> resolver.resolve(type, "missing", "sender")).getMessage()); + } + + @Test void rejectsUnpublishedTopicAndCancelledActivity() { + Topic t = new Topic(); + when(topicRepository.findById("id")).thenReturn(Optional.of(t)); + assertEquals("话题不存在或尚未上线", assertThrows(BusinessException.class, + () -> resolver.resolve("TOPIC", "id", "sender")).getMessage()); + Activity a = new Activity(); a.setReviewStatus("approved"); a.setStatus("CANCELLED"); + when(activityRepository.findById("id")).thenReturn(Optional.of(a)); + assertEquals("活动不存在或已取消", assertThrows(BusinessException.class, + () -> resolver.resolve("ACTIVITY", "id", "sender")).getMessage()); + } + + @Test void rejectsScheduledAnnouncementAndLockedBadge() { + Announcement a = new Announcement(); a.setStatus("approved"); + a.setScheduledPublishAt(new Date(System.currentTimeMillis()+60000)); + when(announcementRepository.findById("id")).thenReturn(Optional.of(a)); + assertEquals("公告不存在或未发布", assertThrows(BusinessException.class, + () -> resolver.resolve("ANNOUNCEMENT", "id", "sender")).getMessage()); + when(achievementDefinitionRepository.findByCode("id")).thenReturn(Optional.of(new AchievementDefinition())); + assertEquals("该徽章尚未获得,无法分享", assertThrows(BusinessException.class, + () -> resolver.resolve("BADGE", "id", "sender")).getMessage()); + } + + @Test void rejectsHiddenProfile() { + when(userRepository.findById("id")).thenReturn(Optional.of(new User())); + assertEquals("对方设置了隐私保护,无法分享", assertThrows(BusinessException.class, + () -> resolver.resolve("USER", "id", "sender")).getMessage()); + } + + @Test void schoolRestrictedGroupChecksMembersAndExcludesPending() { + Activity a = new Activity(); a.setReviewStatus("approved"); a.setScope("学校A"); + when(activityRepository.findById("id")).thenReturn(Optional.of(a)); + GroupMember member = new GroupMember(); member.setUserId("viewer"); + GroupMember pending = new GroupMember(); pending.setUserId("pending"); pending.setRole("PENDING"); + when(groupMemberRepository.findByGroupId("group")).thenReturn(Arrays.asList(member,pending)); + User viewer = new User(); viewer.setId("viewer"); viewer.setUniversityName("学校B"); + when(userRepository.findAllById(Collections.singletonList("viewer"))).thenReturn(Collections.singletonList(viewer)); + ShareCardDTO card = new ShareCardDTO(); card.setTargetType("ACTIVITY"); card.setTargetId("id"); + assertEquals("群内存在无权查看该内容的成员,无法分享", assertThrows(BusinessException.class, + () -> resolver.validateGroup(card,"group")).getMessage()); + verify(userRepository, never()).save(any()); + verify(activityRepository, never()).save(any()); + } + + @Test void validatesTypeAndIdWithoutInferringType() { + assertEquals("分享目标类型不能为空", assertThrows(BusinessException.class, + () -> resolver.resolve(" ","id","sender")).getMessage()); + assertEquals("不支持的分享类型", assertThrows(BusinessException.class, + () -> resolver.resolve("IMAGE","id","sender")).getMessage()); + assertEquals("分享目标ID不能为空", assertThrows(BusinessException.class, + () -> resolver.resolve("TOPIC"," ","sender")).getMessage()); + verifyNoInteractions(topicRepository,activityRepository,announcementRepository,userRepository); + } +} diff --git a/campus-server/src/test/java/com/campus/service/TopicAutoReviewSchedulerTest.java b/campus-server/src/test/java/com/campus/service/TopicAutoReviewSchedulerTest.java new file mode 100644 index 0000000..c2e07e0 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/TopicAutoReviewSchedulerTest.java @@ -0,0 +1,99 @@ +package com.campus.service; + +import com.campus.entity.Topic; +import com.campus.repository.TopicRepository; +import com.campus.service.review.ReviewResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 存量 PENDING 补偿:开关关闭不跑、dry-run 只预览、apply 才真正落库。 + */ +@ExtendWith(MockitoExtension.class) +class TopicAutoReviewSchedulerTest { + + @Mock private TopicRepository topicRepository; + @Mock private ContentReviewService contentReviewService; + @InjectMocks private TopicAutoReviewScheduler scheduler; + + @Test + void doesNothingWhenAutoReviewDisabled() { + scheduler.reviewPendingTopics(); + + verifyNoInteractions(topicRepository, contentReviewService); + } + + @Test + void dryRunOnlyPreviewsAndDoesNotWrite() { + ReflectionTestUtils.setField(scheduler, "autoReviewEnabled", true); + Topic topic = topic("t1", "法轮功"); + when(topicRepository.findByStatus("PENDING")).thenReturn(Collections.singletonList(topic)); + when(contentReviewService.previewTopicReview("法轮功", "简介", null)) + .thenReturn(ReviewResult.rejected("包含敏感词:法轮功")); + + scheduler.reviewPendingTopics(); + + verify(contentReviewService, never()).reviewTopicSync(any(), any(), any(), any()); + verify(topicRepository, never()).save(any(Topic.class)); + assertEquals("PENDING", topic.getStatus(), "dry-run 不改数据"); + } + + @Test + void applyModeRunsAutoReviewForEachPendingTopic() { + ReflectionTestUtils.setField(scheduler, "autoReviewEnabled", true); + ReflectionTestUtils.setField(scheduler, "backfillApply", true); + Topic first = topic("t1", "学习交流"); + Topic second = topic("t2", "校园日常"); + when(topicRepository.findByStatus("PENDING")).thenReturn(Arrays.asList(first, second)); + + scheduler.reviewPendingTopics(); + + verify(contentReviewService).reviewTopicSync("t1", "学习交流", "简介", null); + verify(contentReviewService).reviewTopicSync("t2", "校园日常", "简介", null); + verify(contentReviewService, never()).previewTopicReview(any(), any(), any()); + } + + @Test + void emptyPendingListSkipsPreview() { + ReflectionTestUtils.setField(scheduler, "autoReviewEnabled", true); + when(topicRepository.findByStatus("PENDING")).thenReturn(Collections.emptyList()); + + scheduler.reviewPendingTopics(); + + verifyNoInteractions(contentReviewService); + } + + @Test + void switchSummaryReflectsInjectedFlags() { + ReflectionTestUtils.setField(scheduler, "autoReviewEnabled", true); + ReflectionTestUtils.setField(scheduler, "backfillApply", false); + + assertEquals("enabled=true, backfill-apply=false", scheduler.switchSummary()); + + ReflectionTestUtils.setField(scheduler, "backfillApply", true); + assertEquals("enabled=true, backfill-apply=true", scheduler.switchSummary()); + } + + private Topic topic(String id, String name) { + Topic topic = new Topic(); + topic.setId(id); + topic.setName(name); + topic.setDescription("简介"); + topic.setStatus("PENDING"); + return topic; + } +} diff --git a/campus-server/src/test/java/com/campus/service/TopicDeleteTest.java b/campus-server/src/test/java/com/campus/service/TopicDeleteTest.java new file mode 100644 index 0000000..025873d --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/TopicDeleteTest.java @@ -0,0 +1,147 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.entity.Topic; +import com.campus.entity.User; +import com.campus.repository.TopicAliasRepository; +import com.campus.repository.TopicFavoriteRepository; +import com.campus.repository.TopicFollowRepository; +import com.campus.repository.TopicHotSnapshotRepository; +import com.campus.repository.TopicRepository; +import com.campus.repository.TopicVisitRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 话题删除:软删 + 改名释放名称。 + * + *

核心断言:话题名与「按名字关联」的关注/访问/热度快照、以及别名都要一起改名, + * 这样原名可以重新创建,且同名新话题不会继承旧数据。

+ */ +@ExtendWith(MockitoExtension.class) +class TopicDeleteTest { + + @Mock private TopicFollowRepository followRepository; + @Mock private TopicRepository topicRepository; + @Mock private TopicAliasRepository aliasRepository; + @Mock private TopicHotSnapshotRepository snapshotRepository; + @Mock private TopicVisitRepository visitRepository; + @Mock private TopicFavoriteRepository favoriteRepository; + @Mock private AuditLogService auditLogService; + @Mock private ContentReviewService contentReviewService; + @Mock private ClickGuardService clickGuardService; + + private TopicService service; + + @BeforeEach + void setUp() { + service = new TopicService(followRepository, topicRepository, aliasRepository, + snapshotRepository, visitRepository, favoriteRepository, new AuthorizationService(), + auditLogService, contentReviewService, clickGuardService, + org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void ownerDeleteRenamesTopicAndNameKeyedRows() { + login("owner"); + Topic topic = topic("t1", "话题发布测试", "owner"); + when(topicRepository.findById("t1")).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(Topic.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.deleteTopic("t1"); + + assertEquals(Boolean.TRUE, topic.getDeleted()); + String expected = "话题发布测试_deleted_t1"; + assertEquals(expected, topic.getName()); + verify(followRepository).renameTopicName("话题发布测试", expected); + verify(favoriteRepository).renameTopicName("话题发布测试", expected); + verify(visitRepository).renameTopicName("话题发布测试", expected); + verify(snapshotRepository).renameTopicName("话题发布测试", expected); + verify(aliasRepository).appendSuffixToAliases("t1", "_deleted_t1"); + verify(auditLogService).record(eq("TOPIC_DELETE"), eq("TOPIC"), eq("t1"), + eq("name=话题发布测试"), eq("deleted, name=" + expected), isNull()); + } + + @Test + void longNameIsTruncatedWithinColumnLimit() { + login("owner"); + StringBuilder name = new StringBuilder(); + for (int i = 0; i < 95; i++) { + name.append('话'); + } + Topic topic = topic("1234567890", name.toString(), "owner"); + when(topicRepository.findById("1234567890")).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(Topic.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.deleteTopic("1234567890"); + + assertTrue(topic.getName().length() <= 100, "name 列是 varchar(100)"); + assertTrue(topic.getName().endsWith("_deleted_12345678")); + } + + @Test + void nonOwnerCannotDelete() { + login("other"); + when(topicRepository.findById("t1")).thenReturn(Optional.of(topic("t1", "话题", "owner"))); + + assertThrows(ForbiddenException.class, () -> service.deleteTopic("t1")); + + verify(topicRepository, never()).save(any(Topic.class)); + verifyNoInteractions(followRepository, visitRepository, snapshotRepository, aliasRepository); + } + + @Test + void missingTopicIsRejected() { + login("owner"); + when(topicRepository.findById("missing")).thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.deleteTopic("missing")); + + assertEquals("话题不存在", error.getMessage()); + verifyNoInteractions(followRepository, visitRepository, snapshotRepository, + aliasRepository, auditLogService); + } + + private void login(String userId) { + User user = new User(); + user.setId(userId); + user.setIsAdmin(false); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + private Topic topic(String id, String name, String createdBy) { + Topic topic = new Topic(); + topic.setId(id); + topic.setName(name); + topic.setCreatedBy(createdBy); + topic.setStatus("APPROVED"); + return topic; + } +} diff --git a/campus-server/src/test/java/com/campus/service/TopicDiscoveryServiceTest.java b/campus-server/src/test/java/com/campus/service/TopicDiscoveryServiceTest.java new file mode 100644 index 0000000..b981f03 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/TopicDiscoveryServiceTest.java @@ -0,0 +1,309 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ForbiddenException; +import com.campus.common.PageResult; +import com.campus.dto.response.MyTopicsDTO; +import com.campus.dto.response.TopicDTO; +import com.campus.entity.Topic; +import com.campus.entity.TopicAlias; +import com.campus.entity.TopicFollow; +import com.campus.entity.TopicHotSnapshot; +import com.campus.entity.TopicVisit; +import com.campus.entity.User; +import com.campus.repository.TopicAliasRepository; +import com.campus.repository.TopicFavoriteRepository; +import com.campus.repository.TopicFollowRepository; +import com.campus.repository.TopicHotSnapshotRepository; +import com.campus.repository.TopicRepository; +import com.campus.repository.TopicVisitRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 话题广场读路径:只依赖话题域数据,热度由关注/访问计算。 + * + *

本测试类里没有任何 MomentRepository / MomentService 的 mock —— 这正是解耦的体现: + * TopicService 的构造器已经不再接受动态相关依赖,编译期即可保证不读动态表。

+ */ +@ExtendWith(MockitoExtension.class) +class TopicDiscoveryServiceTest { + @Mock private TopicFollowRepository followRepository; + @Mock private TopicRepository topicRepository; + @Mock private TopicAliasRepository aliasRepository; + @Mock private TopicHotSnapshotRepository snapshotRepository; + @Mock private TopicVisitRepository visitRepository; + @Mock private TopicFavoriteRepository favoriteRepository; + @Mock private AuditLogService auditLogService; + @Mock private ContentReviewService contentReviewService; + @Mock private ClickGuardService clickGuardService; + + private TopicService service; + + @BeforeEach + void setUp() { + service = new TopicService(followRepository, topicRepository, aliasRepository, + snapshotRepository, visitRepository, favoriteRepository, new AuthorizationService(), + auditLogService, contentReviewService, clickGuardService, + org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void catalogOnlyContainsCompleteApprovedTopics() { + Topic approved = topic("校园日常", "APPROVED", "校园话题", "校园日常"); + Topic pending = topic("待审核话题", "PENDING", "等审核", "学习成长"); + Topic shell = topic("game-sports", "APPROVED", null, null); + when(topicRepository.findAll()).thenReturn(Arrays.asList(approved, pending, shell)); + + PageResult page = service.catalog(null, null, "hot", 1, 20); + + assertEquals(1, page.getTotal()); + assertEquals("校园日常", page.getItems().get(0).getName()); + assertEquals(0L, page.getItems().get(0).getVisitCount()); + } + + @Test + void shellTopicsAreHiddenEverywhere() { + Topic shell = topic("challenge-growth", "APPROVED", null, null); + when(topicRepository.findAll()).thenReturn(Collections.singletonList(shell)); + + assertTrue(service.listTopics(20).isEmpty()); + assertTrue(service.rankings("hot", 20).isEmpty()); + assertTrue(service.recommendations(10).isEmpty()); + assertEquals(0, service.captureHotSnapshot()); + verify(snapshotRepository, never()).save(any(TopicHotSnapshot.class)); + } + + @Test + void catalogFiltersByCategoryAndKeyword() { + Topic campus = topic("校园日常", "APPROVED", "校园里的事", "校园日常"); + Topic study = topic("期末复习", "APPROVED", "一起复习", "学习成长"); + when(topicRepository.findAll()).thenReturn(Arrays.asList(campus, study)); + + PageResult result = service.catalog("期末", "学习成长", "latest", 1, 20); + + assertEquals(1, result.getTotal()); + assertEquals("期末复习", result.getItems().get(0).getName()); + } + + @Test + void heatOnlyUsesTopicDomainMetrics() { + Topic topic = topic("话题A", "APPROVED", "简介", "校园日常"); + when(topicRepository.findAll()).thenReturn(Collections.singletonList(topic)); + when(followRepository.countGroupByTopicNameIn(anyCollection())).thenReturn(rows("话题A", 3L)); + when(visitRepository.countGroupByTopicNameIn(anyCollection())).thenReturn(rows("话题A", 2L)); + when(followRepository.countRecentGroupByTopicNameIn(anyCollection(), any(Date.class))) + .thenReturn(rows("话题A", 1L)); + when(visitRepository.countRecentGroupByTopicNameIn(anyCollection(), any(Date.class))) + .thenReturn(rows("话题A", 4L)); + + TopicDTO dto = service.listTopics(10).get(0); + + // 3*1(关注) + 2*1(访问) + 1*5(近7天关注) + 4*3(近7天访问) = 22 + assertEquals(22L, dto.getHeatScore()); + assertEquals(3L, dto.getFollowerCount()); + assertEquals(2L, dto.getVisitCount()); + } + + @Test + void heatOrderIsStableWhenAllMetricsAreZero() { + Topic second = topic("话题B", "APPROVED", "简介", "校园日常"); + Topic first = topic("话题A", "APPROVED", "简介", "校园日常"); + when(topicRepository.findAll()).thenReturn(Arrays.asList(second, first)); + + List names = service.listTopics(10).stream() + .map(TopicDTO::getName).collect(Collectors.toList()); + + assertEquals(Arrays.asList("话题A", "话题B"), names); + } + + @Test + void risingRankingUsesSnapshotDeltaOfTopicHeat() { + Topic topic = topic("期末挑战", "APPROVED", "简介", "学习成长"); + when(topicRepository.findAll()).thenReturn(Collections.singletonList(topic)); + when(followRepository.countGroupByTopicNameIn(anyCollection())).thenReturn(rows("期末挑战", 10L)); + TopicHotSnapshot old = snapshot("期末挑战", 5L, + new Date(System.currentTimeMillis() - 60L * 60 * 1000)); + when(snapshotRepository.findByCapturedAtBetween(any(Date.class), any(Date.class))) + .thenReturn(Collections.singletonList(old)); + + TopicDTO dto = service.rankings("rising", 10).get(0); + + assertEquals(10L, dto.getHeatScore()); + assertEquals(5L, dto.getHeatDelta()); + assertEquals(100, dto.getHeatChangePercent()); + } + + @Test + void newRankingExcludesTopicsCreatedLongAgo() { + Topic fresh = topic("新话题", "APPROVED", "简介", "校园日常"); + fresh.setCreatedAt(new Date()); + Topic old = topic("老话题", "APPROVED", "简介", "校园日常"); + old.setCreatedAt(new Date(System.currentTimeMillis() - 30L * 24 * 60 * 60 * 1000)); + when(topicRepository.findAll()).thenReturn(Arrays.asList(fresh, old)); + + List names = service.rankings("new", 10).stream() + .map(TopicDTO::getName).collect(Collectors.toList()); + + assertEquals(Collections.singletonList("新话题"), names); + } + + @Test + void myTopicsHasFollowingAndRecentVisits() { + login(); + Topic following = topic("校园日常", "APPROVED", "简介", "校园日常"); + when(topicRepository.findAll()).thenReturn(Collections.singletonList(following)); + TopicFollow follow = new TopicFollow(); + follow.setUserId("user"); + follow.setTopicName("校园日常"); + when(followRepository.findByUserId("user")).thenReturn(Collections.singletonList(follow)); + when(topicRepository.findByCreatedByOrderByCreatedAtDesc("user")).thenReturn(Collections.emptyList()); + TopicVisit visit = new TopicVisit(); + visit.setUserId("user"); + visit.setTopicName("校园日常"); + visit.setLastVisitedAt(new Date()); + when(visitRepository.findByUserIdOrderByLastVisitedAtDesc("user")) + .thenReturn(Collections.singletonList(visit)); + + MyTopicsDTO result = service.myTopics(); + + assertEquals(1, result.getFollowingCount()); + assertEquals(0, result.getCreatedCount()); + assertEquals(1, result.getFollowing().size()); + assertEquals(1, result.getRecentVisits().size()); + assertEquals("校园日常", result.getRecentVisits().get(0).getName()); + } + + @Test + void visitingTopicStoresCurrentUsersLastVisit() { + login(); + Topic topic = topic("校园日常", "APPROVED", "简介", "校园日常"); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(topic)); + + service.recordVisit("校园日常"); + + verify(visitRepository).save(argThat(visit -> "user".equals(visit.getUserId()) + && topic.getId().equals(visit.getTopicId()) && "校园日常".equals(visit.getTopicName()) && visit.getLastVisitedAt() != null)); + } + + @Test + void rejectedTopicCannotBeFollowed() { + login(); + when(topicRepository.findByNameForInteraction("未上线")) + .thenReturn(Optional.of(topic("未上线", "REJECTED", "简介", "校园日常"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.toggleFollow("未上线")); + + assertEquals("话题不存在或尚未上线", error.getMessage()); + verify(followRepository, never()).save(any(TopicFollow.class)); + } + + @Test + void normalUserCannotReadReviewQueue() { + login(); + + ForbiddenException error = assertThrows(ForbiddenException.class, () -> service.pendingTopics()); + + assertEquals("仅审核员或管理员可以执行该操作", error.getMessage()); + verify(topicRepository, never()).findByStatus("PENDING"); + } + + @Test + void unauthenticatedFollowReturnsBusinessExceptionInsteadOf500() { + BusinessException error = assertThrows(BusinessException.class, + () -> service.toggleFollow("校园日常")); + + assertEquals("用户未登录", error.getMessage()); + } + + @Test + void captureHotSnapshotWritesTopicMetricsAndKeepsLegacyColumnZero() { + Topic topic = topic("校园日常", "APPROVED", "简介", "校园日常"); + when(topicRepository.findAll()).thenReturn(Collections.singletonList(topic)); + when(followRepository.countGroupByTopicNameIn(anyCollection())).thenReturn(rows("校园日常", 2L)); + when(visitRepository.countGroupByTopicNameIn(anyCollection())).thenReturn(rows("校园日常", 7L)); + + int captured = service.captureHotSnapshot(); + + assertEquals(1, captured); + verify(snapshotRepository).save(argThat(argument -> topic.getId().equals(argument.getTopicId()) && argument.getMomentCount() == 0L + && argument.getFollowerCount() == 2L + && argument.getVisitCount() == 7L + && argument.getHeatScore() == 9L + && argument.getCapturedAt() != null)); + } + + @Test + void detailIsResolvedThroughAlias() { + Topic topic = topic("校园日常", "APPROVED", "简介", "校园日常"); + TopicAlias alias = new TopicAlias(); + alias.setTopicId(topic.getId()); + alias.setAlias("校园生活"); + when(aliasRepository.findByAlias("校园生活")).thenReturn(Optional.of(alias)); + when(topicRepository.findById(topic.getId())).thenReturn(Optional.of(topic)); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(topic)); + + TopicDTO dto = service.getTopic("校园生活"); + + assertEquals("校园日常", dto.getName()); + } + + private List rows(String name, long count) { + return Collections.singletonList(new Object[]{name, count}); + } + + private Topic topic(String name, String status, String description, String category) { + Topic topic = new Topic(); + topic.setId(name); + topic.setName(name); + topic.setStatus(status); + topic.setDescription(description); + topic.setCategory(category); + return topic; + } + + private TopicHotSnapshot snapshot(String name, long heat, Date capturedAt) { + TopicHotSnapshot snapshot = new TopicHotSnapshot(); + snapshot.setTopicName(name); + snapshot.setHeatScore(heat); + snapshot.setFollowerCount(0L); + snapshot.setCapturedAt(capturedAt); + return snapshot; + } + + private void login() { + User user = new User(); + user.setId("user"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } +} diff --git a/campus-server/src/test/java/com/campus/service/TopicDiscussionServiceTest.java b/campus-server/src/test/java/com/campus/service/TopicDiscussionServiceTest.java new file mode 100644 index 0000000..549a345 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/TopicDiscussionServiceTest.java @@ -0,0 +1,266 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.PageResult; +import com.campus.dto.request.TopicDiscussionRequest; +import com.campus.dto.response.MomentDTO; +import com.campus.entity.Topic; +import com.campus.entity.TopicAlias; +import com.campus.entity.User; +import com.campus.repository.TopicAliasRepository; +import com.campus.repository.TopicRepository; +import com.campus.service.event.TopicMergedEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 话题讨论桥接层:话题解析 / 上线校验 / 委托动态侧存储 / 合并迁移。 + * + *

本类只验证"话题域 ↔ 讨论存储"的拼接逻辑;讨论落库口径由 {@link MomentTopicDiscussionTest} 覆盖。

+ */ +@ExtendWith(MockitoExtension.class) +class TopicDiscussionServiceTest { + + @Mock private TopicRepository topicRepository; + @Mock private TopicAliasRepository topicAliasRepository; + @Mock private MomentService momentService; + @Mock private ClickGuardService clickGuardService; + + @InjectMocks private TopicDiscussionService service; + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void publishRejectsUnauthenticatedUserWithExactMessage() { + when(topicRepository.findByName("宿舍夜谈")).thenReturn(Optional.of(topic("宿舍夜谈", "APPROVED"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.publish("宿舍夜谈", request("讨论测试"))); + + assertEquals("用户未登录", error.getMessage()); + verify(momentService, never()).createTopicDiscussion(any(), any(), any(), any(), any(), any()); + } + + @Test + void publishRejectsUnknownTopic() { + login("u1"); + when(topicRepository.findByName("宿舍夜谈")).thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.publish("宿舍夜谈", request("讨论测试"))); + + assertEquals("话题不存在或尚未上线", error.getMessage()); + verifyNoInteractions(momentService); + } + + @Test + void publishRejectsTopicThatIsNotApproved() { + login("u1"); + when(topicRepository.findByName("宿舍夜谈")).thenReturn(Optional.of(topic("宿舍夜谈", "PENDING"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.publish("宿舍夜谈", request("讨论测试"))); + + assertEquals("话题不存在或尚未上线", error.getMessage()); + verifyNoInteractions(momentService); + } + + @Test + void publishResolvesAliasToTargetTopic() { + login("u1"); + TopicAlias alias = new TopicAlias(); + alias.setAlias("夜谈"); + alias.setTopicId("t-100"); + when(topicAliasRepository.findByAlias("夜谈")).thenReturn(Optional.of(alias)); + when(topicRepository.findById("t-100")).thenReturn(Optional.of(topic("宿舍夜谈", "APPROVED"))); + when(topicRepository.findByName("宿舍夜谈")).thenReturn(Optional.of(topic("宿舍夜谈", "APPROVED"))); + when(momentService.createTopicDiscussion(eq("t-100"), eq("宿舍夜谈"), eq("讨论测试"), + any(), any(), any())).thenReturn(dto("m1", "t-100")); + + service.publish("#夜谈", request("讨论测试")); + + verify(momentService).createTopicDiscussion(eq("t-100"), eq("宿舍夜谈"), eq("讨论测试"), + any(), any(), any()); + } + + @Test + void publishDelegatesToMomentDomainAndFillsTopicName() { + login("u1"); + when(topicRepository.findByName("宿舍夜谈")).thenReturn(Optional.of(topic("宿舍夜谈", "APPROVED"))); + when(momentService.createTopicDiscussion(eq("t-100"), eq("宿舍夜谈"), eq("讨论测试"), + any(), any(), any())).thenReturn(dto("m1", "t-100")); + + MomentDTO result = service.publish("宿舍夜谈", request("讨论测试")); + + assertEquals("TOPIC", result.getContentSource()); + assertEquals("t-100", result.getTopicId()); + assertEquals("宿舍夜谈", result.getTopicName()); + verify(clickGuardService).ensureNotTooFrequent( + ClickGuardService.SCOPE_TOPIC_DISCUSSION, "u1", "t-100"); + } + + @Test + void listFillsTopicNameFromTopicTable() { + when(topicRepository.findByName("宿舍夜谈")).thenReturn(Optional.of(topic("宿舍夜谈", "APPROVED"))); + when(momentService.listTopicDiscussions("t-100", "hot", 1, 20)) + .thenReturn(PageResult.of(Collections.singletonList(dto("m1", "t-100")), 1, 1, 20)); + when(topicRepository.findAllById(any())).thenReturn(Collections.singletonList(topic("宿舍夜谈", "APPROVED"))); + + PageResult result = service.list("宿舍夜谈", "hot", 1, 20); + + assertEquals(1, result.getTotal()); + assertEquals("宿舍夜谈", result.getItems().get(0).getTopicName()); + } + + @Test + void listAcceptsHashPrefixInPathVariable() { + when(topicRepository.findByName("宿舍夜谈")).thenReturn(Optional.of(topic("宿舍夜谈", "APPROVED"))); + when(momentService.listTopicDiscussions("t-100", "latest", 1, 20)) + .thenReturn(PageResult.empty(1, 20)); + + service.list("#宿舍夜谈", "latest", 1, 20); + + verify(momentService).listTopicDiscussions("t-100", "latest", 1, 20); + } + + @Test + void myDiscussionsAreDelegatedWithOptionalTopicFilter() { + when(momentService.listMyDiscussions("t-100", 1, 20)) + .thenReturn(PageResult.of(Collections.singletonList(dto("m1", "t-100")), 1, 1, 20)); + when(topicRepository.findAllById(any())).thenReturn(Collections.singletonList(topic("宿舍夜谈", "APPROVED"))); + + PageResult result = service.listMine("t-100", 1, 20); + + assertEquals("宿舍夜谈", result.getItems().get(0).getTopicName()); + verify(momentService).listMyDiscussions("t-100", 1, 20); + } + + @Test + void mergedTopicMigratesItsDiscussions() { + service.onTopicMerged(new TopicMergedEvent("t-source", "t-target")); + + verify(momentService).migrateTopicDiscussions("t-source", "t-target"); + } + + @Test + void mergedEventIgnoresMissingIds() { + service.onTopicMerged(new TopicMergedEvent(null, null)); + + verify(momentService, never()).migrateTopicDiscussions(anyString(), anyString()); + } + + @Test + void updateRequiresLogin() { + BusinessException error = assertThrows(BusinessException.class, + () -> service.update("m1", request("改过的讨论"))); + + assertEquals("用户未登录", error.getMessage()); + verifyNoInteractions(momentService); + } + + @Test + void updateDelegatesToMomentDomainAndFillsTopicName() { + login("u1"); + when(momentService.updateTopicDiscussion(eq("m1"), eq("改过的讨论"), any(), any(), any())) + .thenReturn(dto("m1", "t-100")); + when(topicRepository.findAllById(any())) + .thenReturn(Collections.singletonList(topic("宿舍夜谈", "APPROVED"))); + + MomentDTO result = service.update("m1", request("改过的讨论")); + + assertEquals("宿舍夜谈", result.getTopicName()); + verify(momentService).updateTopicDiscussion(eq("m1"), eq("改过的讨论"), any(), any(), any()); + } + + @Test + void deleteDelegatesToMomentDomain() { + service.delete("m1"); + + verify(momentService).deleteTopicDiscussion("m1"); + } + + @Test + void unresolvedTopicNameFallsBackToOriginalName() { + // 别名存在但话题已被物理删除:仍按原名查,查不到就是「不存在或尚未上线」 + TopicAlias alias = new TopicAlias(); + alias.setAlias("夜谈"); + alias.setTopicId("t-gone"); + when(topicAliasRepository.findByAlias("夜谈")).thenReturn(Optional.of(alias)); + when(topicRepository.findById("t-gone")).thenReturn(Optional.empty()); + when(topicRepository.findByName("夜谈")).thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.list("夜谈", "latest", 1, 20)); + + assertEquals("话题不存在或尚未上线", error.getMessage()); + assertNull(error.getCause()); + } + + private void login(String userId) { + User user = new User(); + user.setId(userId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + } + + private TopicDiscussionRequest request(String content) { + TopicDiscussionRequest request = new TopicDiscussionRequest(); + request.setContent(content); + request.setMediaUrls(Arrays.asList("https://cdn.example.com/a.jpg")); + return request; + } + + private Topic topic(String name, String status) { + Topic topic = new Topic(); + topic.setId("t-100"); + topic.setName(name); + topic.setStatus(status); + return topic; + } + + private MomentDTO dto(String id, String topicId) { + MomentDTO dto = new MomentDTO(); + dto.setId(id); + dto.setTopicId(topicId); + dto.setContentSource("TOPIC"); + return dto; + } + + /** 防御性:列表为空时不应去查话题名。 */ + @Test + void emptyListSkipsTopicNameLookup() { + List items = Collections.emptyList(); + when(topicRepository.findByName("宿舍夜谈")).thenReturn(Optional.of(topic("宿舍夜谈", "APPROVED"))); + when(momentService.listTopicDiscussions("t-100", "latest", 1, 20)) + .thenReturn(PageResult.of(items, 0, 1, 20)); + + service.list("宿舍夜谈", "latest", 1, 20); + + verify(topicRepository, never()).findAllById(any()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/TopicDiscussionVideoSweeperTest.java b/campus-server/src/test/java/com/campus/service/TopicDiscussionVideoSweeperTest.java new file mode 100644 index 0000000..819ba0b --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/TopicDiscussionVideoSweeperTest.java @@ -0,0 +1,91 @@ +package com.campus.service; + +import com.campus.entity.Moment; +import com.campus.repository.MomentRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 话题讨论视频「播放地址」补偿任务的编排逻辑。 + * + *

锁三件事:① 没有待补的讨论时不去碰 VOD;② 逐条补拉;③ 单条失败不中断整批、 + * 查询失败不抛到调度器({@code @Scheduled} 抛异常会被静默吞掉,等于任务假死)。

+ * + *

真正的 VOD 调用({@code VodService.pullPlayInfoAndApply})依赖阿里云客户端, + * 这里只验证编排;它的回填逻辑由 {@code VodService} 承担。

+ */ +@ExtendWith(MockitoExtension.class) +class TopicDiscussionVideoSweeperTest { + + @Mock private MomentRepository momentRepository; + @Mock private VodService vodService; + + @InjectMocks private TopicDiscussionVideoSweeper sweeper; + + @Test + void doesNothingWhenNoDiscussionIsWaiting() { + when(momentRepository.findTopicDiscussionsAwaitingVideo(any(Date.class), any(Pageable.class))) + .thenReturn(Collections.emptyList()); + + sweeper.sweep(); + + verifyNoInteractions(vodService); + } + + @Test + void pullsPlayInfoForEveryWaitingDiscussion() { + when(momentRepository.findTopicDiscussionsAwaitingVideo(any(Date.class), any(Pageable.class))) + .thenReturn(Arrays.asList(moment("m1", "v1"), moment("m2", "v2"))); + when(vodService.pullPlayInfoAndApply(anyString())).thenReturn(true); + + sweeper.sweep(); + + verify(vodService).pullPlayInfoAndApply("v1"); + verify(vodService).pullPlayInfoAndApply("v2"); + } + + @Test + void oneFailureDoesNotAbortTheRestOfTheBatch() { + when(momentRepository.findTopicDiscussionsAwaitingVideo(any(Date.class), any(Pageable.class))) + .thenReturn(Arrays.asList(moment("m1", "v1"), moment("m2", "v2"))); + when(vodService.pullPlayInfoAndApply("v1")).thenThrow(new RuntimeException("阿里云超时")); + when(vodService.pullPlayInfoAndApply("v2")).thenReturn(true); + + assertDoesNotThrow(() -> sweeper.sweep()); + + verify(vodService).pullPlayInfoAndApply("v2"); + } + + @Test + void queryFailureIsSwallowedInsteadOfKillingTheScheduler() { + when(momentRepository.findTopicDiscussionsAwaitingVideo(any(Date.class), any(Pageable.class))) + .thenThrow(new RuntimeException("数据库抖动")); + + assertDoesNotThrow(() -> sweeper.sweep()); + + verifyNoInteractions(vodService); + } + + private Moment moment(String id, String vodVideoId) { + Moment moment = new Moment(); + moment.setId(id); + moment.setVodVideoId(vodVideoId); + moment.setContentSource(Moment.SOURCE_TOPIC); + return moment; + } +} diff --git a/campus-server/src/test/java/com/campus/service/TopicFavoriteTest.java b/campus-server/src/test/java/com/campus/service/TopicFavoriteTest.java new file mode 100644 index 0000000..4e0e564 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/TopicFavoriteTest.java @@ -0,0 +1,247 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.TooManyRequestsException; +import com.campus.dto.response.MyTopicsDTO; +import com.campus.dto.response.TopicDTO; +import com.campus.dto.response.TopicFavoriteDTO; +import com.campus.entity.Topic; +import com.campus.entity.TopicAlias; +import com.campus.entity.TopicFavorite; +import com.campus.entity.User; +import com.campus.repository.TopicAliasRepository; +import com.campus.repository.TopicFavoriteRepository; +import com.campus.repository.TopicFollowRepository; +import com.campus.repository.TopicHotSnapshotRepository; +import com.campus.repository.TopicRepository; +import com.campus.repository.TopicVisitRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** 话题收藏:toggle 语义、可见性、连点、并发兜底,以及收藏数进入列表/「我的话题」。 */ +@ExtendWith(MockitoExtension.class) +class TopicFavoriteTest { + + @Mock private TopicFollowRepository followRepository; + @Mock private TopicRepository topicRepository; + @Mock private TopicAliasRepository aliasRepository; + @Mock private TopicHotSnapshotRepository snapshotRepository; + @Mock private TopicVisitRepository visitRepository; + @Mock private TopicFavoriteRepository favoriteRepository; + @Mock private AuditLogService auditLogService; + @Mock private ContentReviewService contentReviewService; + @Mock private ClickGuardService clickGuardService; + + private TopicService service; + + @BeforeEach + void setUp() { + org.mockito.Mockito.lenient().when(topicRepository.findByNameForInteraction(anyString())) + .thenAnswer(inv -> topicRepository.findByName(inv.getArgument(0))); + service = new TopicService(followRepository, topicRepository, aliasRepository, + snapshotRepository, visitRepository, favoriteRepository, new AuthorizationService(), + auditLogService, contentReviewService, clickGuardService, + org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + private void login() { + User user = new User(); + user.setId("user"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } + + private Topic topic(String name, String status) { + Topic topic = new Topic(); + topic.setId("t1"); + topic.setName(name); + topic.setStatus(status); + topic.setDescription("简介"); + topic.setCategory("校园日常"); + topic.setCreatedAt(new Date()); + return topic; + } + + @Test + void followAlsoWritesStableIdInsteadOfUsingNameAsIdentity() { + login(); + Topic record = topic("校园日常", "APPROVED"); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(record)); + service.toggleFollow("校园日常"); + verify(followRepository).upsertActive(anyString(), eq("user"), eq("校园日常"), eq("t1")); + } + + @Test + void rejectsWithExactMessageWhenNotLoggedIn() { + BusinessException error = assertThrows(BusinessException.class, + () -> service.toggleFavorite("校园日常")); + + assertEquals("用户未登录", error.getMessage()); + verifyNoInteractions(favoriteRepository); + } + + @Test + void rejectsMissingTopicAndWritesNothing() { + login(); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.empty()); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.toggleFavorite("校园日常")); + + assertEquals("话题不存在或尚未上线", error.getMessage()); + verifyNoInteractions(favoriteRepository); + } + + @Test + void rejectsPendingTopicAndWritesNothing() { + login(); + when(topicRepository.findByName("未上线")).thenReturn(Optional.of(topic("未上线", "PENDING"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.toggleFavorite("未上线")); + + assertEquals("话题不存在或尚未上线", error.getMessage()); + verifyNoInteractions(favoriteRepository); + } + + @Test + void firstFavoriteInsertsRowAndReturnsCount() { + login(); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(topic("校园日常", "APPROVED"))); + when(favoriteRepository.countByTopicName("校园日常")).thenReturn(1L); + + TopicFavoriteDTO dto = service.toggleFavorite("校园日常"); + + assertEquals("校园日常", dto.getTopicName()); + assertEquals(Boolean.TRUE, dto.getFavorited()); + assertEquals(Long.valueOf(1L), dto.getFavoriteCount()); + verify(favoriteRepository).insertIgnore(anyString(), eq("user"), eq("校园日常"), eq("t1")); + verify(favoriteRepository, never()).delete(any(TopicFavorite.class)); + } + + @Test + void secondFavoriteRemovesRowAndReturnsFalse() { + login(); + TopicFavorite existing = new TopicFavorite(); + existing.setUserId("user"); + existing.setTopicName("校园日常"); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(topic("校园日常", "APPROVED"))); + when(favoriteRepository.findForInteraction("user", "校园日常")) + .thenReturn(Optional.of(existing)); + when(favoriteRepository.countByTopicName("校园日常")).thenReturn(0L); + + TopicFavoriteDTO dto = service.toggleFavorite("校园日常"); + + assertEquals(Boolean.FALSE, dto.getFavorited()); + assertEquals(Long.valueOf(0L), dto.getFavoriteCount()); + verify(favoriteRepository).delete(existing); + verify(favoriteRepository, never()).insertIgnore(anyString(), anyString(), anyString(), any()); + } + + /** 并发首次收藏:另一个请求已插入,upsert 影响 0 行,也要当作收藏成功,不能抛 500。 */ + @Test + void concurrentInsertIsTreatedAsAlreadyFavorited() { + login(); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(topic("校园日常", "APPROVED"))); + when(favoriteRepository.insertIgnore(anyString(), anyString(), anyString(), any())).thenReturn(0); + when(favoriteRepository.countByTopicName("校园日常")).thenReturn(1L); + + TopicFavoriteDTO dto = service.toggleFavorite("校园日常"); + + assertEquals(Boolean.TRUE, dto.getFavorited()); + } + + @Test + void clickGuardBlocksRapidSecondClick() { + login(); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(topic("校园日常", "APPROVED"))); + doThrow(new TooManyRequestsException("点击过于频繁,操作失败")) + .when(clickGuardService).ensureNotTooFrequent(anyString(), anyString(), anyString()); + + TooManyRequestsException error = assertThrows(TooManyRequestsException.class, + () -> service.toggleFavorite("校园日常")); + + assertEquals("点击过于频繁,操作失败", error.getMessage()); + verifyNoInteractions(favoriteRepository); + } + + @Test + void aliasResolvesToMainTopicName() { + login(); + TopicAlias alias = new TopicAlias(); + alias.setTopicId("t1"); + alias.setAlias("校园"); + Topic main = topic("校园日常", "APPROVED"); + when(aliasRepository.findByAlias("校园")).thenReturn(Optional.of(alias)); + when(topicRepository.findById("t1")).thenReturn(Optional.of(main)); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(main)); + when(favoriteRepository.countByTopicName("校园日常")).thenReturn(1L); + + TopicFavoriteDTO dto = service.toggleFavorite("校园"); + + assertEquals("校园日常", dto.getTopicName()); + verify(favoriteRepository).insertIgnore(anyString(), eq("user"), eq("校园日常"), eq("t1")); + } + + @Test + void listIncludesFavoriteCount() { + login(); + when(topicRepository.findAll()).thenReturn(Collections.singletonList(topic("校园日常", "APPROVED"))); + when(favoriteRepository.countGroupByTopicNameIn(anyCollection())) + .thenReturn(Collections.singletonList(new Object[]{"校园日常", 3L})); + + List topics = service.listTopics(20); + + assertEquals(1, topics.size()); + assertEquals(Long.valueOf(3L), topics.get(0).getFavoriteCount()); + } + + @Test + void myTopicsIncludesFavorites() { + login(); + TopicFavorite favorite = new TopicFavorite(); + favorite.setUserId("user"); + favorite.setTopicName("校园日常"); + when(topicRepository.findAll()).thenReturn(Collections.singletonList(topic("校园日常", "APPROVED"))); + when(favoriteRepository.findByUserIdOrderByCreatedAtDesc("user")) + .thenReturn(Collections.singletonList(favorite)); + when(followRepository.findByUserId("user")).thenReturn(Collections.emptyList()); + when(visitRepository.findByUserIdOrderByLastVisitedAtDesc("user")).thenReturn(Collections.emptyList()); + + MyTopicsDTO result = service.myTopics(); + + assertEquals(1, result.getFavoriteCount()); + assertEquals(1, result.getFavorites().size()); + assertEquals("校园日常", result.getFavorites().get(0).getName()); + assertTrue(Boolean.TRUE.equals(result.getFavorites().get(0).getFavorited())); + } +} diff --git a/campus-server/src/test/java/com/campus/service/TopicManagementServiceTest.java b/campus-server/src/test/java/com/campus/service/TopicManagementServiceTest.java new file mode 100644 index 0000000..8d3f859 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/TopicManagementServiceTest.java @@ -0,0 +1,393 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.TopicReviewRequest; +import com.campus.dto.response.TopicDTO; +import com.campus.entity.Topic; +import com.campus.entity.User; +import com.campus.repository.TopicAliasRepository; +import com.campus.repository.TopicFavoriteRepository; +import com.campus.repository.TopicFollowRepository; +import com.campus.repository.TopicHotSnapshotRepository; +import com.campus.repository.TopicRepository; +import com.campus.repository.TopicVisitRepository; +import com.campus.service.review.ReviewResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 话题写路径:创建、审核(双入参兼容)、别名、合并。 */ +@ExtendWith(MockitoExtension.class) +class TopicManagementServiceTest { + @Mock private TopicFollowRepository followRepository; + @Mock private TopicRepository topicRepository; + @Mock private TopicAliasRepository aliasRepository; + @Mock private TopicHotSnapshotRepository snapshotRepository; + @Mock private TopicVisitRepository visitRepository; + @Mock private TopicFavoriteRepository favoriteRepository; + @Mock private AuditLogService auditLogService; + @Mock private ContentReviewService contentReviewService; + @Mock private ClickGuardService clickGuardService; + + private TopicService service; + + @BeforeEach + void setUp() { + service = new TopicService(followRepository, topicRepository, aliasRepository, + snapshotRepository, visitRepository, favoriteRepository, new AuthorizationService(), + auditLogService, contentReviewService, clickGuardService, + org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + // ---------------- 创建 ---------------- + + @Test + void normalUserCreatedTopicStartsPending() { + authenticate(false, false); + when(topicRepository.findByName("学习交流")).thenReturn(Optional.empty()); + when(topicRepository.save(any(Topic.class))).thenAnswer(invocation -> { + Topic topic = invocation.getArgument(0); + topic.setId("topic"); + return topic; + }); + + TopicDTO dto = service.createTopic("#学习交流", "一起自习", "学习成长", null); + + assertEquals("PENDING", dto.getStatus()); + assertEquals("学习成长", dto.getCategory()); + } + + @Test + void adminCreatedTopicAlsoStartsPendingWhenAutoReviewDisabled() { + authenticate(true, false); + when(topicRepository.findByName("校园")).thenReturn(Optional.empty()); + when(topicRepository.save(any(Topic.class))).thenAnswer(invocation -> { + Topic topic = invocation.getArgument(0); + topic.setId("topic"); + return topic; + }); + + // 自动审核关闭(默认 false)时不再有"管理员直通",与普通用户一致走 PENDING + assertEquals("PENDING", service.createTopic("校园", "校园话题", "校园日常", null).getStatus()); + verify(contentReviewService, never()) + .reviewTopicSync(any(), any(), any(), any()); + } + + @Test + void autoReviewApprovesCleanTopicAndRecordsReview() { + authenticate(false, false); + ReflectionTestUtils.setField(service, "autoReviewEnabled", true); + when(topicRepository.findByName("学习交流")).thenReturn(Optional.empty()); + when(topicRepository.save(any(Topic.class))).thenAnswer(invocation -> { + Topic topic = invocation.getArgument(0); + if (topic.getId() == null) { + topic.setId("topic"); + } + return topic; + }); + ReviewResult passed = ReviewResult.passed(); + when(contentReviewService.previewTopicReview("学习交流", "一起自习", null)).thenReturn(passed); + + TopicDTO dto = service.createTopic("#学习交流", "一起自习", "学习成长", null); + + assertEquals("APPROVED", dto.getStatus(), "通过即上线,不再有 PENDING"); + assertNull(dto.getReviewNote()); + verify(contentReviewService).recordTopicApproval("topic", passed); + verify(contentReviewService, never()).reviewTopicSync(any(), any(), any(), any()); + } + + @Test + void autoReviewRejectsSensitiveTopicWithPublishFailure() { + authenticate(false, false); + ReflectionTestUtils.setField(service, "autoReviewEnabled", true); + when(topicRepository.findByName("法轮功")).thenReturn(Optional.empty()); + when(contentReviewService.previewTopicReview("法轮功", "一起自习", null)) + .thenReturn(ReviewResult.rejected("包含敏感词:法轮功")); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopic("法轮功", "一起自习", "学习成长", null)); + + assertEquals("包含违禁词:法轮功", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + verify(contentReviewService, never()).recordTopicApproval(any(), any()); + } + + @Test + void createRequiresCategory() { + authenticate(false, false); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopic("学习交流", "一起自习", null, null)); + + assertEquals("请选择话题分类", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + @Test + void createRejectsUnknownCategory() { + authenticate(false, false); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopic("学习交流", "一起自习", "不存在的分类", null)); + + assertEquals("话题分类无效", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + @Test + void duplicateNameReturnsExplicitMessage() { + authenticate(false, false); + when(topicRepository.findByName("校园日常")).thenReturn(Optional.of(topic("校园日常", "APPROVED"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopic("校园日常", "简介", "校园日常", null)); + + assertEquals("话题名称已存在", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + @Test + void softDeletedNameIsRejectedInsteadOfHittingUniqueKey() { + authenticate(false, false); + when(topicRepository.findByName("话题发布测试")).thenReturn(Optional.empty()); + when(topicRepository.findByNameIncludingDeleted("话题发布测试")) + .thenReturn(Optional.of(topic("话题发布测试", "PENDING"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopic("话题发布测试", "简介", "校园日常", null)); + + assertEquals("该名称曾被使用且已删除,请更换名称", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + @Test + void softDeletedAliasNameIsRejectedInsteadOfHittingUniqueKey() { + authenticate(false, false); + when(topicRepository.findByName("校园生活")).thenReturn(Optional.empty()); + when(topicRepository.findByNameIncludingDeleted("校园生活")).thenReturn(Optional.empty()); + when(aliasRepository.findByAlias("校园生活")).thenReturn(Optional.empty()); + when(aliasRepository.findByAliasIncludingDeleted("校园生活")) + .thenReturn(Optional.of(new com.campus.entity.TopicAlias())); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopic("校园生活", "简介", "校园日常", null)); + + assertEquals("该名称曾被使用且已删除,请更换名称", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + @Test + void nameCollidingWithAliasReturnsExplicitMessage() { + authenticate(false, false); + when(topicRepository.findByName("校园生活")).thenReturn(Optional.empty()); + when(aliasRepository.findByAlias("校园生活")) + .thenReturn(Optional.of(new com.campus.entity.TopicAlias())); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.createTopic("校园生活", "简介", "校园日常", null)); + + assertEquals("该名称已被其他话题用作别名", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + // ---------------- 审核(双入参兼容) ---------------- + + @Test + void reviewAcceptsStatusAndReviewNote() { + authenticate(true, false); + stubReviewableTopic(); + + TopicReviewRequest request = new TopicReviewRequest(); + request.setStatus("approved"); + request.setReviewNote("内容合规"); + TopicDTO dto = service.reviewTopic("topic", request); + + assertEquals("APPROVED", dto.getStatus()); + assertEquals("内容合规", dto.getReviewNote()); + } + + @Test + void reviewAcceptsLegacyApprovedAndNote() { + authenticate(true, false); + stubReviewableTopic(); + + TopicReviewRequest request = new TopicReviewRequest(); + request.setApproved(false); + request.setNote("不符合话题规范"); + TopicDTO dto = service.reviewTopic("topic", request); + + assertEquals("REJECTED", dto.getStatus()); + assertEquals("不符合话题规范", dto.getReviewNote()); + } + + @Test + void reviewRejectsEmptyDecision() { + authenticate(true, false); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.reviewTopic("topic", new TopicReviewRequest())); + + assertEquals("审核结果不能为空", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + @Test + void reviewRejectsConflictingDecision() { + authenticate(true, false); + TopicReviewRequest request = new TopicReviewRequest(); + request.setStatus("REJECTED"); + request.setApproved(true); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.reviewTopic("topic", request)); + + assertEquals("审核结果不明确", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + @Test + void reviewRejectsUnknownStatus() { + authenticate(true, false); + TopicReviewRequest request = new TopicReviewRequest(); + request.setStatus("PASSED"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.reviewTopic("topic", request)); + + assertEquals("审核状态无效", error.getMessage()); + } + + @Test + void rejectedTopicCanBeReviewedAgain() { + authenticate(true, false); + Topic topic = topic("学习交流", "REJECTED"); + when(topicRepository.findById("topic")).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(Topic.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + TopicReviewRequest request = new TopicReviewRequest(); + request.setStatus("APPROVED"); + TopicDTO dto = service.reviewTopic("topic", request); + + assertEquals("APPROVED", dto.getStatus()); + } + + @Test + void approvedTopicCannotBeReviewedAgain() { + authenticate(true, false); + when(topicRepository.findById("topic")).thenReturn(Optional.of(topic("学习交流", "APPROVED"))); + TopicReviewRequest request = new TopicReviewRequest(); + request.setStatus("APPROVED"); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.reviewTopic("topic", request)); + + assertEquals("话题已审核", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + // ---------------- 别名 / 合并 ---------------- + + @Test + void aliasCannotEqualItsOwnTopicName() { + authenticate(true, false); + when(topicRepository.findById("topic")).thenReturn(Optional.of(topic("校园日常", "APPROVED"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.addAlias("topic", "校园日常")); + + assertEquals("别名不能与话题名称相同", error.getMessage()); + verify(aliasRepository, never()).save(any()); + } + + @Test + void aliasCannotCollideWithExistingTopicName() { + authenticate(true, false); + when(topicRepository.findById("topic")).thenReturn(Optional.of(topic("校园日常", "APPROVED"))); + when(topicRepository.findByName("期末复习")).thenReturn(Optional.of(topic("期末复习", "APPROVED"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.addAlias("topic", "期末复习")); + + assertEquals("该名称已存在同名话题,不能作为别名", error.getMessage()); + verify(aliasRepository, never()).save(any()); + } + + @Test + void mergeRequiresApprovedTarget() { + authenticate(true, false); + when(topicRepository.findById("source")).thenReturn(Optional.of(topic("来源话题", "APPROVED"))); + when(topicRepository.findById("target")).thenReturn(Optional.of(topic("目标话题", "PENDING"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.merge("source", "target")); + + assertEquals("只能合并到已上线的话题", error.getMessage()); + verify(topicRepository, never()).save(any(Topic.class)); + } + + @Test + void mergeCannotTargetItself() { + authenticate(true, false); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.merge("source", "source")); + + assertEquals("不能合并到自身", error.getMessage()); + } + + private void stubReviewableTopic() { + Topic topic = topic("学习交流", "PENDING"); + topic.setCreatedBy("other"); + when(topicRepository.findById("topic")).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(Topic.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + private Topic topic(String name, String status) { + Topic topic = new Topic(); + topic.setId("topic"); + topic.setName(name); + topic.setStatus(status); + topic.setDescription("简介"); + topic.setCategory("校园日常"); + topic.setCreatedAt(new Date()); + topic.setRecommended(false); + topic.setPinned(false); + return topic; + } + + private void authenticate(boolean admin, boolean reviewer) { + User user = new User(); + user.setId("user"); + user.setIsAdmin(admin); + user.setIsReviewer(reviewer); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } +} diff --git a/campus-server/src/test/java/com/campus/service/UserAccountCleanupServiceTest.java b/campus-server/src/test/java/com/campus/service/UserAccountCleanupServiceTest.java new file mode 100644 index 0000000..63b5a0d --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/UserAccountCleanupServiceTest.java @@ -0,0 +1,52 @@ +package com.campus.service; + +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class UserAccountCleanupServiceTest { + @Test + void removesOtherViewersDependenciesBeforeDeletingOwnedMoments() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), eq(Integer.class), anyString())).thenReturn(1); + new UserAccountCleanupService(jdbc).deleteUserData("owner"); + List statements = mockingDetails(jdbc).getInvocations().stream() + .filter(invocation -> invocation.getMethod().getName().equals("update")) + .map(invocation -> (String) invocation.getArgument(0)).collect(Collectors.toList()); + int momentDelete = statements.indexOf("DELETE FROM `moment` WHERE `user_id` = ?"); + assertTrue(momentDelete >= 0); + for (String table : new String[]{"video_like", "video_play_record", "user_blocked_moment"}) { + String dependency = statements.stream().filter(s -> s.startsWith("DELETE FROM `" + table + "`") + && s.contains("SELECT `id` FROM `moment` WHERE `user_id` = ?")).findFirst().orElseThrow(AssertionError::new); + assertTrue(statements.indexOf(dependency) < momentDelete, table); + } + verify(jdbc).update("DELETE FROM `video_play_record` WHERE `user_id` = ? OR `video_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)", "owner", "owner"); + int certificationDelete=statements.indexOf("DELETE FROM xuexin_report WHERE user_id = ?"); + assertTrue(certificationDelete>=0); + assertTrue(certificationDelete= 0); + assertEquals("DELETE FROM `user` WHERE `id` = ?", statements.get(statements.size() - 1)); + } + + @Test + void dependencyFailurePropagatesAndDoesNotDeleteMomentOrUser() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), eq(Integer.class), anyString())).thenReturn(1); + String sql = "DELETE FROM `video_play_record` WHERE `user_id` = ? OR `video_id` IN " + + "(SELECT `id` FROM `moment` WHERE `user_id` = ?)"; + DataIntegrityViolationException failure = new DataIntegrityViolationException("清理失败"); + when(jdbc.update(sql, "owner", "owner")).thenThrow(failure); + assertSame(failure, assertThrows(DataIntegrityViolationException.class, + () -> new UserAccountCleanupService(jdbc).deleteUserData("owner"))); + verify(jdbc, never()).update("DELETE FROM `moment` WHERE `user_id` = ?", "owner"); + verify(jdbc, never()).update("DELETE FROM `user` WHERE `id` = ?", "owner"); + } +} diff --git a/campus-server/src/test/java/com/campus/service/UserRestrictionServiceTest.java b/campus-server/src/test/java/com/campus/service/UserRestrictionServiceTest.java new file mode 100644 index 0000000..d4d7a61 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/UserRestrictionServiceTest.java @@ -0,0 +1,117 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.dto.request.UserRestrictionRequest; +import com.campus.entity.User; +import com.campus.entity.UserRestriction; +import com.campus.repository.UserRepository; +import com.campus.repository.UserRestrictionRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserRestrictionServiceTest { + @Mock private UserRestrictionRepository repository; + @Mock private UserRepository userRepository; + @Mock private AuditLogService auditLogService; + + private UserRestrictionService service; + + @BeforeEach + void setUp() { + service = new UserRestrictionService(repository, userRepository, new AuthorizationService(), auditLogService); + authenticateAdmin(); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + void activeMatchingRestrictionBlocksCapability() { + UserRestriction restriction = restriction("PUBLISH", "ACTIVE", null); + when(repository.findActive(eq("user"), any(), any(Date.class))) + .thenReturn(Collections.singletonList(restriction)); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.ensureAllowed("user", "PUBLISH")); + assertEquals(true, error.getMessage().contains("限制发布")); + } + + @Test + void expiredOrRevokedRestrictionsAreIgnoredByActiveQuery() { + when(repository.findActive(eq("user"), any(), any(Date.class))).thenReturn(Collections.emptyList()); + assertDoesNotThrow(() -> service.ensureAllowed("user", "MESSAGE")); + } + + @Test + void createsPermanentRestrictionWithOperatorAndAudit() { + when(userRepository.existsById("user")).thenReturn(true); + when(repository.save(any(UserRestriction.class))).thenAnswer(invocation -> { + UserRestriction value = invocation.getArgument(0); + value.setId("restriction"); + return value; + }); + UserRestrictionRequest request = new UserRestrictionRequest(); + request.setRestrictionType("comment"); + request.setReason("spam"); + + service.create("user", request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserRestriction.class); + verify(repository).save(captor.capture()); + assertEquals("COMMENT", captor.getValue().getRestrictionType()); + assertEquals("admin", captor.getValue().getCreatedBy()); + assertEquals(null, captor.getValue().getExpiresAt()); + verify(auditLogService).record(eq("USER_RESTRICTION_CREATE"), eq("USER_RESTRICTION"), + eq("restriction"), eq(null), any(), eq("userId=user")); + } + + @Test + void rejectsExpiryBeforeStart() { + when(userRepository.existsById("user")).thenReturn(true); + UserRestrictionRequest request = new UserRestrictionRequest(); + request.setRestrictionType("LOGIN"); + request.setReason("risk"); + request.setStartsAt(new Date(System.currentTimeMillis() + 10_000)); + request.setExpiresAt(new Date()); + assertThrows(BusinessException.class, () -> service.create("user", request)); + } + + private UserRestriction restriction(String type, String status, Date expiresAt) { + UserRestriction item = new UserRestriction(); + item.setRestrictionType(type); + item.setStatus(status); + item.setReason("spam"); + item.setStartsAt(new Date(0)); + item.setExpiresAt(expiresAt); + return item; + } + + private void authenticateAdmin() { + User user = new User(); + user.setId("admin"); + user.setIsAdmin(true); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + } +} diff --git a/campus-server/src/test/java/com/campus/service/UserServiceTest.java b/campus-server/src/test/java/com/campus/service/UserServiceTest.java new file mode 100644 index 0000000..ad665b9 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/UserServiceTest.java @@ -0,0 +1,220 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.config.JwtConfig; +import com.campus.dto.request.RegisterRequest; +import com.campus.dto.request.LoginRequest; +import com.campus.entity.User; +import com.campus.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private JwtConfig jwtConfig; + @Mock + private RedisTemplate redisTemplate; + @Mock + private ValueOperations valueOperations; + @Mock + private UserAccountCleanupService cleanupService; + @Mock + private UserRestrictionService userRestrictionService; + @Mock + private LoginSecurityService loginSecurityService; + @Mock + private UserSessionService userSessionService; + @Mock private CommerceService commerceService; + + private UserService userService; + + @BeforeEach + void setUp() { + userService = new UserService(userRepository, passwordEncoder, jwtConfig, redisTemplate, cleanupService, + userRestrictionService, new RedisRetryExecutor(), loginSecurityService, userSessionService, commerceService, clickGuardService, deactivationCleanupService); + } + + @Test + void getUserByPhoneReturnsTheOnlyActiveAccountAndIgnoresLegacyDeletedRows() { + User deleted = user("deleted", true); + User active = user("active", false); + when(userRepository.findAllByPhoneIncludingDeleted("18800000000")) + .thenReturn(Arrays.asList(deleted, active)); + + assertSame(active, userService.getUserByPhone("18800000000")); + } + + @Test + void getUserByPhoneRejectsDuplicateActiveAccountsWithoutLeakingJpaException() { + when(userRepository.findAllByPhoneIncludingDeleted("18800000000")) + .thenReturn(Arrays.asList(user("one", false), user("two", false))); + + BusinessException error = assertThrows( + BusinessException.class, + () -> userService.getUserByPhone("18800000000")); + + assertEquals("账号数据异常,请联系管理员处理", error.getMessage()); + } + + @Test + void registerCleansEveryLegacyDeletedAccountBeforeCreatingTheNewAccount() { + User deletedOne = user("deleted-1", true); + User deletedTwo = user("deleted-2", true); + when(userRepository.findAllByPhoneIncludingDeleted("18800000000")) + .thenReturn(Arrays.asList(deletedOne, deletedTwo)); + when(passwordEncoder.encode("password")).thenReturn("encoded"); + when(userRepository.saveAndFlush(any(User.class))).thenAnswer(invocation -> { + User saved = invocation.getArgument(0); + saved.setId("new-user-id"); + return saved; + }); + when(jwtConfig.generateToken(anyString(), anyString())).thenReturn("token"); + UserService.LoginResult result = userService.register(registerRequest()); + + verify(cleanupService).deleteUserData("deleted-1"); + verify(cleanupService).deleteUserData("deleted-2"); + assertEquals("token", result.getToken()); + } + + @Test + void registerConvertsConcurrentUniqueConstraintViolationIntoStableBusinessError() { + when(userRepository.findAllByPhoneIncludingDeleted("18800000000")) + .thenReturn(Collections.emptyList()); + when(passwordEncoder.encode("password")).thenReturn("encoded"); + when(userRepository.saveAndFlush(any(User.class))) + .thenThrow(new DataIntegrityViolationException("duplicate phone")); + + BusinessException error = assertThrows( + BusinessException.class, + () -> userService.register(registerRequest())); + + assertEquals("手机号已注册", error.getMessage()); + } + + @Test + void disabledAccountCannotLogin() { + User disabled = user("disabled", false); + disabled.setAccountStatus("DISABLED"); + when(userRepository.findAllByPhoneIncludingDeleted("18800000000")) + .thenReturn(Collections.singletonList(disabled)); + LoginRequest request = new LoginRequest(); + request.setPhone("18800000000"); + request.setPassword("password"); + + when(userRepository.findByIdForUpdate(disabled.getId())).thenReturn(java.util.Optional.of(disabled)); + BusinessException error = assertThrows(BusinessException.class, () -> userService.login(request)); + assertEquals("账号已被管理员停用", error.getMessage()); + } + + @Test + void deactivateStartsCoolingPeriodWithoutDeletingData() { + User active = user("active", false); + active.setAccountStatus("ACTIVE"); + when(userRepository.findByIdForUpdate("active")).thenReturn(java.util.Optional.of(active)); + when(userRepository.save(active)).thenReturn(active); + userService.deactivateAccount("active"); + + assertEquals("DEACTIVATING", active.getAccountStatus()); + assertEquals(true, active.getScheduledDeletionAt().after(active.getDeactivationRequestedAt())); + verify(cleanupService, org.mockito.Mockito.never()).deleteUserData("active"); + } + + @Test + void recoverCancelsScheduledDeletion() { + User user = user("active", false); + user.setAccountStatus("DEACTIVATING"); + user.setDeactivationRequestedAt(new java.util.Date()); + user.setScheduledDeletionAt(new java.util.Date(System.currentTimeMillis() + 86400000)); + when(userRepository.findByIdForUpdate("active")).thenReturn(java.util.Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + assertEquals("ACTIVE", userService.recoverAccount("active").getAccountStatus()); + assertEquals(null, user.getScheduledDeletionAt()); + } + + @org.mockito.Mock private ClickGuardService clickGuardService; + @org.mockito.Mock private AccountDeactivationCleanupService deactivationCleanupService; + + @Test + void recoverRejectsExpiredDeadlineWithoutWrite() { + User user = user("expired", false); + user.setAccountStatus("DEACTIVATING"); + user.setScheduledDeletionAt(new java.util.Date(0)); + when(userRepository.findByIdForUpdate("expired")).thenReturn(java.util.Optional.of(user)); + BusinessException error = assertThrows(BusinessException.class, () -> userService.recoverAccount("expired")); + assertEquals("账号注销冷静期已结束,无法恢复", error.getMessage()); + verify(userRepository, org.mockito.Mockito.never()).save(org.mockito.ArgumentMatchers.any()); + } + + private User coolingLoginUser() { + User user=user("cooling",false);user.setAccountStatus("DEACTIVATING");user.setPassword("encoded"); + user.setDeactivationRequestedAt(new java.util.Date()); + user.setScheduledDeletionAt(new java.util.Date(System.currentTimeMillis()+86400000)); + when(userRepository.findAllByPhoneIncludingDeleted(user.getPhone())).thenReturn(Collections.singletonList(user)); + when(userRepository.findByIdForUpdate(user.getId())).thenReturn(java.util.Optional.of(user)); + return user; + } + @Test void passwordLoginRestoresCoolingAccount() { + User user=coolingLoginUser();LoginRequest request=new LoginRequest();request.setPhone(user.getPhone());request.setPassword("correct"); + when(passwordEncoder.matches("correct","encoded")).thenReturn(true); + userService.login(request); + assertEquals("ACTIVE",user.getAccountStatus());assertEquals(null,user.getScheduledDeletionAt());assertEquals(null,user.getDeactivationRequestedAt()); + verify(userRepository).save(user); + } + @Test void verifiedPhoneLoginRestoresCoolingAccount() { + User user=coolingLoginUser();userService.loginByPhone(user.getPhone()); + assertEquals("ACTIVE",user.getAccountStatus());assertEquals(null,user.getScheduledDeletionAt());verify(userRepository).save(user); + } + @Test void wrongPasswordNeverRestoresAccount() { + User user=coolingLoginUser();LoginRequest request=new LoginRequest();request.setPhone(user.getPhone());request.setPassword("wrong"); + assertEquals("手机号或密码错误",assertThrows(BusinessException.class,()->userService.login(request)).getMessage()); + assertEquals("DEACTIVATING",user.getAccountStatus());verify(userRepository,org.mockito.Mockito.never()).save(any()); + } + @Test void expiredLoginNeverRestoresAccount() { + User user=coolingLoginUser();user.setScheduledDeletionAt(new java.util.Date(0)); + assertEquals("账号注销冷静期已结束,无法恢复",assertThrows(BusinessException.class,()->userService.loginByPhone(user.getPhone())).getMessage()); + verify(userRepository,org.mockito.Mockito.never()).save(any()); + } + + private RegisterRequest registerRequest() { + RegisterRequest request = new RegisterRequest(); + request.setPhone("18800000000"); + request.setPassword("password"); + request.setCaptcha("111111"); + request.setUniversityName("测试大学"); + return request; + } + + private User user(String id, boolean deleted) { + User user = new User(); + user.setId(id); + user.setDeleted(deleted); + user.setPhone("18800000000"); + user.setNickname("测试用户"); + return user; + } +} diff --git a/campus-server/src/test/java/com/campus/service/UserSessionServiceTest.java b/campus-server/src/test/java/com/campus/service/UserSessionServiceTest.java new file mode 100644 index 0000000..2dcdc2a --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/UserSessionServiceTest.java @@ -0,0 +1,80 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.entity.UserSession; +import com.campus.repository.UserSessionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserSessionServiceTest { + @Mock private UserSessionRepository repository; + @Mock private RedisTemplate redisTemplate; + @Mock private ValueOperations values; + private UserSessionService service; + + @BeforeEach + void setUp() { + service = new UserSessionService(repository, redisTemplate, new RedisRetryExecutor()); + } + + @Test + void createsDeviceSessionWithoutPersistingRawToken() { + when(repository.save(any(UserSession.class))).thenAnswer(invocation -> { + UserSession value = invocation.getArgument(0); + value.setId("session"); + return value; + }); + when(redisTemplate.opsForValue()).thenReturn(values); + + UserSession session = service.create("user", "raw-token", 60_000, + "device", "Pixel", "ANDROID", "127.0.0.1", "agent"); + + assertEquals(64, session.getTokenHash().length()); + assertNotEquals("raw-token", session.getTokenHash()); + verify(values).set("user:token:raw-token", "user", 60_000, TimeUnit.MILLISECONDS); + verify(values).set("user:session-token:session", "raw-token", 60_000, TimeUnit.MILLISECONDS); + } + + @Test + void ownerCanRevokeOneSession() { + UserSession session = new UserSession(); + session.setId("session"); + session.setUserId("user"); + session.setStatus("ACTIVE"); + when(repository.findById("session")).thenReturn(Optional.of(session)); + when(redisTemplate.opsForValue()).thenReturn(values); + when(values.get("user:session-token:session")).thenReturn("token"); + + service.revoke("user", "session", "user"); + + assertEquals("REVOKED", session.getStatus()); + verify(redisTemplate).delete("user:token:token"); + verify(repository).save(session); + } + + @Test + void cannotRevokeAnotherUsersSession() { + UserSession session = new UserSession(); + session.setId("session"); + session.setUserId("other"); + when(repository.findById("session")).thenReturn(Optional.of(session)); + assertThrows(BusinessException.class, () -> service.revoke("user", "session", "user")); + } +} diff --git a/campus-server/src/test/java/com/campus/service/VideoPlayViewCountTest.java b/campus-server/src/test/java/com/campus/service/VideoPlayViewCountTest.java new file mode 100644 index 0000000..9353d04 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/VideoPlayViewCountTest.java @@ -0,0 +1,68 @@ +package com.campus.service; + +import com.campus.entity.Moment; +import com.campus.entity.User; +import com.campus.entity.VideoPlayRecord; +import com.campus.repository.MomentRepository; +import com.campus.repository.VideoPlayRecordRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Date; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VideoPlayViewCountTest { + @Mock private VideoPlayRecordRepository videoPlayRecordRepository; + @Mock private MomentRepository momentRepository; + @InjectMocks private VideoPlayService videoPlayService; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void progressUpdatesCountOncePerPlaybackSession() { + User user = new User(); + user.setId("viewer"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + VideoPlayRecord record = new VideoPlayRecord(); + record.setVideoId("video"); + record.setUserId("viewer"); + record.setPlayCount(0); + Moment moment = new Moment(); + moment.setId("video"); + moment.setIsYouthVideo(true); + when(momentRepository.findByIdForView("video")).thenReturn(Optional.of(moment)); + when(videoPlayRecordRepository.findByVideoIdAndUserId("video", "viewer")) + .thenReturn(Optional.of(record)); + + videoPlayService.recordVideoOpen("video"); + Date countedAt = record.getLastPlayTime(); + videoPlayService.updatePlayProgress("video", 5); + videoPlayService.updatePlayProgress("video", 35); + assertEquals(1, record.getPlayCount()); + assertEquals(countedAt, record.getLastPlayTime()); + assertEquals(1, moment.getViewCount()); + verify(videoPlayRecordRepository, times(3)).save(record); + + record.setLastPlayTime(new Date(System.currentTimeMillis() - 31 * 60 * 1000L)); + videoPlayService.updatePlayProgress("video", 42); + assertEquals(2, record.getPlayCount()); + assertEquals(2, moment.getViewCount()); + assertEquals(42, record.getPlayPosition()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/XuexinReportCleanupSchedulerTest.java b/campus-server/src/test/java/com/campus/service/XuexinReportCleanupSchedulerTest.java new file mode 100644 index 0000000..a20f961 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/XuexinReportCleanupSchedulerTest.java @@ -0,0 +1,65 @@ +package com.campus.service; + +import com.campus.entity.XuexinReport; +import com.campus.repository.XuexinReportRepository; +import com.campus.util.OssUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Date; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class XuexinReportCleanupSchedulerTest { + + @Mock private XuexinReportRepository reportRepository; + @Mock private OssUtil ossUtil; + @InjectMocks private XuexinReportCleanupScheduler scheduler; + + @Test + void deletesRemotePdfBeforeDeletingExpiredRecord() { + XuexinReport report = report(); + when(reportRepository.findByStatusAndExpireAtBefore( + org.mockito.ArgumentMatchers.eq(XuexinReport.STATUS_PENDING), any(Date.class))) + .thenReturn(Collections.singletonList(report)); + + scheduler.purgeExpiredPending(); + + InOrder order = inOrder(ossUtil, reportRepository); + order.verify(ossUtil).deleteFile("https://oss.example/report.pdf"); + order.verify(reportRepository).delete(report); + } + + @Test + void keepsDatabaseRecordWhenRemoteDeleteFails() { + XuexinReport report = report(); + when(reportRepository.findByStatusAndExpireAtBefore( + org.mockito.ArgumentMatchers.eq(XuexinReport.STATUS_PENDING), any(Date.class))) + .thenReturn(Collections.singletonList(report)); + doThrow(new RuntimeException("OSS unavailable")) + .when(ossUtil).deleteFile("https://oss.example/report.pdf"); + + scheduler.purgeExpiredPending(); + + verify(reportRepository, never()).delete(report); + } + + private static XuexinReport report() { + XuexinReport report = new XuexinReport(); + report.setId("report-1"); + report.setStatus(XuexinReport.STATUS_PENDING); + report.setReportPdfUrl("https://oss.example/report.pdf"); + return report; + } +} diff --git a/campus-server/src/test/java/com/campus/service/XuexinReportServiceTest.java b/campus-server/src/test/java/com/campus/service/XuexinReportServiceTest.java new file mode 100644 index 0000000..aec312a --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/XuexinReportServiceTest.java @@ -0,0 +1,633 @@ +package com.campus.service; + +import com.campus.common.BusinessException; +import com.campus.common.ServiceUnavailableException; +import com.campus.dto.request.XuexinSubmitRequest; +import com.campus.dto.response.ChsiReportDTO; +import com.campus.dto.response.XuexinDedupDTO; +import com.campus.entity.University; +import com.campus.entity.User; +import com.campus.entity.XuexinReport; +import com.campus.repository.UniversityRepository; +import com.campus.repository.UserRepository; +import com.campus.repository.XuexinReportRepository; +import com.campus.util.StudentNumberGenerator; +import com.campus.util.OssUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Arrays; +import java.util.Date; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 学信网认证:落库判重 + 提交。 + * + *

重点覆盖:同一份材料不能认证两个账号、本人重复提交幂等、 + * 一人一条生效认证(学历不降级 + 冷静期)、以及失败时不落库。

+ */ +@ExtendWith(MockitoExtension.class) +class XuexinReportServiceTest { + + private static final String ME = "user-me"; + private static final String OTHER = "user-other"; + + @Mock private XuexinReportRepository reportRepository; + @Mock private UserRepository userRepository; + @Mock private UniversityRepository universityRepository; + @Mock private com.campus.repository.CollegeRepository collegeRepository; + @Mock private com.campus.repository.MajorRepository majorRepository; + @Mock private StudentNumberGenerator studentNumberGenerator; + @Mock private ClickGuardService clickGuardService; + @Mock private OssUtil ossUtil; + @Mock private CommerceService commerceService; + + @InjectMocks private XuexinReportService service; + + // ==================== 判重:register ==================== + + @Test + void registerCreatesPendingAndReturnsAvailable() { + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())).thenReturn(Optional.empty()); + when(reportRepository.saveAndFlush(any(XuexinReport.class))).thenAnswer(inv -> { + XuexinReport r = inv.getArgument(0); + r.setId("report-1"); + return r; + }); + + ChsiReportDTO dto = reportDto(); + XuexinDedupDTO dedup = service.register(ME, dto, "https://oss.example/xuexin/report.pdf"); + + assertEquals(XuexinDedupDTO.AVAILABLE, dedup.getStatus()); + assertEquals(XuexinDedupDTO.MSG_AVAILABLE, dedup.getMessage()); + assertEquals("report-1", dto.getReportId()); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(XuexinReport.class); + verify(reportRepository).saveAndFlush(captor.capture()); + XuexinReport saved = captor.getValue(); + assertEquals(XuexinReport.STATUS_PENDING, saved.getStatus()); + assertNull(saved.getActiveMarker()); + assertNotNull(saved.getExpireAt()); + assertEquals(ME, saved.getUserId()); + assertEquals("教育部学籍在线验证报告", saved.getReportName()); + assertEquals("2026-09-17", saved.getReportUpdatedDate()); + assertEquals("曹妃甸校区", saved.getBranchCampus()); + assertEquals("人工智能学院", saved.getDepartment()); + assertEquals("AJ3218N50MPXWGEG", saved.getOnlineVerificationCode()); + assertEquals("https://oss.example/xuexin/report.pdf", saved.getReportPdfUrl()); + } + + @Test + void registerReusesOwnPendingWithoutCreatingAnotherRow() { + XuexinReport pending = report(ME, XuexinReport.STATUS_PENDING); + pending.setId("report-1"); + pending.setExpireAt(new Date(System.currentTimeMillis() + 600_000)); + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())) + .thenReturn(Optional.of(pending)); + when(reportRepository.save(any(XuexinReport.class))).thenAnswer(inv -> inv.getArgument(0)); + + ChsiReportDTO dto = reportDto(); + XuexinDedupDTO dedup = service.register(ME, dto, null); + + assertEquals(XuexinDedupDTO.AVAILABLE, dedup.getStatus()); + assertEquals("report-1", dto.getReportId()); + verify(reportRepository, never()).saveAndFlush(any(XuexinReport.class)); + } + + @Test + void registerReplacesPendingPdfAfterDeletingOldObject() { + XuexinReport pending = report(ME, XuexinReport.STATUS_PENDING); + pending.setId("report-1"); + pending.setReportPdfUrl("https://oss.example/old.pdf"); + pending.setExpireAt(new Date(System.currentTimeMillis() + 600_000)); + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())) + .thenReturn(Optional.of(pending)); + when(reportRepository.save(any(XuexinReport.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.register(ME, reportDto(), "https://oss.example/new.pdf"); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(ossUtil, reportRepository); + order.verify(ossUtil).deleteFile("https://oss.example/old.pdf"); + order.verify(reportRepository).save(pending); + assertEquals("https://oss.example/new.pdf", pending.getReportPdfUrl()); + } + + @Test + void registerKeepsPendingRecordWhenOldPdfCannotBeDeleted() { + XuexinReport pending = report(ME, XuexinReport.STATUS_PENDING); + pending.setId("report-1"); + pending.setReportPdfUrl("https://oss.example/old.pdf"); + pending.setExpireAt(new Date(System.currentTimeMillis() + 600_000)); + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())) + .thenReturn(Optional.of(pending)); + org.mockito.Mockito.doThrow(new RuntimeException("OSS unavailable")) + .when(ossUtil).deleteFile("https://oss.example/old.pdf"); + + ServiceUnavailableException e = assertThrows(ServiceUnavailableException.class, + () -> service.register(ME, reportDto(), "https://oss.example/new.pdf")); + + assertEquals("认证材料存储服务暂时不可用,请稍后重试", e.getMessage()); + assertEquals("https://oss.example/old.pdf", pending.getReportPdfUrl()); + verify(reportRepository, never()).save(any(XuexinReport.class)); + verify(reportRepository, never()).saveAndFlush(any(XuexinReport.class)); + } + + @Test + void registerRejectsReportAlreadyCertifiedByAnotherAccount() { + XuexinReport confirmed = report(OTHER, XuexinReport.STATUS_CONFIRMED); + confirmed.setId("report-9"); + confirmed.setActiveMarker(OTHER); + confirmed.setConfirmedAt(new Date()); + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())) + .thenReturn(Optional.of(confirmed)); + + ChsiReportDTO dto = reportDto(); + XuexinDedupDTO dedup = service.register(ME, dto, null); + + assertEquals(XuexinDedupDTO.ALREADY_CONFIRMED_BY_OTHER, dedup.getStatus()); + assertEquals("该学信网报告已被认证", dedup.getMessage()); + assertNull(dto.getReportId()); + verify(reportRepository, never()).saveAndFlush(any(XuexinReport.class)); + } + + @Test + void registerTreatsOwnCertifiedReportAsAlreadyDone() { + XuexinReport confirmed = report(ME, XuexinReport.STATUS_CONFIRMED); + confirmed.setId("report-1"); + confirmed.setActiveMarker(ME); + confirmed.setConfirmedAt(new Date()); + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())) + .thenReturn(Optional.of(confirmed)); + + XuexinDedupDTO dedup = service.register(ME, reportDto(), null); + + assertEquals(XuexinDedupDTO.ALREADY_CONFIRMED_BY_SELF, dedup.getStatus()); + assertEquals("您已完成学信网认证,无需重复提交", dedup.getMessage()); + } + + @Test + void registerRejectsWhileAnotherAccountStillHoldsTheMaterial() { + XuexinReport otherPending = report(OTHER, XuexinReport.STATUS_PENDING); + otherPending.setId("report-2"); + otherPending.setExpireAt(new Date(System.currentTimeMillis() + 600_000)); + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())) + .thenReturn(Optional.of(otherPending)); + + ChsiReportDTO dto = reportDto(); + XuexinDedupDTO dedup = service.register(ME, dto, null); + + assertEquals(XuexinDedupDTO.IN_PROGRESS_BY_OTHER, dedup.getStatus()); + assertEquals("该认证材料正在被其他账号使用,请稍后再试", dedup.getMessage()); + assertNull(dto.getReportId()); + verify(reportRepository, never()).save(any(XuexinReport.class)); + verify(reportRepository, never()).saveAndFlush(any(XuexinReport.class)); + } + + @Test + void registerTakesOverExpiredPendingOfAnotherAccount() { + XuexinReport expired = report(OTHER, XuexinReport.STATUS_PENDING); + expired.setId("report-3"); + expired.setExpireAt(new Date(System.currentTimeMillis() - 60_000)); + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())) + .thenReturn(Optional.of(expired)); + when(reportRepository.save(any(XuexinReport.class))).thenAnswer(inv -> inv.getArgument(0)); + + ChsiReportDTO dto = reportDto(); + XuexinDedupDTO dedup = service.register(ME, dto, null); + + assertEquals(XuexinDedupDTO.AVAILABLE, dedup.getStatus()); + assertEquals("report-3", dto.getReportId()); + assertEquals(ME, expired.getUserId()); + assertTrue(expired.getExpireAt().after(new Date())); + } + + @Test + void registerFallsBackToExistingRowWhenInsertHitsUniqueKey() { + XuexinReport confirmed = report(OTHER, XuexinReport.STATUS_CONFIRMED); + confirmed.setId("report-4"); + confirmed.setActiveMarker(OTHER); + when(reportRepository.findByDedupKeyIncludingDeleted(anyString())) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(confirmed)); + when(reportRepository.saveAndFlush(any(XuexinReport.class))) + .thenThrow(new DataIntegrityViolationException("uk_xuexin_report_dedup_key")); + + XuexinDedupDTO dedup = service.register(ME, reportDto(), null); + + // 并发撞唯一键不能变成 500,要降级成正常判重结论 + assertEquals(XuexinDedupDTO.ALREADY_CONFIRMED_BY_OTHER, dedup.getStatus()); + } + + // ==================== 判重键:纯函数 ==================== + + @Test + void fingerprintIgnoresOcrNoiseLikeSpacesAndFullWidthBrackets() { + XuexinReport a = fingerprintSource("张 三", "某(某)大学", "4年", "2010年09月"); + XuexinReport b = fingerprintSource("张三", "某(某)大学", "4", "2010-09-01"); + + assertEquals(XuexinReportService.buildFingerprint(a), + XuexinReportService.buildFingerprint(b)); + } + + @Test + void dedupKeyIgnoresVerificationCodeBecauseItCanBeRegenerated() { + // 场景:同一份学历,三个月前生成过一份报告,今天又生成了一份 → 在线验证码必然不同。 + // 若按验证码判重,这份材料就能被再次(甚至被另一个账号)认证,判重失效。 + XuexinReport first = fingerprintSource("张三", "某大学", "4年", "2010-09"); + first.setOnlineVerificationCode("AAAAAAAAAA"); + first.setDataFingerprint(XuexinReportService.buildFingerprint(first)); + + XuexinReport regenerated = fingerprintSource("张三", "某大学", "4年", "2010-09"); + regenerated.setOnlineVerificationCode("BBBBBBBBBB"); + regenerated.setDataFingerprint(XuexinReportService.buildFingerprint(regenerated)); + + String firstKey = XuexinReportService.buildDedupKey(first); + String regeneratedKey = XuexinReportService.buildDedupKey(regenerated); + + assertTrue(firstKey.startsWith("FP:")); + assertEquals(firstKey, regeneratedKey); + } + + // ==================== 提交:submit ==================== + + @Test + void submitRejectsMissingReportId() { + XuexinSubmitRequest req = new XuexinSubmitRequest(); + + BusinessException e = assertThrows(BusinessException.class, () -> service.submit(ME, req)); + + assertEquals("认证材料标识缺失,请重新上传报告", e.getMessage()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void submitRejectsReportOfAnotherUser() { + when(userRepository.findById(ME)).thenReturn(Optional.of(user())); + when(reportRepository.findByIdAndUserId("report-x", ME)).thenReturn(Optional.empty()); + + XuexinSubmitRequest req = req("report-x"); + BusinessException e = assertThrows(BusinessException.class, () -> service.submit(ME, req)); + + assertEquals("未找到认证材料,请重新上传报告", e.getMessage()); + verify(userRepository, never()).save(any(User.class)); + verify(reportRepository, never()).markConfirmed(anyString(), anyString(), any(Date.class)); + } + + @Test + void submitRejectsReportAlreadySuperseded() { + User user = user(); + user.setXuexinCertified(true); + user.setXuexinReportId("report-new"); + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + + XuexinReport old = report(ME, XuexinReport.STATUS_SUPERSEDED); + old.setId("report-old"); + when(reportRepository.findByIdAndUserId("report-old", ME)).thenReturn(Optional.of(old)); + + BusinessException e = assertThrows(BusinessException.class, + () -> service.submit(ME, req("report-old"))); + + assertEquals("该认证材料已失效,请重新上传报告", e.getMessage()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void submitRejectsDowngradeToLowerEducation() { + User user = user(); + user.setXuexinCertified(true); + user.setXuexinReportId("report-old"); + user.setEducation("本科"); + user.setXuexinCertifiedAt(new Date(System.currentTimeMillis() - 100L * 24 * 3600 * 1000)); + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + + XuexinReport report = report(ME, XuexinReport.STATUS_PENDING); + report.setId("report-new"); + report.setLevel("专科"); + report.setLevelRank(30); + when(reportRepository.findByIdAndUserId("report-new", ME)).thenReturn(Optional.of(report)); + + BusinessException e = assertThrows(BusinessException.class, + () -> service.submit(ME, req("report-new"))); + + assertEquals("当前已认证学历为「本科」,不支持更换为更低学历「专科」", e.getMessage()); + verify(reportRepository, never()).markConfirmed(anyString(), anyString(), any(Date.class)); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void submitRejectsChangingMaterialWithinCooldown() { + User user = user(); + user.setXuexinCertified(true); + user.setXuexinReportId("report-old"); + user.setEducation("本科"); + user.setXuexinCertifiedAt(new Date(System.currentTimeMillis() - 3600_000L)); // 1 小时前 + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + + XuexinReport report = report(ME, XuexinReport.STATUS_PENDING); + report.setId("report-new"); + report.setLevel("硕士研究生"); + report.setLevelRank(60); + when(reportRepository.findByIdAndUserId("report-new", ME)).thenReturn(Optional.of(report)); + + BusinessException e = assertThrows(BusinessException.class, + () -> service.submit(ME, req("report-new"))); + + assertTrue(e.getMessage().startsWith("认证通过后 3 天内不可更换认证材料,请在 "), e.getMessage()); + verify(reportRepository, never()).markConfirmed(anyString(), anyString(), any(Date.class)); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void submitSucceedsAfterCooldownAndWritesUserFromStoredReport() { + User user = user(); + user.setXuexinCertified(true); + user.setXuexinReportId("report-old"); + user.setEducation("本科"); + user.setXuexinCertifiedAt(new Date(System.currentTimeMillis() - 4L * 24 * 3600 * 1000)); + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + + XuexinReport report = report(ME, XuexinReport.STATUS_PENDING); + report.setId("report-new"); + report.setLevel("硕士研究生"); + report.setLevelRank(60); + report.setEnrollmentDate("2014-09"); + when(reportRepository.findByIdAndUserId("report-new", ME)).thenReturn(Optional.of(report)); + when(reportRepository.markConfirmed(anyString(), anyString(), any(Date.class))).thenReturn(1); + when(universityRepository.findByNameContaining("测试大学")).thenReturn(Arrays.asList(university())); + when(universityRepository.findById("uni")).thenReturn(Optional.of(university())); + when(studentNumberGenerator.generateStudentNumber("uni", "4111010001")) + .thenReturn("41110100010000001"); + when(userRepository.countByNumberIncludingDeleted("41110100010000001")).thenReturn(0L); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User result = service.submit(ME, req("report-new")); + + assertEquals("硕士研究生", result.getEducation()); + assertEquals("测试大学", result.getUniversityName()); + assertEquals("2014级", result.getGrade()); + assertEquals("41110100010000001", result.getNumber()); + assertEquals(Boolean.TRUE, result.getXuexinCertified()); + assertEquals("report-new", result.getXuexinReportId()); + assertNotNull(result.getXuexinCertifiedAt()); + verify(reportRepository).supersedeActive(anyString(), any(Date.class)); + verify(userRepository).save(user); + } + + @Test + void submitIsIdempotentWhenReportIsAlreadyActive() { + User user = user(); + user.setXuexinCertified(true); + user.setXuexinReportId("report-1"); + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + + XuexinReport confirmed = report(ME, XuexinReport.STATUS_CONFIRMED); + confirmed.setId("report-1"); + confirmed.setActiveMarker(ME); + when(reportRepository.findByIdAndUserId("report-1", ME)).thenReturn(Optional.of(confirmed)); + + User result = service.submit(ME, req("report-1")); + + assertEquals(user, result); + verify(reportRepository, never()).markConfirmed(anyString(), anyString(), any(Date.class)); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void submitStillSucceedsWhenStudentNumberCannotBeAssigned() { + User user = user(); + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + + XuexinReport report = report(ME, XuexinReport.STATUS_PENDING); + report.setId("report-1"); + when(reportRepository.findByIdAndUserId("report-1", ME)).thenReturn(Optional.of(report)); + when(reportRepository.markConfirmed(anyString(), anyString(), any(Date.class))).thenReturn(1); + + + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User result = service.submit(ME, req("report-1")); + + // 学号失败不阻塞认证 + assertNull(result.getNumber()); + assertEquals(Boolean.TRUE, result.getXuexinCertified()); + verify(userRepository, times(1)).save(user); + } + + @Test + void submitUsesStoredReportFieldsAndDoesNotAcceptClientOverrides() { + User user = user(); + user.setGender("N"); + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + + XuexinReport report = report(ME, XuexinReport.STATUS_PENDING); + report.setId("report-1"); + report.setGender("男"); + report.setBirthDate("1990-11-23"); + when(reportRepository.findByIdAndUserId("report-1", ME)).thenReturn(Optional.of(report)); + when(reportRepository.markConfirmed(anyString(), anyString(), any(Date.class))).thenReturn(1); + + + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User result = service.submit(ME, req("report-1")); + + assertEquals("M", result.getGender()); + java.util.Calendar cal = java.util.Calendar.getInstance(); + cal.setTime(result.getBirthday()); + assertEquals(1990, cal.get(java.util.Calendar.YEAR)); + assertEquals(java.util.Calendar.NOVEMBER, cal.get(java.util.Calendar.MONTH)); + assertEquals(23, cal.get(java.util.Calendar.DAY_OF_MONTH)); + } + + @Test + void submitKeepsExistingBirthdayInsteadOfOverwritingFromReport() { + User user = user(); + Date original = new Date(0L); + user.setBirthday(original); + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + + XuexinReport report = report(ME, XuexinReport.STATUS_PENDING); + report.setId("report-1"); + report.setBirthDate("1990-11"); + when(reportRepository.findByIdAndUserId("report-1", ME)).thenReturn(Optional.of(report)); + when(reportRepository.markConfirmed(anyString(), anyString(), any(Date.class))).thenReturn(1); + + + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User result = service.submit(ME, req("report-1")); + + // 报告仅有年月,不能编造具体日期覆盖现值。 + assertEquals(original, result.getBirthday()); + } + + @Test + void overwritesIdentityAndResolvesAllEducationIds() { + User user = user(); user.setNumber("existing"); user.setGender("F"); user.setBirthday(new Date(0)); + user.setUniversityId("old"); user.setCollegeId("old"); user.setMajorId("old"); + XuexinReport r = report(ME, XuexinReport.STATUS_PENDING); r.setId("new"); + r.setName(" 张三 "); r.setGender("男"); r.setBirthDate("1990-11-23"); + r.setDepartment("计算机学院"); r.setMajor("软件工程"); r.setEnrollmentDate("2020-09"); + com.campus.entity.College c = new com.campus.entity.College(); c.setId("college"); c.setName("计算机学院"); + com.campus.entity.Major m = new com.campus.entity.Major(); m.setId("major"); m.setName("软件工程"); + when(universityRepository.findByNameContaining("测试大学")).thenReturn(Arrays.asList(university())); + when(collegeRepository.findByUniversityId("uni")).thenReturn(Arrays.asList(c)); + when(majorRepository.findByCollegeId("college")).thenReturn(Arrays.asList(m)); + stubSuccessfulSubmit(user, r); + User result = service.submit(ME, req("new")); + assertEquals("张三", result.getNickname()); assertEquals("M", result.getGender()); + java.text.SimpleDateFormat f = new java.text.SimpleDateFormat("yyyy-MM-dd"); + f.setTimeZone(java.util.TimeZone.getTimeZone("GMT+8")); + assertEquals("1990-11-23", f.format(result.getBirthday())); + assertEquals("uni", result.getUniversityId()); assertEquals("college", result.getCollegeId()); + assertEquals("major", result.getMajorId()); assertEquals("2020级", result.getGrade()); + assertEquals("existing", result.getNumber()); + verify(studentNumberGenerator, never()).generateStudentNumber(anyString(), anyString()); + } + + @Test + void changedSchoolClearsOldChildrenAndUnknownIds() { + User user = user(); user.setNumber("existing"); user.setCollegeName("旧学院"); + user.setCollegeId("old"); user.setMajorName("旧专业"); user.setMajorId("old"); + XuexinReport r = report(ME, XuexinReport.STATUS_PENDING); r.setId("new"); + r.setSchool("新学校"); r.setDepartment(null); r.setMajor(null); + r.setEnrollmentDate(null); r.setDepartureDate(null); + stubSuccessfulSubmit(user, r); + service.submit(ME, req("new")); + assertNull(user.getUniversityId()); assertNull(user.getCollegeName()); assertNull(user.getCollegeId()); + assertNull(user.getMajorName()); assertNull(user.getMajorId()); assertNull(user.getGrade()); + } + + @Test + void ambiguousSchoolsDoNotPickArbitraryId() { + User user = user(); user.setNumber("existing"); + XuexinReport r = report(ME, XuexinReport.STATUS_PENDING); r.setId("new"); + when(universityRepository.findByNameContaining("测试大学")).thenReturn(Arrays.asList(university(), university())); + stubSuccessfulSubmit(user, r); service.submit(ME, req("new")); + assertNull(user.getUniversityId()); assertNull(user.getCollegeId()); assertNull(user.getMajorId()); + } + + @Test + void invalidIdentityDoesNotEraseExistingValues() { + User user = user(); user.setNumber("existing"); user.setGender("M"); user.setBirthday(new Date(0)); + XuexinReport r = report(ME, XuexinReport.STATUS_PENDING); r.setId("new"); + r.setName(" "); r.setGender("未知"); r.setBirthDate("2023-02-30"); + stubSuccessfulSubmit(user, r); service.submit(ME, req("new")); + assertEquals("测试用户", user.getNickname()); assertEquals("M", user.getGender()); + assertEquals(new Date(0), user.getBirthday()); + } + + @Test + void departureYearReplacesStaleGrade() { + User user = user(); user.setNumber("existing"); + XuexinReport r = report(ME, XuexinReport.STATUS_PENDING); r.setId("new"); + r.setEnrollmentDate(null); r.setDepartureDate("2024-06-01"); r.setLevel("本科"); + stubSuccessfulSubmit(user, r); service.submit(ME, req("new")); + assertEquals("2020级", user.getGrade()); + } + + private void stubSuccessfulSubmit(User user, XuexinReport r) { + when(userRepository.findById(ME)).thenReturn(Optional.of(user)); + when(reportRepository.findByIdAndUserId("new", ME)).thenReturn(Optional.of(r)); + when(reportRepository.markConfirmed(anyString(), anyString(), any(Date.class))).thenReturn(1); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + // ==================== 夹具 ==================== + + private static ChsiReportDTO reportDto() { + ChsiReportDTO dto = new ChsiReportDTO(); + dto.setReportName("教育部学籍在线验证报告"); + dto.setReportUpdatedDate("2026-09-17"); + dto.setName("张三"); + dto.setGender("男"); + dto.setBirthDate("1990-01-01"); + dto.setEthnicity("汉族"); + dto.setSchool("测试大学"); + dto.setLevel("本科"); + dto.setBranchCampus("曹妃甸校区"); + dto.setDepartment("人工智能学院"); + dto.setMajor("软件工程"); + dto.setEnrollmentDate("2010-09-01"); + dto.setDepartureDate("2014-06-17"); + dto.setStudyDuration("4年"); + dto.setEducationCategory("普通高等教育"); + dto.setStudyForm("普通全日制"); + dto.setStatus("不在籍(毕业)"); + dto.setOnlineVerificationCode("AJ3218N50MPXWGEG"); + dto.setValidChsiReport(true); + dto.setRawLines(Arrays.asList("教育部学籍在线验证报告", "姓名 张三")); + return dto; + } + + private static XuexinReport report(String userId, String status) { + XuexinReport r = new XuexinReport(); + r.setUserId(userId); + r.setStatus(status); + r.setName("张三"); + r.setSchool("测试大学"); + r.setLevel("本科"); + r.setLevelRank(50); + r.setMajor("软件工程"); + r.setEnrollmentDate("2010-09-01"); + r.setDepartureDate("2014-06-17"); + r.setStudyDuration("4年"); + r.setDedupKey("FP:" + XuexinReportService.buildFingerprint(r)); + return r; + } + + private static XuexinReport fingerprintSource(String name, String school, String duration, + String enrollment) { + XuexinReport r = new XuexinReport(); + r.setName(name); + r.setSchool(school); + r.setLevel("本科"); + r.setMajor("软件工程"); + r.setStudyDuration(duration); + r.setEnrollmentDate(enrollment); + r.setDepartureDate("2014-06-17"); + return r; + } + + private static User user() { + User u = new User(); + u.setId(ME); + u.setNickname("测试用户"); + u.setPhone("13800000000"); + u.setUniversityId("uni"); + u.setUniversityName("测试大学"); + u.setGrade("2010级"); + return u; + } + + private static University university() { + University u = new University(); + u.setId("uni"); + u.setName("测试大学"); + u.setSchoolCode("4111010001"); + return u; + } + + private static XuexinSubmitRequest req(String reportId) { + XuexinSubmitRequest req = new XuexinSubmitRequest(); + req.setReportId(reportId); + return req; + } +} diff --git a/campus-server/src/test/java/com/campus/service/location/ActivityLocationConfigurationTest.java b/campus-server/src/test/java/com/campus/service/location/ActivityLocationConfigurationTest.java new file mode 100644 index 0000000..28127a4 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/location/ActivityLocationConfigurationTest.java @@ -0,0 +1,28 @@ +package com.campus.service.location; + +import com.campus.config.ActivityLocationProperties; +import com.campus.service.ClickGuardService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.redis.core.StringRedisTemplate; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ActivityLocationConfigurationTest { + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(ActivityLocationProperties.class, AmapLocationProvider.class, ActivityLocationService.class) + .withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class)) + .withBean(ClickGuardService.class, () -> mock(ClickGuardService.class)) + .withBean(ObjectMapper.class, ObjectMapper::new); + @Test void disabledStartsWithoutProvider() { + runner.withPropertyValues("app.activity-location.enabled=false").run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(ActivityLocationService.class).doesNotHaveBean(LocationProvider.class); + }); + } + @Test void enabledBindsProviderWithoutNetworkDuringStartup() { + runner.withPropertyValues("app.activity-location.enabled=true", "app.activity-location.key=test-key").run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(LocationProvider.class); + }); + } +} diff --git a/campus-server/src/test/java/com/campus/service/location/ActivityLocationServiceTest.java b/campus-server/src/test/java/com/campus/service/location/ActivityLocationServiceTest.java new file mode 100644 index 0000000..216c136 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/location/ActivityLocationServiceTest.java @@ -0,0 +1,142 @@ +package com.campus.service.location; + +import com.campus.common.*; +import com.campus.config.ActivityLocationProperties; +import com.campus.dto.request.*; +import com.campus.dto.response.ActivityLocationDTO; +import com.campus.entity.User; +import com.campus.service.ClickGuardService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.redis.core.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ActivityLocationServiceTest { + @Mock ObjectProvider providers; + @Mock LocationProvider provider; + @Mock StringRedisTemplate redis; + @Mock ValueOperations values; + @Mock ClickGuardService guard; + ActivityLocationProperties properties; + ObjectMapper mapper = new ObjectMapper(); + ActivityLocationService service; + String resolve = UUID.randomUUID().toString(); + String token = UUID.randomUUID().toString(); + + @BeforeEach void init() { + properties = new ActivityLocationProperties(); properties.setEnabled(true); + service = new ActivityLocationService(properties, providers, redis, mapper, guard); + User user = new User(); user.setId("user"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null)); + } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + void enabled() { when(providers.getIfAvailable()).thenReturn(provider); } + void storage() { when(redis.opsForValue()).thenReturn(values); } + String snapshot(String user, boolean candidates) throws Exception { + ActivityLocationService.Snapshot snapshot = new ActivityLocationService.Snapshot(); + snapshot.setUserId(user); + ActivityLocationDTO dto = new ActivityLocationDTO(); dto.setLocation("图书馆东门"); dto.setLatitude(30.6); dto.setLongitude(104.06); dto.setLocationToken(token); + ActivityLocationDTO.Candidate candidate = new ActivityLocationDTO.Candidate(); + candidate.setCandidateId("one"); candidate.setName("图书馆"); candidate.setAddress("示例校区"); candidate.setLatitude(30.6); candidate.setLongitude(104.06); + if (candidates) dto.setCandidates(Collections.singletonList(candidate)); + snapshot.setResult(dto); return mapper.writeValueAsString(snapshot); + } + @Test void disabledDoesNotWrite() { + properties.setEnabled(false); + ServiceUnavailableException e = assertThrows(ServiceUnavailableException.class, () -> service.resolve(new ActivityLocationRequest())); + assertEquals("地点识别服务暂不可用,请稍后重试", e.getMessage()); + verifyNoInteractions(redis, provider, guard); + } + @Test void invalidTextAndCityDoNotWrite() { + enabled(); ActivityLocationRequest request = new ActivityLocationRequest(); + assertEquals("请输入活动举办地点", assertThrows(BusinessException.class, () -> service.resolve(request)).getMessage()); + request.setText("图书馆"); + assertEquals("请补充活动所在城市", assertThrows(BusinessException.class, () -> service.resolve(request)).getMessage()); + verifyNoInteractions(redis, provider, guard); + } + @Test void resolveReturnsCandidatesWithoutActivityWrite() { + enabled(); storage(); when(providers.getObject()).thenReturn(provider); + when(redis.execute(any(org.springframework.data.redis.core.script.RedisScript.class), anyList())).thenReturn(1L); + when(provider.search("图书馆", "510100", null)).thenReturn(Collections.emptyList()); + ActivityLocationRequest request = new ActivityLocationRequest(); request.setText(" 图书馆 "); request.setCityCode("510100"); + ActivityLocationDTO result = service.resolve(request); + assertTrue(result.getCandidates().isEmpty()); assertEquals(900, result.getExpiresInSeconds()); assertNull(result.getLocationToken()); + verify(values).set(startsWith("activity-location:resolve:"), anyString(), eq(900L), eq(TimeUnit.SECONDS)); + } + @Test void expiredTokenDoesNotChangeRequest() { + enabled(); storage(); ActivityRequest request = new ActivityRequest(); request.setLocationToken(token); request.setLocation("原地址"); + assertEquals("地点确认已过期,请重新确认", assertThrows(BusinessException.class, () -> service.applyConfirmedLocation(request, "user", "create")).getMessage()); + assertEquals("原地址", request.getLocation()); verifyNoInteractions(guard); + verify(values, never()).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); + } + @Test void foreignTokenIsForbidden() throws Exception { + enabled(); storage(); when(values.get("activity-location:token:" + token)).thenReturn(snapshot("other", false)); + ActivityRequest request = new ActivityRequest(); request.setLocationToken(token); + assertEquals("无权限访问该地点确认信息", assertThrows(ForbiddenException.class, () -> service.applyConfirmedLocation(request, "user", "create")).getMessage()); + verifyNoInteractions(guard); + } + @Test void tokenOverridesForgedCoordinates() throws Exception { + enabled(); storage(); when(values.get("activity-location:token:" + token)).thenReturn(snapshot("user", false)); + ActivityRequest request = new ActivityRequest(); request.setLocationToken(token); request.setLatitude(0d); request.setLocation("伪造地址"); + service.applyConfirmedLocation(request, "user", "create"); + assertEquals("图书馆东门", request.getLocation()); assertEquals(30.6, request.getLatitude()); assertEquals(104.06, request.getLongitude()); + verify(guard).ensureNotTooFrequent(eq(ClickGuardService.SCOPE_LOCATION_SUBMIT), eq("user"), anyString()); + } + @Test void confirmRejectsForeignCandidateWithoutWrite() throws Exception { + enabled(); storage(); when(values.get("activity-location:resolve:" + resolve)).thenReturn(snapshot("user", true)); + ActivityLocationRequest request = new ActivityLocationRequest(); request.setResolveId(resolve); request.setCandidateId("other"); + assertEquals("请选择本次识别的有效地点", assertThrows(BusinessException.class, () -> service.confirm(request)).getMessage()); + verifyNoInteractions(provider, guard); verify(values, never()).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); + } + @Test void redisFailureIs503() { + enabled(); storage(); when(values.get(anyString())).thenThrow(new org.springframework.dao.DataAccessResourceFailureException("secret")); + ActivityRequest request = new ActivityRequest(); request.setLocationToken(token); + assertEquals("地点识别服务暂不可用,请稍后重试", assertThrows(ServiceUnavailableException.class, () -> service.applyConfirmedLocation(request, "user", "create")).getMessage()); + } + @Test void validatesLegacyCoordinates() { + assertDoesNotThrow(() -> ActivityLocationService.validateCoordinates(0d, 0d)); + assertDoesNotThrow(() -> service.applyConfirmedLocation(new ActivityRequest(), "user", "create")); + assertEquals("活动经纬度必须同时提供", assertThrows(BusinessException.class, () -> ActivityLocationService.validateCoordinates(1d, null)).getMessage()); + for (double latitude : new double[]{Double.NaN, Double.POSITIVE_INFINITY, 91d}) + assertEquals("无效的经纬度范围", assertThrows(BusinessException.class, () -> ActivityLocationService.validateCoordinates(latitude, 1d)).getMessage()); + verifyNoInteractions(redis, provider, guard); + } + @Test void confirmPersistsTokenAndRetryReusesIt() throws Exception { + enabled(); storage(); + when(values.get("activity-location:resolve:" + resolve)).thenReturn(snapshot("user", true)); + when(redis.execute(any(org.springframework.data.redis.core.script.RedisScript.class), anyList())).thenReturn(1L); + when(values.setIfAbsent(startsWith("activity-location:confirm:"), anyString(), eq(1800L), eq(TimeUnit.SECONDS))).thenReturn(true); + ActivityLocationRequest request = new ActivityLocationRequest(); request.setResolveId(resolve); request.setCandidateId("one"); + ActivityLocationDTO result = service.confirm(request); + assertEquals("图书馆(示例校区)", result.getLocation()); assertNotNull(result.getLocationToken()); + ArgumentCaptor json = ArgumentCaptor.forClass(String.class); + verify(values).set(eq("activity-location:token:" + result.getLocationToken()), json.capture(), eq(1800L), eq(TimeUnit.SECONDS)); + when(values.get(startsWith("activity-location:confirm:"))).thenReturn(json.getValue()); + when(values.get("activity-location:token:" + result.getLocationToken())).thenReturn(json.getValue()); + assertEquals(result.getLocationToken(), service.confirm(request).getLocationToken()); + verifyNoInteractions(provider); + } + @Test void overQuotaDoesNotCallMapOrWriteSnapshot() { + enabled(); when(redis.execute(any(org.springframework.data.redis.core.script.RedisScript.class), anyList())).thenReturn(21L); + ActivityLocationRequest request = new ActivityLocationRequest(); request.setText("图书馆"); request.setCityCode("510100"); + assertEquals("点击过于频繁,操作失败", assertThrows(TooManyRequestsException.class, () -> service.resolve(request)).getMessage()); + verifyNoInteractions(provider); verify(redis, never()).opsForValue(); + } + @Test void mapPointRequiresSupportedCoordinateSystem() throws Exception { + enabled(); storage(); when(values.get("activity-location:resolve:" + resolve)).thenReturn(snapshot("user", true)); + ActivityLocationRequest request = new ActivityLocationRequest(); request.setResolveId(resolve); request.setLatitude(30d); request.setLongitude(104d); request.setCoordinateSystem("WGS84"); + assertEquals("不支持的坐标系", assertThrows(BusinessException.class, () -> service.confirm(request)).getMessage()); + verifyNoInteractions(provider, guard); + } +} diff --git a/campus-server/src/test/java/com/campus/service/location/AmapLocationProviderTest.java b/campus-server/src/test/java/com/campus/service/location/AmapLocationProviderTest.java new file mode 100644 index 0000000..11033ec --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/location/AmapLocationProviderTest.java @@ -0,0 +1,44 @@ +package com.campus.service.location; + +import com.campus.common.ServiceUnavailableException; +import com.campus.config.ActivityLocationProperties; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; +import org.springframework.http.MediaType; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; +import static org.hamcrest.Matchers.containsString; + +class AmapLocationProviderTest { + AmapLocationProvider provider; + MockRestServiceServer server; + @BeforeEach void setup() { + ActivityLocationProperties properties = new ActivityLocationProperties(); properties.setKey("test-key"); + provider = new AmapLocationProvider(properties); + server = MockRestServiceServer.createServer((RestTemplate) ReflectionTestUtils.getField(provider, "restTemplate")); + } + @AfterEach void verify() { server.verify(); } + @Test void searchUsesPoiAndLongitudeFirst() { + server.expect(requestTo(containsString("/v5/place/text"))).andExpect(queryParam("region", "510100")) + .andRespond(withSuccess("{\"status\":\"1\",\"pois\":[{\"name\":\"图书馆\",\"address\":\"东门\",\"location\":\"104.06,30.6\"}]}", MediaType.APPLICATION_JSON)); + assertEquals(30.6, provider.search("图书馆", "510100", null).get(0).getLatitude()); + } + @Test void fallsBackToGeocodingAndSkipsInvalidPoint() { + server.expect(requestTo(containsString("/v5/place/text"))).andRespond(withSuccess("{\"status\":\"1\",\"pois\":[]}", MediaType.APPLICATION_JSON)); + server.expect(requestTo(containsString("/v3/geocode/geo"))).andRespond(withSuccess("{\"status\":\"1\",\"geocodes\":[{\"formatted_address\":\"地址\",\"location\":\"NaN,91\"},{\"formatted_address\":\"地址\",\"location\":\"104,30\"}]}", MediaType.APPLICATION_JSON)); + assertEquals("ADDRESS", provider.search("图书馆", "510100", null).get(0).getPrecision()); + } + @Test void providerFailureNeverExposesKey() { + server.expect(requestTo(containsString("/v5/place/text"))).andRespond(withServerError()); + ServiceUnavailableException e = assertThrows(ServiceUnavailableException.class, () -> provider.search("图书馆", "510100", null)); + assertEquals("地点识别服务暂不可用,请稍后重试", e.getMessage()); assertNull(e.getCause()); + } + @Test void reversePreservesSelectedPoint() { + server.expect(requestTo(containsString("/v3/geocode/regeo"))).andExpect(queryParam("location", "104.06,30.6")) + .andRespond(withSuccess("{\"status\":\"1\",\"regeocode\":{\"formatted_address\":\"地图选点地址\"}}", MediaType.APPLICATION_JSON)); + assertEquals("MAP_POINT", provider.reverse(30.6,104.06).getPrecision()); + } +} diff --git a/campus-server/src/test/java/com/campus/service/review/TextReviewStrategyTest.java b/campus-server/src/test/java/com/campus/service/review/TextReviewStrategyTest.java new file mode 100644 index 0000000..5329be0 --- /dev/null +++ b/campus-server/src/test/java/com/campus/service/review/TextReviewStrategyTest.java @@ -0,0 +1,100 @@ +package com.campus.service.review; + +import com.campus.entity.SensitiveWord; +import com.campus.repository.SensitiveWordRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * 敏感词分级:动态沿用"全级别拒绝",话题只拦高/中危、低危仅记录。 + */ +@ExtendWith(MockitoExtension.class) +class TextReviewStrategyTest { + + @Mock private SensitiveWordRepository sensitiveWordRepository; + @InjectMocks private TextReviewStrategy strategy; + + @Test + void highLevelWordBlocksTopic() { + stub(word("法轮功", "HIGH")); + + ReviewResult result = strategy.review("法轮功", TextReviewStrategy.HIGH_AND_MEDIUM); + + assertFalse(result.isPassed()); + assertEquals("包含敏感词:法轮功", result.getViolationReasons().get(0)); + assertEquals(50, result.getScore()); + } + + @Test + void mediumLevelWordBlocksTopic() { + stub(word("刷单", "MEDIUM")); + + ReviewResult result = strategy.review("刷单", TextReviewStrategy.HIGH_AND_MEDIUM); + + assertFalse(result.isPassed()); + assertEquals(70, result.getScore()); + } + + @Test + void lowLevelWordIsOnlyRecordedForTopic() { + stub(word("垃圾", "LOW")); + + ReviewResult result = strategy.review("垃圾", TextReviewStrategy.HIGH_AND_MEDIUM); + + assertTrue(result.isPassed(), "LOW 词不应拦截话题"); + assertEquals(100, result.getScore()); + assertEquals("LOW:包含敏感词:垃圾", result.getDetails()); + assertTrue(result.getViolationReasons().isEmpty()); + } + + @Test + void legacyReviewStillBlocksLowLevelWord() { + stub(word("垃圾", "LOW")); + + ReviewResult result = strategy.review("垃圾"); + + assertFalse(result.isPassed(), "动态沿用原行为:LOW 词也拒绝"); + assertEquals(90, result.getScore()); + assertEquals("包含敏感词:垃圾", result.getViolationReasons().get(0)); + } + + @Test + void cleanContentPasses() { + stub(word("法轮功", "HIGH")); + + ReviewResult result = strategy.review("今天食堂真好吃", TextReviewStrategy.HIGH_AND_MEDIUM); + + assertTrue(result.isPassed()); + assertEquals(100, result.getScore()); + } + + @Test + void emptyContentPasses() { + ReviewResult result = strategy.review(" ", TextReviewStrategy.HIGH_AND_MEDIUM); + + assertTrue(result.isPassed()); + assertEquals(100, result.getScore()); + } + + private void stub(SensitiveWord... words) { + when(sensitiveWordRepository.findByEnabledTrue()).thenReturn(Arrays.asList(words)); + } + + private SensitiveWord word(String value, String level) { + SensitiveWord word = new SensitiveWord(); + word.setWord(value); + word.setLevel(level); + word.setEnabled(true); + return word; + } +} diff --git a/campus-server/tools/audit_database.py b/campus-server/tools/audit_database.py new file mode 100644 index 0000000..38feddd --- /dev/null +++ b/campus-server/tools/audit_database.py @@ -0,0 +1,136 @@ +"""只读结构与完整性盘点。依赖 PyMySQL、PyYAML;不输出连接信息或业务正文。""" +import argparse +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlsplit + + +def migration_manifest(directory): + result = [] + versions = set() + for file in directory.glob("V*__*.sql"): + match = re.fullmatch(r"V(\d+)__(.+)\.sql", file.name) + if not match: + raise ValueError("迁移文件名不符合整数版本约定") + version = int(match[1]) + if version in versions: + raise ValueError("迁移版本重复:V" + str(version)) + versions.add(version) + # 统一换行,避免 Windows/Linux 检出方式产生假差异。 + content = file.read_text(encoding="utf-8-sig").replace("\r\n", "\n") + result.append({"version": version, "file": file.name, + "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest()}) + return sorted(result, key=lambda item: item["version"]) + + +def compare_snapshots(previous, current): + """只比较结构和迁移指纹,不把统计变化误认为结构漂移。""" + differences = [] + for key in ("tables", "columns", "indexes", "foreign_keys", "migrations"): + before = previous.get(key, []) + after = current.get(key, []) + if before != after: + differences.append(key) + return differences + + +def collect(connection, migrations): + statements = { + "tables": "SELECT TABLE_NAME,ENGINE,TABLE_COLLATION FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", + "columns": "SELECT TABLE_NAME,COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT,EXTRA," + "CHARACTER_SET_NAME,COLLATION_NAME FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA=DATABASE() ORDER BY TABLE_NAME,ORDINAL_POSITION", + "indexes": "SELECT TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,SUB_PART " + "FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() " + "ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX", + "foreign_keys": "SELECT k.TABLE_NAME,k.CONSTRAINT_NAME,k.COLUMN_NAME,k.REFERENCED_TABLE_NAME," + "k.REFERENCED_COLUMN_NAME,r.DELETE_RULE,r.UPDATE_RULE " + "FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS r " + "ON r.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND r.TABLE_NAME=k.TABLE_NAME " + "AND r.CONSTRAINT_NAME=k.CONSTRAINT_NAME WHERE k.TABLE_SCHEMA=DATABASE() " + "ORDER BY k.TABLE_NAME,k.CONSTRAINT_NAME,k.ORDINAL_POSITION", + } + checks = { + "orphan_moment_comments": "SELECT COUNT(*) n FROM moment_comment c LEFT JOIN moment m " + "ON m.id=c.moment_id WHERE m.id IS NULL", + "orphan_video_progress": "SELECT COUNT(*) n FROM video_play_record v LEFT JOIN moment m " + "ON m.id=v.video_id WHERE m.id IS NULL", + "orphan_comment_likes": "SELECT COUNT(*) n FROM moment_comment_like l LEFT JOIN moment_comment c " + "ON c.id=l.comment_id LEFT JOIN moment m ON m.id=c.moment_id " + "WHERE c.id IS NULL OR m.id IS NULL", + } + result = {"captured_at_utc": datetime.now(timezone.utc).isoformat(), + "migrations": migrations, "migration_execution": "UNCONFIRMED"} + with connection.cursor() as cursor: + cursor.execute("START TRANSACTION READ ONLY") + cursor.execute("SELECT VERSION() version, DATABASE() database_name") + result["database"] = cursor.fetchone() + for name, sql in statements.items(): + cursor.execute(sql) + result[name] = cursor.fetchall() + result["integrity"] = {} + for name, sql in checks.items(): + cursor.execute(sql) + result["integrity"][name] = cursor.fetchone()["n"] + # 只有真实历史表存在才读取;不创建 baseline、不 repair、不执行任何版本脚本。 + if any(t["TABLE_NAME"] == "flyway_schema_history" for t in result["tables"]): + cursor.execute("SELECT installed_rank,version,script,checksum,success FROM flyway_schema_history " + "ORDER BY installed_rank") + result["flyway_history"] = cursor.fetchall() + connection.rollback() + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True, help="本地受限目录,勿提交或对外分发") + parser.add_argument("--compare", type=Path, help="之前人工验收的结构快照") + args = parser.parse_args() + import pymysql + import yaml + module = Path(__file__).resolve().parents[1] + manifest = migration_manifest(module / "src/main/resources/db/migration") + config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["spring"]["datasource"] + endpoint = urlsplit(config["url"].removeprefix("jdbc:")) + if endpoint.scheme != "mysql" or not endpoint.hostname or not endpoint.path.strip("/"): + raise ValueError("必须显式配置 MySQL 数据库") + if any("${" in str(config.get(k, "")) for k in ("url", "username", "password")): + raise ValueError("配置含未解析的环境变量,请使用本地已解析配置") + connection = pymysql.connect(host=endpoint.hostname, port=endpoint.port or 3306, + database=endpoint.path.strip("/"), user=config["username"], + password=str(config["password"]), charset="utf8mb4", + connect_timeout=10, read_timeout=30, + cursorclass=pymysql.cursors.DictCursor) + try: + snapshot = collect(connection, manifest) + finally: + connection.close() + # round trip 统一驱动返回的 Decimal 等类型,保证后续比较一致。 + snapshot = json.loads(json.dumps(snapshot, ensure_ascii=False, default=str)) + if args.compare: + previous = json.loads(args.compare.read_text(encoding="utf-8")) + if previous.get("database", {}).get("database_name") != snapshot["database"]["database_name"]: + raise ValueError("禁止将不同数据库名的快照当成同一迁移基线") + snapshot["changed_sections"] = compare_snapshots(previous, snapshot) + args.output.parent.mkdir(parents=True, exist_ok=True) + # 不覆盖已验收证据;每次使用新的文件名。 + with args.output.open("x", encoding="utf-8") as file: + json.dump(snapshot, file, ensure_ascii=False, indent=2) + print(json.dumps({"tables": len(snapshot["tables"]), "integrity": snapshot["integrity"], + "changed_sections": snapshot.get("changed_sections"), + "migration_execution": "UNCONFIRMED"}, ensure_ascii=False)) + return 2 if snapshot.get("changed_sections") or any(snapshot["integrity"].values()) else 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + # 驱动异常可能含主机、用户名或配置内容,不将异常原文写入终端/日志。 + print("数据库盘点失败:" + type(error).__name__ + ";请检查本地配置、权限、依赖和输出路径。") + raise SystemExit(1) diff --git a/campus-server/tools/test_audit_database.py b/campus-server/tools/test_audit_database.py new file mode 100644 index 0000000..4b37a02 --- /dev/null +++ b/campus-server/tools/test_audit_database.py @@ -0,0 +1,34 @@ +import tempfile +import unittest +from pathlib import Path +from audit_database import migration_manifest, compare_snapshots + + +class MigrationAuditTest(unittest.TestCase): + def test_manifest_is_numerically_sorted_and_ignores_line_endings(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + (root / "V10__ten.sql").write_bytes(b"SELECT 10;\r\n") + (root / "V2__two.sql").write_bytes(b"SELECT 2;\n") + before = migration_manifest(root) + self.assertEqual([2, 10], [r["version"] for r in before]) + (root / "V10__ten.sql").write_bytes(b"SELECT 10;\n") + self.assertEqual(before, migration_manifest(root)) + (root / "V10__ten.sql").write_bytes(b"SELECT 11;\n") + self.assertNotEqual(before, migration_manifest(root)) + + def test_duplicate_versions_are_rejected(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + for name in ("V2__a.sql", "V2__b.sql"): + (root / name).write_text("SELECT 1;", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "迁移版本重复"): + migration_manifest(root) + + def test_structure_drift_is_detected_without_count_or_timestamp_noise(self): + self.assertEqual([], compare_snapshots({"integrity": {"n": 1}}, {"integrity": {"n": 0}})) + self.assertEqual(["indexes"], compare_snapshots({"indexes": []}, {"indexes": ["new"]})) + + +if __name__ == "__main__": + unittest.main() diff --git a/campus-server/tools/test_mysql_indexes.py b/campus-server/tools/test_mysql_indexes.py new file mode 100644 index 0000000..eadca41 --- /dev/null +++ b/campus-server/tools/test_mysql_indexes.py @@ -0,0 +1,84 @@ +"""V22 合成数据实测,只使用显式独立测试库。""" +import os +import re +import unittest +from pathlib import Path +from urllib.parse import urlsplit + + +@unittest.skipUnless(os.getenv("QINGHE_INTEGRITY_TEST_URL"), "未配置独立测试库") +class IndexMigrationTest(unittest.TestCase): + def setUp(self): + import pymysql + endpoint = urlsplit(os.environ["QINGHE_INTEGRITY_TEST_URL"].removeprefix("jdbc:")) + database = endpoint.path.strip("/") + if not re.fullmatch(r"qinghe_integrity_test_[a-z0-9_]+", database): + raise ValueError("禁止业务库") + self.db = pymysql.connect(host=endpoint.hostname, port=endpoint.port or 3306, database=database, + user=os.environ["QINGHE_INTEGRITY_TEST_USER"], password=os.environ["QINGHE_INTEGRITY_TEST_PASSWORD"], + charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor) + self.addCleanup(self.db.close) + self.specs = [ + ("activity_payment_order","registration_id","UK_dw3dsgr7ik4xqb0p4l5lwt1t7","uk_payment_registration",True), + ("user_session","token_hash","UK_au1tr2kouh0rhp1us6nqjpmyi","idx_session_token_hash",True), + ("gift_record","sender_id","idx_gift_record_sender","idx_gr_sender",False), + ("activity_participant","activity_id,user_id","idx_ap_activity_user","uk_ap_activity_user",False), + ("moment_like","moment_id,user_id","idx_like_moment_user","uk_like_moment_user",False), + ("moment_comment_like","comment_id,user_id","idx_clike_comment_user","uk_clike_comment_user",False)] + with self.db.cursor() as q: + for t in ["chat_message","notification"] + [s[0] for s in self.specs]: + q.execute("DROP TABLE IF EXISTS `"+t+"`") + q.execute("CREATE TABLE chat_message(seq_id BIGINT PRIMARY KEY,message_id VARCHAR(36),conversation_id VARCHAR(100),deleted TINYINT,created_at DATETIME,content TEXT) ENGINE=InnoDB") + q.execute("CREATE TABLE notification(id BIGINT PRIMARY KEY,user_id VARCHAR(64),deleted TINYINT,is_read TINYINT,type VARCHAR(50),content TEXT) ENGINE=InnoDB") + for t,cols,old,keep,unique in self.specs: + fields=",".join("`"+c+"` VARCHAR(36)" for c in cols.split(",")) + q.execute("CREATE TABLE `"+t+"`(id BIGINT PRIMARY KEY,"+fields+", "+("UNIQUE " if unique else "")+"KEY `"+old+"`("+cols+"), "+("" if t=="gift_record" else "UNIQUE ")+"KEY `"+keep+"`("+cols+")) ENGINE=InnoDB") + q.execute("ALTER TABLE gift_record ADD receiver_id VARCHAR(36),ADD KEY idx_gift_record_receiver(receiver_id),ADD KEY idx_gr_receiver(receiver_id)") + q.executemany("INSERT INTO chat_message VALUES(%s,%s,%s,%s,%s,%s)", + [(i,"m"+str(i),"c"+str(i%200),0,"2026-10-22 00:00:00","测试") for i in range(12000)]) + q.executemany("INSERT INTO notification VALUES(%s,%s,%s,%s,%s,%s)", + [(i,"u"+str(i%200),0,i%3==0,"t"+str(i%7),"测试") for i in range(12000)]) + self.db.commit() + + def migrate(self): + script=(Path(__file__).resolve().parents[1]/"src/main/resources/db/migration/V22__optimize_chat_notification_indexes.sql").read_text(encoding="utf-8") + script="\n".join(s for s in script.splitlines() if not s.lstrip().startswith("--")) + with self.db.cursor() as q: + for statement in script.split(";"): + if statement.strip(): q.execute(statement) + + def test_repeatable_migration_plans_and_uniqueness(self): + self.migrate(); self.migrate() + with self.db.cursor() as q: + q.execute("ANALYZE TABLE chat_message,notification") + plans = [ + ("SELECT * FROM chat_message WHERE conversation_id='c10' AND deleted=0 AND seq_id>0 ORDER BY seq_id LIMIT 20","idx_chat_conv_deleted_seq"), + ("SELECT * FROM chat_message WHERE conversation_id='c10' AND deleted=0 AND seq_id>0 ORDER BY created_at,seq_id LIMIT 20","idx_chat_conv_deleted_time_seq"), + ("SELECT * FROM chat_message WHERE message_id='m10'","idx_chat_message_id"), + ("SELECT * FROM notification WHERE user_id='u10' AND deleted=0 AND is_read=0 LIMIT 20","idx_notification_user_deleted_read"), + ("SELECT * FROM notification WHERE user_id='u10' AND deleted=0 AND type='t1' LIMIT 20","idx_notification_user_deleted_type")] + for sql,index in plans: + q.execute("EXPLAIN "+sql); plan=q.fetchone() + if index == "idx_chat_conv_deleted_time_seq": + # 小会话可能由成本模型选择seq范围再排序,不能把候选索引当成必选项。 + self.assertIn(plan["key"], (index, "idx_chat_conv_deleted_seq"), str(plan)) + q.execute("EXPLAIN "+sql.replace("FROM chat_message", "FROM chat_message FORCE INDEX (idx_chat_conv_deleted_time_seq)")) + ordered=q.fetchone() + self.assertNotIn("filesort",ordered["Extra"] or "") + else: + self.assertEqual(index,plan["key"],str(plan)) + if "ORDER BY" in sql:self.assertNotIn("filesort",plan["Extra"] or "") + for t,cols,old,keep,unique in self.specs: + q.execute("SELECT INDEX_NAME,NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s",(t,)) + indexes=q.fetchall();self.assertFalse(any(r["INDEX_NAME"]==old for r in indexes)) + self.assertTrue(any(r["INDEX_NAME"]==keep for r in indexes)) + if t!="gift_record":self.assertTrue(any(r["INDEX_NAME"]==keep and r["NON_UNIQUE"]==0 for r in indexes)) + q.execute("SELECT COUNT(*) n FROM chat_message");self.assertEqual(12000,q.fetchone()["n"]) + q.execute("SELECT COUNT(*) n FROM notification");self.assertEqual(12000,q.fetchone()["n"]) + + def test_wrong_named_index_definition_stops_without_dropping_duplicates(self): + with self.db.cursor() as q:q.execute("ALTER TABLE chat_message ADD INDEX idx_chat_conv_deleted_seq(message_id)") + with self.assertRaises(Exception):self.migrate() + with self.db.cursor() as q: + q.execute("SHOW INDEX FROM activity_payment_order WHERE Key_name='UK_dw3dsgr7ik4xqb0p4l5lwt1t7'") + self.assertEqual(1,len(q.fetchall())) diff --git a/campus-server/tools/test_mysql_migration.py b/campus-server/tools/test_mysql_migration.py new file mode 100644 index 0000000..20f5d7e --- /dev/null +++ b/campus-server/tools/test_mysql_migration.py @@ -0,0 +1,87 @@ +"""V21 MySQL 实测。只允许显式指定 qinghe_integrity_test_ 前缀的独立测试库。""" +import os +import re +import unittest +from pathlib import Path +from urllib.parse import urlsplit + + +@unittest.skipUnless(os.environ.get("QINGHE_INTEGRITY_TEST_URL"), "未配置独立 MySQL 测试库") +class OrphanMigrationMySqlTest(unittest.TestCase): + def setUp(self): + import pymysql + endpoint = urlsplit(os.environ["QINGHE_INTEGRITY_TEST_URL"].removeprefix("jdbc:")) + database = endpoint.path.strip("/") + if not re.fullmatch(r"qinghe_integrity_test_[a-z0-9_]+", database): + raise ValueError("禁止使用业务数据库运行迁移测试") + self.connection = pymysql.connect(host=endpoint.hostname, port=endpoint.port or 3306, + database=database, user=os.environ["QINGHE_INTEGRITY_TEST_USER"], + password=os.environ["QINGHE_INTEGRITY_TEST_PASSWORD"], charset="utf8mb4") + self.addCleanup(self.connection.close) + self.tables = ["moment", "moment_comment", "moment_comment_like", "video_play_record", + "moment_comment_bak_v21_orphan", "moment_comment_like_bak_v21_orphan", + "video_play_record_bak_v21_orphan"] + with self.connection.cursor() as cursor: + for table in reversed(self.tables): + cursor.execute("DROP TABLE IF EXISTS `" + table + "`") + cursor.execute("CREATE TABLE moment (id VARCHAR(36) PRIMARY KEY,deleted TINYINT NOT NULL) ENGINE=InnoDB") + cursor.execute("CREATE TABLE moment_comment (id VARCHAR(36) PRIMARY KEY,moment_id VARCHAR(36),content TEXT) ENGINE=InnoDB") + cursor.execute("CREATE TABLE moment_comment_like (id VARCHAR(36) PRIMARY KEY,comment_id VARCHAR(36)) ENGINE=InnoDB") + cursor.execute("CREATE TABLE video_play_record (id VARCHAR(36) PRIMARY KEY,video_id VARCHAR(36),play_position INT) ENGINE=InnoDB") + cursor.execute("INSERT INTO moment VALUES ('active',0),('soft-deleted',1)") + cursor.execute("INSERT INTO moment_comment VALUES ('valid','active','保留'),('soft','soft-deleted','软删仍保留'),('orphan','absent','隔离')") + cursor.execute("INSERT INTO moment_comment_like VALUES ('valid-like','valid'),('orphan-like','orphan'),('missing-comment','absent')") + cursor.execute("INSERT INTO video_play_record VALUES ('valid-progress','active',10),('soft-progress','soft-deleted',20),('orphan-progress','absent',30)") + self.connection.commit() + + def run_migration(self, apply=False): + script = (Path(__file__).resolve().parents[1] / + "src/main/resources/db/migration/V21__quarantine_orphan_moment_dependencies.sql").read_text(encoding="utf-8") + # 本脚本不含存储过程或字符串内分号;先移除整行注释。 + script = "\n".join(line for line in script.splitlines() if not line.lstrip().startswith("--")) + try: + with self.connection.cursor() as cursor: + cursor.execute("SET @qinghe_v21_apply = %s", (1 if apply else 0,)) + for statement in script.split(";"): + if statement.strip(): + cursor.execute(statement) + except Exception: + self.connection.rollback() + raise + + def count(self, table): + with self.connection.cursor() as cursor: + cursor.execute("SELECT COUNT(*) FROM `" + table + "`") + return cursor.fetchone()[0] + + def test_dry_run_does_not_change_business_data_or_create_backups(self): + self.run_migration() + self.assertEqual(3, self.count("moment_comment")) + self.assertEqual(3, self.count("video_play_record")) + with self.connection.cursor() as cursor: + cursor.execute("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE '%bak_v21%'") + self.assertEqual(0, cursor.fetchone()[0]) + + def test_apply_backs_up_orphans_preserves_soft_deleted_and_can_repeat(self): + self.run_migration(True) + for table, count in [("moment_comment",2),("moment_comment_like",1),("video_play_record",2), + ("moment_comment_bak_v21_orphan",1),("moment_comment_like_bak_v21_orphan",2), + ("video_play_record_bak_v21_orphan",1)]: + self.assertEqual(count, self.count(table), table) + self.run_migration(True) + self.assertEqual(1, self.count("moment_comment_bak_v21_orphan")) + self.assertEqual(2, self.count("moment_comment")) + + def test_backup_failure_stops_before_deletion(self): + with self.connection.cursor() as cursor: + cursor.execute("CREATE TABLE video_play_record_bak_v21_orphan (id VARCHAR(36) PRIMARY KEY) ENGINE=InnoDB") + with self.assertRaises(Exception): + self.run_migration(True) + self.assertEqual(3, self.count("moment_comment")) + self.assertEqual(3, self.count("video_play_record")) + self.assertEqual(0, self.count("moment_comment_bak_v21_orphan")) + self.assertEqual(0, self.count("moment_comment_like_bak_v21_orphan")) + + +if __name__ == "__main__": + unittest.main() diff --git a/campus-server/tools/test_mysql_topic_id.py b/campus-server/tools/test_mysql_topic_id.py new file mode 100644 index 0000000..6ad58f5 --- /dev/null +++ b/campus-server/tools/test_mysql_topic_id.py @@ -0,0 +1,52 @@ +"""话题ID扩展/回填验证,复用独立测试库连接配置。""" +import os +import re +import unittest +from pathlib import Path +from urllib.parse import urlsplit + +@unittest.skipUnless(os.getenv("QINGHE_INTEGRITY_TEST_URL"), "未配置独立测试库") +class TopicIdMigrationTest(unittest.TestCase): + def setUp(self): + import pymysql + e=urlsplit(os.environ["QINGHE_INTEGRITY_TEST_URL"].removeprefix("jdbc:")) + if not re.fullmatch(r"qinghe_integrity_test_[a-z0-9_]+", e.path.strip("/")):raise ValueError("禁止业务库") + self.db=pymysql.connect(host=e.hostname,port=e.port or 3306,database=e.path.strip("/"), + user=os.environ["QINGHE_INTEGRITY_TEST_USER"],password=os.environ["QINGHE_INTEGRITY_TEST_PASSWORD"],charset="utf8mb4") + self.addCleanup(self.db.close) + self.tables=["topic_follow","topic_favorite","topic_visit","topic_hot_snapshot"] + with self.db.cursor() as q: + for t in self.tables+ [t+"_bak_v24_id" for t in self.tables]+["topic"]:q.execute("DROP TABLE IF EXISTS `"+t+"`") + q.execute("CREATE TABLE topic(id VARCHAR(36) PRIMARY KEY,name VARCHAR(100) UNIQUE,created_at DATETIME,deleted TINYINT) ENGINE=InnoDB COLLATE=utf8mb4_unicode_ci") + q.execute("INSERT INTO topic VALUES('old-id','旧名_deleted_old', '2026-01-01',1),('new-id','旧名','2026-10-01',0)") + for t in self.tables: + q.execute("CREATE TABLE `"+t+"` (id VARCHAR(36) PRIMARY KEY,user_id VARCHAR(36),topic_name VARCHAR(100),created_at DATETIME,captured_at DATETIME) ENGINE=InnoDB") + q.execute("INSERT INTO `"+t+"`(id,user_id,topic_name,created_at) VALUES('valid','u1','旧名','2026-10-02'),('deleted-parent','u2','旧名_deleted_old','2026-02-01'),('ambiguous','u3','旧名','2026-02-01'),('missing','u4','不存在','2026-10-02')") + self.db.commit() + + def run_script(self, file): + text=(Path(__file__).resolve().parents[1]/"src/main/resources/db/migration"/file).read_text(encoding="utf-8") + text="\n".join(line for line in text.splitlines() if not line.lstrip().startswith("--")) + try: + with self.db.cursor() as q: + for s in text.split(";"): + if s.strip():q.execute(s) + except Exception:self.db.rollback();raise + + def test_expand_preview_apply_repeat_and_ambiguous_rows(self): + self.run_script("V23__expand_topic_stable_id.sql") + self.run_script("V23__expand_topic_stable_id.sql") + self.run_script("V24__backfill_topic_stable_id.sql") + with self.db.cursor() as q: + q.execute("SELECT COUNT(*) FROM topic_follow WHERE topic_id IS NOT NULL");self.assertEqual(0,q.fetchone()[0]) + q.execute("SET @topic_id_apply=1") + self.run_script("V24__backfill_topic_stable_id.sql") + with self.db.cursor() as q: + for t in self.tables: + q.execute("SELECT id,topic_id FROM `"+t+"`");rows=dict(q.fetchall()) + self.assertEqual("new-id",rows["valid"]);self.assertEqual("old-id",rows["deleted-parent"]) + self.assertIsNone(rows["ambiguous"]);self.assertIsNone(rows["missing"]) + q.execute("SET @topic_id_apply=1") + self.run_script("V24__backfill_topic_stable_id.sql") + with self.db.cursor() as q: + q.execute("SELECT COUNT(*) FROM topic_follow_bak_v24_id");self.assertEqual(2,q.fetchone()[0])