Compare commits

...

5 Commits

Author SHA1 Message Date
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
lovife 739c0a34a4 fix:正式环境API地址 2026-10-20 08:46:56 +08:00
lovife 1e71c164af fix(mp): 接通微信小程序登录并补齐生产配置
- 前端落地页调用 wx.login → /mp/login,保存 token,并在每次带 scene 进入时刷新登录(F-130 前端断点)
- 后端 /mp/login 的名册/任务查询放入 TenantScope,修复带 taskId 的 scene 必然 500(F-130 后端断点)
- 小程序生产 API 改为 VITE_MP_API_BASE(.env.production),不再写死 localhost
- 生产配置补全:七牛目录 shenxue、连接池/健康检查/优雅停机等;学生端只发小程序,移除 H5 段
- PC 生产 API 改到 admin.shenxue.jxit.com;新增 deploy/prod.env.example 并忽略 prod.env
- ⚠️ application-prod.yml 含生产凭据(按部署要求直接写入),仓库访问范围需收紧
2026-10-20 07:25:57 +08:00
lovife 17c92b29c3 fix:正式环境API地址 2026-10-19 21:39:24 +08:00
lovife 2ff17cd0c9 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 仍能起来
2026-10-19 17:35:33 +08:00
31 changed files with 1233 additions and 220 deletions
+4
View File
@@ -70,3 +70,7 @@ backend/data/
application-local.yml
# 本地私有配置(云存储密钥等)—— 只提交 .example 模板
xt-local.yml
# 生产环境变量(含 JWT / AES / RSA / 七牛真凭据)—— 只提交 .example 模板
deploy/prod.env
deploy/prod-rsa.pem
+5 -6
View File
@@ -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:全套接口断言 ══════════════
+15 -10
View File
@@ -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`
+4 -2
View File
@@ -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 与微信小程序同一套代码)
@@ -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);
}
}
@@ -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);
}
}
@@ -2,6 +2,7 @@ package cn.tianbaotong.fill.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
import java.util.Map;
/**
@@ -25,6 +26,7 @@ import java.util.Map;
* @param miniProgram 微信小程序(填报二维码)
* @param mpH5 H5 填报端(网页授权)
* @param tenantDomain 租户域名(用子域前缀区分租户,如 t1.xxx.com)
* @param cors 跨域白名单
*/
@ConfigurationProperties(prefix = "fill")
public record FillProperties(
@@ -36,9 +38,46 @@ public record FillProperties(
Storage storage,
MiniProgram miniProgram,
MpH5 mpH5,
TenantDomain tenantDomain
TenantDomain tenantDomain,
Cors cors
) {
/**
* 跨域白名单(Spring 的 {@code allowedOriginPatterns} 语义:可写
* {@code http://localhost:*}、{@code https://*.example.com})。
*
* <h3>⚠️ "本站自己的地址"也必须在这里 —— 这条最反直觉,但它是确定的</h3>
*
* <p>浏览器的 CORS <b>只约束跨源请求</b>,可它<b>给同源的 POST/PUT/DELETE 也带
* {@code Origin} 头</b>(GET 不带)。而 Spring MVC 的判据是"请求里有没有 Origin",
* <b>不是</b>"是不是真跨源":只要带了 Origin 又不在白名单里,直接回
* {@code 403 Invalid CORS request}({@code DefaultCorsProcessor#processRequest},
* Spring Framework 7.0.9 里<b>没有</b>同源短路)。
*
* <p>于是"前后端同源部署 = 不需要 CORS"是错的:登录、上传、提交全是 POST,
* 全都会被这一条拒掉。更麻烦的是它<b>只在浏览器里复现</b> ——
* 所有 E2E 脚本都是 Python,不带 Origin 头,所以测试全绿
* (2026-10 生产事故,见 {@code docs/交接摘要.md} §一·四十四)。
*
* <p>所以配置要按"浏览器地址栏里会出现哪些源"来写,而不是按"谁会跨域调我"来写。
*
* @param allowedOrigins 允许的源(pattern)。生产写真实域名,
* 换域名/加域名时改这里或给 {@code XT_CORS_ALLOWED_ORIGINS}
*/
public record Cors(List<String> allowedOrigins) {
/** 本机开发用的兜底值(base 的 {@code application.yml} 里也有一份,这里防 null)。 */
public static final List<String> DEFAULTS = List.of(
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.*.*:*");
/** 没配时退回本机开发值;**生产不许为空**,否则同源 POST 会 403。 */
public List<String> patternsOrDefault() {
return allowedOrigins == null || allowedOrigins.isEmpty() ? DEFAULTS : allowedOrigins;
}
}
/**
* 租户域名:用<b>子域前缀</b>区分租户({@code t1.xxx.com} / {@code t2.xxx.com})。
*
@@ -2,11 +2,14 @@ package cn.tianbaotong.fill.config;
import cn.tianbaotong.fill.security.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
* Web 层装配。
*
@@ -14,11 +17,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
* {@code /doc.html}、{@code /v3/api-docs/**} 不走业务鉴权,
* 它们由各自的机制保护(actuator 只在内网暴露,文档在生产环境关闭)。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final FillProperties props;
@Override
public void addInterceptors(InterceptorRegistry registry) {
@@ -29,25 +34,38 @@ public class WebConfig implements WebMvcConfigurer {
}
/**
* 跨域配置。
* 跨域配置(白名单来自 {@code fill.cors.allowed-origins})。
*
* <p><b>⚠️ 生产环境必须收紧 {@code allowedOriginPatterns}</b>——写 {@code *} 配合
* {@code allowCredentials} 在浏览器规范下无效,而改成 {@code originPatterns} 放行
* 全部则等于取消同源策略。生产配置见 {@code application-prod.yml} 里的
* {@code fill.cors.allowed-origins}。
* <h3>⚠️ 为什么"同源部署"也离不开这张白名单</h3>
*
* <p>浏览器的 CORS 只约束跨源请求,但<b>同源的 POST/PUT/DELETE 也会带
* {@code Origin} 头</b>;而 Spring MVC 只看"有没有 Origin",不看"是不是真跨源",
* 不在白名单里就回 {@code 403 Invalid CORS request}。登录、上传、提交全是 POST ——
* 于是<b>本站自己的地址必须列在这里</b>,否则"前后端同源"这个部署形态反而一定登不上。
*
* <p>这条曾经写死成 localhost/内网,生产域名 {@code https://admin.shenxue.jxit.com}
* 不在里面,表现就是"接口地址明明通、POST 一律 403",而后端日志里一个字都没有
* (403 是 CORS 处理器在进 Controller 之前回的)。详见 {@code docs/交接摘要.md} §一·四十四。
*
* <p>⚠️ 启动时把生效的白名单打出来(见下面的 info 日志):这类事故里最先要看的就是
* "到底放了谁",而白名单藏在配置里、又只在浏览器里才生效,不打印就只能猜。
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
FillProperties.Cors cors = props.cors();
List<String> patterns = cors == null
? FillProperties.Cors.DEFAULTS
: cors.patternsOrDefault();
registry.addMapping("/api/**")
.allowedOriginPatterns(
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.*.*:*"
)
.allowedOriginPatterns(patterns.toArray(String[]::new))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Trace-Id")
.allowCredentials(true)
.maxAge(3600);
log.info("CORS 白名单(/api/**):{} —— 浏览器的同源 POST 也会校验,"
+ "部署域名必须在这里,否则登录会 403「Invalid CORS request」", patterns);
}
}
@@ -167,7 +167,7 @@ public class MpH5AuthService {
if (nz(h5Mapper.countUserRole(tenantId, created.getId(), roleId)) == 0) {
h5Mapper.insertUserRole(tenantId, created.getId(), roleId);
}
log.info("H5 首次登录,已建学生账号:tenant={} uid={} openid={}***",
log.info("微信首次登录(H5 网页授权 / 小程序共用),已建学生账号:tenant={} uid={} openid={}***",
tenantId, created.getId(), openId.substring(0, Math.min(6, openId.length())));
return created;
}
@@ -14,6 +14,7 @@ import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.security.JwtService;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.tianbaotong.fill.support.TenantScope;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -31,6 +32,7 @@ import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 小程序端登录与手机号绑定(《03-接口设计》§5.3)。
@@ -123,17 +125,31 @@ public class MpLoginService {
// 扫的是"任务码"时,顺带把他在这份任务里的名册绑定带回去 ——
// 前端据此决定进"填报页"还是"先核验身份",省掉一次请求
//
// ⚠️ 这段查询**必须**放进租户上下文:roster/task 都是租户表,
// 而登录是匿名请求(还没有登录态)。不做 TenantScope 的话,
// 租户拦截器会因为"拿不到 tenantId"直接拒绝 ——
// 症状是 /mp/login 带 taskId 的 scene 一律 500,而只带 t{tid} 的码却正常
// (那条路径根本不走到这里),很难从现象定位到原因。
if (s.taskId() != null) {
CollectRoster r = rosterMapper.selectOne(Wrappers.<CollectRoster>lambdaQuery()
Map<String, Object> br = TenantScope.callAs(
s.tenantId(), user.getId(), Set.of("STUDENT"), () -> {
CollectRoster r = rosterMapper.selectOne(
Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, s.taskId())
.eq(CollectRoster::getBindUserId, user.getId())
.last("limit 1"));
if (r != null) {
if (r == null) {
return null;
}
CollectTask t = taskMapper.selectById(s.taskId());
Map<String, Object> br = new LinkedHashMap<>();
br.put("taskId", s.taskId());
br.put("taskName", t == null ? null : t.getTaskName());
br.put("rosterId", r.getId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("taskId", s.taskId());
m.put("taskName", t == null ? null : t.getTaskName());
m.put("rosterId", r.getId());
return m;
});
if (br != null) {
vo.setBoundRoster(br);
}
}
@@ -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:
@@ -51,6 +50,16 @@ fill:
# PKCS#8 私钥(单行 Base64,无 PEM 头尾)
private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0=
# ── 跨域白名单(本地开发)────────────────────────────────────────────
#
# ⚠️ 见 application.yml 里的完整说明:**同源的 POST 也会被 CORS 校验**,
# 所以"H5 由 nginx 托管、页面与接口同源"还不够 ——
# 浏览器给的 Origin 是**页面地址**(穿透域名 / localhost),必须列在这里,
# 否则 H5 的登录、上传、提交会在浏览器里 403(而后端日志里一个字都没有)。
# 这份名单对应三种打开方式:pnpm dev:h5(5174) / xt-nginx(80) / 花生壳穿透。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*,http://394nb68658le.vicp.fun}"
# ── 材料文件存储(原图 + 压缩改名后的规范件)──────────────────────────
#
# ⚠️ provider=local 只适合本地开发:**多副本部署下必然丢文件** ——
@@ -130,8 +139,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**。
# 开了它就是"任何人可登录任何学生"的后门。
@@ -1,45 +1,67 @@
# ═══════════════════════════════════════════════════════════
# 生产环境(prod)
#
# ⚠️⚠️ 这个文件里**一个密钥都没有,也永远不要加**。
# 全部由环境变量提供;缺失时 **ProdGuard 会拒绝启动**
# (见 config/ProdGuard.java —— 那 6 条都是"配错了也能启动、
# 而且运行时完全不报错"的配置)。
# ⚠️⚠️ 本文件按部署要求**直接写入了**数据库、Redis、七牛与
# 应用密钥(JWT / AES / HMAC / RSA)—— 它属于**敏感文件**:
# · 仓库可见范围必须收紧(能读它 = 能伪造 token、能解证件号)
# · 不要发到公开渠道,也不要复制到别处
# · 轮换密钥时优先用同名环境变量覆盖(环境变量优先级高于本文件)
#
# ── 必需的环境变量 ──────────────────────────────────────
# 数据库 PG_HOST PG_PORT PG_DB PG_USER PG_PASSWORD
# 缓存 REDIS_HOST REDIS_PORT REDIS_PASSWORD
# 密钥 XT_JWT_SECRET HS512,≥ 64 字节随机值
# XT_AES_KEY AES-256,base64,**用于证件号加密**
# XT_HMAC_KEY HMAC,用于开放接口签名
# XT_RSA_PRIVATE_KEY PKCS#8 单行 base64(登录密码解密)
# 存储 XT_STORAGE_PROVIDER=qiniu
# QINIU_ACCESS_KEY QINIU_SECRET_KEY QINIU_BUCKET QINIU_DOMAIN
# 微信 WX_MP_APP_ID WX_MP_APP_SECRET (小程序)
# WX_H5_APP_ID WX_H5_APP_SECRET (公众号网页授权)
# WX_H5_CALLBACK WX_H5_BASE_URL (必须是 https 正式域名)
# 其他 H5_STATIC_DIR(H5 构建产物目录)
# ── 仍然建议用环境变量提供的项 ─────────────────────────
# 微信小程序 WX_MP_APP_ID WX_MP_APP_SECRET WX_MP_PUBLIC_BASE_URL
# WX_MP_CALLBACK_TOKEN
# 其他 XT_LOG_DIR XT_UPLOAD_DIR XT_SEED_ENABLED
# XT_TENANT_DOMAIN_ENABLED XT_TENANT_DOMAIN_BASE
# XT_CORS_ALLOWED_ORIGINS(**换域名/加域名时必看**:浏览器同源 POST 也走 CORS,
# 漏了本站地址 = 登录 403,见 §一·四十四)
#
# ⚠️ 学生端**只发布微信小程序,不部署 H5**:
# 本文件没有 fill.mp-h5 段。后端 /api/v1/mp/h5/** 接口仍在,
# 未配置时会明确报错,但生产不提供 H5 页面与公众号网页授权。
#
# ⚠️ 密钥怎么生成、怎么轮换、轮换时要注意什么,见
# docs/09-部署与运维.md §3「密钥与凭据」。
# **XT_AES_KEY 一旦加密过证件号就不能直接换**(旧密文解不开且不报错)。
#
# ⚠️ 生产的工作目录里不要放 application-local.yml:
# 基础配置里有 spring.profiles.include: local,它会被加载并可能覆盖本文件。
# ═══════════════════════════════════════════════════════════
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
driver-class-name: org.postgresql.Driver
hikari:
pool-name: xt-hikari-prod
# 生产把池子放大一些:导出打包是长事务,会占住连接
minimum-idle: 10
maximum-pool-size: 40
connection-timeout: 10000
idle-timeout: 600000
max-lifetime: 1700000
# 慢 SQL / 连接未归还从应用侧记一份,方便和 PG 的
# log_min_duration_statement 对照
leak-detection-threshold: 20000
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
host: 1Panel-redis-mIkk
port: 6379
password: redis_GWnh6E
database: 0
timeout: 3s
lettuce:
pool:
max-active: 32
max-idle: 16
min-idle: 4
max-wait: 3s
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false
table: flyway_schema_history
# ⚠️⚠️ 生产**禁止 clean**。它是"清空整个库再重建",
# 在生产上执行等于把所有学校的填报数据删掉。
# 流水线里也不要用 flyway clean —— 见部署文档的回滚一节。
@@ -47,101 +69,206 @@ spring:
# 迁移文件发布后不许改(改了校验失败)。这是保护:它保证
# "生产跑的迁移"与"仓库里的迁移"逐字节一致
validate-on-migrate: true
jackson:
time-zone: Asia/Shanghai
default-property-inclusion: always
deserialization:
fail-on-unknown-properties: false
lifecycle:
# 配合 server.shutdown=graceful:等在途请求处理完再退出
timeout-per-shutdown-phase: 30s
servlet:
multipart:
max-file-size: 50MB
max-request-size: 60MB
web:
resources:
# H5 产物由部署流程放到这个目录(**不进仓库**)
static-locations: ${H5_STATIC_DIR:file:/opt/xt/h5/},classpath:/static/
# ⚠️ 后端是**纯 API 服务**:PC 管理端与学生端(微信小程序)都不由后端托管,
# 页面由 nginx/网关/微信客户端提供(见 application.yml 的 add-mappings 注释)。
# 发布时仍然要"和后端一起发" —— 前后端不同步会出现"页面调用了还不存在的接口"。
add-mappings: false
server:
port: 8080
# 优雅停机。没有它,spring.lifecycle.timeout-per-shutdown-phase 不生效
shutdown: graceful
# 生产在反向代理后面:让 Spring 认 X-Forwarded-* ,
# 否则生成的回调地址会是 http://内网IP:8080
forward-headers-strategy: framework
compression:
enabled: true
mime-types: application/json,text/html,text/css,application/javascript
min-response-size: 2048
error:
# 不要把异常栈与内部错误信息回给调用方
include-stacktrace: never
include-message: never
tomcat:
# 上传是长连接,生产放宽(学生可能从聊天记录里选一个 40MB 的文件)
connection-timeout: 120s
max-swallow-size: 100MB
threads:
max: 200
min-spare: 20
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
# 生产环境关掉 SQL 日志
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
global-config:
banner: false
db-config:
# 软删除字段名,客户明确要求就叫 deleted
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
id-type: auto
logging:
level:
root: warn
cn.tianbaotong.fill: info
# 迁移记录值得留在日志里(root=warn 会把它压掉)
org.flywaydb: info
file:
# ⚠️ 日志按天滚动并保留 30 天:审计要能回答"上周三谁看过那份证件号"
name: ${XT_LOG_DIR:/var/log/xt}/xt-server.log
name: "${XT_LOG_DIR:/var/log/xt}/xt-server.log"
logback:
rollingpolicy:
max-history: 30
max-file-size: 200MB
total-size-cap: 20GB
management:
endpoints:
web:
exposure:
# 生产只暴露健康检查(给负载均衡探活:/actuator/health)
include: health
endpoint:
health:
show-details: never
# 同时提供 /actuator/health/liveness 与 /actuator/health/readiness
probes:
enabled: true
health:
# 探活会实际探测 PG 与 Redis(依赖挂了不能还报 UP)
db:
enabled: true
redis:
enabled: true
# 生产关掉接口文档:它会把全部接口与字段暴露给公网。
# ProdGuard 会检查这一条 —— 开着就拒绝启动。
# ProdEnvCheck 会检查这一条 —— 开着就拒绝启动。
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
management:
endpoints:
web:
exposure:
# 生产只暴露健康检查(给负载均衡探活)
include: health
endpoint:
health:
show-details: never
# ── 本项目业务配置 ────────────────────────────
fill:
jwt:
secret: ${XT_JWT_SECRET}
issuer: tianbaotong-fill
# HS512 签名密钥(≥ 64 字节随机值)。环境变量优先,便于轮换
secret: "${XT_JWT_SECRET:2gtJ3sKcegiZj8SbFuLOPRA8jF6tOwLwV16QzDtLZfaLIKEq0ZHEdneJjg8EDHUJ+CH7coufso0+2qD8dkxoKQ==}"
# PC 8 小时 / 小程序 7 天(《03-接口设计》§1.2)
pc-expire-seconds: 28800
mp-expire-seconds: 604800
crypto:
aes-key: ${XT_AES_KEY}
hmac-key: ${XT_HMAC_KEY}
# AES-256(base64,32 字节),**用于证件号加密**
aes-key: "${XT_AES_KEY:N645XQtx+BDMlXTGe8YkJxUvesj9BYUPNQXLjQUiC/Q=}"
# HMAC,用于开放接口签名与证件号去重 hash
hmac-key: "${XT_HMAC_KEY:1eTpdihb/nZOh2PCpTE5o9SrLr9oGS7EqtbUM/3DNFe98CHxauViRL6beXQB8xci}"
rsa:
private-key: ${XT_RSA_PRIVATE_KEY}
# PKCS#8 私钥(单行 Base64,无 PEM 头尾),用于登录密码解密。
# 公钥由 GET /api/v1/auth/public-key 动态下发,轮换后前端无需重新构建。
private-key: "${XT_RSA_PRIVATE_KEY:MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDnWFBpGQU+ZaSBBe8XWSbSgwlOn5fOr+LH2I4lHtgoOWECKZLL8Ltf/cdRL0cc7ZkMEyE2xvidW+G1ADEGKyy/5LIWZ/+37KfM25JHPhE3v3+K9j05mPG5Yq7Qwdaf9OKkwmL/qc7fPUVyzw/uNL8+oU7JP6Vw1weIxXLjGUHwmTcwGjEXAMiu39xnhxXtUa7oywa2XnGAFZm+bt/YoLFbI04SQ9/o5oFnr7KAUBPW3V8kVejuMMQK/qCy06xhzDBJUw+jYwM3Tu9otlPykZXiVrDC7psImoaFIbvOMRh1PILOa70eHgr86++ZKyyiGqFl5bQ/5jURKfKxHrw7BagbAgMBAAECggEAGj0viEQQuVuGQCYKpf90F530y8iZIMBdPI/Iy4Kx6e9cG9vYQlj6/5pakCWIb3UEDf+2HYQ4Gtea2XsnPvey+yQhY3OHPaFSBOUF3x5gjeNNQCxz6Zj5wwOOKATxkVyRP4ur9oBtg29ryuebpcewxes+HYr3pOfr+wIeE44//DLv/PfGfSnkb4n+eILrm6H4qWfTbZnBwUv1P6F4YJvknuVytkCLtRiZRcmZ/BXD1Uldcb9zQbeGylvjqPTO1wo1Rdq8IhC9ggfJIDMznRb7CMkEN5nE1W8VZgk5adJBoHGXFukQyq7ne5p3HODPrySn5TYBoyH2lo4bDpmIkLYcAQKBgQD4t/gwRxDiWV4R06Vc3kdQ5/C0A5221m19b5d2kGld+W48URbZsZbkGjYQ5eQ+5hDzUnP29kToQUzWjJFP89G1xqep93OzQdVMmTtKakuA+3T+LudvzNmwB/nT+4StwdXQdgImCqkQfVHmBVcmv0pyJP+yHCIT+X3vv33YxNF8AQKBgQDuHiMczAswsV4E4QmPdXgA10udOr1OrGv582adDHzIpAUExPvv1iUBQ3r1lcTYbEzUUbGl+d5xnkmxgc1ulzC0lgNgCrIcCWIJ/nCo8L09lvA4Q/R239i70QldU2Hd8UEL7hzyqr8DeEjyOIwHfqkpl7nGbVI+ukfSLNbv0T2UGwKBgQDssh+WyjruPkBA1Ns9SygxfdnbZsmT8/LU+fr50asEzNi0vDLzz4ca4zrqz+1sUoR1G9XShEHFikd2suWjYhUEWd676QuJ36//KqB7TDc4s5V+ROLpyditJpoBvA5jUeGKdpPK4kBMugbT0edDr65IfJ9+QDnKKphILkO0pHH8AQKBgAqLPxbJWj1/vSAOLvQxSW0QG4MM1NElBWr+Ai82HFhuGuLJLjbCjsTtjgZN6XeD5CsKqwkkdVsmnTDFBKm+/CMi+WdJUfW33KWApNB2SIrvohd7utmAtAItEXMWfCDty+mRU6rTML4gl2pSYrD6RNdYpptIv/mTGhcOcT4RT969AoGBAIZ6boFBwyt3zRD71SMHonujfU3ldeodAtNrpXovAHs3iwNpOUvjYSOAcUSTKB+FcKcR/xk5L9PlskmumNP/DdUXgPyPocjITdSwPT5JCXffgw3p50o+gxvw52EkPPHO9M9gXFZRCE04KF0ToQoYkX267ct/erlddBrbzsVJLCHZ}"
security:
# 登录失败 5 次锁 15 分钟;连续失败 2 次后要求图形验证码(§1.6)
login-max-fail: 5
login-lock-minutes: 15
captcha-after-fails: 2
# ── 跨域白名单(**同源也要配**,见 application.yml 里的完整说明)────────
#
# ⚠️ 浏览器的同源 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看"有没有 Origin":
# 不在白名单里 → 直接 403「Invalid CORS request」→ **登录不上**。
# PC 端是 https://admin.shenxue.jxit.com,接口是同一个域名下的 /api,
# 所以这个地址**必须在**名单里(2026-10-20 生产事故的根因)。
#
# 三个来源分别是:主域名(统一入口)/ PC 管理端 / 所有子域(将来的 t1、t2 租户站点)。
# 换域名或加穿透域名时:改这里,或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com}"
upload:
# 单文件上限 50MB(PRD §10.1)
max-file-size-mb: 50
# 本地落盘目录。七牛只存学生材料;导出包等临时文件仍然写本地盘,
# 所以这个目录在生产上必须可写、且不要放在会被清理的 /tmp 下。
dir: "${XT_UPLOAD_DIR:./data/uploads}"
storage:
# 生产必须 qiniu(local 在多副本下必然丢文件,ProdGuard 会拦)
# 生产必须 qiniu(local 在多副本下必然丢文件,ProdEnvCheck 会拦)
provider: qiniu
prefix: xiaotutongxue
keep-raw: ${XT_STORAGE_KEEP_RAW:false}
self-check-on-startup: ${XT_STORAGE_SELF_CHECK:true}
# bucket 内的目录前缀 —— 本项目的文件都放在 shenxue/ 下
prefix: shenxue
# 默认只保留压缩改名后的规范件(客户确认过,不需要原图当凭证)
keep-raw: "${XT_STORAGE_KEEP_RAW:false}"
# 启动时做一次真实的 写入 → 存在 → 读回 → 删除 自检。
# 换存储 / 换密钥 / 换空间之后值得打开:它能在启动时就把
# "密钥错/空间名错/没有写权限"暴露出来,而不是等第一个学生上传时才失败。
self-check-on-startup: "${XT_STORAGE_SELF_CHECK:true}"
qiniu:
access-key: ${QINIU_ACCESS_KEY}
secret-key: ${QINIU_SECRET_KEY}
bucket: ${QINIU_BUCKET}
domain: ${QINIU_DOMAIN}
region: ${QINIU_REGION:auto}
access-key: "t1PIPGcvBY9lJVXFZFb48maTQsGGhvLsR5QQlNq0"
secret-key: "KGooFdF5eCLdCIMCOD6x5ofMzu4vYE17T5Mvp9qC"
bucket: "file-jxit-private"
domain: "https://file.jxit.com"
# auto = 让 SDK 自己查空间在哪个区(推荐)。
# 「华东-浙江」在新旧控制台对应 z0 与 cn-east-2 两个 id,写错了的症状是上传超时。
region: "${QINIU_REGION:auto}"
# ⚠️ 空间是**私有**的 → 下载走签名链接(带有效期)。
# 因此 URL 不能入库、不能发出去让人明天再打开,每次要用现取。
signed-url-expire-seconds: 1800
# ⚠️ 生产默认关闭初始数据装配,避免默认口令(platform / Platform@123)进生产。
# 首次部署需要建平台管理员时,用 XT_SEED_ENABLED=true 启动一次;
# 它会同时建一个 demo 示范租户,登录后请改掉默认密码并清理 demo 租户,
# 然后把这里改回 false 再重启。
seed:
enabled: "${XT_SEED_ENABLED:false}"
mini-program:
app-id: ${WX_MP_APP_ID}
app-secret: ${WX_MP_APP_SECRET}
app-id: "${WX_MP_APP_ID:wxf01d3fa60f87f0b7}"
app-secret: "${WX_MP_APP_SECRET:d208f690aab50ec7c5c63e6bbeca6a27}"
# ⚠️ 正式发布后用 release。用 trial/develop 生成的码扫开会提示
# 「小程序未发布」,而**接口不报错** —— 只会让人以为码生成得不对
env-version: ${WX_MP_ENV_VERSION:release}
env-version: "${WX_MP_ENV_VERSION:release}"
page: pages/index/index
public-base-url: ${WX_MP_PUBLIC_BASE_URL}
# 图片对外前缀。留空时 qrcodeUrl 返回相对路径(/api/v1/qrcodes/xxx.jpg),
# 由前端用当前域名去拼;前后端同域时这样最省事。
public-base-url: "${WX_MP_PUBLIC_BASE_URL:}"
code-cache-days: 30
# 微信「服务器配置」里的 Token(**不是** appSecret),用于消息推送回调验签。
# 下面已生成一个随机值;要与微信后台填的**完全一致**,否则回调验签失败。
# 不用消息推送回调时可以留空 —— 回调接口会明确拒绝,而不是跳过验签。
callback-token: "${WX_MP_CALLBACK_TOKEN:7c6f88f58fc2a60eefb0b96094d6a935}"
# 微信订阅消息模板 id(形如 ZG8k...):模板码 → 模板 id。
# 没配置的模板码会自动降级为站内信,不会报错;不要在没建模板时填占位值。
# 可用模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY / NOTIFY_UNSUBMITTED
templates: {}
mp-h5:
app-id: ${WX_H5_APP_ID}
app-secret: ${WX_H5_APP_SECRET}
callback-url: ${WX_H5_CALLBACK}
h5-base-url: ${WX_H5_BASE_URL}
# ⚠️⚠️ 生产**必须** false。开了它就是"任何人可登录任何学生"。
# 这是 ProdGuard 的第一条检查,写成 true 直接拒绝启动。
stub-enabled: false
# 不配置 mp-h5:学生端只发布微信小程序,不做 H5 网页授权。
# 后端 /api/v1/mp/h5/** 接口仍在,未配置时会明确报错;
# 生产不提供 H5 页面,也不再需要公众号的 appid / appsecret。
# 租户域名(用子域前缀区分租户):t{tenantId}.{base},如 t1.shenxue.jxit.com。
# 客户 2026-09-29 决定先不做,所以默认关闭;能力本身保留,
# 将来要有正式域名 + 泛域名解析 + 泛域名证书之后才打开。
tenant-domain:
enabled: ${XT_TENANT_DOMAIN_ENABLED:false}
base: ${XT_TENANT_DOMAIN_BASE:}
enabled: "${XT_TENANT_DOMAIN_ENABLED:false}"
base: "${XT_TENANT_DOMAIN_BASE:shenxue.jxit.com}"
scheme: https
@@ -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:
@@ -119,3 +133,19 @@ fill:
upload:
# 单文件上限 50MB(PRD §10.1)
max-file-size-mb: 50
# ── 跨域白名单(Spring 的 allowedOriginPatterns 语义)────────────
#
# ⚠️⚠️ **"前后端同源就不用配 CORS"是错的**,这条已经用一次生产事故验证过:
# 浏览器的 CORS 只约束跨源请求,但它**给同源的 POST/PUT/DELETE 也带上 Origin 头**;
# 而 Spring MVC 只看"请求里有没有 Origin"、不看"是不是真跨源" ——
# 带了 Origin 又不在白名单里,就在进 Controller 之前直接 403「Invalid CORS request」。
# 登录/上传/提交全是 POST,所以**本站自己的地址也必须列在这儿**。
#
# ⚠️ 它只在**浏览器**里复现:e2e 脚本是 Python,不带 Origin 头 → 1700+ 条断言全绿也发现不了
# (2026-10-20 生产登录 403 的原因,见 docs/交接摘要.md §一·四十四)。
#
# ⚠️ 换域名、加域名、加穿透域名 → 改这里或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。
# 启动日志会打印生效的白名单,排障先看那一行。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*}"
@@ -0,0 +1,83 @@
package cn.tianbaotong.fill.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.PropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.cors.CorsConfiguration;
import java.io.IOException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* CORS 白名单的最小单测(2026-10-20 生产事故的护栏,见 {@code docs/交接摘要.md} §一·四十四)。
*
* <p>为什么这条要写成单测:它<b>只在浏览器里才会发生</b> ——
* 浏览器的同源 POST 也会带 {@code Origin},而 Spring 只看"有没有 Origin"。
* 所有 e2e 脚本都是 Python(不带 Origin),所以 1712 条断言全绿也发现不了
* "生产域名没进白名单"。能自动化的只有两件事,都放在这里:
*
* <ol>
* <li><b>生产配置里确实有这份名单,且它覆盖管理端域名</b>("声明了但没接上"的另一面:
* 名单写在注释里、代码里却读不到);</li>
* <li><b>通配符真的能匹配子域,而陌生源仍然被拒</b>(既不能漏,也不能变成"全放开")。</li>
* </ol>
*/
class CorsWhitelistTest {
@Test
void 生产白名单必须覆盖管理端域名与租户子域() throws IOException {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOriginPatterns(生产白名单());
// PC 管理端的真实 Origin(同源部署下,登录/上传/提交的 POST 都会带它)
assertEquals("https://admin.shenxue.jxit.com",
cfg.checkOrigin("https://admin.shenxue.jxit.com"));
// 主域名(统一入口)
assertEquals("https://shenxue.jxit.com",
cfg.checkOrigin("https://shenxue.jxit.com"));
// 将来的租户站点(t1./t12.)走的是同一条 POST 链路
assertEquals("https://t1.shenxue.jxit.com",
cfg.checkOrigin("https://t1.shenxue.jxit.com"));
}
@Test
void 陌生源仍然被拒_白名单不是全放开() throws IOException {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOriginPatterns(生产白名单());
assertNull(cfg.checkOrigin("https://evil.example.com"));
// 后缀拼骗:admin.shenxue.jxit.com.evil.com 不是我们的子域
assertNull(cfg.checkOrigin("https://admin.shenxue.jxit.com.evil.com"));
// 生产是 https,http 版本不放开(避免"降级到 http 的页面也能调接口")
assertNull(cfg.checkOrigin("http://admin.shenxue.jxit.com"));
}
@Test
void 没配时退回本机开发值_不会是空名单() {
assertTrue(new FillProperties.Cors(null).patternsOrDefault().contains("http://localhost:*"));
assertTrue(new FillProperties.Cors(List.of()).patternsOrDefault()
.contains("http://127.0.0.1:*"));
}
/** 把 {@code application-prod.yml} 里的 {@code fill.cors} 真绑一遍(保证"配了就能读到")。 */
private static List<String> 生产白名单() throws IOException {
StandardEnvironment env = new StandardEnvironment();
List<PropertySource<?>> sources = new YamlPropertySourceLoader()
.load("application-prod.yml", new ClassPathResource("application-prod.yml"));
sources.forEach(env.getPropertySources()::addFirst);
FillProperties.Cors cors = Binder.get(env)
.bind("fill.cors", FillProperties.Cors.class)
.orElseThrow(() -> new AssertionError(
"application-prod.yml 里读不到 fill.cors.allowed-origins —— "
+ "同源 POST 会 403「Invalid CORS request」"));
return cors.patternsOrDefault();
}
}
+11
View File
@@ -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
+35
View File
@@ -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:
+95
View File
@@ -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;
}
}
+80
View File
@@ -0,0 +1,80 @@
# ═══════════════════════════════════════════════════════════
# 生产环境变量模板(prod)
#
# 用法:
# cp deploy/prod.env.example deploy/prod.env
# chmod 600 deploy/prod.env
# # 按下面命令生成四个密钥并填进去,再填 PG / REDIS / QINIU / WX
# set -a; . ./deploy/prod.env; set +a
# java -jar xt-server.jar --spring.profiles.active=prod
#
# 1Panel:把每一条粘到「应用 → 环境变量」,**值不要带引号**。
#
# ⚠️ deploy/prod.env 已 gitignore。不要把真值写回本文件。
# ⚠️ 2026-10 曾把 1Panel 的库/Redis 密码直接写进 application-prod.yml
# 并提交。那些值应视为已泄露:要么轮换,要么确认仓库只有可信的人能看。
# ═══════════════════════════════════════════════════════════
# ── PostgreSQL ────────────────────────────────────────────
# 1Panel 里通常就是容器名;换成你的实例地址
PG_HOST=
PG_PORT=5432
PG_DB=
PG_USER=
PG_PASSWORD=
# ── Redis ─────────────────────────────────────────────────
REDIS_HOST=
REDIS_PORT=6379
REDIS_PASSWORD=
# ── 四个密钥(必填,ProdEnvCheck 缺一个就拒绝启动)─────────
#
# XT_JWT_SECRET openssl rand -base64 64 | tr -d '\n'
# XT_AES_KEY openssl rand -base64 32 | tr -d '\n'
# XT_HMAC_KEY openssl rand -base64 48 | tr -d '\n'
# XT_RSA_PRIVATE_KEY openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out prod-rsa.pem
# openssl pkcs8 -topk8 -nocrypt -in prod-rsa.pem -outform DER | base64 -w0
#
# ⚠️ XT_AES_KEY 一旦用来加密过证件号就不能直接换(旧密文解不开且不报错)。
XT_JWT_SECRET=
XT_AES_KEY=
XT_HMAC_KEY=
XT_RSA_PRIVATE_KEY=
# ── 对象存储(生产必须 qiniu)──────────────────────────────
XT_STORAGE_PROVIDER=qiniu
QINIU_ACCESS_KEY=
QINIU_SECRET_KEY=
QINIU_BUCKET=
QINIU_DOMAIN=
QINIU_REGION=auto
# 启动时真的走一遍 写入→存在→读回→删除;密钥/空间配错会当场暴露
XT_STORAGE_SELF_CHECK=true
# false = 只保留压缩后的规范件(默认)
XT_STORAGE_KEEP_RAW=false
# ── 微信:小程序 ──────────────────────────────────────────
WX_MP_APP_ID=
WX_MP_APP_SECRET=
WX_MP_PUBLIC_BASE_URL=
WX_MP_ENV_VERSION=release
# ── 微信:公众号网页授权(H5)─────────────────────────────
# 回调与页面都必须是 https 正式域名,且与 H5 站点同源
WX_H5_APP_ID=
WX_H5_APP_SECRET=
WX_H5_CALLBACK=
WX_H5_BASE_URL=
# ── 其他 ─────────────────────────────────────────────────
XT_LOG_DIR=/var/log/xt
# 按域名区分单位时才打开
XT_TENANT_DOMAIN_ENABLED=false
XT_TENANT_DOMAIN_BASE=
# CORS 白名单(逗号分隔,Spring allowedOriginPatterns 语义)。
# ⚠️ **本站自己的地址也必须列上**:浏览器给同源的 POST 也带 Origin,
# 不在名单里 → 登录/上传一律 403「Invalid CORS request」(2026-10-20 生产事故)。
# PC 前端在 https://admin.shenxue.jxit.com,就是下面这行的默认值
# (application-prod.yml 里已经写了同样的默认,这里只是让它可运维覆盖)。
XT_CORS_ALLOWED_ORIGINS=https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com
+1
View File
@@ -70,6 +70,7 @@
| 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL |
| 2026-10-09 | **验收走查后的四项修正** | ① **去掉 3 条无用菜单**(名单管理 / 表单设计器 / 材料规则)—— 功能都在「收集任务」行内入口,菜单里那一条只是指路;后端菜单键同步删除;② **修掉班主任点工作台必弹 403**(工作台无条件调了要 `org:view` / `progress:viewAll` 的两个接口,班主任都没这两个权限)→ 改为按权限决定发不发请求;③ **新增请求头 `X-Tenant-Id`(§1.2)**:平台运营的「组织架构 / 账号管理 / 工作台」原先拿到的是**所有租户混在一起**的数据,现在必须先在页面上选定学校;④ **修掉开放接口的"推送重试"从未生效**(F-112:定时扫描缺 `@InterceptorIgnore`,异常被 warn 吞掉;E2E 补了"真的重试了"的断言)。详见 `交接摘要` §一·三十三 |
| 2026-10-09 | **二期「角色与授权」上线**(FR-205) | 内置角色**只读** + 自定义角色的权限码 / 菜单可配:新增 **8 个接口**(`docs/03` §4.12 / §5.10,契约总数 121 → **129**)、**V18 迁移**(`sys_role.user_type` + `sys_role_menu`)、错误码 `40045`;权限判定从"零 IO"改为"内置按代码 / 自定义按库 + Redis 缓存";新增每日**内置角色授权同步**(修掉代码与库的静默漂移);PC 新增「角色与授权」页(原型 ⑪)→ **`ready:false` 的菜单归零**;账号页的可选角色 / 用户类型 / 角色名全部改读库(删掉前端三份写死的镜像表)。验证:新增 `scripts/e2e-m10-role.py` **77 条**、`verify-all --with-e2e` 全绿。**「数据范围可配」仍是下一批**(见 `docs/05` M8 的说明) |
| 2026-10-20 | **修生产登录 403「Invalid CORS request」** | 生产 `POST /api/v1/auth/login` 返回 403:**浏览器的同源 POST 也带 `Origin`,而 Spring 只看"有没有 Origin"**,`WebConfig` 的 CORS 白名单却**写死**成 localhost/内网(注释声称读 `fill.cors.allowed-origins`,该属性其实不存在)。改为配置驱动:`FillProperties.Cors` + 各 profile 的 `fill.cors.allowed-origins`(可用 `XT_CORS_ALLOWED_ORIGINS` 覆盖),生产默认含 `admin.shenxue.jxit.com` 与 `*.shenxue.jxit.com`,启动日志打印生效名单。**接口 / 库表 / 契约均无变化**。`docs/09` 新增 §2.4:本站地址必须在白名单 + 反代必须传 `Host`/`X-Real-IP`/`X-Forwarded-For`/`X-Forwarded-Proto`(缺了登录日志 IP 全是 127.0.0.1)。详见 `交接摘要` §一·四十四 |
---
+79 -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,63 @@ curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 /
⚠️ **滚动发布期间会有两个版本同时在跑**,所以:
- **迁移必须是"向后兼容"的**(只加列/加表,不改列类型、不删列)——
否则旧实例会在新库上出错。改列要走"两次发布":这次加新列并双写,下次删旧列。
- **H5 静态产物要一起发**:它是后端托管的,前后端不同步会出现
"页面调用了还不存在的接口"。
- **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 处理。
---
@@ -186,7 +251,11 @@ 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,**换网关时别漏这一条** |
| 浏览器里登录/上传 **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"}` | 见下 |
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |
+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 —— 两者不是同一个人,混用会得到「门禁说你不在名册里」。
+167 -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,164 @@ 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 校验 | ⚠️ **这条原来写的是"同源还省掉 CORS",是错的**:浏览器给同源的 POST 也带 `Origin`,Spring 照样校验白名单 —— 生产登录 403 就是这么来的,见 §一·四十四 |
所以 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 / 证书**:本地由穿透层负责;生产形态由部署侧决定。
---
## §一·四十四 生产登录 403「Invalid CORS request」(2026-10-20,生产事故)
### 一、现象
- `https://admin.shenxue.jxit.com/api/v1/auth/login` 返回 **403**,响应体是一行
`Invalid CORS request`,有 `X-Trace-Id`,但**后端业务日志里一个字都没有**。
- 同一条请求用 curl(**不带 `Origin`**)打同一个地址 → 200,正常返回业务校验 `40001`。
→ **只有浏览器复现**。
- 前端与接口是**同源**的(admin 站点把 `/api` 反代到 `127.0.0.1:8090`)。
按"同源就不用管 CORS"的直觉,这不该发生 —— 而它偏偏发生了。
### 二、根因(两层,都要记住)
1. **浏览器的 CORS 只约束跨源,但它给同源的 POST/PUT/DELETE 也带 `Origin` 头**(GET 不带)。
Spring MVC 的判据是"请求里有没有 Origin",**不是**"是不是真跨源":
`DefaultCorsProcessor#processRequest`(Spring Framework 7.0.9,已反编译确认)
只有 `isCorsRequest → checkOrigin → 不在白名单就 reject`,**没有同源短路**。
于是"同源部署"的登录/上传/提交(全是 POST)照样被 CORS 拦下来。
2. `WebConfig` 的白名单**写死**成 `http://localhost:*` / `http://127.0.0.1:*` / `http://192.168.*.*:*`,
而它的注释写着"生产配置见 `application-prod.yml` 的 `fill.cors.allowed-origins`" ——
**那个属性根本不存在**(`FillProperties` 里没有 `cors`,也没有任何地方读它)。
典型的"声明了但没接上":编译、lint、单测都不会红,`check-wiring.py` 也不覆盖这一类。
### 三、为什么 1712 条 e2e 断言全绿还是漏了
e2e 脚本是 Python `requests`,**不带 `Origin` 头** → 永远走不到 CORS 分支。
本地浏览器验的是 `localhost:5173` / `localhost:80`,正好命中写死的 dev 白名单 → 也绿。
**唯一能暴露它的组合是"浏览器 + 正式域名"**,而那个组合只在生产存在。
> 一般化:**"只在浏览器里发生的行为"(Origin、Referer、预检、Cookie 属性)用脚本测不到**,
> 这类东西必须写进部署检查单,或者用真实浏览器走一遍。
### 四、改了什么
| 位置 | 改动 |
|---|---|
| `FillProperties` | 新增 `Cors.allowedOrigins`(+ `patternsOrDefault()` 兜底) |
| `WebConfig` | 白名单改为**读配置**;启动时 `log.info` 打印生效名单(排障第一眼看它) |
| `application.yml` | 新增 `fill.cors.allowed-origins`:默认 dev 值,可用 `XT_CORS_ALLOWED_ORIGINS` 覆盖 |
| `application-dev.yml` | 补上穿透域名 `http://394nb68658le.vicp.fun` —— **H5 走穿透时 POST 同样会被拦,之前也是坏的** |
| `application-prod.yml` | 默认 `https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com` |
| `deploy/prod.env.example`、`docs/09` §2.4 / §5 | 同步:写清"本站地址必须在白名单"+反代必须传的四个头 |
### 五、验证(本地起 jar,2026-10-20)
| 场景 | 结果 |
|---|---|
| dev 默认白名单 + `Origin: http://localhost:5173` | 200(进到业务校验) |
| dev 默认白名单 + `Origin: https://admin.shenxue.jxit.com` | **403 `Invalid CORS request`** —— 本地 1:1 复现了生产现象 |
| 以生产白名单启动 + `https://admin.shenxue.jxit.com` / `https://shenxue.jxit.com` / `https://t1.shenxue.jxit.com` / `https://t12.shenxue.jxit.com` | 全部 200 |
| `http://admin...` / `https://evil.example.com` / `https://admin.shenxue.jxit.com.evil.com` | 全部 403(既没放开,也没有后缀绕过) |
| OPTIONS 预检(允许的 Origin) | 200 + `Access-Control-Allow-Origin` 回显;不允许的仍 403 |
### 六、部署时要做的两件事
1. **重新打包 + 重启后端**(改的是 Java 与 yml,不是热加载)。
若临时不想发版:nginx 侧加一句 `proxy_set_header Origin "";` 把 Origin 抹掉即可止血
(同源请求本来也不需要它)—— **只当止血,正式修复仍然要发版**。
2. **nginx 的 `/api` location 补四个头**:`Host` / `X-Real-IP` / `X-Forwarded-For` /
`X-Forwarded-Proto`。不补的表现是**登录日志的 IP 全是 `127.0.0.1`**(审计数据作废)、
`Host` 相关逻辑(租户域名)失效、重定向用错协议。顺带确认
`client_max_body_size 50m` 与 300s 超时(见 `docs/09` §2.4)。
### 七、仍然没做的(有意)
1. **没有做"同源自动放行"**:Spring Framework 7 已移除 servlet 版 `CorsFilter`,
要动态放行只能自己写过滤器;而生产的反代当前没传 `Host`,后端也判断不出
"请求来自哪个公网源"。→ 结论:**白名单必须显式包含部署域名**,
这一条写进了 `docs/09` §2.4、`application.yml` 的注释与启动日志。
2. **没有加 e2e 断言**:Python 侧不带 Origin,加了只能测"白名单内/外",
测不到"生产域名有没有配" —— 那属于部署检查单(已写进 `docs/09` §2.4)。
+9
View File
@@ -0,0 +1,9 @@
# 微信小程序生产包(`pnpm build:mp-weixin`)的环境变量
#
# ⚠️ 必须是 **HTTPS 正式域名**,并且要在微信公众平台
# 「开发管理 → 开发设置 → 服务器域名」里配成 **request 合法域名**。
# 否则真机上所有请求都会以 `request:fail url not in domain list` 失败。
#
# ⚠️ 这里指向后端 API 的地址(PC 管理端 .env.production 的 VITE_API_BASE_URL 同源),
# 不是 mini program 的 appid/secret —— 那两项在后端 application-prod.yml 里。
VITE_MP_API_BASE = 'https://admin.shenxue.jxit.com'
+16 -5
View File
@@ -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 一次再看",改一行要等一次完整构建。
@@ -43,7 +43,18 @@ export const API_BASE_URL =
// #endif
// #ifndef H5
export const API_BASE_URL = 'http://localhost:8080'
/**
* 小程序:没有"同域"这个概念,必须写绝对地址。
*
* 正式包用构建变量 `VITE_MP_API_BASE` 指定(见 `.env.production`):
* 必须是 **HTTPS 正式域名**,并且要在微信公众平台
* 「开发管理 → 开发设置 → 服务器域名」里配成 **request 合法域名**。
*
* 不指定时回退到本机 8080 —— 只用于微信开发者工具调试
* (需要在「详情 → 本地设置」里勾"不校验合法域名")。
*/
const MP_API_BASE = (import.meta.env?.VITE_MP_API_BASE ?? '') as string
export const API_BASE_URL = MP_API_BASE !== '' ? MP_API_BASE : 'http://localhost:8080'
// #endif
/** 主题色(与 PC 管理端同一套腾讯云 TDesign 色板,两端视觉要一致)。 */
+63 -12
View File
@@ -44,6 +44,7 @@
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
import { getToken, loginByWechat } from '@/utils/auth'
/**
* 扫码落地页。
@@ -89,15 +90,6 @@ const isError = ref(false)
const loading = ref(false)
const title = ref('材料填报')
/** 取登录态(授权回调把 token 放在 URL fragment 里,落地页存下来的)。 */
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
/**
* 当前 URL 里的 scene(授权回调回来时在 hash 路由的 query 里)。
*
@@ -158,7 +150,7 @@ async function load() {
// ★ 没有 scene 但有登录态 → 说明他是**回来看进度的**,不是扫码进来的。
// 以前这种情况一律报错"请扫描二维码进入",于是学生一旦找不到老师发的那条消息
// 就再也进不来了(通知渠道还没做,他只能靠历史消息)。现在直接送去「我的填报」。
if (token()) {
if (getToken()) {
uni.redirectTo({ url: '/pages/tasks/tasks' })
return
}
@@ -188,7 +180,7 @@ async function load() {
}
}
function start() {
async function start() {
const s = scene.value || sceneFromUrl()
if (!s) {
uni.showModal({
@@ -198,10 +190,69 @@ function start() {
})
return
}
// #ifdef MP-WEIXIN
// 扫码进来时 bootstrap 已经登录过;这里再兜一次:网络抖动、第一次登录失败后,
// 学生点"开始填报"时还有一次机会,而不是带着空 token 去核验页吃 40101。
if (!getToken()) {
try {
await loginByWechat(s)
} catch (e) {
uni.showModal({
title: '登录失败',
content: e instanceof Error ? e.message : '微信登录失败,请稍后重试',
showCancel: false
})
return
}
}
// #endif
// 带上 scene —— 见 sceneFromUrl 的说明(H5 跳转不保留 URL 参数)
uni.navigateTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(s)}` })
}
/**
* 落地页启动。
*
* - 小程序:带 scene 进来就先 `wx.login` 换一次 token(token 7 天有效,
* 旧 token 过期后直接去核验页只会得到 40101);
* - 再读 scene-context,把"哪个学校 / 什么任务 / 能不能填"显示出来。
*
* 登录放在读任务信息**之前**:登录只需要一次 code 交换,很快;
* 这样页面渲染出「开始填报」时 token 一定已经落盘,不会出现
* "学生点得比登录快"导致同一页里发两次 `wx.login`。
* 登录失败也不挡任务信息展示 —— 点「开始填报」时会再试一次并明确报错。
*/
async function bootstrap() {
// #ifdef MP-WEIXIN
let loginError = ''
if (scene.value) {
loading.value = true
try {
// 每次带 scene 进来都换一次 token:小程序 token 有效期 7 天,
// 拿过期 token 去核验页只会得到 40101。登录本身很便宜(一次 code 换 openid)。
await loginByWechat(scene.value)
} catch (e) {
// 已有 token 时登录失败不致命(可能只是网络抖动),继续用旧 token;
// 连 token 都没有才把错误显示出来。
if (!getToken()) {
loginError = e instanceof Error ? e.message : '微信登录失败'
}
}
}
// #endif
await load()
// #ifdef MP-WEIXIN
if (loginError) {
isError.value = true
message.value = loginError
}
// #endif
}
onLoad((options) => {
rawScene.value = options?.scene || options?.q || ''
scene.value = pickScene(options)
@@ -225,7 +276,7 @@ onLoad((options) => {
}
}
load()
void bootstrap()
})
// 下拉刷新:任务被截止/恢复后,学生不必杀进程重进
+142
View File
@@ -0,0 +1,142 @@
import { request } from '@/utils/request'
/**
* 微信小程序登录(契约 §5.3 的 `/mp/login`)。
*
* <h3>为什么必须由前端调一次</h3>
*
* `wx.login()` 拿到的 `code` **一次有效、几分钟过期**,只能由小程序端现取。
* 后端拿它去 `jscode2session` 换 openid,再按 scene 里的租户找/建学生账号。
* 没有这一步就没有 token —— 后面所有接口都会 40101。
*
* <h3>与 H5 网页授权的区别</h3>
*
* H5 的 token 是后端 302 回来时放在 URL 里的(见 pages/index/index.vue 的
* `options.token`);小程序没有这个回调,必须主动调接口换。
* 生产已不部署 H5,学生端只走这一条路。
*/
/** 与后端 MpLoginService.LoginVO 对齐(字段名不要改,后端直接按 JSON 返回)。 */
export interface MpLoginResult {
token: string
expireSeconds: number
needBindPhone: boolean
user: {
id: number
realName: string | null
userType: number
}
/** 扫的是任务码、且这个学生已经绑定名册时才有。 */
boundRoster?: {
taskId: number
taskName: string | null
rosterId: number
} | null
}
const TOKEN_KEY = 'token'
const NEED_BIND_PHONE_KEY = 'needBindPhone'
const USER_KEY = 'mpUser'
const BOUND_ROSTER_KEY = 'boundRoster'
/** 登录态(H5 与小程序共用同一个 storage key)。 */
export function getToken(): string {
try {
return uni.getStorageSync(TOKEN_KEY) || ''
} catch {
return ''
}
}
/** 清掉登录态(切换学生 / 排查登录问题时用)。 */
export function clearToken(): void {
try {
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(NEED_BIND_PHONE_KEY)
uni.removeStorageSync(USER_KEY)
uni.removeStorageSync(BOUND_ROSTER_KEY)
} catch {
// 存储不可用时本来也没有登录态可清
}
}
/** 后端提示"手机号还没绑"。页面上没有绑定入口时先存下来,不影响登录。 */
export function getNeedBindPhone(): boolean {
try {
return uni.getStorageSync(NEED_BIND_PHONE_KEY) === true
} catch {
return false
}
}
function saveLoginResult(r: MpLoginResult): void {
try {
uni.setStorageSync(TOKEN_KEY, r.token)
uni.setStorageSync(NEED_BIND_PHONE_KEY, r.needBindPhone === true)
if (r.user) {
uni.setStorageSync(USER_KEY, r.user)
}
if (r.boundRoster) {
uni.setStorageSync(BOUND_ROSTER_KEY, r.boundRoster)
}
} catch {
// ⚠️ 存不进去也不该让登录失败:本次页面还能用返回的 token,
// 只是下次请求会重新走一次登录。
}
}
/**
* 小程序登录:`wx.login` 拿 code → `POST /api/v1/mp/login` 换 token。
*
* ⚠️ `scene` 不能丢:它决定"你属于哪个学校"(统一应用 + scene 识别租户)。
* 缺失时后端明确报错,不会去猜一个租户 —— 猜错就是 A 校学生登进 B 校。
*/
export function loginByWechat(scene: string): Promise<MpLoginResult> {
// #ifdef MP-WEIXIN
return new Promise<MpLoginResult>((resolve, reject) => {
if (!scene) {
reject(new Error('缺少 scene,无法确定学校;请重新扫描班主任发的二维码'))
return
}
uni.login({
provider: 'weixin',
success: (res) => {
if (!res.code) {
reject(new Error('微信登录失败:没有拿到 code'))
return
}
request<MpLoginResult>({
url: '/api/v1/mp/login',
method: 'POST',
data: { code: res.code, scene }
})
.then((data) => {
if (!data || !data.token) {
reject(new Error('登录失败:后端没有返回 token'))
return
}
saveLoginResult(data)
resolve(data)
})
.catch(reject)
},
fail: (err) => {
reject(new Error(`微信登录失败:${err.errMsg || '未知原因'}`))
}
})
})
// #endif
// #ifndef MP-WEIXIN
return Promise.reject(new Error('当前环境不是微信小程序,无法使用微信登录'))
// #endif
}
/** 已有 token 就直接用;没有就走一次微信登录。 */
export async function ensureLogin(scene: string): Promise<string> {
const existing = getToken()
if (existing) {
return existing
}
const result = await loginByWechat(scene)
return result.token
}
+2 -1
View File
@@ -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)。
*
* 后果不只是"麻烦一点":**改一行要等一次完整构建**,而学生端正是改得最多的地方
* (表单、上传、核验页),反馈慢会直接改变开发方式 ——
+1 -1
View File
@@ -2,5 +2,5 @@
# 生产走同源反向代理
VITE_API_PREFIX = '/api/v1'
VITE_API_BASE_URL = 'https://api.tianbaotong.cn'
VITE_API_BASE_URL = 'https://admin.shenxue.jxit.com'
VITE_BASE = '/'
+2 -2
View File
@@ -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(" ⚠️ 名册行必须**未绑定**,由界面自己走门禁 —— "