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:
+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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user