Files
xiaotutongxue/docs/09-部署与运维.md
T
lovife b94db65ec4 docs: 部署与运维手册 + 用户手册与客服话术
docs/09:环境与发布、密钥清单、迁移与回滚、排障、首次接入 CI 的检查单、本地能验到哪一步、
压测记录(含未测项)。

docs/10:学生/班主任/管理员三份操作手册 + 高频问题标准回答 + 30 分钟培训提纲 +
上线首日检查单;文案与错误码逐条取自代码并回核(抓到两处与实现不符),
核对固化成了 check-manual.py 并进 CI。
2026-09-30 09:02:57 +08:00

336 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 09 · 部署与运维
> 对应里程碑 **M0-04(工程化)** 与 **M7-10(灰度发布 + 生产环境准备 + 数据迁移预案)**。
> 这份文档回答四个问题:**怎么发**、**密钥怎么办**、**升级与回滚怎么做**、**出事看哪里**。
---
## §1 四套环境
| 环境 | profile | 配置来源 | 数据库 | 存储 | 用途 |
|---|---|---|---|---|---|
| 本地 | `dev` | `application-dev.yml`(**含 dev 密钥,已提交**) | `xt-pg` 容器 | `local` | 开发与 E2E |
| 集成 | `test` | `application-test.yml`(**无密钥**,全走环境变量) | CI 临时容器 / 联调库 | `local` 或 `qiniu` | CI 与联调 |
| 预发 | `prod` + 灰度开关 | `application-prod.yml` | 独立库 | `qiniu` | 客户验收 |
| 生产 | `prod` | `application-prod.yml` | 独立库 | `qiniu` | 正式 |
**⚠️ dev 与 test/prod 的密钥不能混用。** `dev` 的 JWT / AES 密钥在仓库里能查到,
拿它能伪造任意用户的 token(含平台运营)。这件事由 `ProdEnvCheck` 在启动时拦下 ——
见 §3.4。
---
## §2 怎么发
### 2.1 本地
```bash
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379)
cd backend
mvn -DskipTests package
java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
```
- ⚠️ **工作目录必须是 `backend/`**:两处相对路径依赖它 ——
`application-local.yml`(本地私有配置,不提交)在这里;
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
- 跑的是 **jar 不是热加载**:改后端要重新 package(先停进程,否则 jar 被占用)
- H5 没有独立 dev server:`cd frontend-mp && pnpm build:h5`,然后访问
`http://localhost:8080`(由后端托管)
### 2.2 CI(GitLab)
`.gitlab-ci.yml` 三个阶段:
| 阶段 | 做什么 | 为什么这样切 |
|---|---|---|
| `check` | 契约对账、实体列对账、文档一致性、**e2e 脚本语法扫描** | 都不需要数据库,几十秒出结果,先在最快的阶段挡掉低级错误 |
| `build` | 后端打包、管理端 typecheck/build、**H5 build** | H5 产物是后面 e2e 的输入,不能跳过 |
| `e2e` | 起 PG+Redis 容器 → 起后端(`test` profile)→ **依次跑全部 `scripts/e2e-*.py`** | 这个仓库**没有单元测试**,E2E 就是主验证手段;一个只跑编译的流水线在这里等于没跑 |
另外单独一个 job `生产环境自检` 跑 `scripts/e2e-prodguard.py` ——
它验的是"**配错时拒绝启动**",而这恰好是最容易被后来的改动悄悄破坏、
且破坏后**一切看起来都正常**的一件事。
**密钥策略:e2e 阶段每次随机生成密钥,不存任何 CI 变量。**
好处不只是省事 —— 它顺带证明**应用没有偷偷依赖某个 dev 默认值**(依赖了这里就红)。
### 2.3 生产发布
```bash
# ① 构建(CI 里做,产物是 xt-server.jar)
mvn -DskipTests package
# ② 先把迁移在一个只读副本上试跑(见 §4.2)
java -jar xt-server.jar --spring.profiles.active=prod \
--spring.flyway.enabled=true --spring.main.web-application-type=none
# ③ 滚动发布:先起新实例、探活通过、再摘旧实例
curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 /
```
⚠️ **滚动发布期间会有两个版本同时在跑**,所以:
- **迁移必须是"向后兼容"的**(只加列/加表,不改列类型、不删列)——
否则旧实例会在新库上出错。改列要走"两次发布":这次加新列并双写,下次删旧列。
- **H5 静态产物要一起发**:它是后端托管的,前后端不同步会出现
"页面调用了还不存在的接口"。
---
## §3 密钥与凭据
### 3.1 清单
| 变量 | 用途 | 生成方式 | 轮换影响 |
|---|---|---|---|
| `XT_JWT_SECRET` | 登录 token 的 HS512 签名 | `openssl rand -base64 64` | **所有人被登出**(要做就安排在低峰) |
| `XT_AES_KEY` | **证件号加密** | `openssl rand -base64 32` | ⚠️ **不能直接换** —— 见 3.2 |
| `XT_HMAC_KEY` | 开放接口签名 / 去重 hash | `openssl rand -base64 48` | 接入方要改签名密钥;`id_card_hash` 类的去重值会变 |
| `XT_RSA_PRIVATE_KEY` | 登录密码解密(PKCS#8 单行 base64) | `openssl genpkey -algorithm RSA ...` | 公钥在前端,**要同时换**(接口 `/auth/public-key` 会下发新公钥) |
| `PG_PASSWORD` / `REDIS_PASSWORD` | 基础设施 | 运维 | 重启即生效 |
| `QINIU_*` | 对象存储 | 七牛控制台 | 换 key 不影响已存文件(URL 是签名的) |
| `WX_*` | 微信小程序 / 公众号 | 微信后台 | 换 secret 后旧 access_token 立刻失效(**服务自己会重新取**) |
### 3.2 ⚠️ AES 密钥不能"直接换"
`collect_roster.id_card_no` / `collect_file` 里的证件号是 **AES 密文**。
换密钥之后**旧密文解不开**,而系统不会报错 —— 它只会显示一串乱码,
或者门禁核验永远匹配不上(`id_card_hash` 也变了)。
正确做法(本项目尚未实现,属二期工程):
1. 新密钥加进来(配置支持"当前密钥 + 历史密钥列表");
2. 写一个离线任务用旧密钥解密、新密钥重加密,并**同时重算 hash 与 mask**;
3. 全部迁完之后,旧密钥才下线。
**在此之前:`XT_AES_KEY` 一旦启用就不要换。** 这条比 token 那条更硬 ——
token 换了只是"重新登录一次",这个换了是"全校证件号读不出来"。
### 3.3 密钥轮换的记录
轮换是**运维动作**,要留痕(谁、什么时候、为什么)。项目里的操作日志表
`sys_operation_log` 可以承接这类记录,但**轮换本身在系统外发生**,
所以还应有独立的运维记录 —— 建议直接用 GitLab 的 issue 或运维台账。
### 3.4 启动自检(`ProdEnvCheck`)
`prod` profile 启动时会先自检,**任何一条不通过就拒绝启动**:
| 检查 | 不检查会怎样 |
|---|---|
| 六个必需环境变量齐全 | 直接起不来(但报错是一堆栈,不是清单 —— 所以这里提前拦) |
| `stub-enabled` 不为 `true` | **任何人可以登录任何学生** |
| 存储 provider 不是 `local` | 多副本下**必然丢文件**,且丢的时候没有任何报错 |
| JWT 密钥不是 dev 那把、且 ≥ 32 字符 | 拿仓库里那把就能**伪造任意用户的 token** |
| AES 密钥不是 dev 那把 | 证件号加密形同虚设 |
| `springdoc.api-docs.enabled` 不为 `true` | 全部接口与字段暴露给公网 |
实现是 `EnvironmentPostProcessor`(跑在任何 bean 之前)。
⚠️ **不能写成 `ApplicationRunner`**:那样缺密钥时 `JwtService` 构造先炸,
用户看到的是一段 56 行栈,而不是这份清单(这是实测踩过的)。
---
## §4 升级、迁移与回滚
### 4.1 迁移
Flyway 管理,迁移文件在 `backend/xt-server/src/main/resources/db/migration/`。
**已发布的迁移文件不许改**(`validate-on-migrate: true` 会校验)——
这是保护:它保证"生产跑的迁移"与"仓库里的"逐字节一致。
⚠️ **生产禁止 `flyway clean`**(`clean-disabled: true`):
它是"清空整个库再重建",在生产上执行等于把所有学校的填报数据删掉。
### 4.2 升级前必做
```bash
# ① 备份(**这是唯一的后悔药**)
pg_dump -Fc -h $PG_HOST -U $PG_USER $PG_DB > backup-$(date +%F-%H%M).dump
# ② 在副本上试跑迁移:先恢复备份到临时库,再对临时库跑一次
createdb xt_dryrun && pg_restore -d xt_dryrun backup-*.dump
java -jar xt-server.jar --spring.profiles.active=prod \
--spring.datasource.url=jdbc:postgresql://.../xt_dryrun \
--spring.main.web-application-type=none # 只跑迁移,不起 web
```
②的价值在于:**迁移失败的最好时机是"还没碰生产库"的时候**。
### 4.3 回滚
| 情形 | 怎么做 |
|---|---|
| 只换了应用版本、没跑新迁移 | 把上一个 jar 换回来,重启。**最快** |
| 跑了新迁移、但迁移是"只加列/加表" | 换回旧 jar 即可(旧版本不认识新列,不影响) |
| 跑了新迁移、且迁移**改了列类型或删了列** | ⚠️ 换回旧 jar 也救不了,必须**恢复备份** —— 所以 §2.3 要求迁移必须向后兼容 |
| 只是某个开关配错 | 改环境变量重启(`ProdEnvCheck` 会先校验一遍) |
**发布前把"回滚要多久"算清楚**,并写在发布单上。恢复一个几十 GB 的库
不是"几分钟"的事。
### 4.4 灰度
一期建议按**租户**灰度(不是按流量):客户是学校,一个学校要么全在新版本、
要么全在旧版本 —— 按流量切会让同一个学校里一半学生走新代码、一半走旧代码,
而"同一份填报单被两个版本各写一半"是最难排查的一类问题。
```bash
XT_TENANT_DOMAIN_ENABLED=true # 如果要按域名区分单位
```
---
## §5 出事看哪里
| 症状 | 先看 | 常见原因 |
|---|---|---|
| 服务起不来 | 启动日志里的 `生产环境启动自检未通过` | §3.4 的六条 |
| 学生上传失败 | `xt-server.log` 里 `MaterialFileService` | 存储密钥错 / 空间名错(开 `XT_STORAGE_SELF_CHECK=true` 能在启动时暴露) |
| 页面 404 但接口正常 | `spring.web.resources.static-locations` | H5 构建产物没放对目录(**应用不会报错**) |
| 定时任务没生效 | 手工触发一次:`POST /api/v1/schedules/run {"job":"close"}` | 见下 |
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |
**定时任务的排查办法**:`POST /api/v1/schedules/run` 可以手工触发一轮
(`close` / `urge` / `timeout` / `raw`),运维补跑也用它。
⚠️ 定时任务最安静的一类故障是"**跑了但什么都没做**":
候选查询的 SQL 别名没加双引号时会被 PostgreSQL 折成小写,
`row.get("tenantId")` 得到 `null`,异常被 catch 吞掉,日志只有一行 warn。
所以四个任务都加了候选行自检 —— 缺 `tenantId` 会把"实际的键"打进日志。
---
## §6 首次接入 CI 的检查单
`.gitlab-ci.yml` 里这几件事**在本地 Windows 上验不到**,第一次上 runner 时要重点看:
- [ ] `services: postgres / redis` 的容器别名能否解析(`PG_HOST=postgres`)
- [ ] runner 镜像里 `apt-get install python3 python3-pip curl postgresql-client` 可用
- [ ] `pip3 install openpyxl requests` 可用(E2E 脚本要用)
- [ ] `openssl rand -base64 64` 生成的随机串里含 `/` `+` `=` 时,
作为**环境变量**传给 Java 没问题(shell 里安全;哪天改用 `.env` 文件则要转义)
- [ ] `artifacts` 的路径与保留期
- [ ] `maven:3.9-eclipse-temurin-21` 与 `node:20` 的镜像拉取速度
- [ ] **`scripts/check-test-profile.py` 要先过**:它验的就是"CI 那个 profile 能不能起来"。
它红了就是配置问题,不要去查 E2E 脚本
- [ ] 密钥生成命令的长度要对:`openssl rand -base64 32` → 44 字符(AES-256 要 32 字节)。
长度不对时 `ProdEnvCheck` 会点名报出来("解出来 30 字节,需要 32 字节")
- [ ] **shell 片段的语法只能在 Linux 上验**:本次开发机的 `cmd.exe` 解析不了
单引号 pathspec(`'frontend-pc/**/*.vue'`),所以"lint 改动文件"那段
是照 Linux 语义写的,首次跑要留意
---
## §7 为什么没有 GitHub Actions
仓库里原本有一个模板自带的 `frontend-pc/.github/workflows/deploy.yml`
(部署到 GitHub Pages)。它在本项目**不可能工作**,已删除:
1. 远端是 **GitLab**(`gitlab.jxyunge.com`),不是 GitHub;
2. 它用 `npm ci` / `npm run build`,而本项目用 **pnpm**(仓库里是 `pnpm-lock.yaml`);
3. 它把管理端当**静态站**发到 Pages —— 而这个前端**必须连着后端**才有意义
(登录、接口、权限全部依赖 `/api`),发上去只会得到一个打不开的演示页。
留着它的害处不是"多一个文件",而是**它会让人以为"我们有部署流水线"**。
---
## §8 本地能验到哪一步
`.gitlab-ci.yml` 是配置文件,**"写了没验"的 CI 最糟**(第一次真跑才红,而那时所有人都在等)。
所以本轮把能本地跑的都跑了一遍:
| 阶段 | 本地怎么验 | 结果 |
|---|---|---|
| `check` | 直接跑那三个校验脚本 + 把 CI 里的 heredoc 片段原样执行 | ✅ |
| `build` | `mvn package`、`pnpm typecheck`、`pnpm build` | ✅ |
| `e2e` | 起 `test` profile 确认能起来(`check-test-profile.py`);全套 E2E 在 `dev` 上跑 | ✅ |
| `生产环境自检` | **真的起三次 jar**(三种误配形态) | ✅ 9/9 |
**本地验不到的**(要在真实 runner 上跑):
`services:` 的容器别名解析、`apt-get`/`pip3` 在 runner 镜像里可用性、
artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows,
`cmd.exe` 解析不了单引号 pathspec —— 所以"只 lint 改动文件"那段是照 Linux 语义写的)。
这几条都在 §6 的检查单里。
---
## §9 压测记录(M7-09)
脚本:`python scripts/load-test.py [--rows N] [--only gate|read|upload|export] [--with-upload]`
⚠️ **本机是"负载生成器 + 后端 + PG + Redis 全在同一台机器(且 PG/Redis 在 Docker 里)"**,
所以绝对值偏悲观;**看比例与形状,不要看绝对值**。
另一条同样重要:**谈响应时间必须带规模**(同一个接口在 60 行与 2000 行上是两件事),
所以报告里每个数字都带规模。
### 9.1 结果(规模:名册 2000 人)
| 场景 | 结果 |
|---|---|
| **门禁核验**(60 人同一出口 IP 同时扫码) | **60/60 成功**(修复前 **20/60**,见 9.3);并发下 p50 218ms / p95 327ms |
| **读接口**(20 并发 × 8 轮 = 640 请求,四类混合) | **0 失败**;单请求中位 **36~47ms**(与页大小 20/50/200 **无关**);20 并发下 p50 156~375ms |
| **名册列表的 SQL 条数** | **恒为 3 条**(`selectPage` + `count` + 组织名映射),**与 pageSize 无关 → 没有 N+1** |
| **DB 侧耗时** | 分页查一页 **1.2ms**、计数 **0.66ms**、组织映射 **0.06ms** |
| **导出** | 前置检查正确拦住(`40035`,见 9.4);大包打包**未测**(需要真实审批过的数据) |
| **材料上传** | **默认不跑**(见 9.4) |
### 9.2 结论:后端没有 N+1,DB 不是瓶颈
三条独立证据:
1. **数 SQL 条数**:一次名册列表请求恒 3 条 SQL,pageSize 从 5 到 200 都是 3 条;
2. **单请求延迟与页大小无关**(20/50/200 的中位分别是 47/36/44ms)——
如果是"按行做重活",这三个数会明显拉开;
3. **DB 侧 2ms 以内**(`EXPLAIN ANALYZE`)。
所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络),
不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。**
### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修)
压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确),
**20 个成功、40 个被限流**(`40071`)。原因是 IP 限流 20 次/分钟,
而它计的是**所有请求**(成功也算)。
真实场景里这有多严重:**一个班的学生坐在同一间教室、连同一个 WiFi**,
出口就是同一个公网 IP。开学第一次收材料,后 40 个学生看到的是
「操作过于频繁,请稍后再试」—— 而他们不知道自己该等多久、也不知道为什么别人能进。
**修法:分开两个计数器,因为目的不同。**
| 计数器 | 计什么 | 目的 | 限额 |
|---|---|---|---|
| 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 |
| 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 |
| 成功(按 IP) | 只计成功 | 防"批量取数据" | 200 次/分钟 |
关键洞见:**枚举攻击产生的是失败,而教室产生的是成功。**
把成功也算进防枚举的额度里,等于用防攻击的代价挡住正常用户 ——
而挡住的量级恰好是"一个班"。
⚠️ 另有一条**刻意不计**的失败:`GATE_ALREADY_BOUND`(两个人抢同一个名册项)。
开学第一天几十人同时扫码必然出现几例,把它算进额度里会让整间教室在几秒内被限流,
**而没有一个人做错了什么**。
### 9.4 两条使用注意
- **上传压测默认关闭**(`--with-upload` 才跑):本机 `application-local.yml` 里
`provider: qiniu`,所以上传落的是**线上那个空间**(bucket=tools-matrix)。
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对:
压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),
也不该往真实桶里灌测试文件(删库不删桶)。
**要跑就先把存储切成本地**:`XT_STORAGE_PROVIDER=local` 重启后端。
- **导出创建被 `40035` 拦住不是失败**:那是铁律(`canExport=false` 时禁止导出)
在服务端的正确生效。脚本会把这种情形报成「被正确拦住」,
并**断言创建接口也必须拒绝** —— 界面禁用按钮只是第一道,服务端是最后一道。
### 9.5 还没测的
| 场景 | 为什么没测 | 怎么测 |
|---|---|---|
| 导出**大包打包**(几百人 / 1GB+) | 需要真实审批过的数据,而造那种数据要跑完整审核链路 | 用 staging 的真实批次,量 `ExportService` 的打包耗时与内存峰值 |
| 上传并发(CPU 密集的压缩) | 见 9.4(会打到外部存储) | 切本地存储后 `--with-upload` |
| 5000 人名册(PRD 提到的导入上限) | 导入本身是同步的,压测里造 5000 行要 ~20s | `--rows 5000`,重点是**导入**耗时与内存 |
| 长时间稳定性(内存泄漏、连接泄漏) | 一次性压测测不出来 | 持续 1~2 小时的低强度压测,看 RSS 与 Hikari 活跃连接数是否单调上升 |