Browse Source

完善本地部署材料与项目说明

main
zzy_0123 6 days ago
parent
commit
1b6cbc65a0
  1. 14
      .gitattributes
  2. 3
      .gitignore
  3. 45
      README-启动说明.md
  4. 220
      README.md
  5. 4
      Start-Backend.cmd
  6. 37
      Start-Backend.ps1
  7. 9
      campus-server/.dockerignore
  8. 51
      campus-server/.env.example
  9. 29
      campus-server/Dockerfile
  10. 76
      campus-server/Start-Local.ps1
  11. 122
      campus-server/Test-LocalDeployment.ps1
  12. 91
      campus-server/compose.local.yml
  13. 21
      campus-server/pom.xml
  14. 10
      campus-server/src/main/java/com/campus/service/campus.code-workspace
  15. 150
      campus-server/src/main/resources/application.example.yml
  16. 61
      campus-server/src/main/resources/db/migration/V12__release_deleted_topic_names.sql
  17. 3
      campus-server/src/main/resources/db/migration/V1__baseline_marker.sql
  18. 16
      campus-server/src/main/resources/db/migration/V2__create_group_announcement.sql
  19. 133
      campus-server/src/main/resources/db/migration/V3__fix_activity_join_duplicates.sql
  20. 101
      campus-server/src/main/resources/db/migration/V8__cleanup_system_seed_topics.sql
  21. 59
      campus-server/src/main/resources/db/migration/V9__decouple_topic_heat_from_moment.sql
  22. 274
      方案分析与实现(md)文件/部署说明-本地部署.md

14
.gitattributes

@ -1,14 +0,0 @@
*.jar binary
*.zip binary
*.xz binary
*.png binary
*.jpg binary
*.ttf binary
*.woff2 binary
*.sql text eol=lf
*.java text eol=lf
*.xml text eol=lf
*.yml text eol=lf
*.md text eol=lf
*.ps1 text eol=crlf
*.cmd text eol=crlf

3
.gitignore

@ -7,8 +7,10 @@
application.yml
application-dev.yml
*.local.yml
!/campus-server/compose.local.yml
.env
.env.*
!.env.example
# ---- 数据库快照与迁移脚本:不入库 ----
*.sql
@ -37,6 +39,7 @@ out/
*.iml
.vscode/
*.iws
*.code-workspace
# ---- 临时目录 ----
.rewrite-backup/

45
README-启动说明.md

@ -1,45 +0,0 @@
# 青核后端(内部完整配置版,2026-10-15)
本包包含当前后端源码、可执行 backend.jar、实际云服务配置和 2026-10-14 数据库快照。包含真实凭据,仅供接手开发使用,请勿上传到 Gitee 或公开分发。没有前端源码及 AI 工作记录。
## 直接启动
1. 安装 JDK 17 或 JDK 21,确保命令行 `java -version` 能运行。也可设置 JAVA_HOME 指向 JDK 安装目录。
2. 完整解压到普通文件夹,不要在 ZIP 预览里运行。双击 `Start-Backend.cmd`,等待出现 `Started CampusApplication`。窗口保持开启,Ctrl+C 停止。
3. 访问 `http://127.0.0.1:8009/actuator/health`,预期 `{"status":"UP"}`。接口文档:`http://127.0.0.1:8009/swagger-ui.html`。API 前缀为 `/api`。
启动不需要 Maven,也不需要在 Windows 中逐个设置业务环境变量。MySQL、Redis、RabbitMQ 使用现有云服务,环信、OSS、OCR、短信、推送、VOD 的真实配置已写入 application.yml。需要能访问这些云服务的网络。
端口占用时可在解压目录运行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\Start-Backend.ps1 -Port 8010
```
## IDEA 开发
打开 `campus-server/pom.xml`,选择 JDK 17 或 21,等待 Maven 依赖下载完成,运行 `com.campus.CampusApplication`。工作目录设为 `campus-server`。`src/main/resources/application.yml` 已包含同样的实际配置,dev 配置的数据库地址也已同步,不需要复制环境变量。
修改源码后如需更新可执行包,安装 Maven 并在解压目录运行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\Start-Backend.ps1 -Rebuild
```
根目录 application.yml 是脚本启动时的外部配置;源码 resources 内配置用于 IDEA 直接启动。修改配置时应同步两处,避免运行方式不同而使用旧值。
## 数据库及联调
默认连接原云数据库 buskbuddy,数据库连接及密码在 application.yml 的 spring.datasource 中。无需导入 SQL 或安装本地 MySQL、Redis、RabbitMQ。随包的数据库 ZIP 是 2026-10-14 的 80 表快照;只有需要建立独立本地数据库时才解压导入,不要将快照导回当前云库。
保留当前联调行为:万能验证码已开启,值为 `111111`;Flyway 关闭以兼容当前 MySQL 5.7;环信使用国内 2 区地址。VOD 回调签名保持原有的未启用状态,没有编造签名;管理员启动授权名单为空,表示不额外授予账号管理员权限。这两个可选设置不影响后端启动。
现有 OssStsService 实际采用直接 AK/SK 上传凭证,不调用 STS AssumeRole;已移除旧配置中不存在的 RAM 角色占位值。后续如要改成真正的 STS 临时凭证,需要创建角色并调整该服务,不属于本包的配置补齐。
## 本包验证
2026-10-15 在交付电脑上使用本包脚本及 JDK 21 实际启动通过,健康检查返回 UP,接口文档返回 HTTP 200。MySQL 查询、Redis 认证及 PING、RabbitMQ 认证、环信令牌获取、OSS 认证读取、OCR 测试图片识别均通过。短信签名与模板查询、推送应用配置查询、VOD 列表及转码模板查询也通过。
未发送真实短信、推送消息,未测试前端实际上传及 VOD 公网回调;同事电脑上的网络、JDK、客户端配置仍需按以上启动方法验证。
手机与电脑在同一局域网时,客户端填写电脑实际 IPv4 地址及 8009 端口,并允许 Java 通过系统防火墙。异地联调需要另行建立外网入口;本包不包含已经失效的临时网址。

220
README.md

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

4
Start-Backend.cmd

@ -1,4 +0,0 @@
@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Start-Backend.ps1"
pause

37
Start-Backend.ps1

@ -1,37 +0,0 @@
[CmdletBinding()]
param([int]$Port = 8009, [switch]$Rebuild)
$ErrorActionPreference = 'Stop'
$javaExe = $null
if ($env:JAVA_HOME) {
$candidate = Join-Path $env:JAVA_HOME 'bin\java.exe'
if (Test-Path -LiteralPath $candidate) { $javaExe = $candidate }
}
if (-not $javaExe) {
$command = Get-Command java -ErrorAction SilentlyContinue
if (-not $command) { throw 'Install JDK 17 or 21 and add Java to PATH (or set JAVA_HOME).' }
$javaExe = $command.Source
}
$jar = Join-Path $PSScriptRoot 'backend.jar'
$configFile = Join-Path $PSScriptRoot 'application.yml'
if (-not (Test-Path -LiteralPath $configFile)) { throw 'application.yml must be next to this script.' }
if ($Rebuild) {
$maven = Get-Command mvn -ErrorAction Stop
Push-Location (Join-Path $PSScriptRoot 'campus-server')
try {
& $maven.Source clean package '-DskipTests'
if ($LASTEXITCODE -ne 0) { throw 'Maven build failed.' }
Copy-Item -LiteralPath 'target\campus-server-1.0.0.jar' -Destination $jar -Force
} finally { Pop-Location }
}
if (-not (Test-Path -LiteralPath $jar)) { throw 'backend.jar is missing; run with -Rebuild and Maven installed.' }
$occupied = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
if ($occupied) { throw "Port $Port is already in use. Stop the existing service or specify -Port 8010." }
Push-Location $PSScriptRoot
try {
Write-Host "API: http://127.0.0.1:$Port/api"
Write-Host "Health: http://127.0.0.1:$Port/actuator/health"
Write-Host 'Keep this window open. Press Ctrl+C to stop.'
$configLocation = 'file:' + $configFile.Replace('\', '/')
& $javaExe -jar $jar "--spring.config.additional-location=$configLocation" "--server.port=$Port"
if ($LASTEXITCODE -ne 0) { throw "Backend exited with code $LASTEXITCODE" }
} finally { Pop-Location }

9
campus-server/.dockerignore

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

51
campus-server/.env.example

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

29
campus-server/Dockerfile

@ -1,18 +1,17 @@
# 指定基础镜像
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/openjdk:8-jre
# 在 campus-server 目录先执行 mvn -Pportable clean verify。
# 从软件源安装 FFmpeg,构建不再依赖开发机的离线压缩包。
FROM eclipse-temurin:17-jre-jammy
# 将本地已下载的 FFmpeg 静态版复制进镜像(无需网络)
COPY ffmpeg-release-amd64-static.tar.xz /tmp/
RUN tar xf /tmp/ffmpeg-release-amd64-static.tar.xz -C /tmp \
&& cp /tmp/ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \
&& cp /tmp/ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ 2>/dev/null; true \
&& chmod +x /usr/local/bin/ffmpeg \
&& rm -rf /tmp/ffmpeg-*
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
VOLUME /tmp
# 复制文件到容器(匹配实际的jar包名)
ADD target/campus-server-1.0.0.jar app.jar
# 申明需要暴露的端口
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","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
ENTRYPOINT ["java", "-Duser.timezone=GMT+08", "-jar", "/app/app.jar", "--spring.config.location=file:/app/config/application.example.yml"]

76
campus-server/Start-Local.ps1

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

122
campus-server/Test-LocalDeployment.ps1

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

91
campus-server/compose.local.yml

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

21
campus-server/pom.xml

@ -240,4 +240,25 @@
</plugin>
</plugins>
</build>
<!-- 本地部署/对外分发专用产物,使用独立目录,避免混入开发机真实配置。 -->
<profiles>
<profile>
<id>portable</id>
<build>
<directory>${project.basedir}/target/portable</directory>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>**/application*.yml</exclude>
<exclude>**/application*.yaml</exclude>
<exclude>**/application*.properties</exclude>
<exclude>**/.env*</exclude>
</excludes>
</resource>
</resources>
</build>
</profile>
</profiles>
</project>

10
campus-server/src/main/java/com/campus/service/campus.code-workspace

@ -1,10 +0,0 @@
{
"folders": [
{
"path": "../../../../../.."
},
{
"path": "../../../../../../../collegeCli"
}
]
}

150
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}

61
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 匹配),再删除备份表。
-- 注意:回滚会把原名重新占住,可能再次出现"无法重建同名话题"的问题。

3
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;

16
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;

133
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;

101
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),
-- 不要用本脚本插数据,避免绕过校验、又产生新的空壳。
-- ---------------------------------------------------------------------------

59
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),那两个备份都可作为恢复源。

274
方案分析与实现(md)文件/部署说明-本地部署.md

@ -0,0 +1,274 @@
# 青核后端本地部署说明
本说明适用于下载源码后,在独立开发环境部署青核后端。不包含真实配置、真实账号或业务数据。源码与现有文档目录位置保持不变。
> 验证边界:本次提供模板、启动器、Compose 与部署流程;未完成 Docker 实机启动及全新 MySQL 空库业务验收。首次部署成功后仍需执行本文验收清单,不能把材料齐备等同于所有功能可用。
## 1. 文件与用途
| 文件 | 用途 | 后续是否适合上传 |
| --- | --- | --- |
| `README.md` | 项目入口 | 是 |
| `campus-server/.env.example` | 环境变量占位示例 | 是 |
| `campus-server/src/main/resources/application.example.yml` | 脱敏 Spring 配置模板 | 是 |
| `campus-server/Start-Local.ps1` | 在独立进程读取本地环境文件、构建与启动 | 是 |
| `campus-server/Test-LocalDeployment.ps1` | 离线验证启动器与部署 JAR 的配置排除 | 是 |
| `campus-server/compose.local.yml` | 独立 MySQL、Redis、RabbitMQ 与可选后端容器 | 是 |
| `campus-server/Dockerfile`、`.dockerignore` | 后端镜像构建与构建上下文控制 | 是 |
| `campus-server/pom.xml` 中 `portable` profile | 打包时排除真实应用配置,产物写入独立目录 | 是 |
| 本文及必要迁移脚本 | 初始化、升级和验收指导 | 审核后上传 |
| 本地 `.env`、现有两份真实 `application.yml` | 部署者的真实连接与凭据 | 否 |
本文是随源码发布的部署材料,原目录位置不变。其他内部文档继续按原排除规则处理,不因发布本文而全部公开;真实应用配置与 `.env` 由部署者在本地准备。
## 2. 环境要求与限制
- JDK 17 或 21、Maven;项目编译目标仍为 Java 8,Spring Boot 2.7.18,业务代码使用 `javax.*`。
- Windows PowerShell 5.1 或 PowerShell 7;容器部署另需已运行的 Docker Desktop Linux engine 和 Docker Compose。
- 首次构建能访问 Maven 仓库;容器构建能访问镜像仓库与 Ubuntu 软件源。
- 模板选择 MySQL 8.4、Redis 7.4、RabbitMQ 4.1 作为独立开发环境组合,尚未完成该组合的运行时兼容性验收;不代表现有共享环境的版本。
- 默认基础设施端口仅绑定 `127.0.0.1`,避开常见端口;不使用现有云数据库。
| 服务 | 宿主机端口 | 容器端口 |
| --- | --- | --- |
| MySQL | 13306 | 3306 |
| Redis | 16379 | 6379 |
| RabbitMQ AMQP | 15672 | 5672 |
| RabbitMQ 管理页面 | 15673 | 15672 |
| 后端 | 8009 | 8009 |
端口均可在本地 `.env` 修改。这里宿主机的 **15672 是 AMQP 端口**,管理页面是 `http://127.0.0.1:15673`。
## 3. 准备本地配置
以下命令从仓库根目录进入模块后执行:
```powershell
cd .\campus-server
java -version
mvn -version
docker compose version
```
仅在 `.env` 尚不存在时复制模板:
```powershell
if (-not (Test-Path -LiteralPath '.env')) {
Copy-Item -LiteralPath '.env.example' -Destination '.env'
}
```
编辑 `.env`,至少替换 `DB_PASSWORD`、`MYSQL_ROOT_PASSWORD`、`REDIS_PASSWORD`、`RABBITMQ_PASSWORD` 和 `JWT_SECRET` 的 `replace-me-...` 示例值。JWT secret 至少 32 字节,使用独立随机值。密码建议使用随机字母、数字组合;当前启动器要求未加引号的 `KEY=value`,不支持变量引用、行尾注释或值中的 `$`。
`DB_NAME=qinghe_local`、`DB_USERNAME=qinghe` 是本地示例名称;不得改为已有生产或共享业务数据库。数据库名及用户名建议只用字母、数字和下划线。环境变量来自本地 `.env`,不要填写到可上传模板中。
可先只检查本地文件格式,不连接服务:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\Start-Local.ps1 -CheckOnly
```
启动器按脚本目录解析 `.env`,不执行其内容,不打印配置值。它只接受 `.env.example` 列出的变量,运行结束后恢复原进程的同名环境变量。启动前显式指定 `application.example.yml`,避免默认加载你现有的两份真实配置。
`--spring.config.location` 会替换默认配置查找位置,但命令行、操作系统环境变量及 `SPRING_APPLICATION_JSON` 仍可覆盖属性。请在没有额外 `SPRING_*` 覆盖项的独立终端执行,不在现有生产运行环境复用本地模板。
## 4. 启动基础设施
所有命令在 `campus-server` 目录执行。先启动 Docker Desktop,再运行:
```powershell
docker compose --env-file .env -f compose.local.yml config --quiet
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` 再启动后端。首次拉取镜像和初始化数据库需要时间。遇到错误时按服务查看日志,不把含敏感信息的完整配置输出粘贴到公共位置。
Compose 项目名默认为 `.env` 中的 `qinghe-local`,数据保存到该项目的命名卷。不要复用现有业务数据卷。已有卷的密码不会随 `.env` 修改而自动更新,MySQL/RabbitMQ 初始化变量主要用于首次创建数据目录;修改密码需在服务内同步处理。
宿主机终端中的同名环境变量优先于 Compose 的 `--env-file`,应使用干净终端,避免覆盖本地配置。Java 不会自动读取 `.env`,请使用下节启动器。
## 5. 构建并启动后端
### 5.1 推荐:基础设施容器 + 宿主机后端
在 `campus-server` 目录执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\Start-Local.ps1
```
脚本先执行 `mvn -Pportable clean verify`,再使用 `target/portable/campus-server-1.0.0.jar` 与脱敏模板启动。构建或测试失败时不继续启动。保持窗口打开,使用 Ctrl+C 停止。
已完成构建且确认产物是当前源码时,可使用:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\Start-Local.ps1 -SkipBuild
```
`portable` 只在显式启用时生效,默认开发打包方式不变;它排除所有 `application*.yml/yaml/properties` 及 `.env*` 资源,不把本机真实配置带入部署 JAR。该配置只检查约定文件名,不代替对源码、其他资源及历史提交的凭据检查。不要分发普通开发方式打出的 JAR,它可能包含本机真实配置。
### 5.2 可选:后端也使用容器
先在宿主机构建部署专用 JAR:
```powershell
mvn -Pportable clean verify
docker compose --env-file .env -f compose.local.yml --profile app up -d --build
docker compose --env-file .env -f compose.local.yml --profile app logs -f backend
```
不要同时启动宿主机后端和后端容器占用同一端口。后端容器使用 `mysql/redis/rabbitmq` 服务名及容器内部端口,Compose 会覆盖 `.env` 中这几项宿主机连接值。
Dockerfile 从软件源安装 FFmpeg 和基础字体,不依赖本机的 `ffmpeg-release-amd64-static.tar.xz`。镜像以非 root 用户运行,PDF 中文渲染如需额外字体应单独补齐并验收。构建上下文只允许部署 JAR 与脱敏模板,不发送真实应用配置或本地 `.env`。
## 6. 首次空库初始化与旧库升级
### 6.1 全新独立开发库
1. Compose 首次启动创建 `DB_NAME` 指定的空数据库与普通业务账号。
2. 保持 `DB_DDL_AUTO=update`、Flyway 关闭,启动当前源码。Hibernate 按当前实体创建表,启动初始化器补入其支持的字典、礼物等预置数据;不包含真实用户、学校全量资料或历史业务数据。
3. 检查启动日志中的 DDL/SQL 错误。Hibernate 可能记录 DDL 错误后继续启动,不能只看到 `Started CampusApplication` 就判定结构完整。
4. 执行下方只读结构检查,并验收相关业务。
5. 若缺少唯一约束或外键,停止对应业务验证,由维护者根据结构差异补齐后再次校验。
**不要在 Hibernate 已按当前实体建表后,再把 V1–V18 全部从头执行。** 现有迁移主要面向历史版本升级,部分 `ADD COLUMN` 与数据回填并不幂等;例如 V18 引用旧版 `raw_lines`,全新实体建出的表不一定存在旧字段。
V1 仅是 `SELECT 1` 基线标记,并非完整建库 SQL。当前没有经过验收的“单份 SQL 完整初始化全部表”基线,本次也未编造该基线。上述是独立开发库的建表与检查流程,不是生产部署验收结论。
可通过数据库客户端连接 `127.0.0.1:13306`,或在模块目录交互执行:
```powershell
docker compose --env-file .env -f compose.local.yml exec mysql mysql -u qinghe -p qinghe_local
```
命令中的账号和数据库名需与 `.env` 一致,密码在交互提示中输入。
只读校验 SQL:
```sql
SELECT DATABASE(), VERSION();
SHOW TABLES;
SHOW CREATE TABLE `user`;
SHOW CREATE TABLE activity_participant;
SHOW CREATE TABLE group_member;
SHOW CREATE TABLE group_announcement;
SHOW CREATE TABLE group_invitation;
SHOW CREATE TABLE xuexin_report;
SELECT table_name, index_name,
GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns_in_order
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND non_unique = 0
AND table_name IN ('user', 'activity_participant', 'group_member',
'group_announcement', 'group_invitation', 'xuexin_report')
GROUP BY table_name, index_name;
SELECT table_name, constraint_name, referenced_table_name, delete_rule
FROM information_schema.referential_constraints
WHERE constraint_schema = DATABASE()
AND table_name IN ('group_announcement', 'group_invitation');
```
重点确认手机号唯一、活动参与 `(activity_id,user_id)`、群成员 `(group_id,user_id)`、每群一条公告、邀请 `(group_id,invitee_id,pending_key)` 和认证 `active_marker` 唯一约束。公告与邀请到 `chat_group` 的外键应为 `ON DELETE CASCADE`;实体使用字符串 ID 时 Hibernate 不会自动替你建出这些关联外键。
如空库由 Hibernate 建表后外键缺失,先用 `SHOW CREATE TABLE` 比较关联列的类型、字符集和排序规则。**仅在新建的独立测试库、确认兼容且外键不存在时**,维护者可人工执行下列补充 DDL:
```sql
ALTER TABLE group_announcement
ADD CONSTRAINT fk_ga_group
FOREIGN KEY (group_id) REFERENCES chat_group(id) ON DELETE CASCADE;
ALTER TABLE group_invitation
ADD CONSTRAINT fk_gi_group
FOREIGN KEY (group_id) REFERENCES chat_group(id) ON DELETE CASCADE;
```
以上 DDL 不是幂等脚本,不允许对旧库或已有同类外键的库直接重复执行;本次未执行。独立库验收通过后可将 `DB_DDL_AUTO` 改为 `validate` 再重启检查,但 Hibernate validate 同样不能替代唯一索引和外键检查。
### 6.2 已有数据的数据库升级
先备份,在隔离副本核对已执行版本和表结构,阅读各脚本及功能对接说明,再人工执行确实缺少的迁移。不得套用空库建表步骤,也不要把 `update` 当成生产升级方案。
本次源码发布包含 V1–V18 的 18 份迁移。SQL 仍保留通用忽略规则,新增迁移须按文件精确审核后纳入版本控制;不能用数据库快照代替脱敏迁移,也不要修改已经执行的历史迁移。包含历史数据清理的 V3、V8、V9、V12 仅适用于对应升级场景,不应自动执行于新库或未经审核的业务库。
**是否需要人工迁移:** 新建隔离库需人工结构核验、按缺项补齐约束;旧库需按版本差异人工执行必要 SQL。Flyway 当前不会自动执行。
## 7. 云服务功能边界
| 功能 | 未配置真实云服务时的边界 |
| --- | --- |
| 数据库 / Redis / RabbitMQ | 必须连接本地独立实例,不能依赖云服务占位值 |
| OSS 文件上传 | 模板保留 OSS Bean,使用本地占位 endpoint;未提供可用存储服务,上传、材料存证等不可用 |
| 短信验证码 | 必填配置键已提供占位;发送短信不可用,万能验证码默认关闭 |
| 推送 | 模板默认关闭 |
| 环信 IM | 留空时初始化器会走现有降级;不能据此宣称群聊、消息同步全功能可用 |
| OCR / 学信网认证 | 需要真实 OCR 与 OSS,模板中的 OCR enabled 不是可靠的统一禁用保障;未配置时不要调用 |
| VOD / 视频 | 需要真实 VOD 权限、回调与可访问域名;本地 JAR 能启动不代表视频可用 |
OSS 不能简单配置 `enabled=false`:现有 `OssUtil` 强制注入 `OSS` Bean,关闭后可能因依赖缺失启动失败。本次不修改业务依赖关系或引入模拟实现。
补充云凭据时只编辑私有 `.env`,核对地域、应用、bucket、回调域名和媒体域名白名单。不要把部署者自己的密钥写回模板。VOD 回调沿用当前协议,不新增虚构签名配置。
## 8. 验收、排错与停止
启动后在另一个终端检查:
```powershell
Invoke-RestMethod 'http://127.0.0.1:8009/actuator/health'
```
预期 `status=UP`。Swagger 为 `http://127.0.0.1:8009/swagger-ui.html`,OpenAPI 为 `http://127.0.0.1:8009/v3/api-docs`,业务前缀 `/api`,认证头 `access_token`。端口变化时同步调整。
验收至少记录:
- 构建及全量测试结果;运行版本和时间。
- MySQL、Redis、RabbitMQ 健康状态及实际连接目标。
- 表、唯一约束、外键及基础种子数据检查结果。
- 一个匿名接口和一个经合法登录认证的业务接口;未接入短信时不声称登录全流程通过。
- 每项云能力单独记录“未接入 / 验证通过 / 失败”,不以 UP 代替业务验收。
| 问题 | 检查方向 |
| --- | --- |
| Docker named pipe / daemon 连接失败 | Docker Desktop Linux engine 是否运行 |
| 连接拒绝、认证失败 | 服务是否 healthy、端口映射、密码和数据卷首次初始化状态 |
| 配置占位符无法解析 | 是否由启动器或 Compose 注入 `.env`,配置名是否拼写一致 |
| 缺少 OSS Bean | 是否误设 `aliyun.oss.enabled=false` |
| SQL 字段或约束错误 | 区分新库与旧库,检查实际结构;不要忽略所有 SQL 报错 |
| OPTIONS 401 | 当前有已知跨域预检缺口;本地服务启动不等于浏览器跨域可用,不通过关闭鉴权绕过 |
停止本地容器并保留数据卷:
```powershell
docker compose --env-file .env -f compose.local.yml --profile app down
```
不使用 `down -v` 作为常规停止方式,它会删除本地卷。本文不提供删除数据的快捷命令。
## 9. 发布范围与验证记录
可上传材料:模板、启动器及离线检查脚本、Compose、Dockerfile、`.dockerignore`、Maven 配置、README、本文及经复核的必要迁移。真实 `.env`、两份真实 `application.yml`、JAR、日志、数据库快照继续留本地。
创建新材料不自动取消内部资料的排除规则;发布时核对目标仓库、分支和最终文件清单,并检查 README 所需链接是否随文档一起发布。本次仅公开本文,其他内部方案、工作记录与开发规范不随源码发布。
2026-10-21 本次本地验证结果:
- `mvn -Pportable clean verify`:BUILD SUCCESS;70 份报告,496 用例,0 失败 / 0 错误 / 0 跳过。
- `Test-LocalDeployment.ps1`:Windows PowerShell 5.1 与 PowerShell 7 各 11 项离线检查通过,涵盖输入校验、独立配置参数、环境恢复及 JAR 排除私有配置。
- Compose 结构与变量插值检查通过(未解析实际私有 `.env`),两份 YAML 语法解析通过。
- 现有两份真实 `application.yml` 的 SHA-256 与改动前相同;没有创建或覆盖用户的 `.env`。
- Docker Desktop 引擎未运行;未启动容器、未构建镜像、未连接共享数据库或云服务,空库和全业务验收仍待执行。
重复执行离线检查(模块目录):
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\Test-LocalDeployment.ps1
```
该脚本仅生成并清理自己的临时测试文件,使用 Java 替身验证启动参数,不启动后端;未生成 portable JAR 时,会跳过对应检查。
## 10. 官方参考
- [Spring Boot 2.7.18 外部配置](https://docs.spring.io/spring-boot/docs/2.7.18/reference/html/features.html#features.external-config.files):显式配置位置与默认查找规则。
- [Docker Compose 环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/):`--env-file`、必填变量及宿主机环境优先级。
- [Docker Compose 启动顺序](https://docs.docker.com/compose/how-tos/startup-order/):`service_healthy` 等待基础设施健康后再启动后端。
Loading…
Cancel
Save