# 全栈部署说明（前端 + 后端 + MySQL + Redis）

本文档描述 HRO 系统在装有 Docker 的目标服务器上的全栈部署方案。系统现已是前后端一体：
Vue3 管理端经 nginx 托管并反代 `/api`，后端 Spring Boot 跑 `prod` profile，数据存 MySQL 8 与 Redis 7。
开发者本机可能没装 Docker，本文所有步骤面向**有 Docker 的目标服务器**。

> 一键起：在 `hro-backend` 目录执行 `docker compose up --build -d`。
> 前端镜像的构建上下文指向同级目录 `../hro-admin-web`，因此两个仓库必须并排放置（见下文「目录结构」）。

## ⚠️ 部署前检查清单（务必逐项确认）

> 这份清单来自一次真实事故：向一台**已在跑现网业务的共用服务器**部署时，因 ① 未检查目标端口被占、② 给服务配了失败自动重启，导致服务反复重启耗尽内存，把整机（连带现网业务）拖到 SSH 失联、只能从云控制台抢救。下面每一项都是用代价换来的，**缺一不可**。

**① 确认每个端口都空闲** —— 别假设。

```bash
ss -tlnp | grep -E ':80 |:8080 |:3306 |:6379 '   # 把要用的端口都查一遍
```

前端 / 后端 / MySQL / Redis 要用的端口逐个查，有占用就换端口或先停。事故的直接起因就是 8090 被现有 neo-runner 占了却没查。

**② 确认内存余量足够** —— JVM 很吃内存。

```bash
free -h     # 看 available
```

一个 Spring Boot 服务的 RSS 约 1.5×`-Xmx`。`available` 必须留足这个量，否则先加 swap 或换机器。**内存已被占满（如 1.6G 机器只剩百来 M）的机器，不要再塞 JVM。**

**③ 确认这是不是共用机** —— 上面有没有别人的业务。

```bash
systemctl list-units --type=service --state=running
ss -tlnp     # 看还有谁在监听
```

共用机上部署：端口、数据库、内存**全部隔离**（独立库 + 独立账号 + 独立端口），并默认假设“我的操作可能拖垮现网”，加倍小心。

**④ 确认有带外恢复通道** —— SSH 不是唯一退路。

部署前先确认能用**云厂商控制台的 VNC / Workbench**（不走 sshd）。一旦机器被拖卡、SSH 失联，这是唯一能进去止血的路。频繁用密码 SSH 还可能触发云端暴力破解防护被临时封，更需要带外通道兜底。

### systemd 服务配置纪律

- **首次部署 / 环境不确定时，不要配 `Restart=on-failure` 之类的无限自动重启。** 服务起不来（端口占用、连不上库）时，无限重启会反复制造 JVM 启动内存峰值，把小内存机器彻底压垮 —— 这是本次事故的放大器。
- 确实要自动重启，必须同时设 `StartLimitIntervalSec` + `StartLimitBurst`（例如 60s 内失败 3 次就停止），让它**快速失败、停下来等人查**，而不是死循环。
- JVM 服务务必显式 `-Xmx` 限堆，按机器内存留足余量。

### 部署执行纪律

- **一步步来**：启动后立即轮询健康 + 看 `free -h` + 确认现网服务仍在，确认无误再下一步，不要一把梭。
- **健康检查要校验“真的是自己的服务”**：这次 8090 上是 neo-runner，`curl` 它的 health 返回了别人的页面、HTTP 200 假阳性。只看状态码不够，要核对响应内容确实来自本服务。
- 改动现网共用组件（如 nginx）用**独立 server / 独立端口**，改完 `nginx -t` 通过再 reload，绝不覆盖现有配置。

## 架构与端口拓扑

```text
浏览器
  -> web (nginx, 容器内 80)          # 唯一对外暴露端口，托管 Vue3 静态资源
       |  location /        -> 静态文件 / SPA 兜底 index.html
       |  location /api/    -> 反向代理（保留 /api 前缀，不 rewrite）
       v
     app (Spring Boot, 容器内 8080, prod profile)
       |
       +-- mysql (8.x, 容器内 3306, 库 hro)   # 仅 docker 网络内可达
       +-- redis (7.x, 容器内 6379)            # 仅 docker 网络内可达，存 token
```

要点：

- **浏览器只接触 `web`（80 端口）**，前后端同源，不存在跨域问题；`HRO_WEB_ORIGIN` 与 CORS 主要用于本地分端口联调场景。
- nginx 的 `/api/` 反代到 docker 网络内的 `http://app:8080`，**保留 `/api` 前缀**（后端 `LegacyApiPrefixFilter` 负责处理），`/api/auth/login` 也直接命中。
- `mysql`、`redis` 是内部依赖，**生产环境不要把 3306 / 6379 映射到宿主机公网**（详见「生产注意事项」）。
- 容器间通过服务名互访（`app` / `mysql` / `redis`），无需写死 IP。

## 目录结构

`web` 服务的构建上下文是同级的前端仓库，两个目录必须并排：

```text
/opt/hro/                      # 任意部署根目录
├── hro-backend/               # 本仓库，含 docker-compose.yml、.env、后端源码
│   └── docker-compose.yml     # 一键编排入口（在此目录执行 docker compose）
└── hro-admin-web/             # 前端仓库（Vue3 + 内置 docker/nginx.conf）
    └── Dockerfile             # node 构建 -> nginx 运行
```

> compose 文件中前端服务的 `build.context` 指向 `../hro-admin-web`。若两个目录不并排，前端镜像会构建失败。
> （注：`docker-compose.yml` 与 `.env.example` 由编排侧维护，本文档不负责其内容；以下命令以约定的服务名 `web`/`app`/`mysql`/`redis` 为准。）

## 全栈部署步骤（已装 Docker 的机器）

1. 进入后端目录并准备环境变量文件：

   ```bash
   cd hro-backend
   cp .env.example .env
   ```

2. 编辑 `.env`，把占位值改成真实配置，**至少**修改以下三项：

   | 变量 | 说明 | 要求 |
   | --- | --- | --- |
   | 数据库密码（`SPRING_DATASOURCE_PASSWORD` / MySQL 相关） | MySQL 账号口令 | 改成强口令，前后端与 MySQL 容器需一致 |
   | `HRO_AUTH_TOKEN_SECRET` | JWT/Token 签名密钥 | **≥ 32 字节**的随机串，不能用示例值 |
   | `HRO_ATTENDANCE_OVERTIME_PERSISTENCE` | 加班规则组数据源开关 | `prod` 默认 **`true`**（走 DB）；非生产默认 `false` |
   | `HRO_RICHTEXT_LOCAL_PERSISTENT_VOLUME_ENABLED` | 本地富文本存储持久卷确认 | 使用 local provider 时，确认卷已挂到 `/opt/hro/uploads` 后置 `true`；否则 prod 拒绝启动 |
   | `HRO_EMPLOYEE_UPLOAD_PERSISTENT_VOLUME_ENABLED` | 员工上传持久卷确认 | 确认证件、合同、银行卡等上传目录位于 `/opt/hro/uploads` 持久卷后置 `true`；否则 prod 拒绝启动 |

   生成一个足够长的随机密钥可用：

   ```bash
   openssl rand -base64 48
   ```

3. 构建并后台启动全部服务：

   ```bash
   docker compose up --build -d
   ```

4. 等待 MySQL 变为 healthy 后 `app` 才会启动（compose 已配 `depends_on` 健康依赖）。首次构建较慢（前端 `npm ci` + 后端 maven 打包），属正常现象。查看启动进度见「日志查看」。

## 拿到真实服务器后的上线流程

### 1. 安装 Docker 与 Compose 插件

以 Ubuntu/Debian 为例（其他发行版参考官方文档）：

```bash
# 安装 Docker Engine 与 compose 插件（官方便捷脚本）
curl -fsSL https://get.docker.com | sh

# 验证 docker 与 compose 插件
docker --version
docker compose version          # 注意是 "compose" 子命令（v2 插件），不是老的 docker-compose
```

> 若 `docker compose version` 不可用，单独安装 `docker-compose-plugin` 包。
> 可选：把当前用户加入 `docker` 组（`sudo usermod -aG docker $USER` 后重新登录）以免每条命令都加 `sudo`。

### 2. 上传 / 拉取两个并排目录

任选一种方式，确保 `hro-backend` 与 `hro-admin-web` 在同一父目录下：

```bash
# 方式 A：git 拉取（推荐）
mkdir -p /opt/hro && cd /opt/hro
git clone <hro-backend 仓库地址> hro-backend
git clone <hro-admin-web 仓库地址> hro-admin-web

# 方式 B：本地打包上传（无法直连仓库时）
#   本地：tar 两个目录后 scp 到服务器，再解压到同一父目录
```

### 3. 配置 `.env` 并启动

```bash
cd /opt/hro/hro-backend
cp .env.example .env
vi .env                         # 按上文表格改占位（DB 密码 / TOKEN_SECRET≥32 / OVERTIME_PERSISTENCE=true）
docker compose up --build -d
```

### 4. 冒烟测试

`web` 已起且 nginx 反代生效后，做两类检查：

```bash
# (a) 后端健康检查：actuator 在 app:8080，未经 nginx /api 反代，
#     因此从容器内或直连 app 端口检查（不要 curl http://<server>/actuator/health，
#     该路径不在 nginx /api/ 反代范围内，会落到 SPA 兜底返回 index.html）。
docker compose exec app curl -s http://localhost:8080/actuator/health
# 期望：{"status":"UP", ...}

# (b) 登录接口冒烟：经 web(80) -> nginx /api -> app，验证前后端链路同源打通
curl -i -X POST "http://<server>/api/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"Admin@123456"}'
# 期望：HTTP 200，响应体返回 token（demo 账号 admin / Admin@123456）
```

> 浏览器直接访问 `http://<server>/` 应能打开管理端登录页并成功登录。

### 5.（可选）域名与 HTTPS

`web` 容器内的 nginx 只监听 80。要上域名/证书，**不要改业务镜像**，推荐二选一：

- **在 `web` 前再加一层反代**（宿主机 nginx / Caddy / Traefik，或云厂商负载均衡 + 证书托管）：
  对外 443 终止 TLS，回源到 `web` 的 80 端口。最省事，业务容器零改动。
- **改前端 nginx 配置**：在 `hro-admin-web/docker/nginx.conf` 增加 443 server 块并挂载证书，重建前端镜像。
  耦合度更高，仅在不想引入外层反代时使用。

## 数据库迁移（Flyway）

- 后端启动时 **Flyway 自动执行** `classpath:db/migration` 下的脚本（`application.yml` 中 `spring.flyway.enabled` 默认 `true`），无需手工建表。
- 当前迁移脚本：

  ```text
  src/main/resources/db/migration/V1__create_attendance_core_tables.sql   # 考勤核心表
  src/main/resources/db/migration/V2__create_overtime_template_group_table.sql  # 加班规则组表
  ```

  - V1：`atd_scheme` / `atd_scheme_manager` / `atd_scheme_scope` / `atd_punch_record` / `atd_summary_task` 等考勤核心表。
  - V2：`atd_overtime_template_group`（加班规则组，第一个从 fixture 迁到真实 DB 的模块；配合 `HRO_ATTENDANCE_OVERTIME_PERSISTENCE=true` 生效）。
- Flyway 在 `flyway_schema_history` 表记录已执行版本，重复启动只补未执行的版本，幂等安全。

## 数据持久化

- MySQL 数据落在命名卷 **`mysql_data`**（挂载到容器 `/var/lib/mysql`）。
- Redis 数据落在命名卷 **`redis_data`**（挂载到容器 `/data`）。
- 富文本图片及员工上传落在命名卷 **`upload_data`**（挂载到 app 的 `/opt/hro/uploads`）；员工文件继续通过鉴权下载接口读取。
- 普通停止/更新（`docker compose down` 不带 `-v`）**不会**删除卷，数据保留。
- 备份示例：

  ```bash
  # MySQL 逻辑备份（按需替换库名/账号）
  docker compose exec mysql sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" hro' > hro_$(date +%F).sql
  ```

> ⚠️ `docker compose down -v` 会**连卷一起删除**，库数据、Redis token、富文本图片和员工上传文件全部丢失，生产慎用。

### ACK 本地上传存储

ACK 多副本使用本地存储时，必须把支持多 Pod 访问的持久卷挂载到所有 backend Pod 的 `/opt/hro/uploads`，并在确认挂载后设置：

```bash
HRO_UPLOAD_VOLUME_ROOT=/opt/hro/uploads
HRO_RICHTEXT_LOCAL_DIR=/opt/hro/uploads
HRO_RICHTEXT_LOCAL_PERSISTENT_VOLUME_ENABLED=true
HRO_RICHTEXT_LOCAL_BASE_URL=/api/uploads
HRO_EMPLOYEE_UPLOAD_DIR=/opt/hro/uploads/employee
HRO_EMPLOYEE_UPLOAD_PERSISTENT_VOLUME_ENABLED=true
```

Ingress 保持现有 `/api` 到 backend 的路由即可。只设置确认开关不能证明 PVC 真实存在；若 StorageClass 仅支持单 Pod 挂载，则生产多副本不应让各 Pod 使用独立本地盘。

## 日志查看

```bash
docker compose logs -f app      # 后端实时日志（看 Flyway 迁移、启动、报错）
docker compose logs -f web      # 前端 nginx 访问/错误日志
docker compose logs -f          # 全部服务
docker compose ps               # 查看各容器状态与健康检查
```

## 更新发布

代码更新后，重新构建并滚动重启（命名卷中的数据保留）：

```bash
cd /opt/hro/hro-backend
git pull                                  # 后端更新；前端在 ../hro-admin-web 同样 git pull
docker compose up --build -d              # 仅重建有变化的镜像并重启
```

- 只发后端：`docker compose up --build -d app`
- 只发前端：`docker compose up --build -d web`
- 清理旧悬空镜像：`docker image prune -f`

## 回滚

```bash
# 1. 把代码切回上一个可用版本（两个仓库都要切）
git checkout <上一个稳定 tag/commit>      # hro-backend
#   ../hro-admin-web 同样 checkout 对应版本

# 2. 重新构建并重启
docker compose up --build -d
```

注意：

- **数据库回滚需谨慎**。Flyway 只前进不自动回退；若新版本带了破坏性 schema 变更，代码回滚后旧版本可能与新表结构不兼容。生产发版前务必备份（见「数据持久化」），破坏性迁移要单独评估。
- 应用层（前后端镜像）回滚是安全的：命名卷数据不随 `down`/重建丢失。

## 生产注意事项

- **Secret 外部化**：`HRO_AUTH_TOKEN_SECRET`、数据库密码等敏感值只放 `.env`（已应在 `.gitignore` 中），不要提交进仓库；有条件用密钥管理服务/CI secret 注入，`.env` 文件权限收紧（`chmod 600 .env`）。`HRO_AUTH_TOKEN_SECRET` 必须 ≥ 32 字节且为随机串。
- **不要把 MySQL / Redis 暴露公网**：生产环境中 `mysql`(3306)、`redis`(6379) 应只在 docker 内部网络可达，避免在 compose 中把它们的端口映射到宿主机公网；确需远程管理时走内网/跳板机/SSH 隧道，并给 Redis 设密码（`REDIS_PASSWORD`）。对外只暴露 `web` 的 80（或经外层反代的 443）。
- **加班持久化开关**：生产必须 `HRO_ATTENDANCE_OVERTIME_PERSISTENCE=true`，否则加班规则组走内存 fixture，重启即丢，且不写 `atd_overtime_template_group` 表。
- **首启等待 MySQL healthy**：`app` 依赖 MySQL 的健康检查后才启动；首次拉起需等 MySQL 初始化完成（数据库较大或机器较慢时耐心等待），不要在 MySQL 尚未 healthy 时误判后端启动失败——用 `docker compose logs -f app` 观察。
- **改默认口令**：上线后务必修改 demo 账号 `admin / Admin@123456`（经 `HRO_AUTH_DEMO_PASSWORD` 覆盖或后续接入正式用户体系），不要让默认口令进生产。
