refactor(h5)!: H5 改由 nginx 托管,后端退回纯 API

用户决定:后端不再对外发静态文件,学生端 H5 交给网关。两者**仍然同源**
(微信只给一个授权回调域名;H5 的接口地址取自 location.origin),
所以前端业务代码、微信后台、application-dev.yml 的 h5-base-url 都不用改。

后端
- 删掉 dev/test/prod 三份 profile 里的 spring.web.resources.static-locations
- 主配置加 spring.web.resources.add-mappings=false —— 用开关而不是"指到空目录":
  后者会连带启用 Spring Boot 的默认位置(classpath:/static 等),谁丢个文件进去就悄悄对外可见
- StaticCacheHeaderFilter → ApiNoStoreFilter,只剩 /api/** 的 no-store
  (静态资源的缓存策略整体归 nginx,避免"改一处漏一处")
- 补 NoHandlerFoundException → 404:关掉静态映射后未匹配路径不再经过
  ResourceHttpRequestHandler,抛的是 NoHandlerFoundException,只处理
  NoResourceFoundException 时访问 / 会变成 500「系统繁忙」并刷 ERROR 日志

deploy
- 新增 xt-nginx(80):发 frontend-mp/dist/build/h5 + /api 反代 8080(host.docker.internal,
  Linux 靠 extra_hosts host-gateway),端口与产物目录走 .env 的 H5_PORT/H5_DIR
- 三条"删了就会出事"的都已带上理由:client_max_body_size 50m(默认 1m,超了回 413
  而后端日志一个字都没有)、/assets 的 immutable 与 *.html 的 no-cache(§一·十 那个
  "发版后学生看到旧页面"的坑)、健康检查写 127.0.0.1(容器里 localhost 先解析到 IPv6,
  nginx 只听 IPv4 → 永远 starting 而首页是好的)

CI 与文档
- CI:学生端 H5 构建从"e2e 的输入"改为编译检查,不再留 artifact(e2e 不消费它)
- 同步 AGENTS.md / README.md / docs/09(启动步骤、CI 表、发布注意、排障表)/ docs/11
- 交接摘要新增 §一·四十三(含理由、验证数字、要人工做的一步与环境限制)

验证
- mvn clean package 通过(必须 clean:删掉的旧类会留在 target/classes 被组件扫描)
- 后端直连:/ 与 /assets/* → 404({"code":40001,"msg":"接口不存在"})、
  /api/.../captcha → 200 + no-store、/doc.html → 302→200、/v3/api-docs 与 /actuator/health → 200
- 经 nginx(80):首页 200 + no-cache、/assets/*.js 200 + immutable、/nope.js 404(无回退)、
  /api 200 + no-store、gzip 生效;2MB multipart 上传 → 401(与直连一致 ⇒ 未踩 1m 的坑)、
  55MB → 413
- e2e(XT_BASE=http://localhost,全程走 nginx):e2e-m0.py 37/37、e2e-m3-h5-auth.py 24/24、
  e2e-m2-scope.py 38/38
- verify-all.py --quick 6/6;check-test-profile.py:test profile 仍能起来
This commit is contained in:
2026-10-19 17:35:33 +08:00
parent a9b2dd2a16
commit 2ff17cd0c9
19 changed files with 407 additions and 130 deletions
+22 -10
View File
@@ -25,18 +25,28 @@
### 2.1 本地
```bash
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379)
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`(本地私有配置,不提交)在这里;
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
- ⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(本地私有配置,不提交)在那里,
是靠相对路径找到的
- 跑的是 **jar 不是热加载**:改后端要重新 package(先停进程,否则 jar 被占用)
- H5 没有独立 dev server:`cd frontend-mp && pnpm build:h5`,然后访问
`http://localhost:8080`(由后端托管)
- **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)
@@ -45,7 +55,7 @@ java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active
| 阶段 | 做什么 | 为什么这样切 |
|---|---|---|
| `check` | 契约对账、实体列对账、文档一致性、**e2e 脚本语法扫描** | 都不需要数据库,几十秒出结果,先在最快的阶段挡掉低级错误 |
| `build` | 后端打包、管理端 typecheck/build、**H5 build** | H5 产物是后面 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` ——
@@ -72,8 +82,8 @@ curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 /
⚠️ **滚动发布期间会有两个版本同时在跑**,所以:
- **迁移必须是"向后兼容"的**(只加列/加表,不改列类型、不删列)——
否则旧实例会在新库上出错。改列要走"两次发布":这次加新列并双写,下次删旧列。
- **H5 静态产物要一起发**:它是后端托管的,前后端不同步会出现
"页面调用了还不存在的接口"。
- **H5 静态产物要和新后端一起发**:H5 由 nginx/网关托管(不再由后端托管,2026-10 调整),
但"同源"没变,前后端不同步还是会出现"页面调用了还不存在的接口"。
---
@@ -186,7 +196,9 @@ XT_TENANT_DOMAIN_ENABLED=true # 如果要按域名区分单位
|---|---|---|
| 服务起不来 | 启动日志里的 `生产环境启动自检未通过` | §3.4 的六条 |
| 学生上传失败 | `xt-server.log` 里 `MaterialFileService` | 存储密钥错 / 空间名错(开 `XT_STORAGE_SELF_CHECK=true` 能在启动时暴露) |
| 页面 404 但接口正常 | `spring.web.resources.static-locations` | H5 构建产物没放对目录(**应用不会报错**) |
| 页面 404 但接口正常 | 穿透/网关指到了哪个端口 | H5 由 nginx 托管(80),**指到后端的 8080 就只剩接口**;另看 `deploy/.env` 的 `H5_DIR` 是否指向真实产物目录(目录不存在时 docker 会建一个**空目录**,nginx 一切正常而页面 404) |
| 上传报 413(后端日志里没有这条请求) | nginx 的 `client_max_body_size` | 默认 1m;`deploy/nginx/xt.conf` 已设 50m,**换网关时别漏这一条** |
| 本机 8080 被别的项目占用 | `netstat -ano \| findstr :8080` | 用 `--server.port=8081` 起后端,e2e 脚本加 `XT_BASE=http://localhost:8081`(脚本读这个环境变量);**不要去杀别人的进程** |
| 定时任务没生效 | 手工触发一次:`POST /api/v1/schedules/run {"job":"close"}` | 见下 |
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |
+6 -3
View File
@@ -407,12 +407,15 @@ KNOWN_DEFECTS = {
### 5.1 前置
```bash
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379)
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) + xt-nginx(80)
# ⚠️ 不要动本机别的 pg-verify/redis-local/sp-redis/vastbase-pg
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
```
⚠️ **工作目录必须是 `backend/`**:`application-local.yml` 与 H5 静态目录两处相对路径依赖它。
⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(本地私有配置)靠这个相对路径找到。
⚠️ **H5 不在 8080 上**(2026-10 起由 nginx 的 80 端口托管,见 `docs/09` §2.1)——
本计划的脚本全打 `/api`,所以只起后端就够;只有要在浏览器里走查**构建产物**时才需要
`pnpm build:h5` + `docker compose up -d nginx`(走查 dev server 则用 `pnpm dev:h5`)。
⚠️ **「jar 比源码新」不算证据**。启动后必须先探活,再跑一条已知脚本冒烟:
```bash
@@ -445,7 +448,7 @@ cd frontend-mp && pnpm dev:h5 # → http://localhost:5174
python scripts/e2e-m3-flow.py --keep --h5-url # 打印带 token + scene 的 H5 地址
```
⚠️ **不要直接打开 stub-login 的 302 落点**:它默认跳到 `WX_H5_BASE_URL`(穿透域名 `http://394nb68658le.vicp.fun`),那里是**后端托管的构建产物**(`frontend-mp/dist/build/h5`,2026-09-29 的旧构建),不是 dev server。`--h5-url` 的做法是:Python 侧请求 stub-login 但**不跟随跳转**,从 `Location` 里取出 `token` 与 `scene`,拼成 `http://127.0.0.1:5174/#/pages/index/index?token=..&scene=..`。
⚠️ **不要直接打开 stub-login 的 302 落点**:它默认跳到 `WX_H5_BASE_URL`(穿透域名 `http://394nb68658le.vicp.fun`),那里是 **nginx 托管的构建产物**(挂的是 `frontend-mp/dist/build/h5`,不是 dev server;产物可能是几天前的构建),不是 dev server。`--h5-url` 的做法是:Python 侧请求 stub-login 但**不跟随跳转**,从 `Location` 里取出 `token` 与 `scene`,拼成 `http://127.0.0.1:5174/#/pages/index/index?token=..&scene=..`。
⚠️ 走查时**名册行必须未绑定**,由界面自己走一遍门禁。原因:L3-A 用的是 `mint()` 手搓的**假 uid**(库里没有对应 `sys_user`),而 stub-login 会**真建** `sys_user` 并拿到真 uid —— 两者不是同一个人,混用会得到「门禁说你不在名册里」。
+93 -2
View File
@@ -887,6 +887,9 @@ P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权
`*.html` `no-cache`、`/api/**` `no-store`。
> 上一轮"浏览器加载旧 chunk"的悬案就是它 —— 已用
> 「从 8080 下载 chunk 与磁盘文件比 SHA-256」证实不是构建问题。
> ⚠️ **2026-10 起**:H5 改由 nginx 托管,这个类只剩 `/api/**` 的 `no-store`
> (并更名为 `ApiNoStoreFilter`);`/assets/**` 与 `*.html` 这两条**搬到了**
> `deploy/nginx/xt.conf` —— 结论没变,只是换了个地方执行。见 §一·四十三。
5. **`edit` 工具的 `oldString` 是子串匹配**。上一轮我把 `function doSubmit() {`
当锚点,而原文是 **`async function doSubmit() {`** —— 于是 `async` 被留在
@@ -3155,8 +3158,9 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
### 七、构建产物也要重跑(**源码改了产物不会变**)
改品牌名时最容易漏的一环:`frontend-mp` 的 H5 是**后端托管的构建产物**
(`dist/build/h5/`,见 `AGENTS.md` 的"先做 H5"),源码改了它一个字都不会变。
改品牌名时最容易漏的一环:`frontend-mp` 的 H5 是**构建产物**
(`dist/build/h5/`,见 `AGENTS.md` 的"先做 H5";2026-10 起托管方从后端换成 `xt-nginx`,
但"源码改了产物一个字都不会变"这条不变,见 §一·四十三),源码改了它一个字都不会变。
本次实测:源码改完后产物里**仍是「填报通」**。
已重跑并复查:
@@ -3717,3 +3721,90 @@ const feedbackNodes = children || feedback ? (…) : this.renderExplains.length
---
## §一·四十三 H5 托管从后端搬到 nginx(2026-10,用户决定)
**决定**:后端不再对外发任何静态文件(`spring.web.resources.add-mappings=false`),
学生端 H5 由 `deploy/` 里的 **`xt-nginx`(80)** 托管,同一个 server 把 `/api` 反代到后端 8080。
PC 管理端不受影响(它一直是独立静态托管)。
### 一、"不托管"不等于"可以跨源"(换托管方时这三条必须继续满足)
| 约束 | 为什么 |
|---|---|
| H5 与 `/api` **同源** | 微信网页授权的「授权回调页面域名」**只能配一个**,回调落在 `/api/v1/mp/h5/callback`,学生 302 回来的页面必须同域 |
| H5 的接口地址是 `location.origin` | `frontend-mp/src/config/index.ts`:跨源时请求会打到页面所在的源,表现是"页面能开、接口全 404" |
| 同源还省掉 CORS | 否则要放开 `fill.cors.allowed-origins`(`WebConfig` 里那段只在 dev 放行 localhost/内网) |
所以 nginx 的形态是"**页面与接口都从 80 出**",而不是"H5 一个端口、接口另一个端口"。
`application-dev.yml` 的 `h5-base-url`(穿透域名,不带端口)因此**一个字都不用改**。
### 二、改了什么
| 位置 | 改动 |
|---|---|
| `application-{dev,test,prod}.yml` | 删掉三份里的 `spring.web.resources.static-locations` |
| `application.yml` | 新增 `spring.web.resources.add-mappings: false` —— 用开关而不是"指到空目录":后者会连带启用 Spring Boot 的**默认**位置(`classpath:/static` 等),谁丢个文件进去就悄悄对外可见 |
| `ApiNoStoreFilter`(原 `StaticCacheHeaderFilter`) | 只剩 `/api/**` → `no-store`;静态资源缓存头整体交给 nginx |
| `GlobalExceptionHandler` | **补 `NoHandlerFoundException` → 404**(见第五节,这是本次改动**引出来**的) |
| `deploy/nginx/xt.conf`(新)、`xt-nginx` 容器(新)、`.env.example` 加 `H5_PORT`/`H5_DIR` | 静态站 + `/api` 反代 + 缓存头 + `client_max_body_size` |
| `.gitlab-ci.yml` | H5 build 从"e2e 的输入"改为**编译检查**,不再留 artifact(e2e 不消费它) |
| `frontend-mp/src/config/index.ts`、`vite.config.ts`、`scripts/e2e-m3-flow.py`、`scripts/_verify-student-flow.py` | 只改注释与提示文案 —— **学生端业务代码一行没动**(同源这个前提没变) |
| `AGENTS.md` / `README.md` / `docs/09` / `docs/11` | 同步(端口、产物去向、排障表、启动步骤) |
### 三、nginx 那份配置里两条"删了就会出事"的
1. `client_max_body_size 50m`:nginx 默认 **1m**,学生从聊天记录里选一张照片就可能超,
超了 nginx 直接回 **413**,而**后端日志里一个字都没有**(请求根本没到应用)——
排查时会一路怀疑"请求没发出去"。
2. `/assets/` 的 `immutable` 与 `*.html` 的 `no-cache`:就是 §一·十 那条踩过的坑
(启发式缓存 → 发版后学生看到旧页面),只是执行者从 Java 过滤器换成了 nginx。
另外 `location /` **故意不做** history 回退:H5 是 hash 路由,回退会让缺失的 chunk
返回 200+HTML,浏览器报 `Unexpected token '<'`,而真因只是文件不存在。
3. 容器**健康检查要写 `127.0.0.1`,不能写 `localhost`**:容器里 localhost 先解析到 IPv6 `::1`,
而 nginx 的 `listen 80` 只监听 IPv4 → wget 报 `Connection refused`,
健康状态永远停在 starting(首页从宿主机看却是 200,极易误判)。
### 四、验证(实测,2026-10-19)
| 手段 | 结果 |
|---|---|
| `mvn clean package` | 通过。⚠️ **必须带 `clean`**:被删掉的旧类会留在 `target/classes` 里,仍会被组件扫描到 |
| 后端直连(8081) | `/`、`/index.html`、`/assets/index.js` → **404**(`{"code":40001,"msg":"接口不存在"}`);`/api/v1/auth/captcha` → 200 + `Cache-Control: no-store`;`/doc.html` → 302 → `/swagger-ui/index.html` 200;`/v3/api-docs`、`/actuator/health` → 200(**证明 `add-mappings` 不影响 springdoc 与 actuator**) |
| nginx(80) | `/` → 200 + `no-cache`;`/assets/index-*.js` → 200 + `public, max-age=31536000, immutable`;`/nope.js` → **404**(无回退);`/api/...` 经反代 → 200 + `no-store`;gzip 生效(265KB 包 → `Content-Encoding: gzip`) |
| 包体上限 | 2MB multipart 上传经 nginx → **401**(与直连一致,证明默认 1m 的坑已避开);55MB → **413**(上限按设计生效) |
| e2e(`XT_BASE=http://localhost`,**全程走 nginx**) | `e2e-m0.py` **37/37**、`e2e-m3-h5-auth.py` **24/24**(H5 授权与 302 落点)、`e2e-m2-scope.py` **38/38** |
| `verify-all.py --quick` | 6/6(契约 / 实体 / 文档 / 手册 / 接线 / 脚本语法) |
| `check-test-profile.py` | test profile **能起来**(随机端口 50767,10.5 秒)—— CI 那套配置在删掉 static-locations 后仍可启动 |
### 五、顺带修掉一个"改出来的"回归(值得记住的因果)
关掉静态映射后,未匹配路径不再经过 `ResourceHttpRequestHandler`,于是抛的是
**`NoHandlerFoundException`**(连 handler 都没有)而不是 `NoResourceFoundException`(资源处理器在、文件不在)。
原代码只处理了后者 → 前者落到兜底 → **访问 `/` 得到 500「系统繁忙」**,日志里还刷 `ERROR 未预期异常`。
已补一个 handler 返回同样的 404 形状。**教训:这类"关掉一个框架默认行为"的改动,
要顺手把它的异常路径也想一遍** —— 否则一个正常的 404 会被报成服务端故障。
(另有一处**既存**、本次未改:`Content-Type: application/octet-stream` 打到上传接口会得到 500
而不是 415 —— `HttpMediaTypeNotSupportedException` 也没进处理器。是否收编由后续决定。)
### 六、要人工做的一步 + 一个环境限制
1. **内网穿透要改指到 nginx 的 80 端口**(原来是 `192.168.3.139:8080`)。
指到 8080 的表现是"**接口好用、页面 404**"。改完之后前端与微信后台都不用动。
2. **本机 8080 现在被别的项目占着**(`com.jxyunge.AdminApplication`)。我们的后端起不来时,
`java -jar ... --server.port=8081` 可以起,但**约 19 个 e2e 脚本把 base 写死成 8080**
(`BASE = "http://localhost:8080"` 或 `Request("http://localhost:8080"+path)`),
`XT_BASE` 对它们无效 —— 本次能跑的三个是认 `XT_BASE` 的。**不要去杀别人的进程。**
### 七、显式不做
1. **不把 PC 管理端搬进来**:它仍是独立静态托管(`pnpm dev` 5173 / 生产由网关发 `dist/`)。
2. **不给 nginx 加 `/actuator/**`、`/doc.html` 的反代**:actuator 只在内部暴露、文档在生产关闭,
要调试直接打后端端口。
3. **不做 HTTPS / 证书**:本地由穿透层负责;生产形态由部署侧决定。