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

32 KiB
Raw Blame History

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 本地

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 生产发布

# ① 构建(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 已有本项目的默认值,换域名时改它或给环境变量):

# 逗号分隔;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 / 协议透传过去

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 升级前必做

# ① 备份(**这是唯一的后悔药**)
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 灰度

一期建议按租户灰度(不是按流量):客户是学校,一个学校要么全在新版本、 要么全在旧版本 —— 按流量切会让同一个学校里一半学生走新代码、一半走旧代码, 而"同一份填报单被两个版本各写一半"是最难排查的一类问题。

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。两件事都不对 —— 压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价), 也不该往真实桶里灌测试文件(删库不删桶)。

    正确跑法(推荐):另起一个用本地存储的实例,把压测打过去,不碰线上桶:

    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 密集的压缩)

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 行上限)

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 导出大包(客户最关心的那条链)

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 分钟)

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 只放跨页面样式