Files
xiaotutongxue/docs/09-部署与运维.md
T
lovife 50ad577b29 fix(cors): 修复生产登录 403「Invalid CORS request」
- 根因:浏览器对**同源**的 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看
  "请求里有没有 Origin"(Spring 7.0.9 的 DefaultCorsProcessor 没有同源短路),
  带了又不在白名单里 → 进 Controller 之前直接 403
- WebConfig 的白名单写死为 localhost/内网;注释声称"生产配置见 application-prod.yml
  的 fill.cors.allowed-origins",但该属性根本不存在、也没人读它 ——
  于是"前后端同源部署"的登录/上传/提交必然登不上(生产 2026-10-20 事故)
- 改为配置驱动:FillProperties.Cors + fill.cors.allowed-origins
  (可用 XT_CORS_ALLOWED_ORIGINS 覆盖);prod 默认
  shenxue.jxit.com / admin.shenxue.jxit.com / *.shenxue.jxit.com;
  dev 补上花生壳穿透域名(H5 走穿透时 POST 同样会被拦)
- 启动日志打印生效白名单,排障先看那一行
- 新增 CorsWhitelistTest(3 条):生产 yml 真能绑定、通配符匹配租户子域、陌生源仍被拒
- docs/09 §2.4:本站地址必须在白名单 + 反代必须传
  Host/X-Real-IP/X-Forwarded-For/X-Forwarded-Proto(缺了登录日志 IP 全是 127.0.0.1);
  交接摘要 §一·四十四 记录事故,并写清"1700+ 条 e2e 全绿为什么还是漏了"

为什么 e2e 没抓到:脚本是 Python requests,不带 Origin 头,永远走不到 CORS 分支;
本地浏览器用的 localhost 正好命中写死的 dev 白名单。唯一能暴露它的是"浏览器 + 正式域名"。

验证:生产白名单下 admin / 主域名 / t1 / t12 → 200,evil / 后缀拼接 / http → 403;
真实 RSA 登录 + Origin: https://admin.shenxue.jxit.com → 200、code=0、拿到 token;
mvn test 15/15;verify-all --quick 6/6;check-test-profile 通过。
接口、库表、契约均无变化。
2026-10-20 09:22:16 +08:00

553 lines
32 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) + xt-nginx(80)
cd frontend-mp && pnpm build:h5 # nginx 挂的是产物目录 → 先出产物
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`(本地私有配置,不提交)在那里,
是靠相对路径找到的
- 跑的是 **jar 不是热加载**:改后端要重新 package(先停进程,否则 jar 被占用)
- **H5 由 `xt-nginx` 托管(`http://localhost/`),后端只提供 `/api`**(2026-10 调整)。
两者**同源**是硬要求:微信网页授权的「授权回调页面域名」只能配一个,
而 H5 的接口地址取自 `location.origin` —— 跨源会让请求打到 nginx 自己身上(页面能开、接口全 404)。
端口与产物目录在 `deploy/.env`(`H5_PORT` / `H5_DIR`),站点配置是 `deploy/nginx/xt.conf`。
- ⚠️ **内网穿透要指到 nginx 的端口(80),不是后端的 8080** ——
指到 8080 的表现是"接口好用、页面 404"。
- nginx 那份配置里有两条**不能删**的:`client_max_body_size 50m`
(默认 1m,学生传一张照片就可能 413,而**后端日志里一个字都没有**)与
`/assets/`、`*.html` 的缓存头(没有它浏览器会启发式缓存,发版后学生看到旧页面,
见 `ApiNoStoreFilter` 的类注释)。
- 只想改学生端页面时不必走 nginx:`cd frontend-mp && pnpm dev:h5` → `http://localhost:5174`
(自带 `/api` 代理,**改一行不用重新 build**)
### 2.2 CI(GitLab)
`.gitlab-ci.yml` 三个阶段:
| 阶段 | 做什么 | 为什么这样切 |
|---|---|---|
| `check` | 契约对账、实体列对账、文档一致性、**e2e 脚本语法扫描** | 都不需要数据库,几十秒出结果,先在最快的阶段挡掉低级错误 |
| `build` | 后端打包、管理端 typecheck/build、**H5 build** | H5 build 现在是**编译检查**(2026-10 起不再由后端托管、e2e 也不消费它),学生端没有单独的 typecheck |
| `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 静态产物要和新后端一起发**:H5 由 nginx/网关托管(不再由后端托管,2026-10 调整),
但"同源"没变,前后端不同步还是会出现"页面调用了还不存在的接口"。
### 2.4 ⚠️ 反向代理有两件事必须做(2026-10-20 生产 403 的教训)
生产形态:前端静态站 `https://admin.shenxue.jxit.com`,`/api` 反代到本机 jar。
这种"**同源**"部署仍然会踩两个坑,两个都**只在浏览器里复现、后端日志里看不出来**。
#### ① 本站地址必须在 CORS 白名单里(否则登录 403)
**症状**:`POST /api/v1/auth/login` 返回 `403`,响应体一行 `Invalid CORS request`,
`X-Trace-Id` 有值(说明请求已经到了后端),但**业务日志里一个字都没有**。
同一条 curl(不带 `Origin`)却正常。
**原因**:浏览器的 CORS 只约束跨源请求,但它**给同源的 POST/PUT/DELETE 也带
`Origin` 头**;而 Spring MVC 的判据是"请求里有没有 Origin",不在白名单里就在进
Controller 之前直接拒掉。`WebConfig` 曾经把白名单写死成 localhost/内网,
生产域名不在里面 —— 于是"同源部署"反而一定登不上。
**配置**(`application-prod.yml` 已有本项目的默认值,换域名时改它或给环境变量):
```bash
# 逗号分隔;Spring 的 allowedOriginPatterns 语义,可写通配
XT_CORS_ALLOWED_ORIGINS=https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com
```
启动日志里有一行 `CORS 白名单(/api/**):[...]`,排障先看它。
**改域名/加域名 / 加内网穿透域名,都要同步这里**(H5 走穿透时同样是 POST 会被拦)。
#### ② 必须把 Host / 客户端 IP / 协议透传过去
```nginx
location ^~ /api {
proxy_pass http://127.0.0.1:8090; # 不带 URI 后缀 = 原样转发 /api/...
proxy_http_version 1.1;
# ⚠️ 不传这几个头,后端看到的永远是 127.0.0.1 + http:
# · 登录日志 / 操作日志的 IP 全是 127.0.0.1(合规审计里这是废数据)
# · 任何按 Host 定位的逻辑(租户域名)失效
# · 重定向 / 绝对链接会用错协议(https 页面被跳去 http)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 学生材料单张可能 >1MB;nginx 默认 1m 直接回 413,而后端日志里没有这条请求
client_max_body_size 50m;
# 导出打包是长事务,别用默认 60s 把还在跑的请求掐掉
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# 上传是流式的,别在 nginx 侧攒完再转发
proxy_request_buffering off;
}
```
> 后端已配 `server.forward-headers-strategy: framework`,有了
> `X-Forwarded-Proto` / `X-Forwarded-For` 它就会按真实协议与客户端 IP 处理。
---
## §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` 也变了)。
正确做法(本项目**缓做**,等 OCR 供应商选型;**不再作为二期功能设计**):
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 但接口正常 | 穿透/网关指到了哪个端口 | H5 由 nginx 托管(80),**指到后端的 8080 就只剩接口**;另看 `deploy/.env` 的 `H5_DIR` 是否指向真实产物目录(目录不存在时 docker 会建一个**空目录**,nginx 一切正常而页面 404) |
| 上传报 413(后端日志里没有这条请求) | nginx 的 `client_max_body_size` | 默认 1m;`deploy/nginx/xt.conf` 已设 50m,**换网关时别漏这一条** |
| 浏览器里登录/上传 **403**,但 curl 正常 | 响应体是不是 `Invalid CORS request` | `Origin` 不在 CORS 白名单里 —— **同源的 POST 也会校验**,本站域名必须在 `XT_CORS_ALLOWED_ORIGINS` 里,见 §2.4 |
| 登录日志的 IP 全是 `127.0.0.1` | nginx 有没有传 `X-Forwarded-For` | 见 §2.4 第 ② 条;缺了它审计数据就废了 |
| 本机 8080 被别的项目占用 | `netstat -ano \| findstr :8080` | 用 `--server.port=8081` 起后端,e2e 脚本加 `XT_BASE=http://localhost:8081`(脚本读这个环境变量);**不要去杀别人的进程** |
| 定时任务没生效 | 手工触发一次:`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 |
> **接线检查(`scripts/check-wiring.py`)为什么值得单独放一个阶段**:它查的是
> "**声明了但没接上**"(权限码没人校验、错误码没人引用、通知模板没有发送点、
> 菜单键前端没有对应项)—— 这一族错**编译 / lint / review 全都不红**,
> 只有"从入口走一遍"才暴露(F-1/F-31/F-50/F-52/F-101/F-106 都是它)。
> 它自己的假阳性也要当心:本轮修了三次(只数注解漏掉服务层、按标识符数引用被同名常量骗过、
> **注释里的提及被当成接线**)—— 所以它现在**先去 Java 注释再数**,
> 并且有意不接线的逐条写在脚本上方的例外表里(带理由),**不静默跳过**。
**本地验不到的**(要在真实 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|export-big|soak] [--with-upload] [--base URL] [--fixture-only] [--files-per-user K] [--split-mb M] [--soak 分钟] [--rps N]`
⚠️ **本机是"负载生成器 + 后端 + PG + Redis 全在同一台机器(且 PG/Redis 在 Docker 里)"**,
所以绝对值偏悲观;**看比例与形状,不要看绝对值**。
另一条同样重要:**谈响应时间必须带规模**(同一个接口在 60 行与 2000 行上是两件事),
所以报告里每个数字都带规模。
**本机规格(2026-09-30 补记,读数字时一起看)**:AMD RYZEN AI MAX+ 392,
12 物理核 / 24 逻辑核,内存 59.6 GB;后端 JVM、PG、Redis、压测进程、浏览器全在同一台机器上。
### 9.1 结果(规模:名册 2000 人,除注明外)
| 场景 | 结果 |
|---|---|
| **门禁核验**(60 人同一出口 IP 同时扫码) | **60/60 成功**(修复前 **20/60**,见 9.3);并发下 p50 218ms / p95 327ms |
| **门禁核验·学校规模**(250 / 500 人同一出口 IP) | **250/250、500/500 成功**(成功上限 200→600 之后;此前 250 人会挡 37 个,见 9.3)。500 并发 p50 1.4s / p95 2.5s —— 是排队,**无人被限流** |
| **读接口**(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** |
| **材料上传**(20 并发,2850KB 真图) | **40/40 成功**;**串行单请求 321ms**(2850KB → 1329KB,**压到 47%**);20 并发 p50 **620ms** / p95 **801ms** / max 851ms;吞吐 **6/s**(见 9.6) |
| **名册导入规模**(5000 行,PRD 说的上限) | **导入接口 20.2s**(2000 行 7.7s);其中"造 Excel"是压测脚本自己的成本 **0.7s**,要分开看;导入后后端 RSS **782 MB** |
| **导出大包**(150 人 / 194.6 MB) | **打包墙钟 13.2s**,单卷 194.7 MB;150 个材料 + 汇总表共 **151 个条目**,ZIP 可解;30 人(38.9 MB)时 **2.8s** |
| **导出分卷**(同一批 150 人,`splitSizeMb=50`) | **4 卷**(49.3 / 49.3 / 49.3 / 46.7 MB),**每一卷都能单独解开**(39/38/38/36 个条目),汇总表在第 1 卷根目录 |
| **导出前置检查** | 不通过时正确拦成 `40035` 且**创建接口也拒绝**(见 9.4) |
| **长跑稳定性**(5 请求/秒 × 60 分钟,名册 2000 行) | 见 **9.5.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。**
同一条道理用在上传上:串行 321ms vs 20 并发 p50 620ms ——
差值就是排队。**压缩本身是 CPU 密集的单线程活**,所以"上传慢"的解法是加 CPU / 限并发,
不是改上传接口。
### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修)
压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确),
**20 个成功、40 个被限流**(`40071`)。原因是 IP 限流 20 次/分钟,
而它计的是**所有请求**(成功也算)。
真实场景里这有多严重:**一个班的学生坐在同一间教室、连同一个 WiFi**,
出口就是同一个公网 IP。开学第一次收材料,后 40 个学生看到的是
「操作过于频繁,请稍后再试」—— 而他们不知道自己该等多久、也不知道为什么别人能进。
**修法:分开两个计数器,因为目的不同。**
| 计数器 | 计什么 | 目的 | 限额 |
|---|---|---|---|
| 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 |
| 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 |
| 成功(按 IP) | 只计成功 | 防"批量取数据" | **600 次/分钟**(2026-09-30 由 200 上调,见下) |
关键洞见:**枚举攻击产生的是失败,而教室产生的是成功。**
把成功也算进防枚举的额度里,等于用防攻击的代价挡住正常用户 ——
而挡住的量级恰好是"一个班"。
⚠️ 另有一条**刻意不计**的失败:`GATE_ALREADY_BOUND`(两个人抢同一个名册项)。
开学第一天几十人同时扫码必然出现几例,把它算进额度里会让整间教室在几秒内被限流,
**而没有一个人做错了什么**。
⚠️ **成功上限经历过一次实测修正(200 → 600,2026-09-30)**:
契约 §1.6 只规定了失败侧(同 openid 10 次/小时、同 IP 20 次/分钟),成功侧上限是本项目
自己加的,而最初定的 **200 恰好卡在"一个年级"的量级** —— 实测 **250 个不同学生
同时核验(全部正确)时 213 个成功、37 个被挡**,提示「当前网络访问过于集中」。
全校在同一个出口 IP 集中填报(开学第一课集体扫码)就会撞上,而**没有人做错了什么**。
上调到 600 后复测:**250 人 250/250、500 人 500/500**,且失败侧两个计数器原样未动。
要再调这个数,先跑 `python scripts/load-test.py --only gate --rows 500` 量一遍。
### 9.4 压测怎么跑(两条使用注意)
- **上传压测默认关闭**(`--with-upload` 才跑),因为本机 `application-local.yml` 里
`provider: qiniu`,上传落的是**线上那个空间**(bucket=tools-matrix):
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对 ——
压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),
也不该往真实桶里灌测试文件(**删库不删桶**)。
**正确跑法(推荐)**:另起一个用本地存储的实例,把压测打过去,不碰线上桶:
```bash
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=dev --server.port=18080 --fill.storage.provider=local
python scripts/load-test.py --base http://localhost:18080 --only upload --conc 20
python scripts/load-test.py --base http://localhost:18080 --only export-big --rows 150 --split-mb 50
```
⚠️ 这个实例**也会跑定时任务**(`@EnableScheduling` 没有开关),
所以量完就把它停掉,别让它长期和 dev 实例并行。
- **导出创建被 `40035` 拦住不是失败**:那是铁律(`canExport=false` 时禁止导出)
在服务端的正确生效。脚本会把这种情形报成「被正确拦住」,
并**断言创建接口也必须拒绝** —— 界面禁用按钮只是第一道,服务端是最后一道。
### 9.5 补测记录(2026-09-30):原来"没测过"的四项
这一节记的是**怎么量出来的**,因为数字会过期、方法不会。
#### 9.5.1 上传并发(CPU 密集的压缩)
```bash
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=dev --server.port=18080 --fill.storage.provider=local
python scripts/load-test.py --base http://localhost:18080 --only upload --conc 20
```
| 量 | 结果 |
|---|---|
| 夹具 | **真 JPEG 2000×1500 / 2850 KB**(噪声图;纯色量不出 CPU 成本) |
| **串行单请求** | **321 ms**,产物 **1329 KB → 压到 47%** |
| 20 并发(40 次上传) | **40/40 成功**;p50 **620 ms** / p95 **801 ms** / max 851 ms |
| 吞吐 | **6 次/秒**(40 次 / 6.6 秒墙钟) |
读法:**单请求 321ms vs 并发 p50 620ms,差值就是排队** —— 压缩是单线程 CPU 活,
所以"上传慢"的解法是加 CPU 或限并发,不是改接口。
⚠️ 上传场景有**前置条件**:学生必须先经过门禁绑定,否则 `POST /mp/submissions` 会被拒;
脚本现在会自己绑定并**断言草稿数**,不成立时明确报"前置不成立"而不是"上传失败"。
#### 9.5.2 名册导入规模(PRD 提到的 5000 行上限)
```bash
python scripts/load-test.py --rows 5000 --fixture-only
```
| 规模 | 造 Excel(脚本自己的成本) | **导入接口**(系统的成本) | 备注 |
|---|---|---|---|
| 150 行 | 0.3 s | **0.8 s** | |
| 2000 行 | 0.4 s | **7.7 s** | |
| **5000 行** | 0.7 s | **20.2 s** | 导入后后端 RSS **782 MB** |
⚠️ 两段必须分开报:**造 Excel 是压测脚本的成本**(openpyxl),混在一起会把脚本的耗时
算到"名册导入"头上 —— 那正是"度量工具的偏差伪装成产品缺陷"。
导入是**同步**的:5000 行会让这个请求占用 20 秒,前端要按长请求处理(别设 10 秒超时)。
#### 9.5.3 导出大包(客户最关心的那条链)
```bash
python scripts/load-test.py --base http://localhost:18080 --only export-big \
--rows 150 --files-per-user 1 # 不分卷
python scripts/load-test.py --base http://localhost:18080 --only export-big \
--rows 150 --files-per-user 1 --split-mb 50 # 强制分卷
```
夹具会自己走完整条业务链:门禁绑定 → 建草稿 → 上传 → 提交 → **初审(班主任)→ 终审(管理员)**
→ 前置检查 → 创建导出 → 轮询到完成 → **逐卷解开验证**。
| 规模 | 原件 | 打包墙钟 | 产物 | 条目数 |
|---|---|---|---|---|
| 30 人 | 38.9 MB | **2.8 s** | 单卷 38.9 MB | 31(30 材料 + 汇总表) |
| 150 人 | 194.6 MB | **13.2 s** | 单卷 194.7 MB | 151 |
| 150 人 + `splitSizeMb=50` | 194.6 MB | **11.4 s** | **4 卷**(49.3 / 49.3 / 49.3 / 46.7 MB) | 39 / 38 / 38 / 36 |
三条结论:
1. **吞吐约 15 MB/s**,且"原件 → 产物"压缩率 **0.0%** —— JPEG 本来就压不动,
打包的瓶颈是磁盘 I/O 而不是 CPU;所以大包的耗时基本可以按体积线性估。
2. **每一卷都能单独解开**(脚本对每卷跑 `zipfile.testzip()`)—— 这是分卷的承诺,
而它只能靠真解一遍来验证,"文件存在"断言不到。
3. 目录结构符合 PRD §5.9:`{批次号}-{任务名}/班级/材料类型/文件`,
汇总 Excel 在**第 1 卷的根目录**。
#### 9.5.4 长跑稳定性(60 分钟)
```bash
python scripts/load-test.py --only soak --rows 2000 --soak 60 --rps 5
```
判据是**趋势**,不是"跑完没崩":每分钟记一行 RSS(从系统取,不是应用自报的堆用量)
与 PG 连接数,最后比较**前 1/3 与后 1/3** 的均值。
**结果(2026-09-30 实测)**:
| 量 | 前 1/3 均值 | 后 1/3 均值 | 结论 |
|---|---|---|---|
| RSS | 603.9 MB | 582.9 MB | **-3.5%,平稳**(GC 还回收了一点) |
| PG 连接数 | 17.0 个 | 6.2 个 | **-63.7%,回落**(定时任务高峰把池撑到上限 20 后收回 minimumIdle≈6) |
| 延迟 p95 | 63.2 ms | 62.8 ms | **-0.7%,纹丝不动** |
样本 51 行(标称 60 分钟,实际每行约 70 秒:300 个请求 × 0.2s 均匀间隔)、
请求 **15300**、失败 **0**。RSS 峰值 622 MB 出现在**定时任务在跑的那几分钟**。
**结论:60 分钟内没有内存泄漏、没有连接泄漏、延迟没有退化。**
(更长的窗口见 9.6 —— 那是仍然没测的。)
⚠️ 读这条曲线时要先知道一件事:**每小时的第 5 分钟、以及每 10 分钟**有定时任务在跑
(`ScheduleJobs` 的 cron),那一刻 PG 连接数会**跳一下**再落回来。
实测见过 6 → 21 → 6 的形状 —— 那是任务在干活,**不是连接泄漏**。
真正的泄漏长这样:**回不去**,且后 1/3 的均值明显高于前 1/3。
### 9.6 仍然没测的
| 场景 | 为什么没测 | 怎么测 |
|---|---|---|
| **真机上的上传**(微信小程序 + 手机相册) | 需要真机与小程序资质 | 见 `docs/01` **D6**:量"选一张 5MB 照片到提交成功"的端到端时间 |
| **对象存储(七牛)的上传并发** | 本地跑会把测试文件灌进真实桶(见 9.4) | 用 staging 的独立 bucket,`provider=qiniu` + `--with-upload` |
| **多副本部署下的导出打包** | 本机只有一个实例 | 两实例同时导出同一任务,确认不会互相覆盖产物(导出线程池与落盘路径都要看) |
| **5000 人**(不是 5000 行)**的全链路** | 5000 行导入已测(9.5.2),但"5000 人都提交 + 审核 + 导出"是另一件事 | 按 9.5.3 的 `export-big` 放大到 5000,注意先看导出分卷与磁盘占用 |
| **生产规模下的长跑(数小时~数天)** | 60 分钟只够发现"快速泄漏" | 在 staging 按 9.5.4 的 soak 跑 12~24 小时,看 RSS 与 PG 连接数是否仍然平稳 |
---
## 附:PC 管理端构建(2026-10 重搭后)
| 项 | 说明 |
|---|---|
| 技术栈 | **纯 Vue 3 + Naive UI 2.42**(已无 `@arco-design/*` 与 Gi 模板依赖) |
| 依赖安装 | `pnpm install --frozen-lockfile`(`pnpm-lock.yaml` 随新 `package.json` 更新,**别用 npm**) |
| 构建 | `pnpm build` → `frontend-pc/dist/`(静态托管,`/api` 由网关/反代指向后端 8080) |
| 检查 | `pnpm typecheck`(**0 错**)、`pnpm lint`(ESLint;**Stylelint 已随重搭移除**) |
| 图标 | `scripts/extract-pc-icons.py` 生成 `src/icons/icon-data.ts`(37 个,内网离线可用);**图标集升级后必须重跑**,否则新图标是空占位且不报错 |
| 主题 | 主题配置在 `src/utils/naive.ts` 一侧;`src/styles/index.scss` 只放跨页面样式 |