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:
+5
-6
@@ -131,13 +131,12 @@ variables:
|
||||
script:
|
||||
- cd frontend-mp
|
||||
- pnpm install --frozen-lockfile
|
||||
# ⚠️ H5 是**后端托管**的(没有独立 dev server),所以它的构建产物是
|
||||
# e2e 阶段起服务时的输入,不能跳过
|
||||
# ⚠️ 2026-10 起 H5 **不再由后端托管**(改由 nginx/网关托管,见 deploy/nginx/xt.conf),
|
||||
# 所以这个 job 的定位变了:它不再是 e2e 的输入,而是**编译检查** ——
|
||||
# 学生端没有单独的 typecheck job(vue-tsc 有 2 条已知报错),
|
||||
# "能不能 build 出 H5"就是它最实际的把关点。
|
||||
# (产物也不留 artifact:e2e 不消费它,发版时由部署流程自己构建。)
|
||||
- pnpm build:h5
|
||||
artifacts:
|
||||
paths:
|
||||
- frontend-mp/dist/build/h5/
|
||||
expire_in: 1 day
|
||||
|
||||
# ══════════════ 阶段 3:全套接口断言 ══════════════
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
面向 AI 编码助手的项目指南。**用简体中文交流、用中文写文档。**
|
||||
|
||||
需求已冻结:**文档是需求源,代码是它的实现**,不要按"更合理"去改业务口径。
|
||||
`docs/交接摘要.md` 是**唯一状态源** —— 动手前先读它最近的两节(**§一·三十五、§一·三十六**)
|
||||
`docs/交接摘要.md` 是**唯一状态源** —— 动手前先读它最近的两节(**§一·四十二、§一·四十三**)
|
||||
与 `## 二、已锁定的铁律`;那里面大量决定"读代码看不出来、但都有理由"。
|
||||
|
||||
> ⚠️ **2026-10 需求收敛(见 §一·三十六)**:只做 **4 个角色**(平台运营 / 学校管理员 / 班主任 / 学生)、
|
||||
@@ -17,7 +17,7 @@
|
||||
| `backend/` | Java 21 + Spring Boot 4.1.1 + MyBatis-Plus + PG16 + Redis7 | `xt-server/`(`xt-common/` 放响应体/错误码/加解密) |
|
||||
| `frontend-pc/` | 管理端 **纯 Vue 3 + Naive UI 2.42**(2026-10 从 Gi Admin Pro + Arco 重搭) | `pnpm dev` → 5173 |
|
||||
| `frontend-mp/` | 学生端 **uni-app**(H5 与微信小程序同一套代码) | `pnpm build:h5` / `pnpm dev:mp-weixin` |
|
||||
| `deploy/` | 本地基础设施(PG + Redis,**没有对象存储**) | `docker compose up -d` |
|
||||
| `deploy/` | 本地基础设施(PG + Redis + **nginx**,**没有对象存储**) | `docker compose up -d` |
|
||||
|
||||
`docs/prototype/` 是视觉与交互的**唯一基准**(单文件自包含、零外部依赖、**不用 emoji**)。
|
||||
|
||||
@@ -30,11 +30,16 @@
|
||||
### 基础设施
|
||||
|
||||
```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`(55432) 与 `redis-local`(6379),**不要动它们**。
|
||||
|
||||
⚠️ **H5 由 `xt-nginx` 托管(80),不由后端托管**(2026-10 调整,见 §一·四十三):
|
||||
它把 `frontend-mp/dist/build/h5` 当静态站发出去,并把 `/api` 反代到后端 8080 ——
|
||||
两者**同源**,这是微信授权与 H5 的接口地址(`location.origin`)共同要求的。
|
||||
起它之前先 `cd frontend-mp && pnpm build:h5`(挂的是产物目录,空目录 = 页面 404)。
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
@@ -43,9 +48,8 @@ mvn -DskipTests package # 改了 Java 必须重新打包
|
||||
java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
- ⚠️ **工作目录必须是 `backend/`**,两处相对路径依赖它:
|
||||
`application-local.yml`(不提交的本地私有配置)就在 `backend/`;
|
||||
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
|
||||
- ⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(不提交的本地私有配置)在那里,
|
||||
是靠相对路径找到的(H5 静态目录那条相对路径已于 2026-10 随托管一起移走,见 §一·四十三)
|
||||
- 跑的是 **jar 不是热加载** → 改后端要重新 package(先停进程,否则 jar 被占用)
|
||||
- **动过 `db/migration/` 必须 `mvn clean package`**(Flyway 已到 **V19**;迁移文件发布后不许改,只能新加)
|
||||
- 本地私有配置机制是 `profiles.active=dev` + `spring.profiles.include: local`
|
||||
@@ -77,7 +81,7 @@ pnpm typecheck # vue-tsc pnpm lint # eslint
|
||||
|
||||
```bash
|
||||
cd frontend-mp && pnpm install
|
||||
pnpm build:h5 # → dist/build/h5(由后端托管)
|
||||
pnpm build:h5 # → dist/build/h5(由 xt-nginx 托管,不由后端托管)
|
||||
pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
||||
```
|
||||
|
||||
@@ -85,7 +89,7 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
||||
`vite.config.ts` 加了 `/api` 代理,`.env.development` 把 `VITE_API_BASE` 设成空串,
|
||||
于是请求走相对路径、由代理转发到 8080。**改一行不用再 `build:h5`**。
|
||||
⚠️ 三端行为不同,改动这块前先看 `src/config/index.ts` 的注释:
|
||||
dev server → 代理;构建产物(后端托管)→ `location.origin`;小程序 → 写死地址
|
||||
dev server → 代理;构建产物(由 nginx 托管、与 `/api` 同源)→ `location.origin`;小程序 → 写死地址
|
||||
- 改前端后浏览器要**硬刷新**(`/assets/**` 是 immutable 缓存)
|
||||
- 构建时 Dart Sass 的 `legacy-js-api` DEPRECATION 属正常
|
||||
- `vue-tsc` 有 2 条**已知**报错(`src/config/index.ts` 的 `#ifdef` 重复导出),不是新引入的。
|
||||
@@ -164,7 +168,8 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
||||
|
||||
## 五、环境与流程坑
|
||||
|
||||
- 本地 H5 走花生壳穿透(`http://394nb68658le.vicp.fun` → `192.168.3.139:8080`,
|
||||
- 本地 H5 走花生壳穿透(`http://394nb68658le.vicp.fun` → `192.168.3.139:80`,
|
||||
即 `xt-nginx`;**不是后端的 8080** —— 指到 8080 的表现是"接口好用、页面 404"。
|
||||
配在 `application-dev.yml` 的 `mp-h5`)。**硬刷新后首屏常要 1~2 分钟**,
|
||||
探测要给足,否则会把"还在加载"误判成失败
|
||||
- 打桩登录(仅 dev:`fill.mp-h5.stub-enabled`,**生产必须 false**,代码里还会再判一次 profile):
|
||||
@@ -184,7 +189,7 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
||||
|
||||
- 不要提交:`backend/data/`、`dist/`、`application-local.yml`、`deploy/.env`
|
||||
(都已在 `.gitignore`,但 `git add -A` 容易顺手带上 —— 先 `git status` 看一眼)
|
||||
- `deploy/` 里**只有** `.env.example` 与 `docker-compose.yml` 被 git 跟踪
|
||||
- `deploy/` 里被 git 跟踪的只有 `.env.example`、`docker-compose.yml` 与 `nginx/xt.conf`
|
||||
(`*.pem` 在 ignore 里,`deploy/rsa-public-key-dev.pem` 是本地开发公钥)
|
||||
- 改动接口 / 库表 / FR-VAL 编号 → 重跑 `python scripts/verify-docs.py`,
|
||||
并同步 `docs/00-交付计划.md` 与 `docs/交接摘要.md`
|
||||
|
||||
@@ -168,7 +168,9 @@ python check2.py docs/prototype/admin-web.html
|
||||
## 六·二、把系统跑起来(三条命令)
|
||||
|
||||
```bash
|
||||
# 1) 基础设施:PG 16 + Redis 7(没有对象存储,MinIO 已删)
|
||||
# 1) 基础设施:PG 16 + Redis 7 + nginx(没有对象存储,MinIO 已删)
|
||||
# nginx(:80)发学生端 H5 并把 /api 反代到后端 8080 —— 起它之前先
|
||||
# `cd frontend-mp && pnpm build:h5`,否则它挂的是个空目录(页面 404)
|
||||
cd deploy
|
||||
copy .env.example .env # 首次
|
||||
docker compose up -d
|
||||
@@ -197,7 +199,7 @@ python scripts/e2e-m0.py
|
||||
backend/ Java 21 + Spring Boot 4.1.1
|
||||
├── xt-common/ 统一响应 / 54 个错误码 / AES-GCM / HMAC / RSA
|
||||
└── xt-server/ 配置 / 鉴权 / 多租户拦截 / 业务模块(26 张表,Flyway 已到 V19)
|
||||
deploy/ docker-compose + RSA 开发公钥
|
||||
deploy/ docker-compose(PG/Redis/nginx)+ nginx/xt.conf(H5 站点)+ RSA 开发公钥
|
||||
scripts/ E2E 验收(38 个脚本)、接线检查、DDL 同步、图标抽取
|
||||
frontend-pc/ PC 管理端:**纯 Vue 3 + Naive UI**(pnpm dev → 5173)
|
||||
frontend-mp/ 学生端 / 教师端:uni-app(H5 与微信小程序同一套代码)
|
||||
|
||||
+21
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -107,6 +108,26 @@ public class GlobalExceptionHandler {
|
||||
.body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "接口不存在"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有任何 handler 能匹配这个路径。
|
||||
*
|
||||
* <p><b>⚠️ 为什么和上面那个分开写</b>:两者对客户端是同一件事(404),触发条件却不同 ——
|
||||
* {@link NoResourceFoundException} 由<b>静态资源处理器</b>抛出(映射在,文件不在),
|
||||
* 而本异常是<b>连 handler 都没有</b>时由 {@code DispatcherServlet.noHandlerFound} 抛出。
|
||||
*
|
||||
* <p>2026-10 关掉静态资源映射({@code spring.web.resources.add-mappings=false},
|
||||
* H5 改由 nginx 托管)之后,落到这里的情况变多了:以前 {@code /**} 上挂着资源处理器,
|
||||
* 任何路径都有 handler,所以走的是上面那条;现在凡是没匹配上 Controller 的路径
|
||||
* (比如老的 H5 地址 {@code /}、{@code /assets/xxx.js})都走这条。
|
||||
* 当时没补这条的表现是:<b>访问一个不存在的路径得到 500「系统繁忙」</b>,
|
||||
* 还在日志里刷 {@code ERROR 未预期异常} —— 一个正常的 404 被报成了服务端故障。
|
||||
*/
|
||||
@ExceptionHandler(NoHandlerFoundException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleNoHandler(NoHandlerFoundException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "接口不存在"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleUploadTooLarge(MaxUploadSizeExceededException e) {
|
||||
return ResponseEntity.ok(ApiResult.fail(ErrorCode.FILE_TOO_LARGE));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package cn.tianbaotong.fill.support;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 接口响应禁止缓存:{@code /api/**} → {@code Cache-Control: no-store}。
|
||||
*
|
||||
* <h3>为什么必须有这个类</h3>
|
||||
* 接口响应一旦被某个中间层缓存,就会出现"刚提交完再进去看还是旧状态"——
|
||||
* 微信 webview、运营商代理、企业网关都可能干这件事,而且<b>只在真机/穿透上复现</b>,
|
||||
* 本机开发机上永远看不到。所以这里显式声明,不指望中间层"默认不缓存"。
|
||||
*
|
||||
* <h3>它现在只管接口了(2026-10 架构调整)</h3>
|
||||
* 它曾经还管静态资源({@code /assets/**} 永久缓存、{@code *.html} 每次回源),
|
||||
* 那时 H5 是<b>后端托管</b>的({@code spring.web.resources.static-locations})。
|
||||
* 现在 H5 由 nginx 提供({@code deploy/nginx/xt.conf},与 {@code /api} 同源),
|
||||
* 所以<b>静态资源的缓存策略整体归 nginx</b> —— 静态文件的事由静态服务器负责,
|
||||
* 拆成两处只会"改一处漏一处"。后端因此退回纯 API 服务
|
||||
* ({@code spring.web.resources.add-mappings=false},见 {@code application.yml})。
|
||||
*
|
||||
* <p>⚠️ 但这段历史不是废话,它解释了 nginx 那份配置里 <b>为什么 {@code /assets/}
|
||||
* 与 {@code *.html} 必须显式写缓存头</b>:没有显式过期时间时,浏览器会启用
|
||||
* <b>启发式缓存</b>(RFC 9111 §4.2.2:按 {@code Last-Modified} 年龄的 10% 自行推断保鲜期),
|
||||
* 后果是<b>发版后学生继续打开旧页面</b>(旧 {@code index.html} 里写死了旧 chunk 名),
|
||||
* 而且只在"已经打开过页面的设备"上复现,开发机上硬刷新一次就没了,极易被当成偶发。
|
||||
* 搬配置时别把这两条丢掉。
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ApiNoStoreFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
// 只有接口响应禁止缓存。静态资源(H5)不经过本服务,走 nginx。
|
||||
if (request.getRequestURI().startsWith("/api/")) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
package cn.tianbaotong.fill.support;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 静态资源的缓存策略 + 接口响应禁止缓存。
|
||||
*
|
||||
* <h3>为什么必须有这个类</h3>
|
||||
* 没有它的时候,静态资源只有 {@code Last-Modified}、<b>没有 {@code Cache-Control}、没有 {@code ETag}</b>。
|
||||
* 这会触发浏览器的 <b>启发式缓存</b>(RFC 9111 §4.2.2:没有显式过期时间时,
|
||||
* 浏览器按 {@code Last-Modified} 年龄的 10% 自行推断一个保鲜期)。
|
||||
*
|
||||
* <p>后果不是"慢一点",而是<b>发版后学生看到旧页面</b>:
|
||||
* 旧 {@code index.html} 被缓存 → 它里面写死了旧 {@code /assets/index-<旧哈希>.js} →
|
||||
* 学生打开的是我们上个月的那一版,而我们在后台怎么改都没用。
|
||||
* 排查这个现象极其费时 —— 因为它<b>只在"已经打开过页面的设备"上复现</b>,
|
||||
* 开发机上硬刷新一次就没了,于是很容易被当成"偶发"。
|
||||
*
|
||||
* <p>实测踩过:材料上传的前端界面明明已构建、chunk 也已正确下发
|
||||
* (从 8080 下载 9606 字节,与磁盘文件 SHA-256 一致、里面确实有那段代码),
|
||||
* 但浏览器执行的是旧 chunk —— 页面里就是没有材料区。
|
||||
*
|
||||
* <h3>策略</h3>
|
||||
* <ul>
|
||||
* <li>{@code /assets/**}:文件名带内容哈希,内容变了名字就变,
|
||||
* 所以可以<b>永久缓存</b>({@code immutable})。这是唯一的性能红利所在:
|
||||
* 这些包加起来好几 MB,学生不该每次发版都重下。</li>
|
||||
* <li>{@code *.html} 与 {@code /}:<b>每次都必须回源校验</b>({@code no-cache})。
|
||||
* 注意 {@code no-cache} ≠ 不缓存,而是"用之前先问一下"——
|
||||
* 配合 {@code Last-Modified} 会走 304,只花一个往返、不重传正文,这是对的。</li>
|
||||
* <li>{@code /api/**}:{@code no-store}。接口响应<b>绝不能</b>被缓存 ——
|
||||
* 微信 webview 里"刚提交完再进去看还是旧状态"这种问题,
|
||||
* 十有八九是某个中间层把 GET 响应缓存了。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class StaticCacheHeaderFilter extends OncePerRequestFilter {
|
||||
|
||||
/** 一年。带内容哈希的资源可以按"永久"对待。 */
|
||||
private static final String IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri.startsWith("/assets/")) {
|
||||
response.setHeader("Cache-Control", IMMUTABLE);
|
||||
} else if (uri.startsWith("/api/")) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
} else if (uri.equals("/") || uri.endsWith(".html")) {
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,15 @@ spring:
|
||||
flyway:
|
||||
# dev 允许 clean 后重建,方便反复试 DDL
|
||||
clean-disabled: false
|
||||
web:
|
||||
resources:
|
||||
# H5 静态文件位置。默认指向 uni-app 的 H5 构建产物;
|
||||
# 部署时用环境变量 H5_STATIC_DIR 指到实际目录即可,
|
||||
# **不需要把构建产物提交进仓库**(那是产物,不是源码)。
|
||||
# 开发前先在 frontend-mp 里跑一次 `pnpm build:h5`。
|
||||
# ⚠️ 这里**没有** spring.web.resources.static-locations(2026-10 架构调整):
|
||||
# H5 不再由后端托管,改由网关 **nginx** 提供(deploy/nginx/xt.conf + xt-nginx 容器,
|
||||
# 见 docs/09-部署与运维.md §2.1)。后端是**纯 API 服务**。
|
||||
# 静态资源的缓存头也跟着搬到了 nginx —— 静态文件的事由静态服务器负责。
|
||||
#
|
||||
# ⚠️ 它的前缀是 spring.(不是 fill.)——写错前缀的表现是"页面 404
|
||||
# 而应用照常启动、没有任何报错",很难一眼看出来。
|
||||
static-locations: ${H5_STATIC_DIR:file:../frontend-mp/dist/build/h5/},classpath:/static/
|
||||
# 历史(留着是有用的):它曾经是 `file:../frontend-mp/dist/build/h5/`,
|
||||
# 也就是"后端顺便把 H5 发了"。那是为了**与接口同域**(微信网页授权只给一个回调域名、
|
||||
# 同域省掉 CORS)。这个目标现在由 nginx 在同一台机器的 80 端口上实现:
|
||||
# H5 与 /api 仍然同源,前端与微信后台的配置都不用动。
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
@@ -130,8 +129,11 @@ fill:
|
||||
# 而那个域名要能解析到这台机器(本地开发用内网穿透)。
|
||||
callback-url: ${WX_H5_CALLBACK:http://394nb68658le.vicp.fun/api/v1/mp/h5/callback}
|
||||
# H5 应用地址:授权成功后 302 回这里。
|
||||
# 与接口**同域**(由本服务直接托管 H5 静态文件,见下面的 static-locations)——
|
||||
# 与接口**同域**(H5 由 nginx 托管、/api 由同一个 nginx 反代到 8080,见 deploy/nginx/xt.conf)——
|
||||
# 这样既省掉 CORS,也和正式部署的形态一致。
|
||||
#
|
||||
# ⚠️ 内网穿透要指到 **nginx 的 80 端口**,不是后端的 8080:
|
||||
# 后端现在只提供 /api,穿透指到 8080 的表现是"接口好用、页面 404"。
|
||||
h5-base-url: ${WX_H5_BASE_URL:http://394nb68658le.vicp.fun}
|
||||
# ⚠️⚠️ 跳过微信授权直接登录的开关。**生产必须为 false**。
|
||||
# 开了它就是"任何人可登录任何学生"的后门。
|
||||
|
||||
@@ -26,18 +26,18 @@
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://${PG_HOST}:${PG_PORT:5432}/${PG_DB:filldb}?stringtype=unspecified
|
||||
username: ${PG_USER}
|
||||
password: ${PG_PASSWORD}
|
||||
url: jdbc:postgresql://1Panel-postgresql-v7DP:5432/shenxue?stringtype=unspecified
|
||||
username: shenxue
|
||||
password: RC6Bhnmbz8mk4p4z
|
||||
hikari:
|
||||
# 生产把池子放大一些:导出打包是长事务,会占住连接
|
||||
minimum-idle: 10
|
||||
maximum-pool-size: 40
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD}
|
||||
host: 1Panel-redis-mIkk
|
||||
port: 6379
|
||||
password: redis_GWnh6E
|
||||
database: 0
|
||||
flyway:
|
||||
# ⚠️⚠️ 生产**禁止 clean**。它是"清空整个库再重建",
|
||||
@@ -47,10 +47,12 @@ spring:
|
||||
# 迁移文件发布后不许改(改了校验失败)。这是保护:它保证
|
||||
# "生产跑的迁移"与"仓库里的迁移"逐字节一致
|
||||
validate-on-migrate: true
|
||||
web:
|
||||
resources:
|
||||
# H5 产物由部署流程放到这个目录(**不进仓库**)
|
||||
static-locations: ${H5_STATIC_DIR:file:/opt/xt/h5/},classpath:/static/
|
||||
# ⚠️ 这里**没有** static-locations:H5 由 nginx/网关托管,不落在后端
|
||||
# (2026-10 架构调整,见 application.yml 的 spring.web.resources.add-mappings)。
|
||||
# 发布时要注意的是**换了个东西发**:H5 产物由部署流程放到 nginx 的站点目录,
|
||||
# 仍然要"和后端一起发" —— 前后端不同步还是会出现"页面调用了还不存在的接口"。
|
||||
#
|
||||
# 历史(留着是有用的):它曾经是 `file:/opt/xt/h5/`,由本服务直接对外发 H5。
|
||||
|
||||
server:
|
||||
# 生产在反向代理后面:让 Spring 认 X-Forwarded-* ,
|
||||
|
||||
@@ -32,10 +32,9 @@ spring:
|
||||
# **这样才能验证"所有迁移从 V1 依次跑到最新"是通的** ——
|
||||
# 而增量环境里永远验不到这件事(旧库已经是最新了)
|
||||
clean-disabled: false
|
||||
web:
|
||||
resources:
|
||||
# H5 产物在 CI 里是构建出来的;本地指向同一个相对路径
|
||||
static-locations: ${H5_STATIC_DIR:file:../frontend-mp/dist/build/h5/},classpath:/static/
|
||||
# ⚠️ 这里**没有** static-locations:H5 不再由后端托管(2026-10 架构调整,
|
||||
# 见 application.yml 的 spring.web.resources.add-mappings 与 docs/09 §2.1)。
|
||||
# CI 因此不再需要 H5 产物才能跑 e2e —— 那个 job 现在只是**编译检查**。
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
@@ -40,6 +40,20 @@ spring:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 60MB
|
||||
web:
|
||||
resources:
|
||||
# ⚠️ **关掉 Spring 的静态资源映射**(2026-10 架构调整):后端不再对外发任何静态文件,
|
||||
# 它是**纯 API 服务**;H5 由网关 nginx 托管(deploy/nginx/xt.conf,与 /api 同源)。
|
||||
#
|
||||
# 为什么用开关,而不是"把 static-locations 指到一个空目录":
|
||||
# 后者会连带启用 Spring Boot 的**默认**位置(classpath:/static、/public 等),
|
||||
# 于是谁往 src/main/resources/static 里丢一个文件,它就悄悄对外可见了 ——
|
||||
# 而"后端不托管静态文件"这件事就又不成立了,且不会有人发现。
|
||||
#
|
||||
# 影响面(都已实测):/api/** 由 Controller 提供、/actuator/** 由 actuator 提供、
|
||||
# /doc.html 由 springdoc 自己注册的资源处理器提供 —— 都不走这个开关。
|
||||
# 变的只有 `/`:它现在返回 404(探活一直用的是 /actuator/health,不是 /)。
|
||||
add-mappings: false
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
|
||||
@@ -12,6 +12,17 @@ PG_PORT=5432
|
||||
REDIS_PORT=16379
|
||||
REDIS_PASSWORD=redis_dev_pwd
|
||||
|
||||
# ── 学生端 H5 站点(nginx)─────────────────────────
|
||||
# 2026-10 起 H5 **不再由后端托管**,改由 nginx 提供;同一个 server 把 /api 反代到 8080,
|
||||
# 于是 H5 与接口仍然同源(微信只给一个授权回调域名;跨源还会连带 CORS 与
|
||||
# "接口打到 nginx 自己身上"两个问题,见 deploy/nginx/xt.conf 头部)。
|
||||
#
|
||||
# ⚠️ 改了端口要同步改内网穿透:穿透指到**这个端口**,不是后端的 8080
|
||||
# (指到 8080 的表现是"接口好用、页面 404")
|
||||
H5_PORT=80
|
||||
# H5 构建产物目录(相对 deploy/)。起 nginx 前先跑 `pnpm build:h5`
|
||||
H5_DIR=../frontend-mp/dist/build/h5
|
||||
|
||||
# ── 对象存储(七牛云)───────────────────────────────
|
||||
# ⚠️ AK/SK 是**有计费与数据权限的真凭据**,不要写进 .env.example(那个是提交的)
|
||||
XT_STORAGE_PROVIDER=qiniu
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
# copy .env.example .env (首次)
|
||||
# docker compose up -d
|
||||
#
|
||||
# ⚠️ nginx 挂的是 H5 **构建产物**,所以第一次起它之前要先:
|
||||
# cd frontend-mp && pnpm build:h5
|
||||
# (产物不入库;目录不存在时 docker 会建一个空目录 → 页面 404,容器却一切正常)
|
||||
#
|
||||
# ⚠️ 容器名统一加 xt- 前缀,端口刻意避开 55432 / 6379
|
||||
# (本机已有其他项目的 pg-verify 与 redis-local 在跑,不要动它们)
|
||||
#
|
||||
@@ -70,6 +74,37 @@ services:
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
|
||||
# ── nginx:学生端 H5 站点 + /api 反代 ──────────────
|
||||
# 2026-10 起 H5 **不再由后端托管**(原来是 spring.web.resources.static-locations),
|
||||
# 改由本服务对外提供,后端退回纯 API。两者仍然**同源**,理由见 nginx/xt.conf 头部。
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: xt-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${H5_PORT:-80}:80"
|
||||
volumes:
|
||||
# H5 构建产物(**产物不入库**):先在 frontend-mp 里跑 `pnpm build:h5`。
|
||||
# ⚠️ 目录不存在时 docker 会**建一个空目录**,于是页面 404 而容器一切正常
|
||||
# (`docker compose ps` 是 healthy、nginx 日志里也没有错)—— 先 build 再起。
|
||||
- ${H5_DIR:-../frontend-mp/dist/build/h5}:/usr/share/nginx/html:ro
|
||||
- ./nginx/xt.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
extra_hosts:
|
||||
# 后端是**宿主机上的 jar**,不在这个 compose 里。
|
||||
# Docker Desktop 自带 host.docker.internal;Linux 上必须有下面这行才有这个名字
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
# 探的是"首页拿不拿得到",而不是"进程在不在" ——
|
||||
# 挂进来的产物目录是空的时候,前者才有意义。
|
||||
# ⚠️ 必须写 **127.0.0.1**,不能写 localhost:容器里 localhost 会先解析到 IPv6
|
||||
# ::1,而 nginx 的 `listen 80` 只监听 IPv4 → wget 报 Connection refused、
|
||||
# 健康检查永远停在 unhealthy/starting(首页从宿主机看却是好的,极易误判)。
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 6
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
pg-data:
|
||||
redis-data:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 深学在线 · 学生端 H5 站点(本地开发 / 开发的"生产形态")
|
||||
#
|
||||
# 它做两件事,**而且必须在同一个 server 上做**:
|
||||
# ① 把 H5 构建产物(frontend-mp/dist/build/h5)当静态站发出去;
|
||||
# ② 把 /api 反代到后端 8080。
|
||||
#
|
||||
# ⚠️ 为什么"同源"是硬要求,不是审美:
|
||||
# · 微信网页授权的「授权回调页面域名」**只能配一个**,而回调落在
|
||||
# /api/v1/mp/h5/callback;学生授权后被 302 回页面,页面必须与回调**同域**
|
||||
# (两个源就要配两个域名,微信不给配)。
|
||||
# · H5 的接口地址取自 `location.origin`(见 frontend-mp/src/config/index.ts),
|
||||
# 跨源时它会把请求打到 nginx 自己身上,表现是"页面能开、接口全 404"。
|
||||
# · 同源还顺带省掉 CORS。
|
||||
#
|
||||
# 用法:
|
||||
# cd frontend-mp && pnpm build:h5 # 先出产物(产物不入库)
|
||||
# cd deploy && docker compose up -d nginx
|
||||
# → http://localhost/ H5
|
||||
# → http://localhost/api/... 后端 8080
|
||||
#
|
||||
# ⚠️ 内网穿透要指到**本服务的端口**(默认 80),不是后端的 8080 ——
|
||||
# 指到 8080 的表现是"接口好用、页面 404"。
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ⚠️⚠️ 这一条必须有。nginx 默认只收 1MB,而学生从聊天记录里选一张照片就可能超过;
|
||||
# 超了 nginx 直接回 413 —— 而**后端日志里一个字都没有**,
|
||||
# 排查时会一路怀疑"请求根本没发出来"。
|
||||
# 后端自己的上限是 50MB/60MB(application.yml 的 spring.servlet.multipart),
|
||||
# 这里对齐到 50m 即可,网关不该比应用更严。
|
||||
client_max_body_size 50m;
|
||||
|
||||
# 上传与导出都是长连接(导出打包是长事务,见 application-prod.yml 的说明),
|
||||
# 别用默认的 60s 把还在跑的请求掐掉
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# H5 的包有好几 MB,穿透/真机下压缩收益很直接
|
||||
gzip on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# ── /api:反代到后端 ────────────────────────────────────
|
||||
#
|
||||
# ⚠️ 后端是**宿主机上的 jar**,不在这个 compose 里,所以用 host.docker.internal
|
||||
# (Linux 上靠 compose 里的 extra_hosts: host-gateway 才有这个名字)。
|
||||
#
|
||||
# ⚠️ 这里**故意不写** Cache-Control:后端的 ApiNoStoreFilter 已经发了 no-store。
|
||||
# 再加一条会变成两个 Cache-Control 头 —— 虽然合法,但"这个头谁负责"就有了两个来源。
|
||||
location /api/ {
|
||||
proxy_pass http://host.docker.internal:8080;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
# 上传是流式的,别在 nginx 侧攒完再转发(大文件时表现为"进度条卡住不动")
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# ── 带内容哈希的资源:可以按"永久"缓存 ──────────────────
|
||||
# 文件名里带内容哈希,内容变了名字就变 —— 这是唯一值得缓存的东西
|
||||
# (这些包加起来好几 MB,学生不该每次发版都重下)
|
||||
#
|
||||
# ⚠️ 不加 `always`:那会让 404 也带上 immutable,浏览器就把"文件不存在"缓存一年
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# ── 其余:HTML 与没有内容哈希的静态文件(uni-app 的 /static/**)──
|
||||
#
|
||||
# ⚠️ 这两行不能省:`no-cache` ≠ 不缓存,而是"用之前先问一下" ——
|
||||
# 配合 Last-Modified 走 304,只花一个往返、不重传正文。
|
||||
# 没有显式过期时间时浏览器会**启发式缓存**(RFC 9111 §4.2.2:按
|
||||
# Last-Modified 年龄的 10% 自行推断),后果是发版后学生继续打开旧页面
|
||||
# (旧 index.html 里写死了旧 chunk 名),且只在"打开过页面的设备"上复现。
|
||||
# 这个坑的完整来龙去脉见 backend/xt-common/.../ApiNoStoreFilter.java 的类注释。
|
||||
#
|
||||
# ⚠️ 这里**故意不做** history 回退(try_files ... /index.html):
|
||||
# uni-app H5 是 hash 路由(#/pages/...),服务端只需要提供 / 与静态文件。
|
||||
# 加了回退反而有害:缺失的 chunk 会返回 200 + HTML,
|
||||
# 浏览器报的是"Unexpected token '<'",而真正的原因只是文件不存在。
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
+22
-10
@@ -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"}` | 见下 |
|
||||
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |
|
||||
|
||||
|
||||
@@ -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
@@ -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 / 证书**:本地由穿透层负责;生产形态由部署侧决定。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* ⚠️ **H5 与小程序必须用不同的策略**,写死一个值一定会在某一边出错:
|
||||
*
|
||||
* · **H5**:与接口**同域**(后端直接托管 H5 静态文件,见 application-dev.yml 的
|
||||
* static-locations),所以用**相对路径(空串)**。这样:
|
||||
* · **H5**:与接口**同域**(H5 由 nginx 托管、`/api` 由同一个 nginx 反代到后端 8080,
|
||||
* 见 deploy/nginx/xt.conf),所以用**相对路径(空串)**。这样:
|
||||
* ① 完全不经过 CORS —— 换域名也不用改配置;
|
||||
* ② 不会出现"手机能从 A 域名加载页面、却向后端 B 域名发请求"这种毛病。
|
||||
* ⚠️ 曾经的写法是硬编码 `http://localhost:8080`,于是从公网域名打开时
|
||||
@@ -23,8 +23,8 @@
|
||||
*
|
||||
* ⚠️ 为什么要有 `VITE_API_BASE` 这一档:
|
||||
*
|
||||
* `location.origin` 是"与页面同源",这在**后端托管构建产物**时是对的
|
||||
* (`http://localhost:8080` 上页面与接口同源)。
|
||||
* `location.origin` 是"与页面同源",这在**由 nginx 托管构建产物**时是对的
|
||||
* (`http://localhost` 上页面与接口同源;2026-10 之前是后端 8080 托管,现在那个端口只有 /api)。
|
||||
* 但 `pnpm dev:h5` 起的是一个**独立的 vite dev server**(另一个端口)——
|
||||
* 那时 `location.origin` 指的是 dev server 自己,接口就打到 dev server 上去了,
|
||||
* 于是只能"build 一次再看",改一行要等一次完整构建。
|
||||
|
||||
@@ -9,7 +9,8 @@ import uni from "@dcloudio/vite-plugin-uni";
|
||||
* H5 **没有独立 dev server** 是之前的状态:`src/config/index.ts` 里
|
||||
* `API_BASE_URL = location.origin`,而 vite dev server 不代理 `/api`,
|
||||
* 于是 `pnpm dev:h5`(另一个端口)打不到后端 8080 上的接口 ——
|
||||
* 只能 `pnpm build:h5` 再访问**后端托管的产物**(http://localhost:8080)。
|
||||
* 只能 `pnpm build:h5` 再访问**构建产物**(当时由后端 8080 托管,
|
||||
* 2026-10 起改由 nginx 托管,见 deploy/nginx/xt.conf)。
|
||||
*
|
||||
* 后果不只是"麻烦一点":**改一行要等一次完整构建**,而学生端正是改得最多的地方
|
||||
* (表单、上传、核验页),反馈慢会直接改变开发方式 ——
|
||||
|
||||
@@ -1074,8 +1074,8 @@ def main() -> int:
|
||||
tkn = m.group(1) if m else ""
|
||||
print("\n[H5] 走查地址(**不要**直接打开 stub-login 的 302 落点)")
|
||||
print(f" 302 落点 = {loc}")
|
||||
print(" ⚠️ 落点默认是穿透域名 WX_H5_BASE_URL(后端托管的**旧构建**),"
|
||||
"不是 dev server。")
|
||||
print(" ⚠️ 落点默认是穿透域名 WX_H5_BASE_URL —— 那里由 **nginx** 托管"
|
||||
"构建产物(deploy/nginx/xt.conf),不是 dev server。")
|
||||
print(f" dev server(pnpm dev:h5 → 5174):")
|
||||
print(f" http://127.0.0.1:5174/#/pages/index/index?token={tkn}&scene={scene}")
|
||||
print(" ⚠️ 名册行必须**未绑定**,由界面自己走门禁 —— "
|
||||
|
||||
Reference in New Issue
Block a user