Compare commits
13 Commits
7df078e0ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 50ad577b29 | |||
| 739c0a34a4 | |||
| 1e71c164af | |||
| 17c92b29c3 | |||
| 2ff17cd0c9 | |||
| a9b2dd2a16 | |||
| 4e68d0e5da | |||
| 623e6393d5 | |||
| e94e76ba0f | |||
| 31e7483464 | |||
| 624a05aa66 | |||
| b516a7c47b | |||
| 9f6d26c650 |
@@ -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
@@ -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,12 +89,16 @@ 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` 重复导出),不是新引入的。
|
||||
⚠️ PC 端不同:重搭后 `pnpm typecheck` 是 **0 错** —— 那边一有报错就是新引入的
|
||||
- 当前节奏:**先做 H5**(可自动化测试、不依赖小程序资质);小程序端代码同步维护
|
||||
- ⚠️ **小程序端目前学生用不了(F-130,P0)**:前端**从来没有调用过 `/mp/login`**(token 永远是空 → 门禁 40101),
|
||||
而后端那条路也不通 —— `scene` 带 taskId 时 `/mp/login` 必然 500(`MpLoginService.java:127` 匿名查租户表被租户拦截器拒)。
|
||||
实测细节见 `docs/11` §5.4 / §6。**除登录外整条链路(落地页/门禁/表单/压缩上传/红黄校验/提交)都是好的**
|
||||
—— 用开发者工具自动化(`wmp_*` 工具族)可以跑,`wx.login()` 在页面上下文里能拿到**真 code**
|
||||
|
||||
## 三、怎么验证(本仓库最不显然的一点)
|
||||
|
||||
@@ -101,8 +109,9 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
||||
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
|
||||
```
|
||||
|
||||
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**37 个脚本 / 1595 条断言**,以最近一次
|
||||
`verify-all --with-e2e` 的输出为准;2026-10 需求收敛删掉了 `e2e-m2-join.py` 与 `e2e-m7-cleanup.py`),
|
||||
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1712 条断言**,以最近一次
|
||||
`verify-all --with-e2e` 的输出为准;2026-10 需求收敛删掉了 `e2e-m2-join.py` 与 `e2e-m7-cleanup.py`,
|
||||
之后又补了 `e2e-m3-flow.py` —— 填报链路的覆盖缺口,见 `docs/11-填报流程端到端测试计划.md`),
|
||||
或一条命令 `python scripts/verify-all.py --with-e2e`
|
||||
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
||||
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
|
||||
@@ -122,7 +131,10 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
||||
**模板里的 `<n-*>` 组件有没有漏 import**(第 ⑧ 项 —— 漏了会被当未知元素渲染,样式整块丢、
|
||||
编译与 lint 都不红)、
|
||||
**`.svg` 资产是不是合法 XML**(第 ⑨ 项 —— SVG 注释里出现连续两个减号就 not well-formed,
|
||||
图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次))。
|
||||
图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次)、
|
||||
**后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案**(第 ⑩ 项 —— 缺一个文案,
|
||||
日志页会**原样回显英文码**;做这一项时才发现 `AUTO_CLOSE` 是字符串字面量写的,
|
||||
所以"提成常量"是让这个检查真正生效的前提)。
|
||||
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
|
||||
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)。
|
||||
⚠️ **检查器自己也会失效**:PC 端重搭后权限码的写法从 `permissions.includes` / `v-hasPerm`
|
||||
@@ -156,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):
|
||||
@@ -176,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`
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| **产品 / 需求** | `数据信息填报系统PRD.md` → `数据信息填报系统PRD-功能逻辑卷.md` | 前者含库表接口,后者是纯功能逻辑 |
|
||||
| **后端开发** | `03-接口设计.md` → `04-核心模块设计说明.md` → `db/schema-postgresql.sql` | 接口收发 → 引擎怎么做 → 建表 |
|
||||
| **前端开发** | `prototype/` → `03-接口设计.md` | 原型是视觉与交互的**唯一基准** |
|
||||
| **测试** | `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 36 条 Golden 断言 + 111 条功能验收 |
|
||||
| **测试** | `11-填报流程端到端测试计划.md`(填报链路 E2E)→ `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 链路覆盖矩阵 + 判据口径;36 条 Golden 断言 + 111 条功能验收 |
|
||||
| **新加入的人** | `交接摘要.md` → 本文件 | 一份文档看清全部状态 |
|
||||
|
||||
### 全部文档
|
||||
@@ -42,6 +42,7 @@
|
||||
| 04 | `04-核心模块设计说明.md` | 56 KB | **6 个模块**伪代码级说明 + 依赖图 + 26 项回归基线 | ✅ |
|
||||
| 05 | `05-开发任务拆解.md` | 31 KB | **100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与风险 | ✅ |
|
||||
| 06 | `06-验收测试清单.md` | 29 KB | **36 条 Golden 断言** + 111 条功能验收 + 签署表 | ✅ |
|
||||
| 11 | `11-填报流程端到端测试计划.md` | 45 KB | **填报链路覆盖矩阵(A~J 十组)+ 判据速查 + 已定案缺陷 F-125~F-129**;配套 `scripts/e2e-m3-flow.py` | ✅ |
|
||||
| — | `交接摘要.md` | 11 KB | **状态源**:客户回执、13 条铁律、已完成、待办、环境、教训 | ✅ |
|
||||
| — | `数据信息填报系统PRD.md` | 190 KB | 主 PRD V2.3+:**125 条 FR**、**21 条 VAL**、**20 张表 DDL**、验收标准 | ✅ |
|
||||
| — | `数据信息填报系统PRD-功能逻辑卷.md` | 88 KB | 纯功能运转逻辑(**不含库表/接口/代码**) | ✅ |
|
||||
@@ -167,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
|
||||
@@ -196,8 +199,8 @@ 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 开发公钥
|
||||
scripts/ E2E 验收(37 个脚本)、接线检查、DDL 同步、图标抽取
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -147,9 +147,8 @@ public class LogController {
|
||||
@Operation(summary = "登录日志",
|
||||
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
|
||||
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
|
||||
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
|
||||
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
|
||||
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
|
||||
+ "登录方式 `loginType`:`PASSWORD`(PC 账号密码)/ `WECHAT`(学生从小程序、H5 走微信授权)。"
|
||||
+ "⚠️ `location`(IP 归属地)一期**恒为 null** —— 没接 IP 库,返回 null 而不是编一个")
|
||||
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
|
||||
@RequestParam(required = false) Long tenantId,
|
||||
@RequestParam(required = false) Long userId,
|
||||
|
||||
@@ -49,6 +49,25 @@ public final class LogDtos {
|
||||
|
||||
private String userAgent;
|
||||
|
||||
/**
|
||||
* 请求方法({@code GET} / {@code POST} / {@code PUT} / {@code DELETE})。
|
||||
*
|
||||
* <p>与 {@link #requestUri}、{@link #extraJson} 一起是「日志详情」要用的三列 ——
|
||||
* 它们都是**轻字段**(实测最大 45 / 125 字节级),所以列表直接带上,
|
||||
* 不需要为详情再开一个按 id 查的接口。
|
||||
*/
|
||||
private String requestMethod;
|
||||
|
||||
/** 请求路径(不含 query string)。 */
|
||||
private String requestUri;
|
||||
|
||||
/**
|
||||
* 补充上下文(JSON 串),如批量操作命中的 id 列表 / 停用组织时的 {@code deactivated}。
|
||||
*
|
||||
* <p>⚠️ 不是每一条都有(一期只有一部分动作带它)。
|
||||
*/
|
||||
private String extraJson;
|
||||
|
||||
private Integer bizCode;
|
||||
|
||||
private String traceId;
|
||||
@@ -74,11 +93,28 @@ public final class LogDtos {
|
||||
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 登录者所属租户({@code 0} = 平台)。
|
||||
*
|
||||
* <p>平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是哪所学校的 ——
|
||||
* 而"某个账号什么时候登进来的"这个问题,在平台视角下**一定**要连着"哪个单位"一起回答。
|
||||
* {@code sys_login_log.tenant_id} 本来就有(V2 建表时的 {@code NOT NULL DEFAULT 0}),
|
||||
* 只是这个 VO 此前没把它带出来。
|
||||
*/
|
||||
private Long tenantId;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String userName;
|
||||
|
||||
/** 用户类型:1 超管 / 2 管理员 / 3 教师 / 4 学生 */
|
||||
/**
|
||||
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
|
||||
*
|
||||
* <p>⚠️ 这里以前写的是「1 超管 / 2 管理员 / 3 教师 / 4 学生」,**是错的**。
|
||||
* 权威来源是 {@code BuiltinRole.UserType} 与 {@code UserService.USER_TYPE_NAMES},
|
||||
* 库列注释({@code sys_user.user_type})也与它们一致 ——
|
||||
* 别按 `教师/学生` 那套去映射,本仓库曾因此把班主任当成社会人员拒掉过一次。
|
||||
*/
|
||||
private Integer userType;
|
||||
|
||||
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT l.id, l.user_id AS userId,
|
||||
SELECT l.id, l.tenant_id AS tenantId, l.user_id AS userId,
|
||||
COALESCE(u.real_name, l.username) AS userName,
|
||||
u.user_type AS userType,
|
||||
CASE l.login_type
|
||||
|
||||
+2
@@ -63,6 +63,8 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
|
||||
l.module, l.action, l.target_type AS targetType, l.target_id AS targetId,
|
||||
l.before_val AS beforeVal, l.after_val AS afterVal,
|
||||
l.ip, l.user_agent AS userAgent, l.biz_code AS bizCode, l.trace_id AS traceId,
|
||||
l.request_method AS requestMethod, l.request_uri AS requestUri,
|
||||
l.extra_json AS extraJson,
|
||||
l.create_time AS createTime
|
||||
FROM sys_operation_log l
|
||||
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
||||
|
||||
+36
-1
@@ -67,8 +67,16 @@ public class OperationLogService {
|
||||
public static final String MODULE_JOIN = "JOIN";
|
||||
public static final String MODULE_USER = "USER";
|
||||
public static final String MODULE_FILE = "FILE";
|
||||
/** 角色与授权(二期)。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
|
||||
/** 角色与授权。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
|
||||
public static final String MODULE_ROLE = "ROLE";
|
||||
/**
|
||||
* 租户(平台运营的「开通 / 修改 / 停用学校」)。
|
||||
*
|
||||
* <p>⚠️ 这类日志的 {@code tenant_id} 是 <b>0</b>(平台身份),不是被操作的学校 ——
|
||||
* 因为写日志用的是<b>操作者</b>的上下文。查"这所学校被谁停过"要用
|
||||
* {@code target_type=TENANT & target_id=<租户 id>},不能用 tenant_id 筛。
|
||||
*/
|
||||
public static final String MODULE_TENANT = "TENANT";
|
||||
|
||||
public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
|
||||
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
|
||||
@@ -80,6 +88,20 @@ public class OperationLogService {
|
||||
public static final String ACTION_RESUME_TASK = "RESUME_TASK";
|
||||
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
|
||||
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
|
||||
/**
|
||||
* 定时任务**自动**截止。
|
||||
*
|
||||
* <p>⚠️ 必须与 {@link #ACTION_CLOSE_TASK} 分开:出了争议时第一个要看的就是
|
||||
* 「这个任务是到点自己关的,还是管理员手动关的」。
|
||||
*/
|
||||
public static final String ACTION_AUTO_CLOSE = "AUTO_CLOSE";
|
||||
/**
|
||||
* 任务状态变更(兜底动作名)。
|
||||
*
|
||||
* <p>只有在调用方**说不出**是哪个动作时才会用到;`patchStatus` 的注释解释了
|
||||
* 为什么"发布"与"恢复"不能共用它(两者都是 →收集中,靠状态推会记成同一个动作)。
|
||||
*/
|
||||
public static final String ACTION_STATUS_CHANGE = "STATUS_CHANGE";
|
||||
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
|
||||
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
|
||||
public static final String ACTION_DELETE_TASK = "DELETE_TASK";
|
||||
@@ -101,6 +123,17 @@ public class OperationLogService {
|
||||
public static final String ACTION_UPDATE_ROLE_MENU = "UPDATE_ROLE_MENU";
|
||||
/** 停用 / 启用角色。 */
|
||||
public static final String ACTION_TOGGLE_ROLE = "TOGGLE_ROLE";
|
||||
/** 开通租户(新建一所学校 + 它的内置角色与管理员账号)。 */
|
||||
public static final String ACTION_CREATE_TENANT = "CREATE_TENANT";
|
||||
/** 改租户信息与套餐(名称 / 类型 / 联系人 / 到期)。 */
|
||||
public static final String ACTION_UPDATE_TENANT = "UPDATE_TENANT";
|
||||
/**
|
||||
* 停用租户 —— <b>该校所有学生与老师立刻登不进来</b>,
|
||||
* 是这套系统里单个动作影响面最大的一类,所以单独一个动作码而不是并进 UPDATE。
|
||||
*/
|
||||
public static final String ACTION_DISABLE_TENANT = "DISABLE_TENANT";
|
||||
/** 恢复启用租户。 */
|
||||
public static final String ACTION_ENABLE_TENANT = "ENABLE_TENANT";
|
||||
|
||||
public static final String TARGET_ROSTER = "ROSTER";
|
||||
public static final String TARGET_TASK = "TASK";
|
||||
@@ -110,6 +143,8 @@ public class OperationLogService {
|
||||
public static final String TARGET_USER = "USER";
|
||||
/** 角色({@code sys_role.id})。 */
|
||||
public static final String TARGET_ROLE = "ROLE";
|
||||
/** 租户({@code sys_tenant.id})。平台级动作的 target,见 {@link #MODULE_TENANT}。 */
|
||||
public static final String TARGET_TENANT = "TENANT";
|
||||
|
||||
/**
|
||||
* 记一条操作日志(**不抛异常**)。
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+22
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -688,7 +688,8 @@ public class TaskService {
|
||||
* **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。
|
||||
*/
|
||||
public void autoClose(Long taskId) {
|
||||
patchStatus(taskId, CollectTask.STATUS_CLOSED, "AUTO_CLOSE");
|
||||
patchStatus(taskId, CollectTask.STATUS_CLOSED,
|
||||
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_AUTO_CLOSE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -718,7 +719,9 @@ public class TaskService {
|
||||
if (from == status) {
|
||||
return;
|
||||
}
|
||||
String act = action == null ? "STATUS_CHANGE" : action;
|
||||
String act = action == null
|
||||
? cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_STATUS_CHANGE
|
||||
: action;
|
||||
operationLog.record(cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
|
||||
act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id,
|
||||
String.valueOf(from), String.valueOf(status));
|
||||
|
||||
+66
-4
@@ -4,6 +4,7 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.api.PageResult;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.common.util.MaskUtil;
|
||||
import cn.tianbaotong.fill.module.log.service.OperationLogService;
|
||||
import cn.tianbaotong.fill.module.tenant.dto.TenantCreateReq;
|
||||
import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
|
||||
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||
@@ -48,6 +49,7 @@ public class TenantService {
|
||||
private final UserMapper userMapper;
|
||||
private final RoleProvisioner roleProvisioner;
|
||||
private final SensitiveCodec codec;
|
||||
private final OperationLogService operationLog;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
// ── 列表 ──────────────────────────────────────
|
||||
@@ -65,7 +67,10 @@ public class TenantService {
|
||||
.orderByDesc(SysTenant::getId);
|
||||
|
||||
if (expireBefore != null && !expireBefore.isBlank()) {
|
||||
query.le(SysTenant::getExpireTime, LocalDate.parse(expireBefore).atStartOfDay());
|
||||
// ⚠️ **含当天**:expire_time 存的是当天 23:59:59(见 parseExpire),
|
||||
// 曾经用 atStartOfDay() 比 —— 于是"今天到期"的租户查不出来(差一天)。
|
||||
// 同型的坑在 LogController.parseTo 上已经修过一次。
|
||||
query.le(SysTenant::getExpireTime, parseExpireBefore(expireBefore));
|
||||
}
|
||||
|
||||
IPage<SysTenant> page = tenantMapper.selectPage(new Page<>(num, size), query);
|
||||
@@ -120,19 +125,76 @@ public class TenantService {
|
||||
|
||||
log.info("已开通租户 {}(id={}),管理员账号 {}", code, tid, req.getAdminUsername());
|
||||
|
||||
// 留痕(F-1 点名「租户开通」是优先留痕点)。
|
||||
// ⚠️ 这行的 tenant_id 是 **0**(平台身份,不是刚建的这所学校)—— 写日志用的是
|
||||
// 操作者的上下文,而 TenantScope.runAs 已在 finally 里恢复了。
|
||||
// 要查"这所学校是谁开通的",用 target_type=TENANT & target_id=tid。
|
||||
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_CREATE_TENANT,
|
||||
OperationLogService.TARGET_TENANT, tid, null,
|
||||
"tenantCode=" + code + "; name=" + t.getTenantName() + "; type=" + t.getTenantType());
|
||||
|
||||
return new CreateResult(tid, initialPassword, "t" + tid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户详情。
|
||||
*
|
||||
* <p>与列表的唯一区别:<b>这里返回联系电话的脱敏值</b>(列表恒为 {@code null},见 {@link #toVo})。
|
||||
*
|
||||
* <p>为什么给脱敏值而不是明文:这是全系统唯一一处「加密存储的联系方式」,
|
||||
* 若在详情里可读且不留痕,就与「看证件号明文必须留痕」({@code VIEW_IDCARD_FULL})
|
||||
* 这条既有规矩自相矛盾。脱敏号足够确认"档里有号",要改直接填新号 ——
|
||||
* {@code PUT} 留空 = 不修改,所以界面不需要回填明文。
|
||||
*/
|
||||
public TenantVO detail(Long id) {
|
||||
SysTenant t = tenantMapper.selectById(id);
|
||||
if (t == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在");
|
||||
}
|
||||
return toVo(t);
|
||||
TenantVO vo = toVo(t);
|
||||
String plain = plainContactPhone(t.getContactPhone());
|
||||
vo.setContactPhone(plain == null ? null : MaskUtil.maskPhone(plain));
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 解析 {@code expireBefore}({@code yyyy-MM-dd},**含当天**)。
|
||||
*
|
||||
* <p>⚠️ 格式不对时**明确报错**,不学 {@code LogController.parseTo} 的"当没传":
|
||||
* 那两个是界面上的文本框自由输入,静默忽略是为了"输错了也别让人看不到日志";
|
||||
* 而这里的值只由界面按当前日期算出来,非法值只可能来自手搓请求 ——
|
||||
* 静默忽略筛选会让人以为"我按到期日筛过了",那正是本仓反复批评的模式。
|
||||
*/
|
||||
private static LocalDateTime parseExpireBefore(String s) {
|
||||
try {
|
||||
return LocalDate.parse(s.trim()).atTime(23, 59, 59);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "到期日期格式不对,应为 2026-12-31");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取联系电话明文(**只给详情用**)。
|
||||
*
|
||||
* <p>这一列是加密列,但库里存在历史明文行(本机 id=1 就是),直接 {@code decrypt}
|
||||
* 会在 GCM 的 Base64 解码处抛 —— 而"解密失败"在这里不该变成 500:
|
||||
* 那会让一所学校的详情页**永远打不开**。所以失败时按明文返回,
|
||||
* 并记一条 WARN 让"还剩多少明文行"可见(历史行不做数据迁移:迁移里拿不到 AES 密钥)。
|
||||
*/
|
||||
private String plainContactPhone(String stored) {
|
||||
if (stored == null || stored.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return codec.decrypt(stored);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("租户联系电话不是密文(历史明文行或密钥变更过),按明文返回:{}", e.toString());
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime parseExpire(String s) {
|
||||
try {
|
||||
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
|
||||
@@ -151,8 +213,8 @@ public class TenantService {
|
||||
vo.setTenantName(t.getTenantName());
|
||||
vo.setTenantType(t.getTenantType());
|
||||
vo.setContactName(t.getContactName());
|
||||
// 联系电话以密文入库,列表页要求脱敏展示时的处理在导出/详情里做,
|
||||
// 这里不返回明文,避免列表接口成为批量拉取联系方式的通道
|
||||
// 联系电话以密文入库:**列表恒不返回**(脱敏值也不行),
|
||||
// 避免列表接口成为批量拉取联系方式的通道。脱敏值只有 detail() 给。
|
||||
vo.setContactPhone(null);
|
||||
vo.setPackageId(t.getPackageId());
|
||||
vo.setExpireTime(t.getExpireTime());
|
||||
|
||||
+100
-15
@@ -4,10 +4,13 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.module.file.entity.CollectFile;
|
||||
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
|
||||
import cn.tianbaotong.fill.module.log.service.OperationLogService;
|
||||
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
|
||||
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;
|
||||
@@ -18,6 +21,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 租户用量统计(《03-接口设计》§6.7)。
|
||||
@@ -45,6 +49,8 @@ public class TenantUsageService {
|
||||
private final TenantMapper tenantMapper;
|
||||
private final CollectRosterMapper rosterMapper;
|
||||
private final FileMapper fileMapper;
|
||||
private final SensitiveCodec codec;
|
||||
private final OperationLogService operationLog;
|
||||
|
||||
/** 默认套餐额度(没有配套餐时的兜底)。 */
|
||||
private static final long DEFAULT_STUDENT_LIMIT = 2000L;
|
||||
@@ -64,15 +70,22 @@ public class TenantUsageService {
|
||||
vo.setTenantName(t.getTenantName());
|
||||
vo.setPeriod(p.text());
|
||||
|
||||
// 学生数:**按证件号去重**(同一个人可能在多个任务里各有一行名册)
|
||||
vo.setStudentCount(distinctStudents());
|
||||
|
||||
// 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)
|
||||
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
|
||||
.select(CollectFile::getFinalSize))
|
||||
.stream().map(CollectFile::getFinalSize)
|
||||
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
|
||||
vo.setStorageMb(bytes / 1024 / 1024);
|
||||
// ⚠️ 这两项必须**切到目标租户的上下文里**再查,不能直接用当前请求的身份查。
|
||||
//
|
||||
// 原因:本接口只有平台运营能调(@RequirePerm(TENANT_VIEW),那个码在
|
||||
// PermissionDef.platformOnlyCodes() 里),而平台运营的 tenantId 是 0 ——
|
||||
// FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true,
|
||||
// **整张表的租户过滤被关掉**。于是"学生数 / 存储量"会算成**全平台**的数,
|
||||
// 却挂在某一所学校的名下:运营拿着它去谈续费,而数字是假的。
|
||||
//
|
||||
// 这个 bug 在本地看不见 —— 库里只有一所学校有数据时,它与正确值完全相等
|
||||
// (ActingTenantResolver 的注释里描述的是同一个陷阱)。
|
||||
// 切成目标租户之后 isPlatformAdmin() 变 false,租户过滤自动恢复,
|
||||
// 不需要给这些查询手写 tenant_id 条件(那样等于放弃拦截器的保护)。
|
||||
Scope scope = TenantScope.callAs(tenantId, 0L, Set.of(),
|
||||
() -> new Scope(distinctStudents(), storageMb()));
|
||||
vo.setStudentCount(scope.students());
|
||||
vo.setStorageMb(scope.storageMb());
|
||||
|
||||
// OCR / 短信:一期没有通道,如实返回 0(见类注释)
|
||||
vo.setOcrCount(0);
|
||||
@@ -94,6 +107,19 @@ public class TenantUsageService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 一次上下文切换里取到的两个「本来就是租户内」的数字。 */
|
||||
private record Scope(long students, long storageMb) {
|
||||
}
|
||||
|
||||
/** 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)。 */
|
||||
private long storageMb() {
|
||||
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
|
||||
.select(CollectFile::getFinalSize))
|
||||
.stream().map(CollectFile::getFinalSize)
|
||||
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
|
||||
return bytes / 1024 / 1024;
|
||||
}
|
||||
|
||||
/** 学生数:按 `id_card_hash` 去重(同一个人在多任务里只算一次)。 */
|
||||
private long distinctStudents() {
|
||||
// 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法:
|
||||
@@ -152,6 +178,7 @@ public class TenantUsageService {
|
||||
if (req == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空");
|
||||
}
|
||||
Integer oldStatus = exist.getStatus();
|
||||
SysTenant upd = new SysTenant();
|
||||
upd.setId(id);
|
||||
if (req.getTenantName() != null) {
|
||||
@@ -161,21 +188,34 @@ public class TenantUsageService {
|
||||
upd.setTenantName(req.getTenantName().trim());
|
||||
}
|
||||
if (req.getTenantType() != null) {
|
||||
if (req.getTenantType() < 1 || req.getTenantType() > 3) {
|
||||
// 取值以 SysTenant.tenantType 的注释为准(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)。
|
||||
// ⚠️ 这里曾经只放行 1~3,而 DDL 默认值与实体注释都是 4 值 ——
|
||||
// 于是"认定机构 / 报名点 / 企业"在前端下拉里选得到,保存却报错。
|
||||
if (req.getTenantType() < 1 || req.getTenantType() > 4) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"租户类型只能是 1(学校)/ 2(机构)/ 3(企业)");
|
||||
"租户类型只能是 1(学校)/ 2(认定机构)/ 3(报名点)/ 4(企业)");
|
||||
}
|
||||
upd.setTenantType(req.getTenantType());
|
||||
}
|
||||
upd.setContactName(req.getContactName());
|
||||
upd.setContactPhone(req.getContactPhone());
|
||||
// ⚠️ contact_phone 是**加密列**(见 V1__business_tables.sql 的列注释)。
|
||||
// 这里曾经是 `upd.setContactPhone(req.getContactPhone())` —— 明文直接落库,
|
||||
// 而读回来的路径会在 AesGcmCipher.decrypt 里 Base64 解不开直接抛,
|
||||
// 表现是「保存成功,但详情页从此打不开」。空值 = **不修改**(不是清空)。
|
||||
if (req.getContactPhone() != null && !req.getContactPhone().isBlank()) {
|
||||
upd.setContactPhone(codec.encrypt(req.getContactPhone().trim()));
|
||||
}
|
||||
upd.setPackageId(req.getPackageId());
|
||||
upd.setExpireTime(req.getExpireTime());
|
||||
upd.setLogoUrl(req.getLogoUrl());
|
||||
upd.setConfigJson(req.getConfigJson());
|
||||
if (req.getStatus() != null) {
|
||||
if (req.getStatus() != 0 && req.getStatus() != 1) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(启用)");
|
||||
// 取值以 SysTenant.status 的注释为准(0 停用 / 1 正常 / 2 试用)。
|
||||
// ⚠️ 这里曾经只放行 0/1 —— 而前端的状态筛选按钮组与 TENANT_STATUS 都有"试用",
|
||||
// 结果是"试用"建不出来也改不出来。
|
||||
if (req.getStatus() != 0 && req.getStatus() != 1 && req.getStatus() != 2) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"状态只能是 0(停用)/ 1(正常)/ 2(试用)");
|
||||
}
|
||||
upd.setStatus(req.getStatus());
|
||||
if (req.getStatus() == 0) {
|
||||
@@ -187,7 +227,52 @@ public class TenantUsageService {
|
||||
tenantMapper.updateById(upd);
|
||||
log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(),
|
||||
upd.getStatus(), upd.getExpireTime());
|
||||
return tenantMapper.selectById(id);
|
||||
|
||||
SysTenant now = tenantMapper.selectById(id);
|
||||
recordChange(id, oldStatus, exist, now, req);
|
||||
return now;
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户变更留痕。
|
||||
*
|
||||
* <p>《08-完成度盘点》的 F-1 点名「租户开通」是优先留痕点,而本模块此前
|
||||
* <b>一条日志都不写</b> —— 开通一所学校、把到期日往后挪、停用一所学校,
|
||||
* 都不留痕,这是整套系统里影响面最大的几类动作之一。
|
||||
*/
|
||||
private void recordChange(Long id, Integer oldStatus, SysTenant before, SysTenant after, SysTenant req) {
|
||||
Integer newStatus = req.getStatus();
|
||||
if (newStatus != null && !newStatus.equals(oldStatus)) {
|
||||
String action = switch (newStatus) {
|
||||
case 0 -> OperationLogService.ACTION_DISABLE_TENANT;
|
||||
case 1 -> OperationLogService.ACTION_ENABLE_TENANT;
|
||||
default -> OperationLogService.ACTION_UPDATE_TENANT;
|
||||
};
|
||||
operationLog.record(OperationLogService.MODULE_TENANT, action,
|
||||
OperationLogService.TARGET_TENANT, id,
|
||||
"status=" + oldStatus, "status=" + newStatus);
|
||||
return;
|
||||
}
|
||||
// 手机号在库里是密文,摘要里只记「有没有设置」(写密文进日志既没用又难看),
|
||||
// 所以"换了一个号"这件事比较不出来 —— 用请求里有没有带它来补一条。
|
||||
boolean phoneChanged = req.getContactPhone() != null && !req.getContactPhone().isBlank();
|
||||
String b = summary(before);
|
||||
String a = summary(after) + (phoneChanged ? "; phone=已更新" : "");
|
||||
if (!b.equals(a)) {
|
||||
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_UPDATE_TENANT,
|
||||
OperationLogService.TARGET_TENANT, id, b, a);
|
||||
}
|
||||
}
|
||||
|
||||
/** 变更摘要(`k=v; k=v`)。`OperationLogService.mask` 会再兜底脱敏一次并截断到列宽。 */
|
||||
private static String summary(SysTenant t) {
|
||||
return "name=" + t.getTenantName()
|
||||
+ "; type=" + t.getTenantType()
|
||||
+ "; contact=" + t.getContactName()
|
||||
+ "; phone=" + (t.getContactPhone() == null || t.getContactPhone().isBlank() ? "-" : "已设置")
|
||||
+ "; package=" + t.getPackageId()
|
||||
+ "; expire=" + t.getExpireTime()
|
||||
+ "; status=" + t.getStatus();
|
||||
}
|
||||
|
||||
// ── 周期解析 ──
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)。详见 `交接摘要` §一·四十四 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+129
-14
@@ -1187,37 +1187,109 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
||||
|
||||
**权限**:运营 **Query**:`keyword`、`status`、`expireBefore`、分页
|
||||
|
||||
`expireBefore`(`yyyy-MM-dd`)**含当天** —— `expire_time` 存的是当天 `23:59:59`,
|
||||
所以比较要补到当天末尾,否则"今天到期"的租户查不出来。格式不对返回 `40001`
|
||||
(**不静默忽略筛选**:这个值只由界面按当前日期算出来,非法值只可能来自手搓请求)。
|
||||
|
||||
**响应**:`{ list: TenantVO[], total }`
|
||||
|
||||
⚠️ 列表里的 `contactPhone` **恒为 `null`** —— 联系电话以密文入库,列表接口不能成为
|
||||
批量拉取联系方式的通道。脱敏值只有 6.9 给。
|
||||
`packageName` 与内嵌 `usage{}`(§3.12 的简表里画了)**不实现**:200 行内嵌用量会变成 N+1 统计。
|
||||
|
||||
### 6.6 `POST /api/v1/tenants`
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `tenantName` | string | ✅ | — |
|
||||
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd` |
|
||||
| `tenantType` | int | ✅ | `1` 学校 / `2` 机构 / `3` 企业 |
|
||||
| `contactName` / `contactPhone` | string | ✅ | 联系人 |
|
||||
| `packageId` | int64 | ✅ | 套餐 |
|
||||
| `expireTime` | string | ✅ | 到期时间 |
|
||||
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd`;**入库时小写化** |
|
||||
| `tenantType` | int | ✅ | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
|
||||
| `contactName` / `contactPhone` | string | ✅ | 联系人;电话**加密入库** |
|
||||
| `packageId` | int64 | | 套餐(可选,无外键) |
|
||||
| `expireTime` | string | ✅ | 到期时间;只给日期时补成当天 `23:59:59` |
|
||||
| `adminUsername` / `adminRealName` / `adminPhone` | string | ✅ | **同时创建首个管理员** |
|
||||
|
||||
**响应**:`{ "id": 1002, "adminInitialPassword": "Kp9#vR4t", "miniProgramScene": "t1002" }`
|
||||
|
||||
**副作用**:切到新租户的作用域发放 6 个内置角色、建管理员并绑定 `SCHOOL_ADMIN`;
|
||||
写一条操作日志(动作 `CREATE_TENANT`,`target_type=TENANT`)。
|
||||
|
||||
### 6.7 `GET /api/v1/tenants/{id}/usage`
|
||||
|
||||
**Query**:`period`(`2026-09` 或 `2026Q3`)
|
||||
**Query**:`period`(`2026-09` 或 `2026Q3`;不传 = 当月)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"studentCount": 1240, "storageMb": 8420, "ocrCount": 3105, "smsCount": 420,
|
||||
"tenantId": 1, "tenantName": "示范职业学校", "period": "2026-09",
|
||||
"studentCount": 1240, "storageMb": 8420, "ocrCount": 0, "smsCount": 0,
|
||||
"quota": { "studentLimit": 2000, "storageLimitMb": 20480, "ocrLimit": 10000, "smsLimit": 2000 },
|
||||
"alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%" } ]
|
||||
"alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%", "used": 8420, "limit": 20480 } ],
|
||||
"notes": [
|
||||
"ocrCount 恒为 0:M4 的识别通道还没接(等 OCR 选型),不是「没用量」",
|
||||
"smsCount 恒为 0:短信通道还没有商务结论(降级链只实现到站内信)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ 三条口径(都不是"实现细节",是**看数的人必须知道的**):
|
||||
|
||||
- `studentCount` / `storageMb` 是**实时**值(它们决定"还能不能再收人"),
|
||||
`period` **只影响计费项**(`ocrCount` / `smsCount`)—— 界面必须写明,
|
||||
否则会以为"2026Q3 的学生数"是历史值;
|
||||
- `ocrCount` / `smsCount` 一期**恒为 0 并带 `notes` 说明**:
|
||||
两条通道都还没接,编一个数字会让运营拿着假数据去谈续费;
|
||||
- 统计**只算这一个租户**。这条不是废话:调用方一定是平台运营(`tenant:view` 是平台专属码),
|
||||
而平台身份的 `tenantId` 是 `0`,租户拦截器对它**整张表关掉过滤** ——
|
||||
实现里必须显式切到目标租户的上下文再统计,否则返回的是**全平台**的数
|
||||
(库里只有一所学校有数据时,它与正确值完全相等,看不出来)。
|
||||
|
||||
### 6.8 `PUT /api/v1/tenants/{id}`
|
||||
|
||||
**权限**:运营
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `tenantName` | string | | 不能是空白串 |
|
||||
| `tenantType` | int | | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
|
||||
| `contactName` | string | | — |
|
||||
| `contactPhone` | string | | **留空 = 不修改**;非空才写,且写入的是**密文** |
|
||||
| `packageId` | int64 | | — |
|
||||
| `expireTime` | string | | `2027-12-31` 或 `2027-12-31T23:59:59` |
|
||||
| `status` | int | | `0` 停用 / `1` 正常 / `2` 试用 |
|
||||
|
||||
**不传的字段不修改**(不是"清空")。**`tenantCode` 不可修改**:它出现在小程序扫码
|
||||
落地页、域名解析与对接文档里,改了会让已经发出去的东西失效,而我们通知不到客户那边。
|
||||
|
||||
**留痕**(本接口必须留痕,因为它是这套系统里影响面最大的一类动作):
|
||||
|
||||
| 变更 | 动作码 |
|
||||
|---|---|
|
||||
| `status` → `0` | `DISABLE_TENANT`(该校所有学生与老师**立刻登不进来**,单独一个码) |
|
||||
| `status` → `1` | `ENABLE_TENANT` |
|
||||
| `status` → `2` | `UPDATE_TENANT` |
|
||||
| 其它字段有变化 | `UPDATE_TENANT`(`before` / `after` 是 `k=v; k=v` 摘要) |
|
||||
|
||||
⚠️ 这些日志行的 `tenant_id` 是 **`0`**(写日志用的是**操作者**的上下文,平台运营的租户号是 0),
|
||||
不是被操作的那所学校。要查"这所学校被谁停过",用 `target_type=TENANT` + `target_id=<租户 id>`,
|
||||
**不能用 `tenantId` 筛**。
|
||||
|
||||
**响应**:`TenantVO`(同 6.9)
|
||||
|
||||
### 6.9 `GET /api/v1/tenants/{id}`
|
||||
|
||||
**权限**:运营 **响应**:`TenantVO`,其中 `contactPhone` 是**脱敏值**(形如 `139****1111`)
|
||||
|
||||
这是**唯一**能拿到联系电话的接口。为什么不给明文:这是全系统唯一一处"加密存储的联系方式",
|
||||
若在详情里可读且不留痕,就与「看证件号明文必须留痕」(`VIEW_IDCARD_FULL`)这条既有规矩
|
||||
自相矛盾;而脱敏值足够确认"档里有号",要改直接填新号(6.8 留空 = 不修改)。
|
||||
|
||||
⚠️ 库里存在**历史明文行**(早期版本的写入路径没有加密)。服务端对解密失败
|
||||
按明文处理并记一条 WARN —— 否则一所学校的详情页会**永远打不开**,
|
||||
而那种故障看起来像"系统坏了",不像"数据里有一行是旧的"。
|
||||
|
||||
---
|
||||
|
||||
## §7 任务与配置
|
||||
@@ -2477,7 +2549,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
||||
|
||||
#### `GET /api/v1/logs/operations`
|
||||
|
||||
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
|
||||
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetType`、`targetId`、`from` / `to`、分页
|
||||
|
||||
> `targetType` + `targetId` 是**最重要的查询方式**:靠关键字全文匹配会把
|
||||
> 「改了它」和「顺带提到它」混在一起,而合规结论不能建立在这种匹配上。
|
||||
>
|
||||
> `from` / `to` 只给 10 位日期时,`to` 会补成当天 `23:59:59` —— 不补的话
|
||||
> 「查 9 月 30 日」查不到当天的记录。
|
||||
|
||||
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
|
||||
|
||||
@@ -2496,28 +2574,55 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
||||
{ "id": 70001, "operatorId": 10086, "operatorName": "王老师", "module": "ROSTER",
|
||||
"action": "UPDATE_IDCARD", "targetType": "ROSTER", "targetId": 9012,
|
||||
"beforeVal": "3610292009******10", "afterVal": "3610292009******99",
|
||||
"ip": "10.1.2.3", "userAgent": "Chrome/120", "createTime": "2026-09-28T16:20:00+08:00" }
|
||||
"ip": "10.1.2.3", "userAgent": "Chrome/120",
|
||||
"requestMethod": "PUT", "requestUri": "/api/v1/roster/9012",
|
||||
"extraJson": "{\"orgId\":43,\"taskId\":487}",
|
||||
"bizCode": 0, "traceId": "3f9c…", "createTime": "2026-09-28T16:20:00+08:00" }
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**必记的敏感操作**(PRD FR-506 / FR-1202)
|
||||
**动作码全集**(`action`,权威来源是后端 `OperationLogService` 的 `ACTION_*` 常量,
|
||||
PC 端 `apis/xt/log.ts` 的 `OPERATION_ACTIONS` 是它的文案表 —— 两端由
|
||||
`scripts/check-wiring.py` 第 ⑩ 项**逐字核对**,缺一个界面上就会原样回显英文码)
|
||||
|
||||
| `action` | 场景 |
|
||||
|---|---|
|
||||
| `UPDATE_IDCARD` | **改名册证件号**(留痕强制) |
|
||||
| `UPDATE_ROSTER_NAME` | 改名 |
|
||||
| `DELETE_ROSTER` | 删名册项 |
|
||||
| `PUBLISH_TASK` / `PAUSE_TASK` / `ARCHIVE_TASK` | 任务状态流转 |
|
||||
| `PUBLISH_TASK` | 发布任务 |
|
||||
| `PAUSE_TASK` / `RESUME_TASK` | 暂停 / 恢复("恢复"意味着曾经暂停过,必须与"发布"分开记) |
|
||||
| `CLOSE_TASK` / `AUTO_CLOSE` | 手动截止 / **定时任务自动截止**(出争议时第一个要看的就是这个区别) |
|
||||
| `STATUS_CHANGE` | 任务状态变更(兜底名:调用方说不出是哪个动作时才用) |
|
||||
| `ARCHIVE_TASK` / `DELETE_TASK` | 归档 / 删除已归档任务 |
|
||||
| `REVOKE_AUDIT` | **撤回审核** |
|
||||
| `EXPORT_CREATE` | 创建导出 |
|
||||
| `EXPORT_CREATE` / `EXPORT_DOWNLOAD` | 创建导出 / 下载导出文件 |
|
||||
| `PROXY_UPLOAD` | 代传材料 |
|
||||
| `VIEW_IDCARD_FULL` | **查看完整身份证号**(单独留痕) |
|
||||
| `CONFIG_COMPRESS` | 改压缩配置 |
|
||||
| `CREATE_ROLE` / `UPDATE_ROLE` / `DELETE_ROLE` | 角色的增删改 |
|
||||
| `GRANT_ROLE` / `UPDATE_ROLE_MENU` / `TOGGLE_ROLE` | **改授权** / 改可见菜单 / 停用启用角色 |
|
||||
| `CREATE_TENANT` / `UPDATE_TENANT` | 开通租户 / 改租户信息·套餐 |
|
||||
| `DISABLE_TENANT` / `ENABLE_TENANT` | **停用租户**(该校所有人立刻登不进来)/ 恢复启用 |
|
||||
|
||||
**模块 `module`**:`TASK` / `ROSTER` / `FILE` / `ROLE` / `TENANT`。
|
||||
(`AUDIT` / `EXPORT` / `USER` / `JOIN` 四个常量**当前没有写入点** —— 界面上的模块下拉
|
||||
刻意不列它们:选中只会得到一张空表,让人以为"这段时间没有审核动作"。)
|
||||
|
||||
**脱敏**:`beforeVal` / `afterVal` 中的证件号**一律脱敏存储**,即使日志泄露也不暴露明文。
|
||||
服务层还会再兜底脱敏一次并截断到 512 字符(两层都做,是因为"忘记脱敏"的后果不可逆)。
|
||||
|
||||
**两列刻意不采集**:`request_param`(请求参数)与 `duration_ms`(耗时)**没有任何写入点**
|
||||
(全库 0 行有值)。要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,
|
||||
而合规留痕本来就不需要它们 —— 界面上不展示、也不做筛选。
|
||||
|
||||
> **一期只做查询,不做日志导出**(`backend/.../module/log/controller/LogController.java` 的类注释):
|
||||
> 导出等于把「谁在什么时候看了谁的身份证」这份名单落到本地磁盘上,
|
||||
> 而"有人导出过这份日志"本身也要留痕 —— 一个追着自己尾巴的循环。
|
||||
> 这条口径**只写在代码注释里**过一段时间,所以在这里也记一笔。
|
||||
|
||||
#### `GET /api/v1/logs/logins`
|
||||
|
||||
@@ -2525,7 +2630,17 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
||||
|
||||
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
|
||||
|
||||
**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
|
||||
**响应**:`{ list: [{ id, tenantId, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
|
||||
|
||||
⚠️ 三条口径:
|
||||
|
||||
- `tenantId`(`0` = 平台)**必须有**:平台运营看到的是跨单位的登录日志,
|
||||
没有这一列就分不清一行是哪所学校的;
|
||||
- `userType` 是 **`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生**
|
||||
(权威来源是 `BuiltinRole.UserType` 与 `UserService.USER_TYPE_NAMES`,库列注释也一致)
|
||||
—— **不要**按某些模板里那套「1 教师 / 2 学生」去映射;
|
||||
- `location`(IP 归属地)**一期恒为 `null`**:没接 IP 库,留 null 而不是编一个。
|
||||
界面**不要显示这一列**(否则整列空白,看起来像功能坏了)。
|
||||
|
||||
| `loginType` | 含义 |
|
||||
|---|---|
|
||||
|
||||
+21
-2
@@ -17,7 +17,7 @@
|
||||
> | PC 业务菜单 | 12 | **10** |
|
||||
> | 小程序页面 | 10 | **9** |
|
||||
> | 原型屏 | 32 | **29** |
|
||||
> | E2E 脚本 | 39 | **37**(断言 1698 → **1595 条**,0 失败 0 跳过) |
|
||||
> | E2E 脚本 | 39 | **37**(断言 1698 → **1617 条**,0 失败 0 跳过);**2026-10 新增 `e2e-m3-flow.py` → 38 个 / 1712 条** |
|
||||
>
|
||||
> **删除的三块**:① 内置角色「副班主任 `DEPUTY_TEACHER`」「审核员 `AUDITOR`」;
|
||||
> ② **历史整理器**(`module/cleanup`,5 接口 + `cleanup:run` + `collect_cleanup_job` 表 + PC 页 + 原型屏);
|
||||
@@ -64,7 +64,7 @@
|
||||
| 管理端(PC) | 登录/工作台/组织/账号/租户/收集任务 + 3 个配置抽屉 + 名册与报考范围 + 审核台 + 导出中心 / 通知记录 / 操作日志 / 字典管理 / **角色与授权**(**纯 Vue 3 + Naive UI**,2026-10 重搭);**已删除**:加入申请页、历史整理器页(2026-10) | 表单设计器已上线 | 约 100% |
|
||||
| 学生端(H5/小程序) | 落地页、门禁核验、填报、**我的任务 / 撤回 / 进度 / 消息中心**(**加入申请页已删除**,门禁失败只提示联系老师) | 临期提醒页、催报 | 约 90% |
|
||||
| 班主任端(小程序) | 看板 / 审核卡片流 / 详情与驳回(右滑通过、左滑不绑驳回)+ **代传材料 / 催报 / 学生材料预览**(后端与 MP 页面均已就绪) | 临期提醒入口 | 约 95% |
|
||||
| 基础设施 | PG 16 + Redis 7 + 七牛(`provider=qiniu` 已验)、Flyway **V1~V19**、本地存储兜底、导出产物落盘 + 流式下载 + 分卷与过期清理、**37 个 E2E 脚本(断言数以 `verify-all` 为准)+ 4 个机械校验脚本** | CI/CD、test/prod 环境、压测、灰度 | 约 58% |
|
||||
| 基础设施 | PG 16 + Redis 7 + 七牛(`provider=qiniu` 已验)、Flyway **V1~V19**、本地存储兜底、导出产物落盘 + 流式下载 + 分卷与过期清理、**38 个 E2E 脚本(断言数以 `verify-all` 为准)+ 4 个机械校验脚本** | CI/CD、test/prod 环境、压测、灰度 | 约 58% |
|
||||
|
||||
**契约接口实现度(按 §4 分类)**
|
||||
|
||||
@@ -485,6 +485,25 @@
|
||||
| F-109 | ⚠️ **`Map.of(...)` 拼模板变量 → 一条通知都没发**:`Map.of` **不接受 null 值**,而"这个学生查不到姓名"完全正常(学生账号是扫码核验时建的,`real_name` 可能还没填)→ 抛 NPE → 被外层"通知失败不影响业务"的 try/catch 吞成**一行 warn**,于是**驳回通知全丢,而驳回本身照样成功**。这是 F-25「被吞掉的异常」的第二次:**降级逻辑越完善,这类错越安静** | 新增 `NotifyRequest.vars(...)`(null 值直接跳过 —— 模板渲染对"没有值"的处理本来就是渲染成空);姓名改为**优先取名册上的名字**(那才是"他在这个任务里叫什么")。发现方式:`e2e-m5-notify-wiring.py` 第一次跑就红了(标题对、正文里姓名是空的) |
|
||||
| F-110 | ⚠️ **一条断言写成了"全库计数",于是它会在几天后突然变红**:`e2e-m7-schedule.py` 的"刚提交的不该被提醒"数的是**全库**的「审核超时」消息,而超时扫描是**跨租户**的 —— 库里只要存在别的老填报单(几天前别的脚本留下的),它就会照样提醒。本轮真的红了,而**代码一行都没改**(那些单子的 `submit_time` 终于超过 48 小时了) | 断言**限定到本次夹具的收件人与任务**(`user_id` + 正文里的任务名)。**教训:跨租户扫描类功能的断言必须"限定到自己的夹具"** —— 否则它测的是"库里干不干净",而那是另一件事(与 F-42「cleanup 静默失败污染后续脚本」同一族) |
|
||||
| F-111 | ⚠️ **`ON CONFLICT (id) DO UPDATE` 不更新用户名 → 看起来像密码错**:E2E 夹具按 id upsert 测试账号,只更新了 `real_name`,于是上一轮残留的行保留着**旧用户名**,而脚本按新用户名登录 → `40012 账号或密码错误`。第一反应会去查密码哈希(而它是对的) | 夹具改为**固定用户名 + `username = EXCLUDED.username`**(幂等);已写进 `e2e-m5-notify-wiring.py` 的注释 |
|
||||
| F-112 | 🔴 **`GET /tenants/{id}/usage` 在平台账号下算的是「全平台」的数**(做租户管理时发现,是本轮最严重的一条)。平台运营的 `tenantId` 是 `0`,而 `MybatisPlusConfig.FillTenantLineHandler.ignoreTable()` 对平台身份**直接返回 true —— 整张表的租户过滤被关掉**。于是 `TenantUsageService.distinctStudents()`(查 `collect_roster`)与存储量统计(查 `collect_file`)都**不带租户条件**,返回的是全平台的学生数与存储占用,却标着某一所学校的名字 —— 运营会拿着它去谈续费。⚠️ **而这个接口的调用者一定是平台运营**(`tenant:view` 在 `PermissionDef.platformOnlyCodes()` 里),所以它**从来只在"过滤被关掉"的上下文里跑过**。⚠️ 更麻烦的是它**在本地完全看不出来**:实测库里 `collect_roster` 34 行、`collect_file` 22 行**全在 `tenant_id=1`**,只有一所学校有数据时,错误值与正确值**完全相等**(`ActingTenantResolver` 的类注释描述的正是这个陷阱) | 已修:统计段包进 `TenantScope.callAs(tenantId, 0L, Set.of(), ...)` —— 切走之后 `isPlatformAdmin()` 变 false,租户过滤自动恢复(本仓既有机制,`OpenDataService` / `OpenPushService` 在用)。**没有**给查询手写 `tenant_id` 条件(那等于放弃拦截器的保护)。E2E 新增夹具(往另一个租户直插一行 `collect_roster`)+ 断言"接口值 == 库里 `where tenant_id=1` 的去重数",`e2e-m8-orgfile.py` 74/74 |
|
||||
| F-113 | 🔴 **`PUT /tenants/{id}` 把手机号明文写进加密列 → 详情接口必然 500**:`create` 走 `codec.encrypt(...)`,而 `update` 是 `upd.setContactPhone(req.getContactPhone())`;DB 列注释写着「联系电话(AES加密存储)」。`AesGcmCipher.decrypt` 对非 Base64 输入直接抛(该类**没有 dev 旁路**),于是**存过一次电话,这所学校的详情页就永远打不开**。实测 `sys_tenant.id=1` 存的就是明文 `13800138000`(来源不可考:全仓库没有任何脚本 PUT 过租户电话,3 处 `contactPhone` 都是 `POST /tenants`) | 已修:非空才 `codec.encrypt(...)` 写入(**空 = 不修改**,不是清空);详情返回**脱敏值**(不给明文:这是全系统唯一一处加密存储的联系方式,可读且不留痕会与「看证件号明文必须留痕」自相矛盾)。**读路径必须容错**:解密失败按明文处理 + `log.warn`(历史行不做数据迁移 —— 迁移里拿不到 AES 密钥),否则详情页是"永久 500"这种看起来像系统坏了的故障 |
|
||||
| F-114 | ⚠️ **租户的两个枚举值"能建不能改"**:`tenantType` 在 `update()` 里只放行 `1~3`,而 DDL / 实体注释 / 契约 / 前端下拉都是**4 值**(`1` 学校 `2` 认定机构 `3` 报名点 `4` 企业);`status` 只放行 `0/1`,而实体与 DDL 是 `0` 停用 / `1` 正常 / **`2` 试用** —— 前端的状态筛选按钮组里就有「试用」,选了永远是空列表 | 已修:`tenantType` 1~4、`status` 0/1/2,报错文案与实体注释逐字对齐;E2E 断言 `status=2` 与 `tenantType=4` 都能改 |
|
||||
| F-115 | ⚠️ **租户的开通 / 修改 / 停用原先一条日志都不写**:`update()` 只有 `log.warn` / `log.info`(那是给运维看系统日志的,不是合规留痕)。F-1 的修改建议里明确点了「租户开通」是优先留痕点 —— 停用一所学校是这套系统里**影响面最大的单个动作** | 已修:新增模块 `TENANT`、动作 `CREATE_TENANT` / `UPDATE_TENANT` / `DISABLE_TENANT` / `ENABLE_TENANT`(停用与启用不并进 `UPDATE_TENANT`:出争议时第一个要看的就是这个区别);`before/after` 是 `k=v` 摘要(手机号只记"有没有设置"—— 密文写进日志既没用又难看,所以"换了一个号"改用请求里带没带它来补一条)。⚠️ 这些行的 `tenant_id` 是 **0**(平台身份),查"这所学校被谁停过"要用 `target_type=TENANT` + `target_id`,**不能用 `tenantId` 筛** —— 已写进 `MODULE_TENANT` 常量注释与契约 §6.8 |
|
||||
| F-116 | ⚠️ **`expireBefore` 差一天**:`LocalDate.parse(...).atStartOfDay()` 与存进去的当天 `23:59:59` 比 → 「今天到期」的租户**查不出来**。同型的坑在 `LogController.parseTo` 上已经修过一次(那次是"查 9 月 30 日查不到当天")。另外格式不对时原先直接抛 50000 | 已修:`atTime(23,59,59)`(含当天);格式不对返回 `40001` 并说明格式 —— **刻意不学** `LogController.parseTo` 的"当没传":那两个是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";而这个值只由界面按当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了" |
|
||||
| F-117 | ⚠️ **`AUTO_CLOSE`(定时任务自动截止)是字符串字面量,不在常量表里**:`TaskService.autoClose()` 直接传 `"AUTO_CLOSE"`。所以"后端 `ACTION_*` 常量 ↔ 前端文案"这种检查**本来就扫不到它** —— 而它正是量最大的那类任务日志(库里 TASK 1548 行里的大头),界面上会**原样回显这个英文码**。(`STATUS_CHANGE` 兜底名同样是字面量) | 已修:提成 `OperationLogService.ACTION_AUTO_CLOSE` / `ACTION_STATUS_CHANGE` 并在 `TaskService` 用常量;新增检查 `check-wiring.py` 第 ⑩ 项(27 个动作码两端逐字核对),**已反向验证**(删掉前端一条文案 → 报红并 exit 1)。**教训**:这一族检查的价值取决于被检查的那一侧有没有被规范化 |
|
||||
| F-118 | ⚠️ **日志页的「模块」下拉里有两个永远筛不到的项**:`moduleOptions` 只有 4 项(ROSTER/TASK/AUDIT/EXPORT),其中 `AUDIT` / `EXPORT` **后端一个写入点都没有**,而真正会写的 `FILE`(代传材料)与 `ROLE`(6 个角色动作)**不在下拉里** —— 选中死项只会得到空表,让人以为"这段时间没有审核动作" | 已修:`apis/xt/log.ts` 导出 `MODULE_OPTIONS`(TASK/ROSTER/FILE/ROLE/TENANT)与 `TARGET_TYPE_OPTIONS`,口径写明"只列后端真有写入点的模块"。⚠️ 顺带登记:`MODULE_AUDIT` / `MODULE_EXPORT` / `MODULE_USER` / `MODULE_JOIN` 四个常量当前**零调用点**,其中 `MODULE_JOIN` 是需求收敛后遗留的死常量 |
|
||||
| F-119 | ⚠️ **`LogController` 的 `/logins` 描述与事实相反**:`@Operation` 里写着「一期的登录日志只有 PC 账号密码登录…目前不写这张表(见 F-31)」,而 **F-31 已闭环**(`WechatLoginLogger` 会写 `login_type=2`)。这是"注释从正确的当时留到了不正确之后"那类问题 | 已修:描述改为按 `loginType`(`PASSWORD` / `WECHAT`)说明,并写明 `location` **恒为 null**(没接 IP 库) |
|
||||
| F-120 | 📌 **`request_param` / `duration_ms` 有列、无写入点**:全库 1758 行里两列都是 **0 行有值**(`max(length(extra_json))=45`、`max(length(user_agent))=125`,而这两列从来没被 `setXxx` 过)。它们也不在 `OperationLogMapper.listOperations` 的 SELECT 里 | **不做**(已写进契约 §13.4):要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,而 `OperationLogService` 的类注释已论证过"合规留痕不用 AOP";界面上不展示、也不做筛选。本轮只把**轻字段** `request_method` / `request_uri` / `extra_json` 补进列表 SELECT(实测一页 20 行约 2 KB),因此**不需要**为详情新开接口 |
|
||||
| F-121 | 📌 **`docs/db/schema-postgresql.sql` 缺三张表**:只含 `sys_tenant`,没有 `sys_operation_log` / `sys_login_log` / `sys_tenant_usage_snapshot`(这三张只在 Flyway 迁移 `V2` / `V9` / `V17` 里)。按它建库会缺日志与用量快照 | **本轮不动**(登记待办):Flyway 才是权威,那份 DDL 是给 DBA 看的快照,补齐它属独立一件事 |
|
||||
| F-122 | 📌 **只在平台身份下可达、且查业务表的接口 = 租户越界的风险面**:F-112 暴露的是一个**模式**,不是一处笔误 —— 凡是"平台专属权限码 + 查带 `tenant_id` 的表",都会因为 `ignoreTable()` 对平台返回 true 而**静默跨租户** | 本轮把 `tenant` 模块逐接口过了一遍(`list` / `detail` / `create` / `update` / `usage`),**只有 `usage` 统计业务表**,已修;`logs` 模块自己显式处理了范围(`logScopeTenantId`)。**留下的排查线索**:以后新增平台专属接口时,先问一句"它查的是不是带 tenant_id 的表" |
|
||||
| F-123 | ⚠️ **登录日志没有租户列 → 平台视角下这些行"不属于任何学校"**:`LoginLogVO` 11 个字段里没有 `tenantId`,而平台运营看到的日志是**跨单位**的(`log:viewAll`)。于是"某个账号什么时候登进来的"这个问题答不完整 —— 筛得了单位,却看不出每行属于哪个单位。`sys_login_log.tenant_id` 从 V2 建表起就有(`NOT NULL DEFAULT 0`),只是 VO 没带出来 | 已修:`LoginLogVO` 加 `tenantId`、`LoginLogMapper.listLogins` 的 SELECT 加 `l.tenant_id AS tenantId`、契约 §13.4 同步;界面上登录日志表在「时间」后加「单位」列(`0` → 平台),**仅平台身份显示**(学校管理员自己就是那个单位,加了是噪音) |
|
||||
| F-124 | ⚠️ **`LoginLogVO.userType` 的 Javadoc 与真实枚举错位**:写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,而权威口径(`BuiltinRole.UserType`、`UserService.USER_TYPE_NAMES`、库列注释、《03》§2.7)是 **1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生**。实测库里 `user_type=1` 的行就是 `platform` 账号。这是**同一个坑的第三次出现**(该错位枚举曾导致"把班主任当成社会人员拒掉") | 已修:Javadoc 改为正确枚举 + 写明"权威来源是 `BuiltinRole.UserType`,别按模板那套映射"。前端本来就用对了(复用 `USER_TYPE_NAMES`,没按评审意见里那句错的口径写),所以这处只影响读代码的人 |
|
||||
| F-125 | ⚠️ **学生被驳回后「改不了任何东西」(P0 · 阻断闭环)**:`status=4` 时改字段 → `40030`(`SubmissionService.java:158-161`)、传材料 → `40030`(`MaterialFileService.java:103-105`)、删材料 → `40030`(`:338-341`)、撤回 → `40001`(只放行 1/2,`StudentTaskService.java:190-196`),而**重新提交是允许的**(`SubmissionService.java:293-297`)—— 于是学生能看到"被驳回,请修改后重新提交"却什么都没法改。**四处信号同时说他能改**:`statusText`(`StudentTaskService.java:142`)、`canFillNow=true`(`:105-106`,`underReview` 只算 1/2/3)、`progress.canEdit=true`(`:311`)、`progress.canSubmit=true`(`:313`)。契约站学生一边:《03》`§2.4`(`docs/03-接口设计.md:239-248`)写 `4 已驳回 → 可流转到 1(学生重提交)` | **本轮只测不改**,已登记 `docs/11` F-125 + `scripts/e2e-m3-flow.py` 三条 XFAIL(`EP-E09`/`EP-F10`/`EP-F10b`/`EP-I09`)。**为什么 1617 条断言没抓到**:`scripts/e2e-m5-audit.py:531-538` 重交前只改了库里的 `reject_count`,**没改任何业务字段** —— 它证明的是"原样重交能过",而问题在"改了反而过不去"。**建议最小改法**(改业务口径,须确认):三处写守卫从 `!= STATUS_DRAFT` 放宽为 `∉ {STATUS_DRAFT, STATUS_REJECTED}`,与 `submit`、`progress.canEdit` 对齐 |
|
||||
| F-126 | 📌 **`scripts/e2e-m3-draft.py` 的 docstring 自述虚高**:`:11` 写着 `[x] …已提交的不能直接改`,但该文件**全文没有这条断言**(全仓唯一涉及 `40030` 的是 `scripts/e2e-m5-proxy.py:277`,测的是**代传**路径)。docstring 的 `[x]` 清单在本仓库被当作覆盖率的**唯一账本**(`verify-all` 的汇总与多份文档的"37 个脚本 / N 条断言"都建立在它上面),所以虚高比 F-84 更隐蔽:断言缺失不会让任何东西变红,反而让覆盖率**看起来更好** | 本轮已补断言:`EP-E08`(已提交后 `PUT` → `40030`)与 `EP-F09`(已提交后上传 → `40030`),均通过。**教训固化成规程**:`docs/11` §3.1 要求用 `grep 'check(\|ok('` 核对既有脚本的**实际**断言而不是读自述 —— 这条规程一上来就抓到了本项 |
|
||||
| F-127 | 📌 **契约 §9.10 的口径与实现不一致,且未登记在「有意偏离」清单里**:《03》`§9.10`(`docs/03-接口设计.md:1986-1996`)写"校验失败返回 `40001` + `data.blocking[]/warnings[]/canForceSubmit`",实现是 **HTTP 200 + `code:0`** + `data{submitted:false,status,statusText,redCount,yellowCount,infoCount,issues[]}`(`FillController.java:59-70`,代码注释给了理由:校验不通过是正常业务结果、要一次给全清单)。实现口径与项目约定一致、37 个脚本也都按它写,**但 `§17.2` 的 D-1~D-12(`docs/03-接口设计.md:2972-2988`)里没有它** | 本轮只提议:在 `§17.2` 补一行 **D-13**(表格仍 5 列,不影响 `verify-docs.py`)。**后果**:文档读者会写出 `code != 0` 的判据,而那是**恒假**的 —— 测试静默变绿 |
|
||||
| F-128 | ⚠️ **扩展字段「存得进、读不出」,必填扩展字段会让整个任务永久无法提交(P0 · 阻断功能)**:`PUT /mp/submissions/{id} {"extrasJson":{...}}` → `code=0` 且库里 `extras_json` 确实写进去了,但 `GET /mp/tasks/{id}/form` 该字段 `value` 恒为 `null`,提交时 `VAL-22` 恒报"还有必填项没填"。**根因**:`FillService.assemble`(`FillService.java:106-159`)构造取值表只有两个来源 —— 名册列、以及 `schema.getFields()` 中 `SubValues.hasColumn(key)` 成立的**列**,**从不把 `draft.extrasJson` 合进 `values`**;而扩展字段的值只存在 `extras_json` 里。于是 `SubmissionValidator.validate`(`SubmissionValidator.java:104-109`)重建的 `values` 里也没有它,`checkRequiredFields` 的 `blank()` 恒为真 | **本轮只测不改**,已登记 `docs/11` F-128 + 两条 XFAIL(`EP-D09` 回显、`EP-J02e` 提交)。**为什么至今没暴露**:内置表单(`BuiltinFields.java`)**一个扩展字段都没有**,既有 37 个脚本用的全是内置表单 → 这条路径从来没有数据走过。**建议最小改法**(改读取口径,须确认):在 `FillService.java:144-158` 那段循环之后,把 `schema.getExtras()` 的每个键从 `draft.getExtrasJson()` 解析后 `values.put(key, v)`;⚠️ 不要顺手把扩展字段塞进 `changed_fields`(那是给内置列用的) |
|
||||
| F-129 | 📌 **把已声明的扩展字段放进 `formData` 不会报错,值被静默丢弃**:`{"formData":{"extra_upgrade_note":"..."}}` → `code=0`,`extras_json` 仍是 `{}`。对照:**未声明**的键放进 `formData` 会明确 `40001`(`SubmissionService.java:178-182`,注释写着"不静默忽略");而**已声明**的扩展键在 `defs` 里查得到(`:169-171` 把 extras 也放进了 `defs`),于是躲过那道检查,接着 `SubValues.apply(sub, key, value)` 对它**没有对应列、静默什么都不做** | 契约要求扩展字段走 `extrasJson`(`DraftDtos.java:44`),属"用错参数";但同一个方法里"不静默忽略"的原则对两种塞错口袋的待遇不一致,前端写错一次就是学生白填一遍。已登记 `docs/11` F-129 + `EP-E16`(钉住现状)。**建议**:`SubValues.apply` 遇到"既不是列、又确实是声明过的扩展键"时抛 `40001`,提示"扩展字段请放在 `extrasJson`" |
|
||||
| F-130 | ⚠️ **微信小程序端的学生完全进不来,登录链路断成两截(P0 · 功能不可用)**:开发者工具模拟器里实测,扫任务码进小程序 → 落地页正常 → 点「开始填报」→ 填姓名 + 身份证 → 点「核对并继续」→ 屏幕只有**「未登录或登录已失效」**。**断点 A(前端)**:小程序端**从来没有调用过 `/mp/login`** —— 全仓 `frontend-mp/src` 里唯一写 token 的地方是 `pages/index/index.vue:222`(从 **H5 授权回调**的 `options.token` 取),而 9 个页面的 `token()` 都读 `uni.getStorageSync('token')`;搜不到 `wx.login`/`uni.login`/`/mp/login` 的**调用**(只有 `utils/request.ts:22` 一句注释「登录流程见契约 §5.x 的 /mp/login,**属 M3**」),也**没有 `web-view` 兜底**。**断点 B(后端)**:`/mp/login` 只要 `scene` 带 taskId 就**必然 500** —— `MpLoginService.java:127` 用普通 `BaseMapper.selectOne` 查 `collect_roster`,而该接口是 `@PublicApi`(匿名)→ **租户拦截器默认拒绝** → `BizException(40101)` → 被 MyBatis 包成 `PersistenceException` → `GlobalExceptionHandler` → **HTTP 500 / code 50000「系统繁忙,请稍后再试」** | **本轮只测不改**,已登记 `docs/11` F-130 与 §5.4。**对照实验**(同秒同端点,只改 scene):`t1` → **200 + token**;`t1-k2313` → **500** —— 即"只在永远用不到的场景下能用"(学生扫的码必然带 taskId)。日志铁证:`已建学生账号:uid=1389` 之后紧跟 `Cause: BizException: 未登录或登录已失效`,栈顶 `MpLoginService.login(MpLoginService.java:127)`。**副作用已核实**:`login()` 是 `@Transactional`,账号随异常**回滚**(库里只有 `scene=t1` 成功建出的 uid 1390),所以是"每次点都 500、什么也没留下",不是"攒下孤儿账号"。**为什么至今没暴露**:① `/mp/login` 没有任何 E2E 覆盖(`scripts/e2e-m3-gate.py:18` 写着"脚本拿不到 wx.login code" —— **本轮证明它拿得到**,开发者工具自动化里可在页面上下文调 `wx.login()` 换到真 code);② 文档互相矛盾(`docs/08:147` 记「学生端 `/mp/login` + 手机号绑定 ✅ 已完成」指的是**后端**,`docs/交接摘要.md:563` 还留着「`/mp/login`(小程序登录)仍未做」),**没有一处说"前端没接"**。**修法方向**(未实施):该分支改走**专用 Mapper + `@InterceptorIgnore(tenantLine=\"true\")` + 显式带 tenant_id**(AGENTS.md 那条铁律,`MpH5Mapper.selectByOpenId(tenantId, openId)` 就是这么做的,这里漏了);另外 40101 不该被包成 50000 对外 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+79
-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,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"}` | 见下 |
|
||||
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |
|
||||
|
||||
|
||||
+10
-3
@@ -8,17 +8,21 @@
|
||||
|
||||
---
|
||||
|
||||
## §1 三种角色,三个入口
|
||||
## §1 四种角色,四个入口
|
||||
|
||||
| 角色 | 入口 | 设备 | 主要做什么 |
|
||||
|---|---|---|---|
|
||||
| **学生** | 扫学校/班主任发的**二维码** | 手机(微信小程序 / H5) | 核对身份 → 填表 → 上传材料 → 提交 |
|
||||
| **班主任** | 微信小程序(同一套码,登录后进教师端) | 手机 | 看本班进度、审本班材料、代传、催报 |
|
||||
| **学校管理员** | 电脑浏览器打开管理端 | PC | 建任务、配表单与材料、导名册、终审、导出交付包 |
|
||||
| **平台运营** | 电脑浏览器打开管理端(账号**不用填单位编码**) | PC | 开通学校、改到期与联系人、停用学校、查全平台日志 |
|
||||
|
||||
**⚠️ 学生与老师的小程序是同一个**,靠账号角色区分。学生第一次要"核验身份"(填姓名+证件号),
|
||||
这一步就是**门禁**:名册里没有的人进不去(这是产品铁律,见 §5.1)。
|
||||
|
||||
**⚠️ 平台运营是唯一跨单位的角色**:它看的是**所有学校**的数据,所以在"组织与账号""收集管理"
|
||||
这类本来就是单位内的页面上,它必须先选"**当前学校**"(顶栏那个切换),选之前看到的是混在一起的。
|
||||
|
||||
---
|
||||
|
||||
## §2 学生操作手册
|
||||
@@ -199,7 +203,9 @@
|
||||
| 改学校/院系/班级结构 | 组织与账号 → 组织架构(也支持 Excel 导入) |
|
||||
| 加一个下拉选项(如多了种学历写法) | 配置 → 字典管理(**内置项不可改名**,要加就追加自定义项) |
|
||||
| 给某类人配一套能力范围 | 组织与账号 → 角色与授权(见 §4.5) |
|
||||
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 操作日志 |
|
||||
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 日志(「操作日志」页签,点某一行看详情) |
|
||||
| 查"某个账号什么时候登进来的、从哪登的" | 平台运维 → 日志 → 「登录日志」页签 |
|
||||
| 开通一所新学校 / 改到期日 / 停用学校 | 平台运维 → 租户管理(**仅平台运营可见**;停用前会二次确认) |
|
||||
| 查"通知到底发出去没有" | 收集管理 → 通知记录 |
|
||||
| 看某个任务的分班进度 | 工作台 → 数据看板(选一个任务) |
|
||||
|
||||
@@ -261,7 +267,8 @@ A:系统上限是 3 次(防反复折腾)。到上限后由班主任/管理
|
||||
3. **屏幕上的完整提示文字**(照抄,不要转述 —— 提示文案与错误码是一一对应的);
|
||||
4. **发生时间**(精确到分钟)。
|
||||
|
||||
有了这四样,技术同学可以直接去「操作日志」与「通知记录」里定位。
|
||||
有了这四样,技术同学可以直接去「日志」(平台运维菜单,含操作日志与登录日志两个页签)
|
||||
与「通知记录」里定位 —— 日志详情里能拿到 `traceId`,那是后端排查时最有用的一个线索。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
# 填报流程端到端测试计划
|
||||
|
||||
> 版本:V1.0 | 日期:2026-10
|
||||
> 读者:测试、开发(自测)、项目经理(验收)
|
||||
> 配套:《03-接口设计.md》§9 / §11、《06-验收测试清单.md》、《04-核心模块设计说明.md》
|
||||
> 执行入口:`python scripts/e2e-m3-flow.py`
|
||||
> 核心原则:**能用真实数据断言的不靠人眼;能自动化的一次写、长期跑**(与《06》同口径)
|
||||
|
||||
---
|
||||
|
||||
## 一、目标与范围
|
||||
|
||||
### 1.1 一句话
|
||||
|
||||
把「学生扫码 → 门禁 → 填报 → 传材料 → 提交 → 我的任务 → 撤回 → 审核 → 驳回 → 重交」这条**完整链路**做成一张**覆盖全、责任清**的用例矩阵,并只把**没人覆盖**的那些用例写成可执行脚本。
|
||||
|
||||
### 1.2 为什么不能只靠既有脚本
|
||||
|
||||
本仓库已有 **37 个 `scripts/e2e-*.py` / 1617 条断言**,且 **37 个全绿**(见《交接摘要》§一·四十的验证一节)。但它们是**按模块切片的**:门禁一个脚本、草稿一个脚本、提交一个脚本、审核一个脚本。切片测试有三个固定的盲区,本计划就是为它们而写:
|
||||
|
||||
| 盲区 | 具体表现 | 本计划的对策 |
|
||||
|---|---|---|
|
||||
| **跨步骤的状态一致性没人管** | 「名册行的 `submit_status` 从 0 走到 3」被四个脚本各断言一格,但**没有人断言这条轨迹是连续的** | §3.11 J 组 |
|
||||
| **切片之间那条缝** | 驳回后「能重新提交」被 `e2e-m5-audit.py:534` 证明了,但「能**先改再**提交」没有人证明 —— 而后者才是驳回的意义 | §3.10 I 组、§6 |
|
||||
| **脚本自述 ≠ 实际断言** | `e2e-m3-draft.py:11` 自述覆盖「已提交的不能直接改」,实际**全文没有这条断言**(无 `40030` 出现) | §3.1 的核对规程 |
|
||||
|
||||
> **规律**:切片测试的通过率越高,「缝里那一块」越安全 —— 因为没有人会去看缝里。F-84(「HTTP 200 + code 0」不等于动作成功,`docs/08-完成度盘点与问题清单.md:452`)就是同一个规律的产物。
|
||||
|
||||
### 1.3 分层与执行方式
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ L3-B 人工/浏览器走查(加分项) │
|
||||
│ H5 真机形态:门禁 → 填报 → 传图 → 提交 → 进度 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ L3-A 端到端接口链路(自动 · 本计划主证据) │
|
||||
│ scripts/e2e-m3-flow.py + 既有 37 个 e2e 脚本 │
|
||||
│ 真实接口 + docker exec xt-pg 查库断言 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ L2 机械校验(自动 · 不需要后端) │
|
||||
│ check-api-parity / check-entity-columns / verify-docs │
|
||||
│ check-manual / check-wiring │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.4 成功判据(DoD)
|
||||
|
||||
| # | 判据 | 怎么验 |
|
||||
|:--:|---|---|
|
||||
| 1 | 覆盖矩阵每行都有「覆盖者」,**没有一行是空的** | 人眼过一遍 §三 |
|
||||
| 2 | `scripts/e2e-m3-flow.py` 退出码 0 | 跑它 |
|
||||
| 3 | 该脚本 **至少 90 条断言** | 脚本末尾的 `通过 X 失败 Y 共 Z`(**实测 95**) |
|
||||
| 4 | `python scripts/verify-all.py --with-e2e` 全绿,脚本数 37 → 38 | 跑它 |
|
||||
| 5 | 缺陷有 XFAIL 登记 + F 编号,**且缺陷修好后 XPASS 会报红** | §4.5(**实测 6 条 XFAIL**) |
|
||||
| 6 | 演示态(任务 17 / 2097 / 2215)与名册绑定**不被本脚本破坏** | 跑完查库(**实测残留 0**) |
|
||||
|
||||
---
|
||||
|
||||
## 二、权威口径与判据来源
|
||||
|
||||
### 2.1 期望从哪来(裁决顺序)
|
||||
|
||||
1. **契约**:《03-接口设计.md》§9.1~§9.12(学生填报)、§11(审核)、§2.4 提交单状态、§2.5 红黄蓝级别、§2.6「无」的语义。
|
||||
2. **实现**:`backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/*.java`、`module/file/service/*.java`、`module/mp/service/*.java`。
|
||||
3. **常量**:`CollectSubmission.java:122-127`、`CollectRoster.java:87-99`、`xt-common/.../api/ErrorCode.java`。
|
||||
|
||||
⚠️ **契约与实现不一致时,以契约为期望**,把差异写成 XFAIL + 缺陷记录。**不许**按「实现更合理」把期望改成实现的样子 —— 那等于让测试给实现背书(AGENTS.md 铁律:文档是需求源)。
|
||||
|
||||
### 2.2 明确**不测**的部分
|
||||
|
||||
| 不测 | 原因 |
|
||||
|---|---|
|
||||
| `40004` / `40005` / `40006` / `40043` / `50002` / `50003` / `50004` | M4(OCR、归属校验、串档检测)**一期缓做**,等供应商选型 —— 这些码零引用,见 `scripts/check-wiring.py:130-149` 的例外表 |
|
||||
| `collectMode=2`(社会收集)/ `certType=2,3` | 2026-10 需求收敛,一律 `40044` |
|
||||
| 微信真实授权(非打桩) | 需要真人在微信里点「允许」;打桩路径已覆盖同一段发 token 的代码 |
|
||||
| 导出(M6)/ 通知(M7)/ 压测 | 各自有专门的脚本与文档,不在填报链路 |
|
||||
|
||||
### 2.3 判据速查
|
||||
|
||||
**提交单状态**(`CollectSubmission.java:122-127`,契约 §2.4)
|
||||
|
||||
| 值 | 含义 | 契约允许流转到 |
|
||||
|:--:|---|---|
|
||||
| 0 | 草稿 | 1 |
|
||||
| 1 | 待初审 | 2 / 4 |
|
||||
| 2 | 待终审 | 3 / 4 |
|
||||
| 3 | 已通过 | 5 |
|
||||
| 4 | 已驳回 | **1(学生重提交)** |
|
||||
| 5 | 已归档 | — |
|
||||
|
||||
**名册行 `submit_status`**(`CollectRoster.java:87-99`):`0` 未提交 / `1` 草稿 / `2` 待审 / `3` 已通过 / `4` 已驳回。
|
||||
|
||||
**任务状态**(`collect_task.status`):`0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` 暂停。**只有 `1` 能填报**(`SubmissionService.java:339-347`、`FillService.java:71-81`)。
|
||||
|
||||
**问题级别**(契约 §2.5):`1` 信息(蓝)/ `2` 警告(黄,可确认后提交)/ `3` 错误(红,必须改)。
|
||||
|
||||
---
|
||||
|
||||
## 三、覆盖矩阵
|
||||
|
||||
### 3.1 怎么读这张表
|
||||
|
||||
`覆盖者` 一列只有四种取值:
|
||||
|
||||
| 取值 | 含义 |
|
||||
|---|---|
|
||||
| `既有脚本 文件:行` | 该断言**已经存在**且已在跑(`verify-all.py --with-e2e`) |
|
||||
| **本脚本** | 既有脚本**没有**覆盖,由 `scripts/e2e-m3-flow.py` 实现 |
|
||||
| `人工走查` | 只能靠浏览器/真机看的(本计划的 L3-B 部分) |
|
||||
| `M4 缓做` | 一期不做,**不写用例** |
|
||||
|
||||
⚠️ **「既有脚本」这一列是核对出来的,不是抄脚本自述的。** 核对规程:
|
||||
|
||||
```bash
|
||||
grep -n 'check(\|ok(' scripts/e2e-m3-*.py scripts/e2e-m5-audit.py
|
||||
```
|
||||
|
||||
一条一条对照实际断言文案。**这条规程本身就是必要的**:`e2e-m3-draft.py:11` 的 docstring 自述「已提交的不能直接改」,而该文件全文没有这条断言 —— 自述覆盖了一个它没测的东西。
|
||||
|
||||
### 3.2 A 组 · 扫码落地页(匿名)
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| A1 | 合法 scene,返回租户名/任务名/能否填报 | `valid=true`,`canSubmit` 与任务状态一致 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
| A2 | 非法 scene / 不存在的租户 / 不存在的任务 | 三种**不同的**提示,不泄漏是否存在 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
| A3 | 跨租户:A 租户的 scene 拼 B 租户的 taskId | 当作「不存在」 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
| A4 | 带班级的 scene:班级名只在**该班确实在覆盖范围内**时才返回 | 否则不返回 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
| A5 | 限流(scene 可穷举) | 能挡住批量扫描 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
|
||||
### 3.3 B 组 · 登录与鉴权
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| B1 | `GET /mp/h5/stub-login?openid&scene` | 302;`Location` 是 **hash 路由**格式 `...#/pages/index/index?token=..&scene=..`,token **不在** query | 既有脚本 `scripts/e2e-m3-h5-auth.py:163` |
|
||||
| B2 | stub-login 不带 scene | 302 + `authError=打桩登录需要带合法的 scene`(不猜租户) | 既有脚本 `scripts/e2e-m3-h5-auth.py` |
|
||||
| B3 | 拿到的 token 真的能用 | `GET /mp/tasks` → `code=0` | 既有脚本 `scripts/e2e-m3-h5-auth.py` |
|
||||
| B4 | **不带 `Authorization`** 调 `/mp/tasks` | `40101` | **本脚本** |
|
||||
| B5 | **签名被篡改**的 token | `40101` | **本脚本** |
|
||||
| B6 | **`exp` 已过期**的 token | `40101` | **本脚本** |
|
||||
|
||||
> B4~B6 的意义:既有脚本都在证明「有效 token 能用」(`e2e-m3-mytask.py:140`、`e2e-m3-gate.py:175`),**没有一条证明「无效 token 不能用」**。认证的负向路径从来没人走过。
|
||||
|
||||
### 3.4 C 组 · 门禁核验
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| C1 | 姓名 + 证件号与名册一致 | `code=0`,绑定该行,返回姓名与**脱敏**证件号 | 既有脚本 `scripts/e2e-m3-gate.py:339` |
|
||||
| C2 | 号码不在名册 | `40007` | 既有脚本 `scripts/e2e-m3-gate.py:366` |
|
||||
| C3 | 姓名不对 | `40007`,且提示与 C2 **逐字相同**(防枚举) | 既有脚本 `scripts/e2e-m3-gate.py:364` |
|
||||
| C4 | 已绑自己的人再核验 | `code=0`(幂等,刷新页面不该出现「已被登记」) | 既有脚本 `scripts/e2e-m3-gate.py:356` |
|
||||
| C5 | 另一个账号核同一名册行 | `40072` | 既有脚本 `scripts/e2e-m3-gate.py:379` |
|
||||
| C6 | 号码格式乱 / 前 10 次失败 / 第 11 次限流 | `40007` × 10 → `40071` | 既有脚本 `scripts/e2e-m3-gate.py:417,430-431` |
|
||||
| C7 | **明文证件号不落任何地方** | 门禁**响应全文**无明文;库里 `collect_roster` 只有 `id_card_no`(密文)/`id_card_hash`/`id_card_mask`;`sys_operation_log.request_param` 也无明文 | 响应字段侧:既有脚本 `scripts/e2e-m3-gate.py:339`;**全文扫 + 库 + 日志侧:本脚本**(5 条断言,全绿) |
|
||||
| C8 | **任务已截止/已暂停时核验** | **仍 `code=0`**(`MpRosterService.java:107-175` 不判任务状态,这是**有意的解耦**);但紧随其后的 `GET /mp/tasks/{id}/form` 必须 `40008` | **本脚本** |
|
||||
|
||||
> C8 是「钉住一个刻意的设计」。它看起来像 bug(截止了还能核验?),其实绑定与任务状态解耦是对的:学生可能只是回来核对身份。**必须有用例把它钉住**,否则后来者「顺手加个校验」就把它改了,而且没有任何东西会红。
|
||||
|
||||
### 3.5 D 组 · 表单结构与三态
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| D1 | 未过门禁取表单 | `40007` | 既有脚本 `scripts/e2e-m3-draft.py:139` |
|
||||
| D2 | **不存在的 taskId** | `40001「任务不存在或已删除」` | **本脚本** |
|
||||
| D3 | **任务为草稿(0)** | `40008` | **本脚本** |
|
||||
| D4 | **任务已暂停(4)/ 已截止(2)/ 已归档(3)** | `40008`(逐状态各一次) | **本脚本** |
|
||||
| D5 | 收集中:内置字段 **21 项**、分区结构完整 | `len(fields)==21` | 既有脚本 `scripts/e2e-m3-draft.py:153` |
|
||||
| D6 | 三态:`occupation/worktype/job_level` 锁定且值来自名册;`education` 可改且 `prefilled=true` | 见断言 | 既有脚本 `scripts/e2e-m3-draft.py:154-157` |
|
||||
| D7 | **表单响应里搜不到明文证件号**(全文扫一遍) | 只有 `******` 形式的脱敏值 | **本脚本** |
|
||||
| D8 | **条件可见性(字段侧)**:`is_student` 真/假切换后,学籍三项与工作证明类字段的可见性 | 与 `SubmissionValidator.visible()` 同口径(未知运算符 **fail-open**) | **本脚本** |
|
||||
| D9 | 有草稿时取表单带出 `submissionId`,草稿值覆盖名册值 | 见断言 | 既有脚本 `scripts/e2e-m3-draft.py:184-192` |
|
||||
| D10 | **扩展字段要能回显**:用 `extrasJson` 存进去的值,下次取表单要在字段上带出来 | 见期望 → 实测恒为 `null`(**XFAIL · F-128**) | **本脚本**(`EP-D09`) |
|
||||
|
||||
> D8 只做**字段侧**:条件**材料**侧的排除已被 `e2e-m3-submit.py:297` 与 `e2e-m3-mytask.py:179` 覆盖,不要重复。
|
||||
|
||||
### 3.6 E 组 · 草稿
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| E1 | `POST /mp/submissions` 幂等 | 首次 `created=true`,再次 `created=false` 且 id 相同,库里只有 1 份 | 既有脚本 `scripts/e2e-m3-draft.py:161-171` |
|
||||
| E2 | `PUT` 合并式保存:只传变化字段 | 其它字段不受影响 | 既有脚本 `scripts/e2e-m3-draft.py:175-192` |
|
||||
| E3 | 改锁定字段 | `40001`,提示含「老师」「班主任」;库里没被改 | 既有脚本 `scripts/e2e-m3-draft.py:197` |
|
||||
| E4 | 锁定字段改成**原值** | `code=0`(不算改动) | 既有脚本 `scripts/e2e-m3-draft.py:206` |
|
||||
| E5 | 传不认识的字段 | `40001「表单里没有这个字段」`(不静默忽略) | 既有脚本 `scripts/e2e-m3-draft.py:211` |
|
||||
| E6 | 清空预填字段 | 库里真变 `NULL`;回显为空,**不被名册值顶回** | 既有脚本 `scripts/e2e-m3-draft.py:226-234` |
|
||||
| E7 | `changed_fields` 留痕 | 只含真改过的 | 既有脚本 `scripts/e2e-m3-draft.py:216-221` |
|
||||
| E8 | **已提交(status=1)后 `PUT`** | `40030` | **本脚本** |
|
||||
| E9 | **已驳回(status=4)后 `PUT`** | 契约 §2.4 期望可改 → 实测 `40030`(**XFAIL · F-125**) | **本脚本** |
|
||||
| E10 | 越权:用 B 的 token 改 A 的单 | `40001` | **本脚本** |
|
||||
| E11 | 不存在的 submissionId | `40001` | **本脚本** |
|
||||
| E12 | 建草稿不带 `taskId` | `40001「缺少 taskId」` | **本脚本** |
|
||||
| E13 | **建草稿后把任务暂停,再 `PUT`** | `40008` | **本脚本** |
|
||||
| E14 | **扩展字段白名单**:未声明的 `extra_*` → 拒;已声明的 → 落 `extras_json` | `40001` / 落库 | **本脚本** |
|
||||
| E15 | **扩展字段塞错口袋**:已声明的扩展键放进 `formData`(而不是 `extrasJson`) | `code=0` 但值被**静默丢弃**(**F-129**,钉住现状) | **本脚本**(`EP-E16`) |
|
||||
|
||||
> E8 是本计划**第一个被核对出来的自述缺口**:`scripts/e2e-m3-draft.py:11` 的 docstring 写着「已提交的不能直接改」,而全仓**没有任何断言涉及 `40030`**(唯一相关的是 `scripts/e2e-m5-proxy.py:277`,测的是**代传**那条路,不是学生自己的 `PUT`)。
|
||||
|
||||
### 3.7 F 组 · 材料
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| F1 | 上传 → 落库 → `process` | `final_size ≤ maxSizeKb*1024`;`standard_name` 按模板含姓名 + **脱敏号(用 X 不用 `*`)** | 既有脚本 `scripts/e2e-m3-file.py:314-336` |
|
||||
| F2 | 档位生效(P2 证件翻拍 → 长边限 1600)/ 原始件保留 | 见断言 | 既有脚本 `scripts/e2e-m3-file.py` |
|
||||
| F3 | `process` 幂等:重复处理不产生第二份规范文件 | 见断言 | 既有脚本 `scripts/e2e-m3-file.py:364` |
|
||||
| F4 | 格式白名单外的格式 | `40002` | 既有脚本 `scripts/e2e-m3-file.py:374` |
|
||||
| F5 | 超过份数上限 | `40001` | 既有脚本 `scripts/e2e-m3-file.py:376` |
|
||||
| F6 | **按字节判格式**:`.txt` 改名 `.jpg` → 拒;`.jpg` 里装 PNG → 收 | 不信后缀 | 既有脚本 `scripts/e2e-m3-file.py:423,429` |
|
||||
| F7 | **空文件** | `40001「文件是空的」` | **本脚本** |
|
||||
| F8 | **不存在的 materialCode** | `40001「这个任务里没有该材料项:xxx」` | **本脚本** |
|
||||
| F9 | **已提交(status=1)后上传 / 删除** | `40030` | **本脚本**(⚠️ **代传**侧的 `40030` 已由 `scripts/e2e-m5-proxy.py:277` 覆盖,学生自己那条路没有) |
|
||||
| F10 | **已驳回(status=4)后上传 / 删除** | 契约期望可改 → 实测 `40030`(**XFAIL · F-125**) | **本脚本** |
|
||||
| F11 | 越权:上传到 A 的单 / 删 A 的文件 | `40001` | **本脚本** |
|
||||
| F12 | `/mp/files/{id}/url`:本人 `code=0` + 非空 url;别人 `40001` | 见期望 | **本脚本** |
|
||||
| F13 | **§9.5 `upload-token` 预检 + 秒传**:同 hash 第二次 → `existed=true` 且**不给 uploadUrl** | 秒传是真的 | **本脚本** |
|
||||
| F14 | **§9.7 `batch-process`**:>200 个 fileId → `40011`;正常批量 → 逐份走同一 `process` | 见期望 | **本脚本** |
|
||||
| F15 | **§9.8 预览**:`prefer=original` 但原始件不存在 → 退到规范件,且 `actualKind` **如实告知** | 不假装是原图 | **本脚本** |
|
||||
| F16 | 条件材料:在校生不被要求工作证明 | 不阻断提交 | 既有脚本 `scripts/e2e-m3-submit.py:297` |
|
||||
| F17 | 越权:对**别人的文件**调 `process` → `40001` | 不靠前端隐藏按钮 | **本脚本** |
|
||||
| F18 | 越权:列**别人的填报单**的材料 → `40001` | 见期望 | **本脚本** |
|
||||
|
||||
### 3.8 G 组 · 提交与校验(红黄蓝全覆盖)
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| G1 | 缺必传材料 | `VAL-05` 红 | 既有脚本 `scripts/e2e-m3-submit.py:229` |
|
||||
| G2 | 缺必填字段 | `VAL-22` 红 | 既有脚本 `scripts/e2e-m3-submit.py:230` |
|
||||
| G3 | `VAL-01` **不该误报**(`id_card_no` 是密文,不能当明文算校验位) | 不出现 `VAL-01` | 既有脚本 `scripts/e2e-m3-submit.py:231` |
|
||||
| G4 | 在校生三项缺项 | `VAL-16` 红;缺项要**列全**;补齐后消失 | 既有脚本 `scripts/e2e-m3-submit.py:244,248,252` |
|
||||
| G5 | `VAL-15`:四级勾 2 科 → 红;勾了补考但 0 科 → **黄** | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py:258,268,279` |
|
||||
| G6 | 只黄无红 → 先 `submitted=false`;带 `confirmWarnings=true` 再提交 → `submitted=true` | 两次调用 | 既有脚本 `scripts/e2e-m3-submit.py` |
|
||||
| G7 | 提交后落库 + 名册行 → 2;重复提交被拒 | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py`、`scripts/e2e-m5-audit.py:308` |
|
||||
| G8 | **一次给全**:多类问题同时返回(不是改一条再拒一次) | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py` |
|
||||
| G9 | **「无」是合法值**:填「无」不算缺失(契约 §2.6,`docs/03-接口设计.md:258-265`) | 不报 `VAL-22` | **本脚本** |
|
||||
| G10 | **`VAL-22` 只对「可见 + 必填 + 非锁定」字段报** | 不要求在校生填「补考理论」 | **本脚本** |
|
||||
| G11 | **`VAL-17`**:工作年限「80」→ 红(0~60 阿拉伯数字) | 红 | **本脚本** |
|
||||
| G12 | **`VAL-17` 的非阿拉伯数字分支**:「三年」根本存不进去(`work_years` 是 `smallint`)→ 那段校验**没有入口** | 钉住「死分支」这一事实 | **本脚本**(`EP-G11a`) |
|
||||
| G12 | **`VAL-01` 正向**:名册行证件号为空 → 红「名册里没有你的身份证号」 | 红 | **本脚本**(G3 只测了不该误报) |
|
||||
| G13 | **`VAL-09`** 同一张图传到两个材料项 → 黄 | — | 既有脚本 `scripts/e2e-m3-submit.py:319` |
|
||||
| G14 | **`VAL-08` 清晰度偏低 / `VAL-20` 体积偏小** → 黄(不阻断) | 见期望 | **本脚本** |
|
||||
| G15 | 越权提交别人的单 → `40001`;无 token 提交 → `40101` | 见期望 | **本脚本** |
|
||||
| G16 | 任务暂停 / 已截止时提交 → `40008`;解绑门禁后提交 → `40007` | 见期望 | **本脚本** |
|
||||
| G17 | **判据纪律**:断言 `data.submitted` 而非 `code==0`(F-84) | 失败信息带红黄计数与 issues | 全脚本约定 |
|
||||
| G18 | **红色不能靠 `confirmWarnings` 强提绕过**(契约:它只对黄色有效,`SubmitDtos.java:100-101`) | `submitted=false` 且 `redCount>0` | **本脚本** |
|
||||
|
||||
### 3.9 H 组 · 我的任务 / 进度 / 撤回
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| H1 | 可见范围 = 名册绑定(没绑的人看不到该任务) | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:161` |
|
||||
| H2 | 草稿态:`canFillNow=true`、状态文案「还没开始」、计入 `todoCount` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:154-157` |
|
||||
| H3 | 审核中 / 已通过 / 已截止:`canFillNow=false` + `fillBlockReason` 各自不同 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:228,287,301-302` |
|
||||
| H4 | **`progressPercent` 是字符串**(`"0%"` → `"100%"`),分母只算**可见的必传材料** | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:189,213` |
|
||||
| H5 | 材料清单只列本次适用的(在校生 3 项,不含工作证明);`done/required/issueLevel` 正确 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:179-185` |
|
||||
| H6 | 时间轴只列**确实发生过**的节点 | 「没发生的事不许出现」 | 既有脚本 `scripts/e2e-m3-mytask.py:193,217` |
|
||||
| H7 | 撤回 status=1 → `status=0`、`submit_time` 清空、**名册行回 1** | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:235-241` |
|
||||
| H8 | 撤回边界:草稿态再撤 → 拒;已通过 → `40031` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:251,279` |
|
||||
| H9 | 越权:别人看我的进度 / 别人撤回我的单 → 拒 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:200,202` |
|
||||
| H10 | **status=4 时 `progress.canEdit` / `canSubmit`** | 实测 `true` —— 与 E9/F10 的 `40030` **直接矛盾**(**F-125 的运行时证据**) | **本脚本** |
|
||||
| H11 | **撤回后再改再提交**,且 `reject_count` 不因撤回增加 | 见期望 | **本脚本** |
|
||||
| H12 | **撤回 → 再提交**这一对(既有脚本只覆盖了「撤回」那一半,没有一段把它闭合) | 撤回回 0/名册 1;重交回 1/名册 2 | **本脚本** |
|
||||
|
||||
> H10 的证据来自 `StudentTaskService.java:311-313`:`setCanEdit(st == null \|\| st == 0 \|\| st == 4)`、`setCanSubmit(同)`。**读模型说「能改能交」,写模型(`saveDraft`/`storeFile`)说「`40030`」** —— 同一个类文件里的两个方法互相矛盾。
|
||||
|
||||
### 3.10 I 组 · 审核闭环与驳回重交
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| I1 | 班主任待办:被指定的初审人看得到、同角色但没被指定的看不到;跨班不可见 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:316,336` |
|
||||
| I2 | 初审通过 → `status=2`、名册行 3、审核记录 `action=1` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:406-436` |
|
||||
| I3 | 有红的单不允许通过 → `40032` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:385` |
|
||||
| I4 | 批量通过只带无问题项;红黄一律跳过并说明原因 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:452,485` |
|
||||
| I5 | 驳回:必填原因码、`R999` 必须带说明 → `status=4`、`reject_count`、名册行 4、审核记录 `action=2` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:496-517` |
|
||||
| I6 | 驳回达上限 → `escalated=true`(升级管理员,**不是**错误码) | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:528` |
|
||||
| I7 | **驳回后学生能重新提交** → `status=1`、名册行回 2 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:534,537` |
|
||||
| I8 | **驳回后学生侧的提示**:「被驳回,请修改后重新提交」+ `canFillNow=true` + 计入 todo | 见期望 | **本脚本** |
|
||||
| I9 | **驳回后先整改再提交**(改一个字段 + 换一份材料,再提交) | 契约 §2.4 期望可改 → 实测改不动(**XFAIL · F-125**) | **本脚本** |
|
||||
| I10 | 已通过后撤回 → `40031`;导出后 → `40033`;归档后 → `40034` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:279`、`scripts/e2e-m5-audit.py:557,565` |
|
||||
|
||||
> ⚠️ **I7 与 I9 的区别就是本计划存在的理由**:`scripts/e2e-m5-audit.py:531-538` 在重交前**只改了库里的 `reject_count`**(`UPDATE collect_submission SET reject_count=3`),**没有改任何业务字段** —— 它证明的是「原样重交能过」,而驳回的全部意义在于「改了再交」。切片测试把这一格填成了绿色,缝里的东西没人看。
|
||||
|
||||
### 3.11 J 组 · 跨步骤一致性(本脚本独有价值)
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| J1 | **名册行 `submit_status` 全轨迹**:`0` →建草稿`1` →提交`2` →驳回`4` →重交`2` →终审`3`,每一步查库 | 五步全对且**连续** | **本脚本** |
|
||||
| J2 | **表单升版后旧草稿提交**:`PUT /tasks/{id}/form-schema` 升版并加一个必填 `extra_*`,再提交旧草稿 | **不报 `40025`**;按**当前** schema 校验(新必填项会拦);`collect_submission.form_version` 保持创建时的快照值 | **本脚本** |
|
||||
| J3 | **并发提交**(两个连接同时 `POST /submit`) | 如实钉住当前行为(`SubmissionService.java:280-282` 明写一期不做并发保护),把「无保护」写成**已知事实**而不是假装有 | **本脚本** |
|
||||
|
||||
> **J1 的价值**:`scripts/e2e-m5-audit.py:308,436,517,537` 分别断言过 `2`、`3`、`4`、`2`,但**分散在四个上下文里**,没有任何一条断言「这一行的状态是连续走过来的」。分散断言无法发现「中间跳了一格」。
|
||||
> **J2 的顺序依赖**:它会改夹具 schema,必须排在 G 组之后(见 §4.2)。
|
||||
> **J3 的态度**:不做并发保护的**决定是书面的**(代码注释里写了为什么,以及将来要改的话该怎么改)。测试的职责是把这个决定**钉成可观测事实**,而不是替它加锁。
|
||||
|
||||
### 3.12 小结(实测口径,2026-10)
|
||||
|
||||
| 覆盖者 | 用例数 |
|
||||
|---|---|
|
||||
| 既有脚本(已在跑) | 约 55 |
|
||||
| **本脚本** | **45** |
|
||||
| 人工走查(L3-B / L3-C,加分项) | L3-B 4 条里走了 3 条(第 4 条未走,见 §5.3);**L3-C 小程序端另走了 10 步**(第 2 条由它补上) |
|
||||
| M4 缓做(不写用例) | 7 个错误码 |
|
||||
|
||||
**实测结果**:
|
||||
|
||||
| 跑法 | 通过 | 失败 | XFAIL |
|
||||
|---|:--:|:--:|:--:|
|
||||
| `--group chain` | 74 | 0 | 5 |
|
||||
| `--group errors` | 27 | 0 | 1 |
|
||||
| 无参数(chain + errors) | **95** | **0** | **6** |
|
||||
|
||||
(三种跑法都通过;`共 95` 已超过 DoD 要求的 90 条。)
|
||||
|
||||
---
|
||||
|
||||
## 四、脚本设计(`scripts/e2e-m3-flow.py`)
|
||||
|
||||
### 4.1 夹具
|
||||
|
||||
**两个任务**,都带随机 suffix:
|
||||
|
||||
| 任务 | 用途 | 状态 |
|
||||
|---|---|---|
|
||||
| `taskMain` | 跑 chain 组(完整链路) | 全程保持**收集中** |
|
||||
| `taskState` | 跑 errors 组里的**状态机边界**(草稿/暂停/截止/归档 → `40008`) | 按用例切换 |
|
||||
|
||||
**构造顺序**(每一步都走真实接口,与 `scripts/e2e-m5-audit.py:177-267` 同形):
|
||||
|
||||
1. `admin` / `Admin@123` + 验证码 `demo` 登录(学校管理员)
|
||||
2. 取一个 `orgType=3` 的班级
|
||||
3. 建初审班主任(`HEAD_TEACHER`、`userType=3`,拿返回的 `initialPassword` 登录)
|
||||
4. 建两个任务(`collectMode=1`、`certType=1`)
|
||||
5. 生成 xlsx 名册(2 人,`with_checksum` 补校验位)→ `upload_roster(..., {"orgId": ...})`
|
||||
6. `PUT /tasks/{id}/orgs`(覆盖班级 + 指定初审人)
|
||||
7. `POST /tasks/{id}/materials`:必传材料,**`minSizeKb=1`、`minLongSide=100`**
|
||||
8. `POST /tasks/{id}/publish`
|
||||
|
||||
⚠️ 第 7 步的下限必须调小。理由见 `scripts/e2e-m5-audit.py:255-263` 的实测注释:E2E 用的小图会被材料目录的默认下限判成「体积偏小 / 清晰度偏低」→ **黄色警告**,于是「绿色单」这个前提不成立,后面所有断言跟着错。**断言失败的样子像功能坏了,其实只是夹具不满足前提。**
|
||||
|
||||
⚠️ **绝不触碰**演示任务 17 / 2097 / 2215 及其名册绑定。跑 `scripts/e2e-m3-*.py` 会清掉 `collect_roster.bind_user_id`(AGENTS.md 点过),本脚本只操作自建任务。
|
||||
|
||||
### 4.2 用例顺序依赖
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|---|---|
|
||||
| `chain` 组必须整体跑 | J1 的轨迹断言依赖前序步骤真的发生过 |
|
||||
| **J2 必须在 G 组之后** | J2 会升版夹具的表单 schema;提前跑会让后续所有 `VAL-22` 断言多一个必填项 |
|
||||
| D3/D4/E13/G16 用 `taskState` | 它们会把任务切到暂停/截止/归档;用 `taskMain` 会毁掉 chain 组 |
|
||||
| `errors` 组内的越权用例需要**两个学生** | 夹具准备 2 行名册即可 |
|
||||
|
||||
### 4.3 命令行参数
|
||||
|
||||
```bash
|
||||
python scripts/e2e-m3-flow.py # 全部(chain + errors)
|
||||
python scripts/e2e-m3-flow.py --group chain # 只跑链路(J1 轨迹在这里)
|
||||
python scripts/e2e-m3-flow.py --group errors # 只跑错误矩阵
|
||||
python scripts/e2e-m3-flow.py --fixture-only # 只建夹具,打印 id/token,不清理、不跑用例
|
||||
python scripts/e2e-m3-flow.py --keep # 跑完不清理(配合人工走查)
|
||||
python scripts/e2e-m3-flow.py --h5-url # 打印可直接打开的 H5 地址(见 §5.3)
|
||||
python scripts/e2e-m3-flow.py --clean <taskId> # 只清理指定的夹具任务
|
||||
python scripts/e2e-m3-flow.py --full # 连限流类慢用例一起跑(默认跳过)
|
||||
```
|
||||
|
||||
### 4.4 清理
|
||||
|
||||
放 `finally`,**失败路径也要跑** —— F-85 的教训是「清理只在正常结束时跑,于是失败路径才是残留的来源」。
|
||||
|
||||
按 `task_id` 依次删:`collect_audit_record` → `collect_file` → `collect_submission` → `collect_roster` → `collect_material_def` → `collect_task_org` → `collect_export_task` → `collect_task`;再删 `sys_user_role` / `sys_user`(自建班主任)与临时 xlsx。最后**断言残留任务数为 0**。
|
||||
|
||||
### 4.5 XFAIL 机制
|
||||
|
||||
```python
|
||||
KNOWN_DEFECTS = {
|
||||
"EP-E09": "F-125", # 驳回后 PUT 被 40030 拒
|
||||
"EP-F10": "F-125", # 驳回后上传被 40030 拒
|
||||
"EP-F10b": "F-125", # 驳回后删除被 40030 拒(与上传是两条独立的守卫)
|
||||
"EP-I09": "F-125", # 驳回后整改不落库
|
||||
"EP-D09": "F-128", # 扩展字段存进去却回显不出来
|
||||
"EP-J02e": "F-128", # 必填扩展字段永远满足不了
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **登记之后,用例的断言要写「契约期望」而不是「现状」** —— 否则 XFAIL 会变成一条永远通过的断言,等于把缺陷写成了规格。`EP-I09` 第一版就写错了:它只断言「原样重交能成功」,而那本来就成功(`scripts/e2e-m5-audit.py:534` 已证明),于是它报了个 `XPASS` 说"缺陷已修"。改成断言「整改的值真的落库了」之后才如实变成 XFAIL。
|
||||
|
||||
| 情形 | 输出 | 计入失败 |
|
||||
|---|---|:--:|
|
||||
| 该用例按契约期望**失败** | `[XFAIL] EP-E09(已知缺陷 F-125)` | 否 |
|
||||
| 该用例**通过**了 | `[XPASS] EP-E09 缺陷似乎已修 —— 请删掉 KNOWN_DEFECTS 里的登记` | **是** |
|
||||
|
||||
为什么要 XPASS 报红:否则缺陷修好之后没有任何信号,登记会永远留在那里,而下一个人会以为它还在。
|
||||
为什么可以 XFAIL:否则 `verify-all --with-e2e` 长期红,红着红着就没人看了 —— 那比不测更糟。
|
||||
|
||||
### 4.6 判据纪律(每一条都有血)
|
||||
|
||||
| # | 纪律 | 血的来源 |
|
||||
|:--:|---|---|
|
||||
| 1 | 业务错误也是 **HTTP 200**,判 `code`;只有 `401/403/429/5xxxx` 看 HTTP 状态码 | 项目约定,`ErrorCode.httpStatus()` |
|
||||
| 2 | **「HTTP 200 + code 0」≠ 动作成功**:提交/发布/导入/批量审核一律断 `data` 里的结论字段(`submitted`/`created`…),失败信息带红黄计数与 issues | **F-84**(`docs/08-完成度盘点与问题清单.md:452`) |
|
||||
| 3 | 每个接口级断言配一条**查库断言** | 「接口说成功了」与「数据库真的变了」是两件事 |
|
||||
| 4 | 软删字段是 `smallint`:写 `deleted=0`,**不是** `=false` | PG 会报 `operator does not exist: smallint = boolean` |
|
||||
| 5 | `Map` 结果的列别名会被**折成小写**,查库别用驼峰别名 | **F-54 / F-83** |
|
||||
| 6 | 清理放 `finally`,并断言残留数 | **F-85** |
|
||||
| 7 | 末尾必须打印 `通过 X 失败 Y 共 Z` | `scripts/verify-all.py:143` 靠这行正则统计;格式不对会被判成「无汇总」而算失败 |
|
||||
| 8 | 门禁限流按 openid 计(10 次/小时):学生 uid 每次现造;要清 Redis 键必须带 `-a redis_dev_pwd --no-auth-warning` **并检查返回码** | `scripts/dev-seed-audit.py:123-141`(不看返回码 → 「清了但没清掉」) |
|
||||
|
||||
---
|
||||
|
||||
## 五、执行步骤
|
||||
|
||||
### 5.1 前置
|
||||
|
||||
```bash
|
||||
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 不在 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
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/auth/captcha # 期望 200
|
||||
python scripts/e2e-m3-draft.py # 期望 27/27
|
||||
```
|
||||
|
||||
### 5.2 L3-A 主流程
|
||||
|
||||
```bash
|
||||
python scripts/e2e-m3-flow.py --fixture-only # 夹具前提不满足时立刻退出并打印清单
|
||||
python scripts/e2e-m3-flow.py --group chain # 实测:通过 74 失败 0 XFAIL 5
|
||||
python scripts/e2e-m3-flow.py --group errors # 实测:通过 27 失败 0 XFAIL 1
|
||||
python scripts/e2e-m3-flow.py # 实测:通过 95 失败 0 XFAIL 6
|
||||
python scripts/verify-all.py --with-e2e # 全量回归(38 个脚本,约 10 分钟)
|
||||
```
|
||||
|
||||
⚠️ **跑完确认没有残留**(本脚本自己在 `finally` 里清,但失败路径值得再看一眼):
|
||||
|
||||
```bash
|
||||
docker exec -e PGPASSWORD=fill_dev_pwd xt-pg psql -U fill -d filldb -t -A \
|
||||
-c "SELECT count(*) FROM collect_task WHERE task_name LIKE 'E2E流程%'" # 期望 0
|
||||
ls scripts/_tmp_flow_* # 期望没有
|
||||
```
|
||||
|
||||
### 5.3 L3-B 浏览器走查(加分项)
|
||||
|
||||
```bash
|
||||
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`),那里是 **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 —— 两者不是同一个人,混用会得到「门禁说你不在名册里」。
|
||||
|
||||
⚠️ **门禁要手动输入身份证号,而库里只有密文**。`scripts/_reveal-roster-idcard.py` 能解,但它把 task id **写死在 `:59`(1719)**;查别的任务用:
|
||||
|
||||
```bash
|
||||
python scripts/dev-reveal-roster-idcard.py <taskId> # 只读,只打印;解密方式见 AesGcmCipher
|
||||
```
|
||||
|
||||
⚠️ `--fixture-only` 不会触发清理(那是给手工排查用的),走查完要自己收尾:
|
||||
删任务(`--clean <taskId>`,或按 `docs/11` §4.4 的顺序逐个表删)、
|
||||
删 stub-login 建出来的 `sys_user`(它不在脚本的夹具清单里)、删 `scripts/_tmp_flow_*.xlsx`。
|
||||
|
||||
走查看这四件**接口测不出来**的事(**2026-10 已实测前 3 条,见下表**):
|
||||
|
||||
| # | 看什么 | 为什么接口测不出来 | 实测结果 |
|
||||
|:--:|---|---|---|
|
||||
| 1 | 无 scene 但有 token 时,是否跳到「我的填报」而不是报错 | `frontend-mp/src/pages/index/index.vue:156-169` 的分支 | ✅ 直达「我的填报」。⚠️ 但**不能靠"去掉 URL 里的 scene"来复现** —— scene 已进本地缓存,落地页仍会用它;要直接开 `#/pages/tasks/tasks?token=..` |
|
||||
| 2 | 有黄色警告时**弹确认框**,点「仍然提交」才真的提交 | `fill.vue:706-743` 的两次调用;`doSubmit` 不能把事件对象当 `confirmWarnings` 传(`:688-693` 的注释记过这个坑) | ✅ **已在 L3-C 小程序端走到**(H5 侧本轮只造出红色;小程序端用 72×72 的 logo 触发 `VAL-08` 黄单 → 页内「仍然提交」→ 提交成功) |
|
||||
| 3 | 驳回状态下每次失焦都提示「保存失败:…请先撤回」 | `fill.vue:378-380` —— **这就是 F-125 在学生眼里的样子** | ✅ **复现**(原文见下) |
|
||||
| 4 | 传图后结果卡显示**命名前后对比**与压缩后体积 | 只能看 | 未走(浏览器自动化无法选本地文件,见下) |
|
||||
|
||||
**第 3 条实测到的原话**(把 `status` 改成 4 后改任意字段并失焦):
|
||||
|
||||
```
|
||||
保存失败:已提交的填报单不能直接修改,请先撤回
|
||||
```
|
||||
|
||||
而**同一屏上没有任何「撤回」入口**;退到「我的填报」,卡片上写着
|
||||
**「被驳回,请修改后重新提交」**、按钮写着**「修改后重新提交」**,
|
||||
点进去却每一次修改都失败 —— 界面自己的行动号召把学生带进死胡同。
|
||||
这正是 F-125 的完整用户侧证据链(接口侧是 4 条 XFAIL)。
|
||||
|
||||
⚠️ **第 4 条为什么没走**:材料上传要选本地文件,而本轮可用的浏览器自动化**没有文件选择能力**。
|
||||
所以"传图 → 命名对比 → 压缩体积"这条只能人工走查;接口侧的等价断言在 L3-A(`EP-F05` 系列与 `EP-F15`)。
|
||||
|
||||
### 5.4 L3-C 微信小程序端走查(2026-10 补)
|
||||
|
||||
> 起因:需求方明确要求「**学生提交的过程要测微信小程序端**」。小程序与 H5 是同一套源码
|
||||
> (`frontend-mp/`,`#ifdef` 条件编译),但**小程序端此前从未被任何自动化跑过** —— AGENTS.md 的
|
||||
> 节奏是"先做 H5(可自动化测试、不依赖小程序资质)"。
|
||||
|
||||
**环境**(一次性的,`wmp_doctor` 可自检):微信开发者工具已装且已登录、自动化端口 9420、
|
||||
项目 = `frontend-mp/dist/dev/mp-weixin`(`pnpm dev:mp-weixin` 的产物;`project.config.json` 里
|
||||
`urlCheck:false`、appid `wxf01d3fa60f87f0b7` **与后端 `fill.mini-program.app-id` 逐字相同**)。
|
||||
|
||||
**前置**:① `python scripts/e2e-m3-flow.py --fixture-only --keep` 建夹具(名册行必须**未绑定**);
|
||||
② `python scripts/dev-reveal-roster-idcard.py <taskId>` 取门禁要手输的明文姓名/身份证。
|
||||
|
||||
**实测步骤与结果**(iPhone 12/13,SDK 3.17.3,开发者工具 8.0.5):
|
||||
|
||||
| # | 操作(自动化动作) | 结果 |
|
||||
|:--:|---|---|
|
||||
| 1 | `reLaunch /pages/index/index?scene=t1-k2313` | 落地页正常:示范职业学校 / E2E流程 / **收集中** / scene 回显 —— **匿名接口在小程序里也是通的** |
|
||||
| 2 | `tap .land__btn`(开始填报)→ `pages/verify/verify` | 正常 |
|
||||
| 3 | 两个 `input` 填姓名 + 身份证 → `tap button` | ❌ **「未登录或登录已失效」**(后端 40101)→ **F-130** |
|
||||
| 4 | 用 `evaluate` 在页面上下文里调 `wx.login()` + `wx.request` 打 `/api/v1/mp/login` | ❌ scene 带 taskId → **HTTP 500 / code 50000**;同秒换 `scene=t1` → ✅ **200 + token** → **F-130** |
|
||||
| 5 | `wx.setStorageSync('token', …)`(桥接,见下)后重走 1~3 | ✅ 门禁通过:流程甲 / 脱敏号 / 班级 / 报考三项锁定;库里 `bind_user_id=1390`、`bind_type=1` |
|
||||
| 6 | 进 `pages/fill/fill?taskId=2313` | ✅ 表单 68 个元素全渲染,锁定项标注正确 |
|
||||
| 7 | `mockWx chooseImage` → `tap`「+ 选择文件」 | ✅ 控制台 `[材料压缩] IDCARD P2 证件翻拍:4KB → 2KB(q88)` → `collect_file` 落库,规范名 `流程甲…-3610292008XXXXXX13-身份证.jpg` |
|
||||
| 8 | `tap`「提交审核」 | ✅ 页内红单:`VAL-05 缺少必传材料:身份证` + 「知道了,去修改」 |
|
||||
| 9 | 传材料后再提交 | ✅ 页内黄单:`VAL-08「身份证」清晰度偏低(72×72)` + **「仍然提交」**(= L3-B 第 2 条,H5 那轮没走到的确认框) |
|
||||
| 10 | `tap`「仍然提交」 | ✅ 提交成功:`collect_submission.status=1`、`submit_time` 写入、`collect_roster.submit_status=2` |
|
||||
|
||||
**结论**:小程序端**除登录外整条链路都是好的** —— 落地页、门禁、动态表单、前端压缩上传、
|
||||
红黄两档校验与确认框、提交后的名册流转,全部在真机模拟器里跑通。
|
||||
唯一的断点是登录(**F-130**),而且它断在两处、任一处都足以让小程序不可用。
|
||||
|
||||
⚠️ **第 5 步是"桥接",不是正路**:直接把 token 塞进 storage 只是为了把「登录坏了」与
|
||||
「其余链路好不好」分开验证。**修好 F-130 之后这一步必须删掉。**
|
||||
|
||||
⚠️ **为什么没有做成 `.flow.mjs` 固化**:本轮可选的小程序自动化动词(`reLaunch`/`tap`/`input`/
|
||||
`evaluate`/`mockWx`)都实测过,但**流程文件的字段形状**(带 `index` 的 `input`、带选择器的
|
||||
`assert.text`、设 storage 的 `wx` 动词)没有可验证的样例 —— **宁可留一份可照着做的步骤表,
|
||||
也不往仓库里放一个跑不起来、却看起来像能跑的脚本**(这与 F-126 的教训是同一条)。
|
||||
等 F-130 修好、登录不再需要桥接时,再把它固化成 flow 文件更划算。
|
||||
|
||||
⚠️ **别忘了收尾**:`--fixture-only` 不触发清理;**登录成功那一次**(本轮是 `scene=t1` 那次)会真建
|
||||
`sys_user`(本轮 1 个:uid 1390,并被门禁绑到名册行 21695),它不在脚本的夹具清单里,要手工删。
|
||||
失败的登录**不建账号**(事务回滚,见 F-130 的副作用一节)。
|
||||
|
||||
---
|
||||
|
||||
## 六、已知缺陷(已定案)
|
||||
|
||||
### F-130【P0 · 功能不可用】微信小程序端的学生**完全进不来** —— 登录链路断成两截
|
||||
|
||||
**症状**(真机模拟器实测):扫任务码进小程序 → 落地页正常 → 点「开始填报」→ 填姓名 + 身份证 →
|
||||
点「核对并继续」→ 屏幕显示 **「未登录或登录已失效」**。
|
||||
|
||||
**断点 A(前端):小程序端从来没有调用过 `/mp/login`。**
|
||||
|
||||
- 全仓 `frontend-mp/src` 里**唯一**写 token 的地方是 `pages/index/index.vue:222`
|
||||
(从 **H5 授权回调**的 `options.token` 取),而这条路径是 H5 专有的;
|
||||
- 9 个页面(`fill` / `verify` / `tasks` / `progress` / `messages` / `dashboard` / `audit` / `audit-detail` / `subscribe`)
|
||||
的 `token()` 都读 `uni.getStorageSync('token')`;
|
||||
- 全仓搜不到 `wx.login` / `uni.login` / `/mp/login` 的**调用**(只有 `utils/request.ts:22` 一句注释
|
||||
「登录流程见契约 §5.x 的 /mp/login,**属 M3**」);也**没有 `web-view` 兜底**。
|
||||
- ⇒ 小程序里 token **永远是空串**,`POST /mp/roster/match` 不带 Authorization → 后端 40101。
|
||||
|
||||
**断点 B(后端):`/mp/login` 只要 scene 带 taskId 就必然 500。**
|
||||
|
||||
- `MpLoginService.login` 在 `:126-130` 有个"顺带把名册绑定带回去"的分支:
|
||||
`rosterMapper.selectOne(... collect_roster ...)`。`rosterMapper` 是**普通 `BaseMapper`**,
|
||||
而 `/mp/login` 是 **`@PublicApi`(匿名)** → **租户拦截器默认拒绝** → `BizException(40101)`
|
||||
→ MyBatis 包成 `PersistenceException` → `GlobalExceptionHandler` → **HTTP 500 / code 50000「系统繁忙,请稍后再试」**。
|
||||
- 日志铁证(本轮实测):`MpH5AuthService : H5 首次登录,已建学生账号:tenant=1 uid=1389` 成功后紧接着
|
||||
`### Error querying database. Cause: cn.tianbaotong.fill.common.exception.BizException: 未登录或登录已失效`,
|
||||
栈顶 `at cn.tianbaotong.fill.module.mp.service.MpLoginService.login(MpLoginService.java:127)`。
|
||||
- **对照实验**(同秒、同端点、只改 scene):
|
||||
|
||||
| scene | 结果 |
|
||||
|---|---|
|
||||
| `t1`(不带任务) | **HTTP 200 + token**(`needBindPhone:true`,`user{id,realName:"微信用户",userType:4}`) |
|
||||
| `t1-k2313`(带任务) | **HTTP 500 / code 50000** |
|
||||
|
||||
- ⇒ `/mp/login` **只在"永远用不到的那个场景"下能用** —— 学生扫的码必然带 taskId(不然没有任务可填)。
|
||||
- **副作用(已核实,不要写成"留下一堆半成品账号")**:日志里明明打着
|
||||
`MpH5AuthService : H5 首次登录,已建学生账号:tenant=1 uid=1389`,但 `login()` 是 `@Transactional` 的,
|
||||
账号与 `sys_user_role` 都随异常**回滚**了 ——
|
||||
`SELECT id FROM sys_user WHERE id IN (1389,1390)` 只有 **1390**(那是本轮用 `scene=t1` 成功调出来的那个)。
|
||||
所以症状是「学生每次点都 500,什么也没留下」,**不是**「攒下一堆孤儿账号」;
|
||||
唯一残留是**自增 id 被消耗**(1389 成了空号)。
|
||||
- 修法方向(**未实施,属改代码**):该分支改成走**专用 Mapper + `@InterceptorIgnore(tenantLine="true")` + 显式带 tenant_id**
|
||||
—— 这正是 AGENTS.md 里"匿名接口必须走专用 Mapper"那条铁律,`MpH5Mapper` 就是这么做的
|
||||
(`selectByOpenId(tenantId, openId)`),而这里漏了。另外**异常不该以"系统繁忙"对外**:
|
||||
40101 被包成 50000,把"没登录"讲成了"服务器坏了"。
|
||||
|
||||
**为什么至今没暴露**:
|
||||
|
||||
- `/mp/login` **没有任何 E2E 覆盖** —— `scripts/e2e-m3-gate.py:18` 明确写着「登录(`/mp/login`)需要真实的
|
||||
`wx.login` code —— 脚本拿不到」。**本轮证明它拿得到**:开发者工具自动化里可以在页面上下文调 `wx.login()`
|
||||
拿到**真的** code(后端真换到了 openid),所以这条链路是可以自动化的。
|
||||
- 文档**互相矛盾**:`docs/08:147` 写着「~~P1-14~~ 学生端 `/mp/login` + 手机号绑定 → ✅ **已完成**」
|
||||
(指的是**后端**),而 `docs/交接摘要.md:563` 还留着「`/mp/login`(小程序登录)仍未做」。
|
||||
两边都不算错,但**没有任何一处说"前端没接"** —— 与 F-126 同一类:"宣布完成"与"界面上能用"之间的落差,
|
||||
`check-wiring.py` 的十项检查**没有一项会发现**(它查权限码/错误码/模板/菜单键,不查"契约端点有没有前端调用方")。
|
||||
|
||||
### F-125【P0 · 阻断闭环】学生被驳回后**改不了任何东西**
|
||||
|
||||
**现象**:填报单进入 `status=4`(已驳回)后,学生:
|
||||
|
||||
| 动作 | 结果 | 代码 |
|
||||
|---|---|---|
|
||||
| 改字段(`PUT /mp/submissions/{id}`) | `40030` | `SubmissionService.java:158-161` |
|
||||
| 传材料(`POST /mp/files/upload`) | `40030` | `MaterialFileService.java:103-105` |
|
||||
| 删材料(`DELETE /mp/files/{id}`) | `40030` | `MaterialFileService.java:338-341` |
|
||||
| 撤回(`POST /mp/submissions/{id}/withdraw`) | `40001`(只放行 1/2) | `StudentTaskService.java:190-196` |
|
||||
| **重新提交**(`POST .../submit`) | **允许** | `SubmissionService.java:293-297` |
|
||||
|
||||
**于是闭环断了**:学生只能**原样重交**,而驳回的意义正是「改了再交」。
|
||||
|
||||
**而且系统在四处同时告诉他「你可以改」**:
|
||||
|
||||
| 信号 | 值 | 代码 |
|
||||
|---|---|---|
|
||||
| `GET /mp/tasks` 的 `statusText` | 「被驳回,请修改后重新提交」 | `StudentTaskService.java:142` |
|
||||
| `GET /mp/tasks` 的 `canFillNow` | `true`(`underReview` 只算 1/2/3,**4 不算**) | `StudentTaskService.java:105-106` |
|
||||
| `GET /mp/submissions/{id}/progress` 的 `canEdit` | `true`(`st == 0 \|\| st == 4`) | `StudentTaskService.java:311` |
|
||||
| 同一个接口的 `canSubmit` | `true` | `StudentTaskService.java:313` |
|
||||
|
||||
**为什么 1617 条断言没抓到**:`scripts/e2e-m5-audit.py:531-538` 证明过「驳回后能重新提交」,但它在重交前只改了库里的 `reject_count`,**没有改任何业务字段**。切片测试把这一格填成了绿色。
|
||||
|
||||
**契约站在哪一边**:`docs/03-接口设计.md:239-248` 明确写 `4 已驳回 → 可流转到 1(学生重提交)`;`docs/03-接口设计.md:2040` 的撤回约束只写 `status ∈ {1,2}`,没有为「驳回后怎么改」留出口。**所以按契约,改不动就是实现缺一条路径。**
|
||||
|
||||
**建议的最小改法**(**改业务口径,须用户确认后再动**):把三处写守卫从 `!= STATUS_DRAFT` 放宽为 `∉ {STATUS_DRAFT, STATUS_REJECTED}`,与 `submit`(`SubmissionService.java:293-297`)和 `progress.canEdit`(`:311`)的口径对齐。涉及:`SubmissionService.java:158-161`、`MaterialFileService.java:103-105`、`MaterialFileService.java:338-341`。
|
||||
|
||||
**备选方案**(不动写守卫,改撤回守卫):让 `withdraw` 也接受 `status=4`(驳回 → 草稿 → 改 → 重交)。缺点是学生要先点一次「撤回」,而界面在驳回态**不显示撤回按钮**(`StudentTaskService.java:109` 的 `canWithdraw` 只算 1/2)。
|
||||
|
||||
### F-128【P0 · 阻断功能】扩展字段存进去却读不出来 —— 必填扩展字段会让整个任务**永久无法提交**
|
||||
|
||||
**现象**(`EP-D09` / `EP-J02e`,已实测):
|
||||
|
||||
```
|
||||
PUT /mp/submissions/{id} {"extrasJson": {"extra_upgrade_note": "已补填"}}
|
||||
→ code=0,库里 extras_json = {"extra_upgrade_note": "已补填"} ← 写成功了
|
||||
GET /mp/tasks/{task}/form
|
||||
→ 该字段的 value = null ← 读不出来
|
||||
POST /mp/submissions/{id}/submit
|
||||
→ {"submitted": false, "redCount": 1,
|
||||
"issues":[{"ruleCode":"VAL-22","title":"还有必填项没填:升版新增必填"}]} ← 填了也说没填
|
||||
```
|
||||
|
||||
**根因**:`FillService.assemble`(`FillService.java:106-159`)构造取值表 `values` 时只有两个来源 ——
|
||||
|
||||
1. `rosterValuesOf(roster)`(名册列)
|
||||
2. 遍历 `schema.getFields()` 中 `SubValues.hasColumn(key)` 成立的键,读 `collect_submission` 的**列**
|
||||
|
||||
**它从来没有把 `draft.extrasJson` 合进 `values`。** 而扩展字段的值只存在 `extras_json` 里,于是:
|
||||
|
||||
- `withValue(f, values)` 对扩展字段恒取到 `null` → **学生填的扩展字段重开表单就没了**(以为没保存上,再填一遍);
|
||||
- `SubmissionValidator.validate` 的 `values`(`SubmissionValidator.java:104-109`)来自同一份装配 → `checkRequiredFields` 的 `blank(values.get(key))` **恒为真** → **必填扩展字段永远满足不了**。
|
||||
|
||||
**影响**:
|
||||
|
||||
| # | 影响 | 严重度 |
|
||||
|:--:|---|:--:|
|
||||
| 1 | 表单里加了**必填**扩展字段 → 该任务对**所有人**永久阻断,没有任何办法满足它 | **P0(功能不可用)** |
|
||||
| 2 | 学生填的扩展字段保存后在界面上消失(数据在库里,但读不出来) | P1 |
|
||||
| 3 | 依赖 `values` 的下游(校验、导出前置判断)都看不到扩展字段的值 | P1 |
|
||||
|
||||
**为什么至今没暴露**:内置表单(`BuiltinFields.java`)**一个扩展字段都没有**(`extras` 为空),
|
||||
而既有 37 个脚本用的都是内置表单 —— 所以这条路径**从来没有数据走过**。它是"表单设计器给任务加了扩展字段"这个真实用法才会踩到的。
|
||||
|
||||
**建议的最小改法**(**改业务口径/数据读取口径,须确认后再动**):在 `assemble` 里把 `extrasJson` 合进 `values`,
|
||||
即对 `schema.getExtras()` 的每个键,从 `draft.getExtrasJson()` 解析后 `values.put(key, v)`。
|
||||
位置在 `FillService.java:144-158` 之后(那段循环之后、构造 sections 之前)。
|
||||
⚠️ 注意 `changed_fields` 的语义:扩展字段没有列,不要顺手把它们塞进 `changed_fields`(那是给内置列用的)。
|
||||
|
||||
### F-129【P2 · 静默丢数据】把已声明的扩展字段放进 `formData` 不会报错,值直接没了
|
||||
|
||||
`EP-E16` 实测:`{"formData": {"extra_upgrade_note": "塞错口袋"}}` → `code=0`,`extras_json` 仍是 `{}`。
|
||||
|
||||
对照:**未声明**的键放进 `formData` 会明确 `40001`(`SubmissionService.java:178-182`,注释写着"不静默忽略")。
|
||||
而**已声明**的扩展键放进 `formData` 时,`defs` 里查得到(`:169-171` 把 extras 也放进 `defs`),
|
||||
于是躲过了那道检查,接着 `SubValues.apply(sub, key, value)` 对它**没有对应列,静默什么也不做**。
|
||||
|
||||
契约要求扩展字段走 `extrasJson`(`DraftDtos.SaveReq.java:44`),所以这属于**用错参数**;
|
||||
但同一个方法里"不静默忽略"的原则对两种塞错口袋的待遇不一致,前端写错一次就是学生白填一遍。
|
||||
**建议**:`SubValues.apply` 遇到既不是列、又确实是声明过的扩展键时,抛出 40001 并提示"扩展字段请放在 extrasJson"。
|
||||
|
||||
### F-126【P2 · 脚本自述与断言不符】(已处理)
|
||||
|
||||
`scripts/e2e-m3-draft.py:11` 的 docstring 自述 `[x] 未过门禁不能建草稿;已提交的不能直接改`,但该文件**全文没有这条断言**(全仓唯一涉及 `40030` 的断言是 `scripts/e2e-m5-proxy.py:277`,测的是代传路径)。
|
||||
|
||||
**本轮已补**:`EP-E08`(已提交后 `PUT` → `40030`)与 `EP-F09`(已提交后上传 → `40030`)已落地并通过。
|
||||
**建议**:把 `scripts/e2e-m3-draft.py:11` 的自述改准(去掉那半句,或指向本脚本)。
|
||||
|
||||
**为什么值得记**:docstring 的 `[x]` 清单在本仓库被当作覆盖率的**唯一账本**(`scripts/verify-all.py` 的汇总、多份文档的「37 个脚本 / N 条断言」都建立在它上面)。自述虚高与 F-84 同类,而且**更隐蔽** —— 断言缺失不会让任何东西变红,反而让覆盖率看起来更好。
|
||||
**教训**:本计划 §3.1 的 `grep 'check(\|ok('` 核对规程**不是走形式** —— 它一上来就抓到了这一条。
|
||||
|
||||
### F-127【P2 · 文档口径未登记】§9.10 的「校验失败返回 40001」与实现不一致
|
||||
|
||||
- **契约**(`docs/03-接口设计.md:1988,1997-2019`):错误码 `40001 阻断`,`data` 里给 `blocking[]` / `warnings[]` / `canForceSubmit`。
|
||||
- **实现**(`FillController.java:59-70`):**HTTP 200 + `code:0`**,`data` 是 `{submitted:false, status, statusText, redCount, yellowCount, infoCount, issues[]}`。代码注释给了理由:校验不通过是**正常业务结果**,前端要一次拿到完整清单才能渲染红黄绿。
|
||||
|
||||
实现的口径与项目约定(业务错误不占 HTTP 状态码)一致,也已被 37 个脚本按 `data.submitted` 判定 —— **但 `§17.2 有意偏离契约的地方` 里没有它**(D-1~D-12,`docs/03-接口设计.md:2972-2988`)。后果是文档读者会写出 `code != 0` 的判据,而那是**恒假的**,测试会静默变绿。
|
||||
|
||||
**建议**:在 §17.2 补一行 **D-13** 记录该偏离(表格仍是 5 列,不影响 `verify-docs.py`)。本轮只提议,不在测试轮里动契约。
|
||||
|
||||
### 待定案 → 已定案(实测结论,2026-10)
|
||||
|
||||
| 问题 | 定案 | 证据 |
|
||||
|---|---|---|
|
||||
| 驳回后**重新提交**能否过校验(内容未变时) | **能过** —— 这也正是 F-125 的欺骗性所在:交得上去,但改不了 | 既有 `scripts/e2e-m5-audit.py:534` + 本脚本 `EP-I09` |
|
||||
| 表单升版后旧草稿提交是否报 `40025` | **不报**。`submit` 不比对 `form_version`;按**当前** schema 校验;库里 `form_version` 保持建单时快照 | 本脚本 `EP-J02b/J02c/J02d` |
|
||||
| 并发提交的实际行为 | **无保护**,两个请求都能拿到 `submitted=true`,落库状态仍自洽 | 本脚本 `EP-J03`(钉成已知事实) |
|
||||
| 任务已截止/暂停时能否核验身份 | **能**(门禁与任务状态**有意解耦**),但紧随其后的取表单会被 `40008` 拦住 | 本脚本 `EP-C08b/C08c` |
|
||||
| `VAL-17` 的「非阿拉伯数字」分支能否触发 | **触发不了**:`collect_submission.work_years` 是 `smallint`,「三年」在写库前就被类型挡住 → 该分支是死代码 | 本脚本 `EP-G11a`(钉住事实) |
|
||||
| 扩展字段能不能存、能不能读 | **存得进、读不出**(F-128) | 本脚本 `EP-D09/EP-J02e`(XFAIL) |
|
||||
|
||||
---
|
||||
|
||||
## 七、与《06-验收测试清单》的映射
|
||||
|
||||
《06》是**客户向**清单,含 M4 与已取消二期的条目,本计划**不改它**,只在此建立对应关系。
|
||||
|
||||
| 《06》条目 | 本计划 | 说明 |
|
||||
|---|---|---|
|
||||
| §3.1 主流程 A(A1~A23) | A/B/C 组 + chain 组 | 其中 A10/A11(材料移动)、A6(`changedFields` 记 phone)依赖 M4,**暂无自动验收** |
|
||||
| §3.4 异常边界 E1~E27 | §3 各组的错误用例 | E5/E16/E17/E18/E24/E25/E26/E27 属 M4(OCR/归属),**缓做**;E21~E23 随「加入申请」撤回一并作废 |
|
||||
| §3.2 主流程 B(社会收集) | — | 已作废(`40044`) |
|
||||
| §3.3 主流程 C(历史整理器) | — | 已取消 |
|
||||
| §4 功能验收 F-500 / F-800 | §3.9 / §3.10 | 名册与审核侧 |
|
||||
| §2 Golden Assertions(G-*) | 不在本计划 | 那是引擎级纯函数,用 112 个真实文件做断言,与链路无关 |
|
||||
|
||||
---
|
||||
|
||||
## 八、变更记录
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|---|---|---|
|
||||
| V1.0 | 2026-10 | 首版。覆盖矩阵 40 条本脚本用例 + 约 55 条既有脚本用例;登记 F-125 / F-126 / F-127 |
|
||||
| V1.1 | 2026-10 | 按实测结果回填:本脚本用例 40 → **45**;新增 **F-128**(扩展字段存得进读不出,必填扩展字段永久阻断,P0)与 **F-129**(扩展字段塞进 `formData` 被静默丢弃);F-126 已补断言;「待定案」六项**全部定案**;三种跑法的实测数字回填 §3.12 / §5.2 |
|
||||
| V1.2 | 2026-10 | 补 L3-B 真实浏览器走查结论(§5.3):走了 4 条里的 3 条,**F-125 的用户侧证据链拿到**(「保存失败:…请先撤回」+ 同一屏没有撤回入口 + 列表页按钮「修改后重新提交」);记下两个走查坑(scene 会进本地缓存、`--fixture-only` 不触发清理)与新增工具 `scripts/dev-reveal-roster-idcard.py` |
|
||||
| V1.3 | 2026-10 | **新增 §5.4 L3-C 微信小程序端走查**(需求方要求「学生提交的过程要测微信小程序端」):在开发者工具模拟器里把 10 步走完,**除登录外整条链路都通**(落地页 / 门禁 / 表单 / 前端压缩上传 / 红单 / 黄单确认 / 提交后名册流转);登记 **F-130(P0,小程序端登录断成两截:前端从不调 `/mp/login` + 后端 `MpLoginService.java:127` 匿名查租户表被拦截 → 50000)**;§5.3 第 2 条由 L3-C 补上。**本轮不改业务代码** |
|
||||
+703
-5
@@ -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` 被留在
|
||||
@@ -2470,7 +2473,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
|
||||
### 四、终验与提交
|
||||
|
||||
- `verify-all --with-e2e` → 见提交信息里的最终数字(37 个 E2E 脚本全绿)。
|
||||
- `verify-all --with-e2e` → 见提交信息里的最终数字(当时 37 个 E2E 脚本全绿;**现已 38 个 / 1712 条断言**,见 §一·四十一)。
|
||||
- E2E 总数 36 → **37**(新增 `e2e-m1-task-delete.py`),断言数相应增加。
|
||||
|
||||
---
|
||||
@@ -2847,7 +2850,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
| 手段 | 结果 |
|
||||
|---|---|
|
||||
| `scripts/e2e-m10-role.py`(新增) | **77/77**:改授权后**同一个 token** 立刻生效 / 停用立刻失效 / 内置角色只读 / 越权与自锁 / 删除守卫 `40045` / 内置角色权限以代码为准 + 同步回填幂等 / 跨租户 |
|
||||
| `python scripts/verify-all.py --with-e2e` | **9 项全过 / 0 失败 / 0 跳过**;**37 个脚本 / 1595 条断言 / 0 失败**(2026-10 收敛删掉 2 个脚本后已重跑填数) |
|
||||
| `python scripts/verify-all.py --with-e2e` | **9 项全过 / 0 失败 / 0 跳过**;**37 个脚本 / 1617 条断言 / 0 失败**(2026-10 收敛删掉 2 个脚本后已重跑填数;**现已 38 个 / 1712 条**,见 §一·四十一) |
|
||||
| 浏览器实测(真点) | 新建自定义角色 → 账号页角色候选出现它(且平台角色已不在候选里)→ 改权限落库(`sys_role_permission` 逐字核对)→ 删除(软删 + 授权/菜单行一并软删);截图留证 |
|
||||
| 机械校验 | `check-api-parity`(契约 **119** / 已实现 118 / 缺口 1)、`verify-docs` 22/22(原型 **29** 屏)、`check-wiring` 未接线 0、`check-entity-columns` 28 实体一致、`check-manual` 0 错 |
|
||||
| 前端 | `pnpm typecheck` 0 错、`eslint` 0 错(`views/role/**`、`apis/xt/role.ts` 与改动过的账号页) |
|
||||
@@ -2892,7 +2895,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
| PC 业务菜单 | 12 | **10** |
|
||||
| 小程序页面 | 10 | **9** |
|
||||
| 原型屏 | 32 | **29**(学生 10 / 班主任 9 / 管理端 10) |
|
||||
| E2E 脚本 | 39 | **37**(删 `e2e-m2-join.py`、`e2e-m7-cleanup.py`);断言 1698 → **1595 条** |
|
||||
| E2E 脚本 | 39 | **37**(删 `e2e-m2-join.py`、`e2e-m7-cleanup.py`);断言 1698 → **1617 条**。**2026-10 又新增 `e2e-m3-flow.py` → 38 个 / 1712 条**(见 §一·四十一) |
|
||||
|
||||
> ⚠️ **两个容易算错的数**(都踩过):
|
||||
> ① **契约是 119,不是 116**。116 是"删 13 个**代码端点**"(cleanup 8 + join 5),
|
||||
@@ -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`,
|
||||
但"源码改了产物一个字都不会变"这条不变,见 §一·四十三),源码改了它一个字都不会变。
|
||||
本次实测:源码改完后产物里**仍是「填报通」**。
|
||||
|
||||
已重跑并复查:
|
||||
@@ -3183,4 +3187,698 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
根 `AGENTS.md` 的接线检查说明补上第 ⑨ 项。
|
||||
|
||||
|
||||
## §一·三十九 平台运营的租户管理 + 日志详情(2026-10)
|
||||
|
||||
**起因**:用户要求「设计开发平台运营人员对租户的管理功能,还有日志的详情查看功能」。
|
||||
|
||||
### 一、口径变更(**这一节最先要看的东西**)
|
||||
|
||||
`frontend-pc/src/views/tenant/index.vue` 原先头部写着:
|
||||
|
||||
> ⚠️ 列表**没有操作列**:不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)
|
||||
|
||||
**这条口径本轮被用户推翻**(选的是「全量:详情 + 用量 + 编辑 + 停用/启用」)。
|
||||
所以列表现在有操作列(详情 / 编辑 / 停用·启用),并且新增了详情抽屉与编辑弹窗。
|
||||
|
||||
⚠️ **变更理由要一起记下来,否则下一个人读到旧注释会以为是 rebase 丢的**:
|
||||
这条旧口径的动机是"少一个误操作入口",而运营真正需要的是**改到期日**(续费)
|
||||
与**停用学校**(欠费 / 退租)—— 这两个动作没有入口时,只能改数据库。
|
||||
|
||||
另外三条已确认的口径(都是"不做什么",比做什么更容易被顺手破坏):
|
||||
**套餐只读**(全库没有 `sys_package` 表,`package_id` 是无外键裸 bigint)、
|
||||
**不补原型屏**(`docs/prototype/admin-web.html` 保持 10 屏,`合计屏数 : 29` 不动)、
|
||||
**不新增后端接口**(契约 119 不变)。
|
||||
|
||||
### 二、本轮最关键的一件事:用量接口算的是「全平台」的数(F-112)
|
||||
|
||||
**这不是顺手发现的,是审查计划时逐条对照代码挖出来的**,而且它比原计划里所有 UI 改动都严重:
|
||||
|
||||
- 调用方一定是平台运营(`/tenants/{id}/usage` 挂 `tenant:view`,而它在
|
||||
`PermissionDef.platformOnlyCodes()` 里);平台运营的 `tenantId` 是 **0**;
|
||||
- 而 `MybatisPlusConfig.FillTenantLineHandler.ignoreTable()` 对平台身份**直接返回 true**
|
||||
—— **整张表的租户过滤被关掉**;
|
||||
- 于是 `TenantUsageService.distinctStudents()`(查 `collect_roster`)与存储量统计
|
||||
(查 `collect_file`)都**不带租户条件** → 返回全平台的学生数与存储占用,
|
||||
却标着某一所学校的名字。
|
||||
|
||||
**⚠️ 它在本地完全看不出来**:实测库里 `collect_roster` 34 行、`collect_file` 22 行
|
||||
**全在 `tenant_id=1`** —— 只有一所学校有数据时,错误值与正确值**完全相等**。
|
||||
`ActingTenantResolver` 的类注释描述的正是这个陷阱("当只有一所学校有数据时,
|
||||
它和学校管理员看到的一模一样")。
|
||||
|
||||
**修法与验证**:
|
||||
- 统计段包进 `TenantScope.callAs(tenantId, 0L, Set.of(), ...)`(本仓既有机制,
|
||||
`OpenDataService` / `OpenPushService` 在用)。**没有**手写 `tenant_id` 条件 ——
|
||||
那等于放弃拦截器的保护;切走上下文之后 `isPlatformAdmin()` 变 false,过滤自动恢复。
|
||||
- E2E **必须有夹具**才证伪得了:`e2e-m8-orgfile.py` 往新建的 E2E 租户**直插一行
|
||||
`collect_roster`**(该表 NOT NULL 且无默认值的列只有 5 个,不需要走导入夹具),
|
||||
再断言"接口值 == 库里 `where tenant_id=1` 的去重数",并**额外断言
|
||||
`全平台去重数 > 一号学校去重数`** —— 否则这条断言是空转的(这次实测两值确实不等)。
|
||||
结果 **74/74**。
|
||||
|
||||
### 三、另外四条后端缺陷(都在同一次改动里修掉)
|
||||
|
||||
| # | 缺陷 | 后果 |
|
||||
|---|---|---|
|
||||
| F-113 | `PUT /tenants/{id}` 把手机号**明文**写进加密列(`create` 是加密的) | `AesGcmCipher.decrypt` 解不开 Base64 直接抛 → **存过一次电话,这所学校详情页永远打不开**。实测 `id=1` 存的就是明文 `13800138000` |
|
||||
| F-114 | `tenantType` 只放行 1~3(实体/DDL/契约/前端都是 **4 值**);`status` 只放行 0/1(实体有 **2 试用**,前端筛选按钮里就有) | 这两类值**能建不能改**,选了「试用」永远是空列表 |
|
||||
| F-115 | 开通 / 修改 / 停用租户**一条日志都不写**(只有给运维看的 `log.warn`) | 停用一所学校是这套系统里影响面最大的单个动作,却查不到是谁干的 |
|
||||
| F-116 | `expireBefore` 用 `atStartOfDay()` 比 → **差一天**,「今天到期」查不出来 | 续费提醒那类用法正好踩中 |
|
||||
|
||||
对 F-113 的读路径要特别说一句:**必须容错**(解密失败按明文处理 + `log.warn`),
|
||||
否则故障形态是"详情页永久 500",看起来像系统坏了而不是"库里有一行旧数据"。
|
||||
**历史明文行不做数据迁移** —— 迁移里拿不到 AES 密钥。
|
||||
|
||||
留痕口径(**容易记错**):新增 `MODULE_TENANT` 与 4 个动作码,
|
||||
`DISABLE_TENANT` / `ENABLE_TENANT` **不并进** `UPDATE_TENANT`(出争议时第一个要看的就是这个区别)。
|
||||
⚠️ 这些日志行的 `tenant_id` 是 **0**(写日志用的是**操作者**的上下文),
|
||||
查"这所学校被谁停过"要用 `target_type=TENANT` + `target_id`,
|
||||
**不能用 `tenantId` 筛** —— 这条已经写进常量注释与契约 §6.8,因为反直觉。
|
||||
|
||||
### 四、日志详情:为什么不新增接口
|
||||
|
||||
详情要的 `request_method` / `request_uri` / `extra_json` 都是**轻字段**
|
||||
(实测 `max(length(extra_json))=45`、`max(length(request_uri))=31`、`max(length(user_agent))=125`,
|
||||
一页 20 行约 2 KB),所以直接把三列补进 `listOperations` 的 SELECT 就行。
|
||||
|
||||
⚠️ **"别让重字段驮在列表上"这个理由在这里不成立**:真正重的 `request_param` /
|
||||
`duration_ms` **全库 1758 行里 0 行有值**(从来没有 `setXxx` 过)。
|
||||
新增 `GET /logs/operations/{id}` 反而要改契约 119 与 4 份文档。**所以没加。**
|
||||
|
||||
### 五、`AUTO_CLOSE` 与新的检查 ⑩(**这一族检查的价值取决于被检查的那一侧**)
|
||||
|
||||
日志页的 `actionText()` **查不到就原样回显那个码**。而 `AUTO_CLOSE`(定时任务自动截止)
|
||||
**根本不在常量表里** —— 它在 `TaskService.autoClose()` 里是**字符串字面量**写的。
|
||||
也就是说"后端常量 ↔ 前端清单"这种检查**本来就扫不到它**,而它正是量最大的那类任务日志。
|
||||
|
||||
所以这件事要做两步:① 把字面量提成常量(连带 `STATUS_CHANGE` 兜底名);
|
||||
② 新增 `check-wiring.py` 第 ⑩ 项(27 个动作码两端逐字核对)。
|
||||
**已反向验证**:删掉前端一条文案 → 报红 + exit 1。
|
||||
|
||||
⚠️ 写这个检查时踩到的坑:同文件里 `MODULE_OPTIONS` / `TARGET_TYPE_OPTIONS` 也是
|
||||
`{ value: 'TASK' }` 的形状,**整文件扫会把模块名当成动作码收进来** —— 所以只从
|
||||
`OPERATION_ACTIONS` 这个数组里取。
|
||||
|
||||
### 六、顺带修掉的五处
|
||||
|
||||
- **F-117**:上面的 `AUTO_CLOSE` 字面量。
|
||||
- **F-118**:日志页「模块」下拉里 `AUDIT` / `EXPORT` **后端一个写入点都没有**,
|
||||
而真会写的 `FILE` / `ROLE` **不在下拉里** —— 换成了导出的 `MODULE_OPTIONS`
|
||||
(口径:**只列后端真有写入点的**,否则选中死项只会得到空表,让人以为"这段时间没有审核动作")。
|
||||
- **F-119**:`LogController` 的 `/logins` 描述与事实相反(还写着"目前不写这张表(见 F-31)",
|
||||
而 F-31 已闭环)。
|
||||
- **F-123**:**登录日志没有租户列** —— 平台运营看到的日志是跨单位的(`log:viewAll`),
|
||||
而 `LoginLogVO` 里没有 `tenantId`,于是筛得了单位却看不出每行属于哪个单位。
|
||||
`sys_login_log.tenant_id` 从 V2 建表起就有,只是 VO 没带出来。
|
||||
已修(VO + mapper SELECT + 契约 + 界面加「单位」列,**仅平台身份显示**)。
|
||||
- **F-124**:`LoginLogVO.userType` 的 Javadoc 写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,
|
||||
与权威口径(`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生)错位 ——
|
||||
**这是同一个坑的第三次出现**(该错位枚举曾导致"把班主任当成社会人员拒掉")。
|
||||
已修,并在注释里写明权威来源。⚠️ 前端当时**没有**按评审意见里那句错的口径写、
|
||||
而是复用了 `USER_TYPE_NAMES` —— 这处只影响读代码的人。
|
||||
|
||||
### 七、前端一处刻意的选择:页面内 Tab 用**分段按钮**而不是 `NTabs`
|
||||
|
||||
日志页要做「操作日志 / 登录日志」两个页签,选了工具条上的分段按钮组,原因有两条:
|
||||
① `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
|
||||
否则表格高度算成 0、**一行都看不见**(本仓踩过);
|
||||
② naive 2.45.3 的 `NTabs` 用 `tab="..."` prop 只渲染注释节点、必须用 `#tab` 插槽。
|
||||
按钮组在视觉上就是两个页签,且不动高度链。**这条已写进 `frontend-pc/AGENTS.md`。**
|
||||
|
||||
### 八、验证
|
||||
|
||||
| 手段 | 结果 |
|
||||
|---|---|
|
||||
| `e2e-m8-orgfile.py` | **74/74**(租户口径断言现共 74 条:脱敏 / 密文 / 留痕 / 状态流转 / 4 值类型 / 到期日边界 / 用量越界) |
|
||||
| `e2e-m5-logs.py` | **44/44**(回归:日志留痕、脱敏、`targetType`+`targetId`、`from`/`to` 当天边界、越权 403) |
|
||||
| `check-wiring.py` | ⑦⑧⑨⑩ 全绿;**⑩ 反向验证过**(删一条文案 → 红 + exit 1) |
|
||||
| `check-api-parity.py` | 契约 119 / 已实现 118 / 缺口 1(**没变**,符合"不加接口"的决定) |
|
||||
| `verify-docs.py` | 22/22 |
|
||||
| `pnpm typecheck` / `pnpm lint` | 0 错 |
|
||||
|
||||
⚠️ **后端改了必须重新打包重启再验**:本轮有两次"改完忘了重打包"的空转风险
|
||||
(`LoginLogVO` 加字段那次、以及 `AUTO_CLOSE` 提常量那次)。
|
||||
判据是**看返回体里有没有新字段**,而不是"接口通了就算"——
|
||||
`/logs/logins` 返回 `tenantId`、`/tenants/1` 返回 `138****8000`(而不是 500),
|
||||
这两条同时成立才说明跑的是新 jar。
|
||||
|
||||
### 九、约定同步
|
||||
|
||||
`frontend-pc/AGENTS.md` 与 `.agents/skills/code-vue/SKILL.md` 新增两条:
|
||||
「详情用 `NDrawer` + `NDescriptions`,**不要为详情新开路由**」、
|
||||
「页面内页签用工具条分段按钮,写明高度链的原因」;
|
||||
根 `AGENTS.md` 的接线检查说明补第 ⑩ 项。
|
||||
|
||||
## §一·四十 收集任务页面「描述」精简 + 一处长期静默的故障(2026-10)
|
||||
|
||||
**起因**:用户要求「收集任务页面表单描述要简洁直观」,并点名
|
||||
「新建收集任务」里的 **本期范围** 区块(`本期只做 学校收集 + 自主认定:收集模式与认定类型由系统固定,界面上不开放其它取值。`)
|
||||
不该出现在表单里;同时要求审查收集任务的全部页面。
|
||||
|
||||
### 一、删掉「本期范围」区块(决定 + 理由)
|
||||
|
||||
`TaskFormModal.vue` 里删掉了 **`NDivider` + 两个 `disabled` 的 `NInput`(收集模式 / 认定类型)+ 一整块 `NAlert`**,
|
||||
原位只留一段注释。三个理由,缺一个都会被后来者"改回去":
|
||||
|
||||
1. 它的信息是**恒定的**(`collectMode=1` / `certType=1` 由后端 `TaskService.assertScopeSupported` 强制),
|
||||
对建任务的人零决策价值;
|
||||
2. 它是 **PRD 口径**,不是界面文案 —— 真正该放的地方是 `docs/01-需求规格说明书`;
|
||||
3. ⚠️ 保持它只会**暗示系统支持切换模式**,而范围收敛已经定了不设二期。
|
||||
|
||||
⚠️ **请求体里那两行写死不能跟着删**:`confirm()` 仍必须带 `collectMode: 1` / `certType: 1`
|
||||
(`TaskService.java:323-332` 的 `assertScopeSupported` 对 `create`(:248) 与 `update`(:351) 都调,
|
||||
传别的值返回 **40044**)。这是本轮最容易"顺手删错"的地方。
|
||||
|
||||
### 二、本轮最要紧的发现:`#feedback` 会**完全遮住**校验错误(长期静默故障)
|
||||
|
||||
**机制**(naive **2.45.3**,`frontend-pc/node_modules/naive-ui/es/form/src/FormItem.mjs:416`):
|
||||
|
||||
```js
|
||||
const feedbackNodes = children || feedback ? (…) : this.renderExplains.length ? this.renderExplains.map(…) : null
|
||||
```
|
||||
|
||||
`#feedback` 插槽存在时 `renderExplains` **永不渲染** —— 而 `renderExplains` 就是校验错误所在。
|
||||
所以一个字段只要写了提示语,它的**必填/格式错误就一个字都显示不出来**,
|
||||
用户看到的是「红框 + 一句无关的正常提示」→ 现象上等价于「点了保存没反应」。
|
||||
|
||||
**为什么以前没被发现**:写出这句话的人(`RoleFormModal.vue:90-97` 的注释)已经知道这个机制,
|
||||
但**只在自己那个文件里绕开了**,没有固化成约定,也没有人扫过全仓 → 另外 7 处继续踩。
|
||||
|
||||
**⚠️ 并且这个插槽不接收任何参数**(同文件 `:412` 用的是 `_utils/vue/resolve-slot.mjs:23-25` 的
|
||||
`resolveWrappedSlot(slot, wrapper)`,即 `wrapper(slot())` —— **零参数**):
|
||||
所以**不存在**「`#feedback="{ status }"` 里顺手把错误渲染出来」这种优雅修法。
|
||||
想内联渲染错误只有一条路:给 `NFormItem` 的 **`feedback` prop 传渲染函数**(`FormItem.mjs:420-425` 的分支),
|
||||
那要自写包装组件 + `h()` 手绘插槽 + 自行转发 `label`/`path`/`validationStatus`,
|
||||
而本仓**没有任何 `.tsx` 文件**。→ 列为独立一批(见末节「显式不做⑤」)。
|
||||
|
||||
**本轮的修法(7 处,与 `TenantEditModal.vue:119-139` 的既有写法同形)**:
|
||||
`catch (errors) { message.error(firstError(errors) ?? '请检查表单填写'); return }`
|
||||
|
||||
| 文件 | 行 | 该表单里被遮住的错误 |
|
||||
|---|---|---|
|
||||
| `frontend-pc/src/views/login/index.vue` | `firstError` :127、`catch` :142 | 「租户码」必填(`#feedback` 那句常驻提示) |
|
||||
| `frontend-pc/src/views/task/TaskFormModal.vue` | `firstError` :205、`catch` :225 | 批次号格式、填报截止必填、命名模板必填 |
|
||||
| `frontend-pc/src/views/task/TaskMaterialDrawer.vue` | `firstError` :369、`catch` :380 | 材料类型 / 格式 / 体积 / 份数 |
|
||||
| `frontend-pc/src/views/task/TaskFormSchemaDrawer.vue` | `firstError` :437、`catch` :447 | 字段标识格式(`extra_` 前缀)与重名 |
|
||||
| `frontend-pc/src/views/user/UserFormModal.vue` | `firstError` :200、`catch` :210 | 手机号 / 角色类 |
|
||||
| `frontend-pc/src/views/org/OrgFormModal.vue` | `firstError` :124、`catch` :134 | 组织类型必填 |
|
||||
| `frontend-pc/src/views/role/RoleFormModal.vue` | `firstError` :135、`catch` :145 | 角色码必填与格式、用户类型 `type:'number'` 那个坑 |
|
||||
|
||||
⚠️ **`firstError` 必须摊平后再取**:naive 的 `Form.validate()` reject 出来的是**嵌套数组**
|
||||
(`Form.mjs:96-107` 把每一层的 `result.errors` 整个 `push` 进 `errors`),
|
||||
所以 `errors[0].message` **恒为 `undefined`**,`?? 兜底` 会把每条错误都变成「请检查表单填写」。
|
||||
实测踩到过:第一版就是 `errors[0].message`,toast 出来的是兜底文案。
|
||||
现在 7 份实现都是 `for (const entry of errors.flat(2)) { … }`。
|
||||
|
||||
**实测证据**(真浏览器,后端 8080 + `pnpm dev` 5173):
|
||||
|
||||
| 触发 | 弹出 |
|
||||
|---|---|
|
||||
| 登录页留空点登录 | `请填写账号` |
|
||||
| 新建任务留空点「创建任务」 | `请填写任务名称` |
|
||||
| 新增扩展字段填 `abc` 点确定 | `必须以 extra_ 开头,且只含小写字母、数字、下划线` |
|
||||
| 角色弹窗留空点「创建」 | `请填写角色码`(**回归**,它本来就有这写法) |
|
||||
|
||||
### 三、批次号在「修改」时锁死(**用户本轮拍板**)
|
||||
|
||||
`TaskFormModal.vue:276-291`:编辑时 `:disabled="isEdit"`,feedback 出 `batchNoHint`(`:152-158`),
|
||||
即「任务编码 **2026090241** 已按它生成,不可再改」。
|
||||
|
||||
依据与决策一起记:`task_code` = 批次号 + 4 位序号(`TaskService.nextTaskCode`,`:757-768`),
|
||||
且它**同时是文件目录名**;而服务端 `TaskService.update:365-366` 会**无条件**写入 `batch_no`,
|
||||
没有"编码已生成"的保护 —— 改了以后列表里的批次号与 `task_code` 前缀就永久对不上,且没有任何提示。
|
||||
|
||||
⚠️ **这个缝隙没有任何测试覆盖**:既有 e2e 改草稿时传的是**同一个** `batchNo`
|
||||
(`scripts/e2e-m1-task.py:293-299`),只测过「已发布不可改 `collectMode`」(`:378-384`)。
|
||||
所以这次是**由前端守住**,不是修了后端。只在界面上只读,服务端该字段仍可写。
|
||||
|
||||
新建态(不是编辑)保持可填,提示改成了「4~8 位数字。任务编码 = 批次号 + 4 位序号(如 202609 → 2026090001)」——
|
||||
它解释的是**用户填这一格时会产生的疑问**,而不是描述实现;
|
||||
`4~8 位数字` 与后端 `TaskUpsertReq.java:36` 的 `@Pattern("^\\d{4,8}$")` 逐字一致,不要改。
|
||||
|
||||
### 四、文案改动清单(改前 → 改后)
|
||||
|
||||
| 位置 | 改前 | 改后 |
|
||||
|---|---|---|
|
||||
| `TaskFormModal.vue` 填报截止 | 只选日期时按当天 23:59:59 处理 | 选到日即可,系统按当天 23:59 计算 |
|
||||
| 同上 预览标题 | `预览(以「张同学 / … / 身份证」为例)` | 标题只留「文件名预览」,样例挪到正文(`.task-form__preview-note`) |
|
||||
| 同上 弹窗标题 | 修改任务 | 修改收集任务 |
|
||||
| 同上 保存按钮 | 保存(两态同一文案) | 创建任务 / 保存修改 |
|
||||
| 同上 变量行标签 | 插入变量(12px) | 追加变量(11px;`appendVariable` 本来就是追加到末尾,不是插入到光标) |
|
||||
| 同上 命名模板提示 | 必须含 … —— 缺了任一项,文件名就无法唯一归属到人 | 必须含 …。用下面的按钮追加变量 |
|
||||
| `TaskFormSchemaDrawer.vue` 分区标题 | 内置字段(官方 21 项,不可删除) | 内置字段 |
|
||||
| 同上 内置字段提示 | 这些是认定机构要的官方字段。**不想要某一项时不要删**(也删不掉),把它的「条件显示」设为「不收集」即可。… | 不想收的字段不用删(也删不掉):把它的「条件显示」设为「不收集」。… |
|
||||
| 同上 扩展字段工具行 | `扩展字段的值落在 extras_json,key 必须带 extra_ 前缀 —— 前缀是防重名的护栏…` | **整条删除**(理由留在代码注释里) |
|
||||
| 同上 字段标识 label / 提示 | 字段标识(key)/ 必须以 extra_ 开头,只含小写字母、数字、下划线 | 字段标识 / 只能用小写字母、数字、下划线;改动后导出列名会跟着变 |
|
||||
| 同上 删除确认 | 删除后学生端不再显示这一项。确认删除? | 删除后学生端不再显示这一项。注意:要等「保存为新版本」后才真正生效。确认删除? |
|
||||
| `TaskMaterialDrawer.vue` 体积上限提示 | 可配范围 307~5120KB(认定机构的接收能力) | 可配 307~5120 KB(0.3M~5M) |
|
||||
| 同上 模板按钮提示 | 按模板初始化是幂等的,已有的材料不会重复添加 | 按模板初始化只补缺失的材料,已有的不会重复添加 |
|
||||
| 同上 空表提示 | 必传材料一项都没有时,任务发布不了(服务端会返回 40018) | 必传材料一项都没有时,任务发布不了 |
|
||||
| 同上 别名提示 | …用于从历史文件名里认出这是哪一项材料 —— 别名不全,该认出来的就认不出来 | …用于从历史文件名里认出这是哪一项材料 |
|
||||
| 同上 来源引导语 | 提示 = placeholder 全文 | **删掉该 `#feedback`**(同一句话出现两遍,且多一个 feedback 只会让格子更高) |
|
||||
|
||||
⚠️ 两条容易改错的地方:
|
||||
|
||||
- 「字段标识」**不能**写「按字段名称自动生成」:中文没法可靠转拼音,
|
||||
`suggestExtraKey()`(`frontend-pc/src/apis/xt/form-schema.ts:187-198`)对中文标签**一律落到 `extra_field`**
|
||||
(代码注释解释了为什么不猜拼音)。实测「家长联系电话」→ `extra_field`。
|
||||
- 「批次号」的提示在锁定后**变短**了:`task_code` 已经显示了
|
||||
「任务编码 2026090241 已按它生成,不可再改」,再写一遍"同时作为任务编码前缀"是重复。
|
||||
|
||||
### 五、刻意**不**动的地方(比改了什么更容易被后来者推翻)
|
||||
|
||||
| 不动 | 理由 |
|
||||
|---|---|
|
||||
| `TaskOrgDrawer.vue` 全文 | 四句提示都对应**真实误解点**(只能勾到班级一层 / 初审人留空 = 该班负责人初审 / 应填人数留空 = 不比对 / 只读原因),改短会直接改变行为理解 |
|
||||
| `TaskRosterDrawer.vue` 全文 | 说明都是「必须解释的异常」(表头认到第 6 行、列识别结果、体检阻断 vs 警告、报考范围是聚合不是上传计划);改短会让人去改自己那份**本来正确**的 Excel |
|
||||
| 四个抽屉的 `EDITABLE = [0, 1, 4]` | 是**功能**,不是文案;且与服务端放行口径一致 |
|
||||
| `index.vue:128-134` 的 `BLOCKING_HINTS` | 发布失败时「去哪里补」的指引,与文案优化无冲突(本轮回归验证过:仍输出「请点本行的『名单管理』…」) |
|
||||
| `tenant/index.vue` 的 `catch { return }` | 该表单**没有** `#feedback`,错误本来就显示在字段下,不必多加一条 toast 约定 |
|
||||
| 只读状态的操作列「只读」 | **用户本轮明确要求保留**(`index.vue:392`、`TaskFormSchemaDrawer.vue:214`/`:341` 都还在) |
|
||||
| 不新建 `src/utils/first-error.ts` | 仓库里没有单函数 utils 的先例,`frontend-pc/AGENTS.md` 要求最小改动、禁止凭空引入新抽象;7 份同形实现各自留在组件里 |
|
||||
|
||||
### 六、验证
|
||||
|
||||
| 手段 | 结果 |
|
||||
|---|---|
|
||||
| `pnpm typecheck` | 0 错(唯一一次红是 `NAlert` 变成未使用 import,已删) |
|
||||
| `pnpm lint` | 0 错 |
|
||||
| `check-wiring.py` | ⑧「`<n-*>` 组件未 import 的 0 处」等 10 项全绿,未接线声明 0 条 |
|
||||
| `verify-docs.py` | 22/22 |
|
||||
| 真浏览器走查 | 「新建收集任务」弹窗里搜不到 `本期`/`收集模式`/`认定类型`;批次号提示与锁死、footer 两态文案、`追加变量` 标签、`文件名预览` 分栏全部符合预期 |
|
||||
| 四个抽屉逐屏读文案 | 搜不到 `extras_json` / `extra_` 前缀规则 / `幂等` / `接收能力` / `纠错的时间` / `识别结果只用于提示` / `官方 21 项` |
|
||||
| 发布阻断回归 | 空草稿点「发布」仍被拦下,横幅 +「请点本行的『名单管理』…」两处都在 |
|
||||
|
||||
**没有跑 e2e**:38 个 `scripts/e2e-*.py` 全部打 HTTP 接口,无一渲染 PC 页面;本轮**没改后端**。
|
||||
契约 119 / 错误码 54 / 原型屏数 29 全不变。前端 `/assets/**` 是 immutable 缓存 → 走查后要**硬刷新**。
|
||||
|
||||
### 七、显式不做(已记录,不混进本轮)
|
||||
|
||||
1. **克隆任务**:`POST /api/v1/tasks/{id}/clone` 后端完整可跑(`TaskController.java:169-190` + `TaskCloneService`),
|
||||
`docs/08-完成度盘点与问题清单.md` 标 P1-11「已完成」,`e2e-m5-notify-wiring.py` 还在测它
|
||||
(班主任 → `40301`、管理员 → 成功),但**前端 `apis/xt/task.ts` 里连函数都没有**,
|
||||
"宣布完成"与"界面上能用"之间的落差**没有任何检查会发现**(`check-wiring.py` 10 项里没有这一类)。
|
||||
**用户本轮明确决定先不做。**
|
||||
2. `POST /api/v1/tasks/{id}/save-as-template`(保存为模板)同样前端零调用。
|
||||
3. `TaskPublishResult.qrcodeUrl` 发布后被丢弃(`frontend-pc/src/apis/xt/task.ts:108` 定义、全仓零使用)。
|
||||
4. `formTemplateId` / `materialTemplateId` 建任务时初始化未实现(`docs/03-接口设计.md` §7.2 列了;
|
||||
材料模板现在只能建完任务后从抽屉「按模板初始化」)。
|
||||
5. **把校验错误内联渲染到字段下方**:见本文第二节 —— 插槽零参数,唯一的正路是包装组件 + `feedback` 渲染函数,
|
||||
且需先验证「`h()` 出来的元素能否收到变更后的 `validationStatus`」,未验证前不开工。
|
||||
6. `index.vue:486-487` 操作列 340px 内排 6~7 个文字动作(草稿态:编辑/配置班级/名单管理/材料规则/表单设计/发布/删除),
|
||||
收成「主操作 + 下拉」会改动行内交互范式,需单独确认。
|
||||
|
||||
---
|
||||
|
||||
## §一·四十一 填报流程端到端测试计划(2026-10)
|
||||
|
||||
> 需求原话:**"列一个测试计划,端到端测试完整的填报流程,测试用例尽量覆盖可能的错误情况。"**
|
||||
> 落地物两份:`docs/11-填报流程端到端测试计划.md`(·覆盖矩阵与判据口径)+ `scripts/e2e-m3-flow.py`(·只补缺口的可执行脚本)。
|
||||
|
||||
### 一、为什么不能只"再写一个 E2E 脚本"
|
||||
|
||||
先做了一件比写脚本更重要的事:**核对既有 5 个 `e2e-m3-*.py` + `e2e-m5-audit.py` 到底断言了什么**——
|
||||
不是读它们的 docstring(那是自述),而是 `grep 'check(\|ok('` 逐条抄出**实际断言**。
|
||||
结论:**填报链路的 100 条左右用例里,约 55 条已被覆盖**,剩下 45 条是真空。
|
||||
于是本轮**只写这 45 条**:原计划"完整重写一遍链路"会多出约 1000 行、**零新信号**。
|
||||
|
||||
### 二、交付物
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|:--:|---|---|
|
||||
| 1 | `docs/11-填报流程端到端测试计划.md`(新建) | 覆盖矩阵 **A~J 十组**(每行都有「覆盖者」列,**空行 0**)、判据速查表、执行步骤、已定案缺陷 F-125~F-129、与《06》的映射 |
|
||||
| 2 | `scripts/e2e-m3-flow.py`(新建,1122 行) | `--group chain\|errors\|all`、`--fixture-only`、`--keep`、`--h5-url`、`--full`、`--clean <taskId>`;自建夹具 + `finally` 清理(失败路径也清) |
|
||||
| 3 | `README.md` / `docs/08` / `docs/交接摘要.md` / `scripts/verify-all.py` / `AGENTS.md` | 脚本数 37 → **38**、断言 1617 → **1712** 同步;`docs/08` 登记 F-125~F-129 |
|
||||
|
||||
### 三、覆盖矩阵的结论(本脚本 45 条)
|
||||
|
||||
| 组 | 本脚本补的缺口 |
|
||||
|---|---|
|
||||
| B 鉴权 | 无 token / 签名篡改 / `exp` 过期 → `40101` |
|
||||
| C 门禁 | 明文证件号**全文扫**(响应 + 库 + 日志);**任务已截止时门禁仍成功**(有意解耦),紧随的取表单才 `40008` |
|
||||
| D 表单 | 不存在的 taskId;任务 0/4/2/3 逐状态 `40008`(**「恢复」那一步要断言"又能用了"**,否则断言退化成"永远 40008");响应全文无明文 |
|
||||
| E 草稿 | 已提交后改 → `40030`;**已驳回后改 → `40030`(XFAIL F-125)**;越权 / 不存在的单 / 缺 taskId;暂停后改 → `40008`;扩展字段白名单 + **塞错口袋被静默丢弃(F-129)** |
|
||||
| F 材料 | 空文件、不存在的材料项、**已提交/已驳回后传删 → `40030`**、越权(含 `process` 与列表两个端点)、签名链接归属、§9.5 秒传、§9.7 超 200 → `40011`、§9.8 `prefer=original` 回退时 `actualKind` 如实告知 |
|
||||
| G 校验 | 「无」是合法值;`VAL-22` 只对「可见+必填+非锁定」;`VAL-17`;`VAL-01` 正向;`VAL-08`/`VAL-20` 黄;越权提交;暂停/截止提交 `40008`;解绑门禁 `40007`;**红色不能靠 `confirmWarnings` 绕过** |
|
||||
| H 撤回 | `status=4` 时 `progress.canEdit/canSubmit` **都是 true**(F-125 的运行期证据);**撤回 → 再提交**闭环 |
|
||||
| I 驳回 | 学生侧提示「被驳回,请修改后重新提交」+ `canFillNow=true`;**整改再提交走不通(XFAIL F-125)** |
|
||||
| J 跨步骤 | 名册 `submit_status` **全轨迹** `0→1→2→4→2→3` 逐步查库;**表单升版后提交旧草稿**(不报 `40025`、按当前 schema 校验、`form_version` 是快照);并发提交(钉住"无保护") |
|
||||
|
||||
### 四、实测数字(2026-10)
|
||||
|
||||
| 跑法 | 通过 | 失败 | XFAIL |
|
||||
|---|:--:|:--:|:--:|
|
||||
| `--group chain` | 74 | 0 | 5 |
|
||||
| `--group errors` | 27 | 0 | 1 |
|
||||
| 无参数(两组) | **95** | **0** | **6** |
|
||||
| `python scripts/verify-all.py --with-e2e` | **9 项全过 / 0 失败 / 0 跳过**;**38 个脚本 / 1712 条断言 / 0 失败**(297s) | | |
|
||||
|
||||
(1712 − 1617 = **95**,正好是本脚本新增的断言数 —— 这个等式本身就是"没有重复覆盖"的证据。)
|
||||
跑完残留夹具任务 **0 个**、临时文件 **0 个**;演示任务 17 / 2097 / 2215 未被触碰。
|
||||
⚠️ 但仍要记得:跑 `e2e-m3-*` 会**清掉名册绑定**(既有行为,不是本脚本引入的)。
|
||||
|
||||
### 五、本轮定案的缺陷(**只测不改**)
|
||||
|
||||
| 编号 | 一句话 | 状态 |
|
||||
|---|---|---|
|
||||
| **F-125** | **学生被驳回后改不了任何东西** —— 改字段/传材料/删材料全 `40030`、撤回 `40001`,而 `progress.canEdit`/`canSubmit`/`canFillNow`/`statusText` 四处同时告诉他"你可以改"。契约 `§2.4` 也站学生一边(4 → 1 可整改) | **P0 · 阻断闭环**,4 条 XFAIL |
|
||||
| **F-128** | **扩展字段存得进、读不出** —— `assemble` 从不把 `extrasJson` 合进 `values`,于是填了也不回显、**必填扩展字段永远满足不了**(该任务对所有人永久阻断) | **P0 · 阻断功能**,2 条 XFAIL |
|
||||
| F-129 | 把**已声明**的扩展字段放进 `formData`:`code=0` 但值被静默丢弃(未声明的键反而会明确 `40001`) | P2,钉住现状 |
|
||||
| F-126 | `e2e-m3-draft.py:11` 自述「已提交的不能直接改」但**全文没有这条断言** —— docstring 的 `[x]` 清单在本仓库被当覆盖率唯一账本 | 本轮已补断言 |
|
||||
| F-127 | 契约 `§9.10` 写"校验失败返回 `40001`",实现是 HTTP 200 + `code:0` + `Report`;实现口径合理但**没登记进 `§17.2` 的有意偏离清单(D-1~D-12)** | 只提议补 D-13 |
|
||||
|
||||
**为什么 F-125 没被 1617 条断言抓到**:`e2e-m5-audit.py:531-538` 重交前只改了库里的 `reject_count`,
|
||||
**没改任何业务字段** —— 它证明的是"原样重交能过",而问题恰恰在"**改了**反而过不去"。
|
||||
**为什么 F-128 没暴露**:内置表单 `BuiltinFields.java` **一个扩展字段都没有**,既有脚本用的全是内置表单 →
|
||||
这条路径从来没有数据走过。
|
||||
|
||||
### 六、两条教训(已固化成规程)
|
||||
|
||||
1. **自述不算覆盖**。"读 docstring 说覆盖了什么"会得到虚高的覆盖率,而且**不会让任何东西变红**。
|
||||
改成 `grep 'check(\|ok('` 抄实际断言 —— 这条规程一上来就抓到 F-126。
|
||||
2. **XFAIL 的断言必须写「契约期望」,不能写「现状」**。`EP-I09` 第一版只断言"原样重交能成功",
|
||||
而那本来就成功 → 它报了个 `XPASS` 说"缺陷已修"。**把缺陷写成规格,比不写用例更危险。**
|
||||
同理:状态机用例不能只断言"永远 `40008`"(那会掩盖"状态机卡死"),
|
||||
序列里必须留一步断言**恢复后可填**。
|
||||
|
||||
### 七、显式不做
|
||||
|
||||
① **不改业务代码**(F-125 / F-128 / F-129 的建议写在 `docs/11` 与 `docs/08` 里,等确认);
|
||||
② 不为 M4 未实现功能写用例(`40004/40005/40006/40043/50002/50003/50004`);
|
||||
③ 不新增依赖(只用标准库 + 复用既有 `scripts/e2e-*.py` 的工具函数,**不自己再写一份** `call/upload/mint`);
|
||||
④ 不把浏览器自动化引入仓库;⑤ 不动 `docs/06-验收测试清单.md`(客户向,含 M4 与已取消二期);
|
||||
⑥ 范围只含填报链路,导出(M6) / 通知(M7) / 压测不在本轮。
|
||||
|
||||
### 八、L3-B 真实浏览器走查(加分项,4 条里走了 3 条)
|
||||
|
||||
`pnpm dev:h5`(5174)+ `--fixture-only --keep --h5-url` 拿地址,用真浏览器走了一遍:
|
||||
|
||||
| 走到的 | 结果 |
|
||||
|---|---|
|
||||
| 扫码落地页(①) | 「示范职业学校 / E2E流程 d7545c / 收集中」+ scene 回显 |
|
||||
| 门禁核验(③) | 手输姓名 + 身份证 → 「✓ 核对通过」,带出脱敏号与老师预填的班级/报考三项;**库里确认** `bind_user_id=1385`、`bind_type=1`、`submit_status=1` |
|
||||
| 表单(④) | 17 个字段全部渲染;锁定项标「由老师按报考计划确定,不可修改」;**补考三项按 `visibleWhen` 正确隐藏** |
|
||||
| 自动保存(⑤) | 改「本职业工作年限」失焦 → 「已保存 10:05:29」,库里 `work_years=3`、`changed_fields` 为 NULL(名册没有该项 → 不算"改过老师填的",逻辑正确) |
|
||||
| 提交被拦(⑦) | 「还不能提交 / 必须处理 1 / `VAL-05` 缺少必传材料:身份证」——红色清单原样渲染 |
|
||||
| **F-125 的用户侧证据** | 把 `status` 改成 4 后改字段失焦 → **「保存失败:已提交的填报单不能直接修改,请先撤回」**,而**同一屏没有任何撤回入口**;退到「我的填报」卡片写着**「被驳回,请修改后重新提交」**、按钮**「修改后重新提交」** —— 界面自己的行动号召把学生带进死胡同 |
|
||||
|
||||
未走的两条:黄警告确认框(夹具只造出红色)、传图结果卡(**本轮可用的浏览器自动化没有文件选择能力**,只能人工走查)。
|
||||
两个坑记进 `docs/11` §5.3 了:① 不能用"去掉 URL 里的 scene"复现「无 scene 有新 token」,**scene 已进本地缓存**;
|
||||
② `--fixture-only` **不触发清理**,走查完要自己删任务 + 删 stub-login 真建的 `sys_user` + 删 `_tmp_flow_*.xlsx`。
|
||||
新增工具 `scripts/dev-reveal-roster-idcard.py <taskId>`(只读;原 `_reveal-roster-idcard.py` 的 task id **写死在 :59**)。
|
||||
|
||||
⚠️ **走查/全量 E2E 之后要恢复演示态**:`e2e-m3-*` 会清 `collect_roster.bind_user_id`,
|
||||
本轮跑完全量后已按本文件「演示态的现状」把 **task 17 / 名册行 38 重新绑回 `sys_user.id=151`**(`bind_type=1`、`submit_status=1`)。
|
||||
⚠️ 顺带发现:文档里记的演示草稿 `collect_submission.id=31`(`work_unit=测试单位XYZ`)**已不在库里**,
|
||||
当前 task 17 上的草稿是 **id=60(`work_unit` 为空)**——那份"接着往下试"的演示草稿已经不是原来那份了。
|
||||
|
||||
### 九、L3-C 微信小程序端走查(2026-10 补,需求方点名要求)
|
||||
|
||||
需求方明确要求**「学生提交的过程要测微信小程序端」**——这正是本仓库唯一"代码同步维护、却从没被任何自动化跑过"的一面
|
||||
(AGENTS.md 的节奏是「先做 H5」)。用微信开发者工具自动化(`wmp_*` 工具族 / `miniprogram-automator` 0.12.1)在
|
||||
模拟器里(iPhone 12/13、SDK 3.17.3、工具 8.0.5)把 10 步走完了,完整步骤表在 `docs/11` §5.4。
|
||||
|
||||
**结论:小程序端除登录外整条链路都是好的,只有一根线断了 —— 见 F-130。**
|
||||
|
||||
| 走到的 | 结果 |
|
||||
|---|---|
|
||||
| 落地页 | 「示范职业学校 / E2E流程 1b2dc4 / **收集中**」+ scene 回显(匿名接口在小程序里同样通) |
|
||||
| 门禁核验 | 通过;库里 `bind_user_id=1390`、`bind_type=1` |
|
||||
| 填报表单 | 68 个元素全渲染,锁定项标注正确 |
|
||||
| **传材料(这是 H5 那轮没走到的)** | mock `chooseImage` → 控制台 `[材料压缩] IDCARD P2 证件翻拍:4KB → 2KB(q88)` → `collect_file` 落库,规范名 `流程甲…-3610292008XXXXXX13-身份证.jpg` |
|
||||
| 提交(红) | 页内红单 `VAL-05 缺少必传材料:身份证` + 「知道了,去修改」 |
|
||||
| **提交(黄 → 确认框)** | `VAL-08「身份证」清晰度偏低(72×72)` + 页内**「仍然提交」** → 这才是 `docs/11` §5.3 第 2 条(H5 那轮没造出黄色,本轮被小程序补上了) |
|
||||
| 落库 | `collect_submission.status=1`(待初审)、`submit_time` 写入、`collect_roster.submit_status=2` |
|
||||
|
||||
**F-130(P0)一句话**:小程序端 **A** 从来不调 `/mp/login`(token 永远空 → 门禁 40101),**B** 就算调了,
|
||||
`scene` 带 taskId 时 `/mp/login` 必然 500(`MpLoginService.java:127` 匿名查租户表被租户拦截器拒 → 40101 被包成 50000)。
|
||||
对照实测:同秒同端点,`scene=t1` → **200 + token**,`scene=t1-k2313` → **500**——而学生扫的码必然带 taskId。
|
||||
|
||||
⚠️ **本轮走查是"桥接"验的**:把 token 直接塞进 storage,只为把「登录坏了」与「其余链路好不好」分开。
|
||||
**F-130 修好后这一步必须删。**
|
||||
⚠️ **可自动化**:`/mp/login` 此前没有 E2E 覆盖,理由是「脚本拿不到 `wx.login` code」
|
||||
(`scripts/e2e-m3-gate.py:18`)——**本轮证明拿得到**:在页面上下文里调 `wx.login()` 能得到**真 code**,
|
||||
后端真换到了 openid。所以这条链路是可以固化的。
|
||||
⚠️ **收尾**:`--fixture-only` 不触发清理;登录成功那次真建了 `sys_user`(uid 1390,已绑名册行 21695),
|
||||
失败的登录会**回滚**(不建账号)。
|
||||
|
||||
---
|
||||
|
||||
## §一·四十二 弹窗遮罩毛玻璃 + 提示折行清理(2026-10)
|
||||
|
||||
> 需求原话:**"1.给系统中的弹窗背景添加毛玻璃效果 2.系统中的label或tip中还存在换行,优化一下。"**
|
||||
> 用户拍板:**只做 PC 管理端**(学生端小程序/H5 不动);毛玻璃**只加在遮罩层**,弹层本体保持纯白。
|
||||
|
||||
### 一、毛玻璃:只能用 CSS,主题变量改不到
|
||||
|
||||
`frontend-pc/src/styles/index.scss` 的 `:root` 新增两个变量(`:41-42`),并在 `:142-147` 新增:
|
||||
|
||||
```scss
|
||||
.n-modal-mask.n-modal-mask,
|
||||
.n-drawer-mask.n-drawer-mask:not(.n-drawer-mask--invisible) {
|
||||
background-color: var(--xt-mask-bg); /* rgb(16 24 40 / 26%) */
|
||||
backdrop-filter: blur(var(--xt-mask-blur)) saturate(130%); /* 8px */
|
||||
-webkit-backdrop-filter: blur(var(--xt-mask-blur)) saturate(130%);
|
||||
}
|
||||
```
|
||||
|
||||
- **为什么不能走 `App.vue` 的 `themeOverrides`**:Naive 把遮罩色**写死在 css-render 里** ——
|
||||
`naive-ui/es/modal/src/styles/index.cssr.mjs:13` 的 `modal-mask` = `rgba(0,0,0,.4)`、
|
||||
`es/drawer/src/styles/index.cssr.mjs:136` 的 `drawer-mask` = `rgba(0,0,0,.3)`;
|
||||
Modal / Drawer 的主题里**没有 mask token**(`modal/styles/light.mjs` 只有 color / textColor / boxShadow)。
|
||||
- **三种弹层一条规则覆盖**:`useDialog()` 走 `es/dialog/src/DialogEnvironment.mjs:4,137`
|
||||
(`createBlock(Modal_default, …)`)→ 与普通弹窗共用 `.n-modal-mask`(**本轮实机确认**)。
|
||||
- 双类名与 `.n-card.n-card` 同一招(css-render 运行时注入 `<head>`,先后顺序不保证);
|
||||
`.n-drawer-mask--invisible`(`show-mask="transparent"`,`es/drawer/.../index.cssr.mjs:143`)
|
||||
必须排除,否则"看不见的遮罩"会被染色变成"看得见的挡板"(仓里暂时没人用这个属性,是给以后留的)。
|
||||
- 观感:Chrome 不会让 `backdrop-filter` 跟着 `opacity` 淡入;实机截图看**没有**明显"硬弹",
|
||||
所以**没有**加 keyframes 动画。
|
||||
- 自查一行:`getComputedStyle(document.querySelector('.n-modal-mask')).backdropFilter`
|
||||
→ `blur(8px) saturate(1.3)`,弹层本体(`.n-card.n-modal` / `.n-dialog`)仍是 `rgb(255, 255, 255)`。
|
||||
|
||||
### 二、提示折行:9 处(格子宽度都是写死的,与窗口宽度无关)
|
||||
|
||||
| 位置 | 改法 | 实测 |
|
||||
|---|---|---|
|
||||
| `views/task/TaskFormModal.vue:374`(样式 `:431`) | `.n-form-item-blank` 是 **flex 行**:提示与 `<code>` 被并排压缩 → 包一层 `__preview-box`(`width:100%`),提示独占一行 | 191px / **3 行** → 603px / 1 行 |
|
||||
| `views/task/TaskFormSchemaDrawer.vue:622` | "只能用小写字母、数字、下划线;改动后导出列名会跟着变" → 去掉"改动后…会" | 276px 里 2 行 → 264px / 1 行 |
|
||||
| `views/task/TaskMaterialDrawer.vue:624` | "档位决定分辨率与清晰度,一般不用改" → "影响清晰度,一般不用改"(字段 label 已是「压缩档位」) | 192px 里 2 行 → 156px / 1 行 |
|
||||
| `views/task/TaskMaterialDrawer.vue:576` | 别名提示删掉"这是哪一项材料 —— 该认出来的"(第二行只剩"不出来"三个字) | 641px 里 2 行 → 1 行 |
|
||||
| `views/task/TaskOrgDrawer.vue:283` | "留空表示不比对,填了才与名册比" → "留空 = 不比对"(后半句是前半句的逆否) | 160px 定宽列里 2 行 → 1 行 |
|
||||
| `views/user/UserFormModal.vue:331` | "登录名不可修改:改了会让历史记录与导出包里的账号对不上" → "不可修改:会让历史记录与导出包对不上" | 264px 里 2 行 → 216px / 1 行 |
|
||||
| `views/user/UserFormModal.vue:343` | "列表只返回脱敏值,这里必须重填一次(加密存储)" → "必须重填一次(列表只给脱敏值)" | 252px 里 2 行 → 180px / 1 行 |
|
||||
| `views/role/RoleFormModal.vue:250` | 删掉"建好后在右侧授权矩阵里增删"(矩阵就在同屏) | 480px 里 2 行 → 324px / 1 行 |
|
||||
| `views/audit/FilePreview.vue:140` | "(链接过期或已删除)" → "(链接已过期)" | `.fp__box` 固定 108×108:120px 的文字折 3 行且被 `overflow:hidden` **裁掉** → 2 行(`<br>` 是刻意的) |
|
||||
|
||||
**label 侧本轮 0 命中**:可达的 10 个路由(`/dashboard`、`/collect/{task,audit,export,notify}`、
|
||||
`/setting/dict`、`/org/{manage,user,role}`、`/platform/log`)与打开的弹层/抽屉里,
|
||||
标签**没有一个折行**(表单全 `label-placement="top"`、表格列宽写死、表头折行由 CSS 截断)。
|
||||
**未实测**:`/platform/tenant`(本账号 403)、字典的新增弹窗(无按钮)、审核详情抽屉(列表为空)、
|
||||
租户详情/编辑弹窗 —— 静态估算不折行,实施时顺带看一眼。
|
||||
|
||||
### 三、刻意不改
|
||||
|
||||
`views/dict/index.vue:425-429` 的项码提示(56 字)、`TaskRosterDrawer` 的长说明、
|
||||
`views/audit/FilePreview.vue:179` 的 `.fp__missing`(`<br>` = 标题行 + 说明段)、
|
||||
`ExportDetailDrawer` 的 `.ed__muted.ed__tip` 长段落 —— 都是**说明性长文**,折行是预期;
|
||||
压短会丢真实后果("查询不报错、只显示空白"这类)。
|
||||
|
||||
### 四、约定沉淀
|
||||
|
||||
- `frontend-pc/AGENTS.md` 新增**第 11 条**:提示要在自己的格子里单行放下 + 各格子的字数经验值
|
||||
(12px:`span 12` ≈ 23 汉字 / `span 8` ≈ 16 / 两列网格 ≈ 21 / 520px 弹窗全宽 ≈ 39;
|
||||
11px 的 `__fallback` 在 160px 定宽列 ≈ 14)+ `.n-form-item-blank` 是 flex 行 + 遮罩只能用 CSS。
|
||||
- `frontend-pc/.agents/skills/code-css/SKILL.md`:变量表补 `--xt-mask-bg` / `--xt-mask-blur`,
|
||||
新增「弹窗遮罩(毛玻璃)」小节。
|
||||
- **审计手法**(贴控制台,逐页/逐弹层跑):只看**叶子元素**、文字 2~60 字、
|
||||
**只数文本行盒的 top 数**(不数元素盒顶)。
|
||||
⚠️ 两个误报类必须人工看一眼:① 行内元素跨行(长段落里的 `<code>`,如导出页的 `certutil`);
|
||||
② **元素盒顶与文本行顶差 ≥ 0.6 行高**(垂直居中/大 padding 的块,如导出表的日期单元格)——
|
||||
所以判"命中"要看 `display` 与宽度,别直接改文案。
|
||||
|
||||
### 五、验证
|
||||
|
||||
| 手段 | 结果 |
|
||||
|---|---|
|
||||
| `pnpm typecheck` | 0 错 |
|
||||
| `pnpm lint` | **1 条既有报错**:`frontend-pc/AGENTS.md:91`(第 10 条的 ```ts 片段是裸 `} catch`,ESLint 的 markdown 处理器解析不了)。已单独取 `HEAD` 版本复现同样一条 → **与本轮无关**;本轮新增的 markdown 与代码 0 新增报错 |
|
||||
| `check-wiring.py` | 10 项全绿,未接线声明 0 条 |
|
||||
| `verify-docs.py` | 23 项全部通过 |
|
||||
| 实机(1386×800,Vite dev + 后端 8080) | 三种弹层遮罩 `backdropFilter = blur(8px) saturate(1.3)`、底色 `rgba(16, 24, 40, 0.26)`,本体仍 `rgb(255, 255, 255)`;9 处提示逐个量 `height ÷ lineHeight === 1`(`.fp__fail` 那处用同款样式的离屏复现量);10 个路由 + 弹层/抽屉审计 **0 命中** |
|
||||
| 截图 | `C:\Users\93292\Downloads\pc-mask-glass-2026-10-14.jpg`(useDialog「还不能发布」+ 背后毛玻璃) |
|
||||
|
||||
**没有跑 e2e**:38 个 `scripts/e2e-*.py` 全打 HTTP 接口,无一渲染 PC 页面;本轮**没改后端、没改接口与库表**,
|
||||
契约 119 / 错误码 54 / 原型屏数 29 全不变。前端 `/assets/**` 是 immutable 缓存 → 走查后要**硬刷新**。
|
||||
|
||||
### 六、显式不做
|
||||
|
||||
1. **学生端(uni-app H5 与小程序)的遮罩与文案**:`uni.showModal` 是原生弹窗,CSS 改不到;
|
||||
小程序端还有 F-130 未解。
|
||||
2. **浮层类**(`NPopover` / `NTooltip` / `NDropdown` / 下拉面板)的毛玻璃 —— 用户只要"弹窗背景"那一层。
|
||||
3. `docs/prototype/*`:管理端原型没有弹窗,无需同步。
|
||||
4. `frontend-pc/AGENTS.md:91` 那条**既有 lint 报错**:只报告、未顺手改(要把 ```ts 换成 ```text
|
||||
或补成完整函数才行,属另一件事)。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## §一·四十三 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)。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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 色板,两端视觉要一致)。 */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
// 下拉刷新:任务被截止/恢复后,学生不必杀进程重进
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* 后果不只是"麻烦一点":**改一行要等一次完整构建**,而学生端正是改得最多的地方
|
||||
* (表单、上传、核验页),反馈慢会直接改变开发方式 ——
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
├── code-ts/SKILL.md # TS 命名 / 导入 / 显式 import
|
||||
├── code-vue/SKILL.md # SFC 写法与模板约定
|
||||
├── code-css/SKILL.md # SCSS 与样式边界
|
||||
├── project-guide/SKILL.md # 技术栈 / 目录 / 七条最容易写错的约定
|
||||
├── project-guide/SKILL.md # 技术栈 / 目录 / 九条最容易写错的约定
|
||||
├── project-crud/SKILL.md # 列表 + 表单的标准写法
|
||||
├── project-api/SKILL.md # 接口调用(没有 Mock)
|
||||
└── git-commit/SKILL.md # 提交规范(与框架无关)
|
||||
|
||||
@@ -30,6 +30,7 @@ description: SCSS 写法、样式作用域与全局样式的边界。编写或
|
||||
| `--xt-card-pad-x` / `--xt-card-pad-y` | `18px` / `16px` | 卡片内容区左右 / 上下内边距(**别在页面里写死**,见下) |
|
||||
| `--xt-band-bg` | `#f7f8fa` | 浅灰色带:卡片头 / 表头 / 卡片内的子格子 |
|
||||
| `--xt-text` / `--xt-text-2` / `--xt-text-3` | `#1f2229` / `#667085` / `#98a2b3` | 主 / 次 / 弱文字 |
|
||||
| `--xt-mask-bg` / `--xt-mask-blur` | `rgb(16 24 40 / 26%)` / `8px` | 弹窗遮罩的底色与模糊半径(见下节) |
|
||||
|
||||
### ⚠️ 卡片内边距:**不要让页面自己写**
|
||||
|
||||
@@ -67,6 +68,30 @@ Naive 的规则是 `cB("card-content", "padding: 0 var(--n-padding-left) var(--n
|
||||
3. **区域间隔靠"色带 + 描边",不是靠加大 margin**:卡片之间 `--xt-gap`,卡片内区块 12~20px。
|
||||
上一版的教训是把卡片写成 `:bordered="false"` + 内容区白底 → 白卡片浮在白底上,整页没有任何边界。
|
||||
|
||||
## 弹窗遮罩(毛玻璃):**只能用 CSS**,别去找主题变量
|
||||
|
||||
遮罩层(弹窗背后那层灰黑)走毛玻璃:`styles/index.scss` 里一条规则覆盖
|
||||
`NModal` / `NDrawer` / `useDialog()` 三种弹层(`useDialog` 内部就是 `NModal`)。
|
||||
|
||||
```scss
|
||||
.n-modal-mask.n-modal-mask,
|
||||
.n-drawer-mask.n-drawer-mask:not(.n-drawer-mask--invisible) {
|
||||
background-color: var(--xt-mask-bg);
|
||||
backdrop-filter: blur(var(--xt-mask-blur)) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(var(--xt-mask-blur)) saturate(130%);
|
||||
}
|
||||
```
|
||||
|
||||
- ⚠️ **为什么不能用 `App.vue` 的 `themeOverrides`**:Naive 把遮罩色**写死在 css-render 里**
|
||||
(`modal/src/styles/index.cssr.mjs` 的 `modal-mask` = `rgba(0,0,0,.4)`、
|
||||
`drawer/src/styles/index.cssr.mjs` 的 `drawer-mask` = `rgba(0,0,0,.3)`),
|
||||
Modal / Drawer 的**主题里没有 mask token** —— 主题变量改不到它。
|
||||
- ⚠️ **双类名**:与 `.n-card.n-card` 同一招(css-render 运行时注入 `<head>`,先后顺序不保证,把权重提到 (0,2,0))。
|
||||
- ⚠️ **`.n-drawer-mask--invisible`**(`show-mask="transparent"`)必须排除:它是"看不见的遮罩",
|
||||
被染色就变成"看得见的挡板"。`.n-modal-mask` 没有对应的隐藏变体。
|
||||
- 自查:`getComputedStyle(document.querySelector('.n-modal-mask')).backdropFilter`
|
||||
与弹层本体的 `backgroundColor`(应为 `rgb(255, 255, 255)`)。
|
||||
|
||||
## 按钮的可见性
|
||||
|
||||
- 页面**主操作**(新建 / 导入 / 批量通过…)用 `type="primary"`,**默认尺寸**(不要 `size="small"`)。
|
||||
|
||||
@@ -63,6 +63,29 @@ naive-ui 2.45.3 里,`tab="文字"` **属性**写法渲染不出任何文字(
|
||||
原因:属性值走 `normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回
|
||||
Text VNode,被当成块 `createBlock(vnode)` 处理;插槽那条分支返回数组,所以正常。
|
||||
|
||||
### 页面内的「页签」优先用工具条上的分段按钮
|
||||
|
||||
如果只是在一屏里切换两张表(例:日志页的「操作日志 / 登录日志」),
|
||||
**用两个 `NButton` 做分段控件,不要用 `NTabs`**:
|
||||
|
||||
- `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
|
||||
否则表格高度算成 0、**一行数据都看不见**(本仓踩过这个坑);
|
||||
- 上面那个 `tab="..."` 的属性坑也在同一条路上,能绕开就绕开。
|
||||
|
||||
两个按钮在视觉上就是页签,而且不动高度链。各页签的 `list` / `total` / `pageNum` /
|
||||
`loading` / 筛选条件要**分开维护**,切换时重置到第 1 页。
|
||||
|
||||
## 详情用抽屉,不要为详情新开路由
|
||||
|
||||
`NDrawer` + `NDrawerContent` + `NDescriptions` / `NDescriptionsItem`,
|
||||
模板抄 `src/views/audit/AuditDetailDrawer.vue`。列表里的入口是**操作列**里的一个
|
||||
`text` 按钮(`size="small"`)。
|
||||
|
||||
⚠️ 抽屉打开时该调哪个接口要看清楚:**列表接口可能刻意不返回某个字段**。
|
||||
例:`/api/v1/tenants` 的 `contactPhone` **恒为 `null`**(联系电话以密文入库,
|
||||
列表不能成为批量拉取联系方式的通道),脱敏值只有 `/api/v1/tenants/{id}` 给 ——
|
||||
拿行数据凑详情,界面上会显示成"没有联系电话",而这种错看起来像数据问题,不像代码问题。
|
||||
|
||||
## 权限与状态色
|
||||
|
||||
```ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: project-guide
|
||||
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及七条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及十条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||
---
|
||||
|
||||
# 项目总览(PC 管理端)
|
||||
@@ -42,7 +42,7 @@ src/
|
||||
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
|
||||
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
|
||||
|
||||
## 七条最容易写错的约定
|
||||
## 十条最容易写错的约定
|
||||
|
||||
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
|
||||
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
|
||||
@@ -60,6 +60,22 @@ src/
|
||||
**不要换回文字字形标**;页签用 `public/favicon.svg`(加粗版),改一个要同步另一个。
|
||||
⚠️ SVG 注释里**不能出现连续两个减号**(XML 规范禁止)→ 整个文件 not well-formed,
|
||||
图片裂开而控制台一句话都不报;`python scripts/check-wiring.py` 第 ⑨ 项会核对。
|
||||
8. **详情用抽屉、不新开路由**:`NDrawer` + `NDrawerContent` + `NDescriptions`,
|
||||
抄 `src/views/audit/AuditDetailDrawer.vue`;入口是操作列里的 `text` 按钮(`size="small"`)。
|
||||
⚠️ 先确认**列表接口给没给详情要的字段** —— 例:`/tenants` 的 `contactPhone` 恒为 `null`,
|
||||
脱敏值只有 `/tenants/{id}` 给;拿行数据凑详情会显示成"没有联系电话"。
|
||||
9. **页面内的页签用工具条分段按钮,不要 `NTabs`**:`NTabPane` 要额外 flex 化才能维持
|
||||
`.page-card` 高度链,否则表格高度算成 0、**一行都看不见**(本仓踩过);
|
||||
且第 5 条那个 `tab="..."` 的坑也在这条路上。各页签的 list/page 状态分开维护。
|
||||
10. **`#feedback` 会遮住校验错误**:`NFormItem` 只要用了这个插槽,
|
||||
`renderExplains`(校验错误)就**永不渲染**(`children || feedback ? feedback : …`),
|
||||
用户看到的是「红框 + 一句无关的正常提示」=「点了保存没反应」。
|
||||
所以带 `#feedback` 的表单,`validate()` 失败必须自己
|
||||
`message.error(firstError(errors) ?? '请检查表单填写')`。
|
||||
⚠️ `firstError` **必须摊平后再取**(naive reject 的是**嵌套数组**,`errors[0].message` 恒为
|
||||
`undefined`);写法抄 `src/views/tenant/TenantEditModal.vue`。
|
||||
⚠️ 该插槽**不接收任何参数**,没有 `#feedback="{ status }"` 这种写法 ——
|
||||
想内联渲染错误只能给 `feedback` **prop** 传渲染函数(要自写包装组件)。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -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 = '/'
|
||||
|
||||
+43
-1
@@ -43,7 +43,7 @@ pnpm lint # ESLint(@antfu/eslint-config)
|
||||
- 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引
|
||||
- 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`)
|
||||
|
||||
## 七条最容易写错的约定
|
||||
## 十条最容易写错的约定
|
||||
|
||||
1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**,
|
||||
标签统一写 **PascalCase**(`<NButton>`)。
|
||||
@@ -70,3 +70,45 @@ pnpm lint # ESLint(@antfu/eslint-config)
|
||||
⚠️ **SVG 的注释里不能出现连续两个减号**(XML 规范禁止):会让整个文件 not well-formed,
|
||||
表现是图片裂开、**控制台一句话都不报**,typecheck/lint 也全绿。
|
||||
`python scripts/check-wiring.py` 第 ⑨ 项会核对这件事。
|
||||
8. **详情用抽屉,不要为详情新开路由**:`NDrawer` + `NDrawerContent` + `NDescriptions`,
|
||||
模板抄 `src/views/audit/AuditDetailDrawer.vue`。列表页的"详情"入口是操作列里的一个
|
||||
`text` 按钮(`size="small"`,见第 6 条的行内按钮约定)。
|
||||
⚠️ 抽屉打开时该调哪个接口要看清楚:**列表接口可能刻意不返回某个字段**
|
||||
(例:`/tenants` 的 `contactPhone` 恒为 `null`,脱敏值只有 `/tenants/{id}` 给),
|
||||
拿行数据凑详情会显示成"没有联系电话"。
|
||||
9. **页面内的页签用工具条上的分段按钮,不要用 `NTabs` / `NTabPane`**:
|
||||
① `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
|
||||
否则表格高度算成 0、**一行数据都看不见**(本仓踩过);
|
||||
② 见第 5 条,naive 2.45.3 的 `tab="..."` 属性写法本身也是坏的。
|
||||
两个按钮做的分段控件在视觉上就是页签,且不动高度链。
|
||||
多个页签各自的 `list` / `total` / `pageNum` / `loading` 要分开维护,切换时重置到第 1 页。
|
||||
10. **`NFormItem` 一旦用了 `#feedback` 插槽,校验错误就被完全遮住** ——
|
||||
naive 的渲染是 `children || feedback ? feedback : renderExplains`(2.45.3 的 `FormItem.mjs`),
|
||||
插槽存在时 `renderExplains` **永不渲染**,用户看到的是「红框 + 一句无关的正常提示」,
|
||||
现象上等价于「点了保存没反应」。所以**凡是有 `#feedback` 的表单,`validate()` 失败必须自己弹**:
|
||||
|
||||
```ts
|
||||
} catch (errors) {
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ `firstError` **必须摊平后再取**:naive 的 `Form.validate()` reject 的是**嵌套数组**
|
||||
(`Form.mjs` 把每层 `result.errors` 整个 push 进去),`errors[0].message` 恒为 `undefined`,
|
||||
兜底文案会把每条错误都吞成「请检查表单填写」。写法抄 `src/views/tenant/TenantEditModal.vue`。
|
||||
⚠️ 这个插槽**不接收任何参数**(内部用 `resolveWrappedSlot(slot, wrapper)` = `wrapper(slot())`),
|
||||
所以**没有**「`#feedback="{ status }"` 里顺手渲染错误」这种写法 —— 想内联显示错误只能给
|
||||
`NFormItem` 的 `feedback` **prop** 传渲染函数,那需要自写包装组件,别顺手试。
|
||||
11. **提示(`#feedback` / `__hint`)要在自己的格子里单行放下**:放不下就**改短**,或把这一项改成
|
||||
`:span="24"`。格子的**可用宽度是写死的**(弹窗宽度写死、两列网格、`span 8`),**窗口拉宽没有用**。
|
||||
2026-10 实测过一轮:提示折成 2~3 行时读起来根本不像一句话(最惨的一处被 flex 挤到 191px、折了 3 行)。
|
||||
12px 字的经验值:`span 12`(620px 弹窗)≈ **23 个汉字**、`span 8`(680px 弹窗)≈ **16 个**、
|
||||
`.uf__grid` 两列 ≈ **21 个**、全宽表单项(520px 弹窗)≈ **39 个**
|
||||
(11px 的 `.org-drawer__fallback` 在 160px 定宽列里 ≈ 14 个)。
|
||||
⚠️ **`.n-form-item-blank` 是 flex 行**(`form-item` 是 grid、blank 区是 flex)→ 同一个 `NFormItem`
|
||||
里放两个元素会被并排压缩;要"提示在上、内容在下"必须包一层块级容器
|
||||
(写法见 `src/views/task/TaskFormModal.vue` 的 `__preview-box`)。
|
||||
⚠️ **弹窗遮罩的毛玻璃只能用 CSS**:Naive 把遮罩色写死在 css-render 里,Modal / Drawer 主题
|
||||
**没有 mask token**,`App.vue` 的 `themeOverrides` 改不到它 —— 规则在 `src/styles/index.scss`,
|
||||
细节见 `.agents/skills/code-css/SKILL.md`。
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
*
|
||||
* ## ⚠️ 一期的已知边界(界面上要说明,不能让人以为系统坏了)
|
||||
*
|
||||
* - 登录日志**只有 PC 账号密码登录**;学生从微信进来目前不写这张表;
|
||||
* - `location`(IP 归属地)**恒为 null**:没接 IP 库,宁可留空也不编一个;
|
||||
* - IP 取的是代理链第一跳,**可以伪造** —— 用于排查,不作合规证据。
|
||||
* 真正的证据是"哪个账号在什么时候做了什么"。
|
||||
* - `request_param` / `duration_ms` 两列**没有任何写入点**(全库 0 行有值),
|
||||
* 所以界面上不展示、也不做筛选,别为它们写查询。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
|
||||
@@ -35,6 +37,12 @@ export interface OperationLog {
|
||||
afterVal?: string
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
/** 请求方法 GET / POST / PUT / DELETE —— 详情里展示 */
|
||||
requestMethod?: string
|
||||
/** 请求路径(不含 query string) */
|
||||
requestUri?: string
|
||||
/** 补充上下文(JSON 串);不是每条都有,可能是合法的 `null` */
|
||||
extraJson?: string
|
||||
bizCode?: number
|
||||
traceId?: string
|
||||
createTime?: string
|
||||
@@ -47,6 +55,13 @@ export interface OperationLogPage {
|
||||
|
||||
export interface LoginLog {
|
||||
id: number
|
||||
/**
|
||||
* ⚠️ `0` = **平台**(不是 null)。
|
||||
*
|
||||
* 平台运营看到的是**跨单位**的登录日志,没有它分不清一行是哪所学校/单位的
|
||||
* (《03-接口设计》§13.4 `GET /logs/logins`:`tenantId`(`0` = 平台)**必须有**)。
|
||||
*/
|
||||
tenantId?: number
|
||||
userId?: number
|
||||
userName?: string
|
||||
userType?: number
|
||||
@@ -66,6 +81,8 @@ export interface LoginLogPage {
|
||||
}
|
||||
|
||||
export function getOperationLogs(params: {
|
||||
/** ⚠️ 只有 `log:viewAll`(平台运营)能指定别的租户;学校管理员传别人的租户号会 40301 */
|
||||
tenantId?: number
|
||||
operatorId?: number
|
||||
module?: string
|
||||
action?: string
|
||||
@@ -80,6 +97,7 @@ export function getOperationLogs(params: {
|
||||
}
|
||||
|
||||
export function getLoginLogs(params: {
|
||||
tenantId?: number
|
||||
userId?: number
|
||||
userType?: number
|
||||
result?: boolean
|
||||
@@ -91,7 +109,12 @@ export function getLoginLogs(params: {
|
||||
return http.get<LoginLogPage>('/logs/logins', params)
|
||||
}
|
||||
|
||||
/** 敏感动作 → 文案。**与后端 OperationLogService 的常量一致**。 */
|
||||
/**
|
||||
* 动作码 → 文案。
|
||||
*
|
||||
* ⚠️ **必须与后端 `OperationLogService` 的 `ACTION_*` 常量逐字一致**(`check-wiring.py` 第 ⑩ 项双向核对)。
|
||||
* 缺一个不会报错 —— `actionText` 会原样回显那个码,看起来像"系统没翻译"。
|
||||
*/
|
||||
export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'UPDATE_IDCARD', label: '改名册证件号' },
|
||||
{ value: 'UPDATE_ROSTER_NAME', label: '改名册姓名' },
|
||||
@@ -100,9 +123,13 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'PAUSE_TASK', label: '暂停任务' },
|
||||
{ value: 'RESUME_TASK', label: '恢复任务' },
|
||||
{ value: 'CLOSE_TASK', label: '截止任务' },
|
||||
{ value: 'AUTO_CLOSE', label: '自动截止任务' },
|
||||
{ value: 'STATUS_CHANGE', label: '改任务状态' },
|
||||
{ value: 'ARCHIVE_TASK', label: '归档任务' },
|
||||
{ value: 'DELETE_TASK', label: '删除已归档任务' },
|
||||
{ value: 'REVOKE_AUDIT', label: '撤回审核' },
|
||||
{ value: 'EXPORT_CREATE', label: '创建导出' },
|
||||
{ value: 'EXPORT_DOWNLOAD', label: '下载导出文件' },
|
||||
{ value: 'PROXY_UPLOAD', label: '代传材料' },
|
||||
{ value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' },
|
||||
{ value: 'CONFIG_COMPRESS', label: '改压缩配置' },
|
||||
@@ -113,18 +140,51 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'DELETE_ROLE', label: '删除角色' },
|
||||
{ value: 'GRANT_ROLE', label: '改角色授权' },
|
||||
{ value: 'UPDATE_ROLE_MENU', label: '改角色菜单' },
|
||||
{ value: 'TOGGLE_ROLE', label: '停用/启用角色' }
|
||||
{ value: 'TOGGLE_ROLE', label: '停用/启用角色' },
|
||||
{ value: 'CREATE_TENANT', label: '开通学校' },
|
||||
{ value: 'UPDATE_TENANT', label: '修改学校' },
|
||||
{ value: 'DISABLE_TENANT', label: '停用学校' },
|
||||
{ value: 'ENABLE_TENANT', label: '启用学校' }
|
||||
]
|
||||
|
||||
export function actionText(v?: string) {
|
||||
return OPERATION_ACTIONS.find((a) => a.value === v)?.label || v || '—'
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块筛选的可选项。
|
||||
*
|
||||
* ⚠️ **只列后端真的会写的模块**(有 `OperationLogService.MODULE_*` 调用点)。
|
||||
* `MODULE_AUDIT` / `MODULE_EXPORT` / `MODULE_USER` / `MODULE_JOIN` 四个常量**一个调用点都没有** ——
|
||||
* 把它们列进下拉,选中后只会得到一张空表,让人以为"这段时间没有审核动作"。
|
||||
* (加模块时先确认后端有写入口,再往这里加。)
|
||||
*/
|
||||
export const MODULE_OPTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'TASK', label: '任务' },
|
||||
{ value: 'ROSTER', label: '名册' },
|
||||
{ value: 'FILE', label: '材料' },
|
||||
{ value: 'ROLE', label: '角色与授权' },
|
||||
{ value: 'TENANT', label: '租户(平台)' }
|
||||
]
|
||||
|
||||
/** 对象类型筛选的可选项。口径同 `MODULE_OPTIONS`:只列真有写入点的。 */
|
||||
export const TARGET_TYPE_OPTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'TASK', label: '任务' },
|
||||
{ value: 'ROSTER', label: '名册项' },
|
||||
{ value: 'ROLE', label: '角色' },
|
||||
{ value: 'TENANT', label: '租户' }
|
||||
]
|
||||
|
||||
export const LOGIN_TYPES: Record<string, string> = {
|
||||
PASSWORD: '账号密码',
|
||||
WECHAT: '微信授权',
|
||||
PHONE: '手机号快捷'
|
||||
}
|
||||
|
||||
/** 需要重点看的动作(改证件号、看明文证件号)——列表里高亮。 */
|
||||
export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER']
|
||||
/**
|
||||
* 需要重点看的动作 —— 列表里高亮。
|
||||
*
|
||||
* `DISABLE_TENANT`:停用一所学校 = 该校所有学生与老师**立刻登不进来**,
|
||||
* 是单个动作里影响面最大的一类,必须一眼看见。
|
||||
*/
|
||||
export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER', 'DISABLE_TENANT']
|
||||
|
||||
@@ -59,6 +59,66 @@ export function getTenantDetail(id: number) {
|
||||
return http.get<TenantVO>(`/tenants/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改租户(§6.8 的 `PUT /tenants/{id}`)。
|
||||
*
|
||||
* ⚠️ 三个「只在这里能看出来的口径」:
|
||||
* - `tenantCode` **不可修改**(不在请求体里):它出现在小程序扫码落地页与对接文档里;
|
||||
* - `contactPhone` **留空 = 不修改**(后端非空才写,且写进去的是密文);
|
||||
* - `status` 可以给 `2`(试用)—— 与 `TENANT_STATUS` 的三个值一致。
|
||||
*/
|
||||
export interface TenantUpdateReq {
|
||||
tenantName?: string
|
||||
tenantType?: number
|
||||
contactName?: string
|
||||
/** 留空 = 不修改 */
|
||||
contactPhone?: string
|
||||
packageId?: number
|
||||
expireTime?: string
|
||||
/** 0 已停用 / 1 正常 / 2 试用 */
|
||||
status?: number
|
||||
}
|
||||
|
||||
export function updateTenant(id: number, data: TenantUpdateReq) {
|
||||
return http.put<TenantVO>(`/tenants/${id}`, data)
|
||||
}
|
||||
|
||||
/** 用量统计(§6.7)。`ocrCount` / `smsCount` 一期**恒为 0**,见响应里的 `notes`。 */
|
||||
export interface TenantUsage {
|
||||
tenantId: number
|
||||
tenantName: string
|
||||
/** 统计周期,回显用:`2026-09` 或 `2026Q3` */
|
||||
period: string
|
||||
/** 实时值:按证件号去重的人数 */
|
||||
studentCount: number
|
||||
/** 实时值:材料规范件的体积合计(MB) */
|
||||
storageMb: number
|
||||
ocrCount: number
|
||||
smsCount: number
|
||||
quota: {
|
||||
studentLimit: number
|
||||
storageLimitMb: number
|
||||
ocrLimit: number
|
||||
smsLimit: number
|
||||
}
|
||||
alerts: Array<{ type: string, level: number, ratio: string, used: number, limit: number }>
|
||||
/** 某几项为什么是 0(避免把"没通道"当成"没用量")—— 界面要如实展示 */
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 用量统计。
|
||||
*
|
||||
* ⚠️ `studentCount` / `storageMb` 是**实时**算的,`period` 只影响计费项(OCR / 短信)
|
||||
* 的统计周期标签 —— 界面上必须写清楚,否则会以为「2026Q3 的学生数」是历史值。
|
||||
*
|
||||
* @param id 租户 id
|
||||
* @param period `2026-09`(月)或 `2026Q3`(季);不传 = 当月
|
||||
*/
|
||||
export function getTenantUsage(id: number, period?: string) {
|
||||
return http.get<TenantUsage>(`/tenants/${id}/usage`, period ? { period } : {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 开通租户:一次请求完成「建租户 + 发放 6 个内置角色 + 创建首个管理员」。
|
||||
* 返回的 `adminInitialPassword` 只在这一次响应里,界面必须让用户当场记下。
|
||||
|
||||
@@ -142,7 +142,10 @@ export const XT_MENUS: XtMenu[] = [
|
||||
},
|
||||
{
|
||||
key: 'log',
|
||||
title: '操作日志',
|
||||
// ⚠️ `key` 保持 'log' 不动(后端 BuiltinRole 的菜单键与 check-wiring.py 第 ⑥ 项都按它核对,
|
||||
// 改 key 会让平台角色的菜单静默少一项);改的只是**显示名** ——
|
||||
// 这一页现在有「操作日志 / 登录日志」两个页签,叫「操作日志」会名不副实。
|
||||
title: '日志',
|
||||
icon: 'log',
|
||||
path: '/platform/log',
|
||||
component: 'log/index',
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
--xt-text: #1f2229;
|
||||
--xt-text-2: #667085;
|
||||
--xt-text-3: #98a2b3;
|
||||
|
||||
/* 弹窗遮罩:比 Naive 原生的 .4/.3 浅一档,模糊才透得出来(见下方「弹窗遮罩」) */
|
||||
--xt-mask-bg: rgb(16 24 40 / 26%);
|
||||
--xt-mask-blur: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -117,6 +121,31 @@ body {
|
||||
border-bottom-color: var(--xt-card-border-weak);
|
||||
}
|
||||
|
||||
/* ── 弹窗遮罩(毛玻璃) ─────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 三种弹层的遮罩层都变毛玻璃:`NModal`、`NDrawer`、以及 `useDialog()` 的提示框。
|
||||
* **弹层本体保持纯白**,只处理背后那一层。
|
||||
*
|
||||
* ⚠️ 为什么只能用 CSS、改不到 `App.vue` 的 themeOverrides:Naive 把遮罩色**写死在
|
||||
* css-render 里**(`modal/src/styles/index.cssr.mjs` 的 `modal-mask` = `rgba(0,0,0,.4)`、
|
||||
* `drawer/src/styles/index.cssr.mjs` 的 `drawer-mask` = `rgba(0,0,0,.3)`),
|
||||
* Modal / Drawer 的主题里**没有 mask token** —— 主题变量改不到它。
|
||||
* ⚠️ 为什么用双类名:理由同上一条(css-render 运行时注入 <head>,先后顺序不保证),
|
||||
* 与 `.n-card.n-card` 是同一招。
|
||||
* ⚠️ `DialogEnvironment` 里 `useDialog()` 走的是 `NModal`(`dialog/src/DialogEnvironment.mjs`),
|
||||
* 所以它和普通弹窗共用 `.n-modal-mask`,一条规则全覆盖。
|
||||
* ⚠️ `.n-drawer-mask--invisible`(`show-mask="transparent"`)必须排除在外:它是
|
||||
* "看不见的遮罩",被这条规则染上颜色就变成"看得见的挡板"。仓里目前没人用这个属性,
|
||||
* 这个排除是给以后用的。`.n-modal-mask` 没有对应的隐藏变体。
|
||||
*/
|
||||
.n-modal-mask.n-modal-mask,
|
||||
.n-drawer-mask.n-drawer-mask:not(.n-drawer-mask--invisible) {
|
||||
background-color: var(--xt-mask-bg);
|
||||
backdrop-filter: blur(var(--xt-mask-blur)) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(var(--xt-mask-blur)) saturate(130%);
|
||||
}
|
||||
|
||||
/* ── 列表页卡片 ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -137,7 +137,7 @@ onMounted(loadNormal)
|
||||
<NSpin v-if="loading" :size="16" />
|
||||
<img v-else-if="src" :src="src" class="fp__img" alt="材料预览" />
|
||||
<div v-else class="fp__fail">
|
||||
预览取不到<br>(链接过期或已删除)
|
||||
预览取不到<br>(链接已过期)
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp__name" :title="fileName">{{ fileName }}</div>
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<script setup lang="ts">
|
||||
import type { OperationLog } from '@/apis/xt/log'
|
||||
import {
|
||||
NButton,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { actionText, MODULE_OPTIONS, SENSITIVE_ACTIONS } from '@/apis/xt/log'
|
||||
|
||||
/**
|
||||
* 操作日志详情。
|
||||
*
|
||||
* 输入是**一整行 `OperationLog`**(列表已经把它全查回来了),所以这里**不再打接口** ——
|
||||
* 详情只是"把列表里被截断/没展示的字段摊开",多点一次网络请求没有任何信息增益。
|
||||
*
|
||||
* ## 为什么几处刻意"不截断"
|
||||
*
|
||||
* 这一屏是排查用的:
|
||||
* - `userAgent` 要能整条读完 —— 兼容性问题全在尾巴上(内核版本、各家小程序的 webview 标识),
|
||||
* 截成省略号等于把最有用的一段藏掉;
|
||||
* - `beforeVal → afterVal` 要能整段读完 —— 列表里那是单行摘要,这里再截一次就白点进来了。
|
||||
* 两者统一 `word-break: break-all`。
|
||||
*
|
||||
* ## 为什么灰字写「请求参数 / 耗时:本版本未采集」
|
||||
*
|
||||
* 库里 `request_param` / `duration_ms` 两列**没有任何写入点**(全库 0 行有值,见 `apis/xt/log.ts` 的说明)。
|
||||
* 空着会让人以为"这条日志恰好没采到",从而一直等一个永远不会出现的字段 —— 如实说没有,比留白有用。
|
||||
*/
|
||||
defineOptions({ name: 'LogDetailDrawer' })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/**
|
||||
* 租户 id → 单位名。
|
||||
*
|
||||
* 列表页(平台身份)已经查过一次,这里复用同一个映射,**不为一个名字再打一次 `/tenants`**;
|
||||
* 拿不到时退回 `#id` —— 详情页显示 `#3` 不好看,但比编一个名字安全。
|
||||
*/
|
||||
tenantNames?: Record<number, string>
|
||||
}>(), { tenantNames: () => ({}) })
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const visible = ref(false)
|
||||
const log = ref<OperationLog | null>(null)
|
||||
|
||||
/** 动作标签:敏感动作(改证件号 / 看完整证件号 / 删名册项 / 停用学校)在列表与详情里**同一套口径** */
|
||||
const actTag = computed<{ text: string, type: 'info' | 'error' }>(() => {
|
||||
const action = log.value?.action
|
||||
return {
|
||||
text: actionText(action),
|
||||
type: action && SENSITIVE_ACTIONS.includes(action) ? 'error' : 'info'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 单位(列表里那一列的口径,与筛选下拉同词)。
|
||||
*
|
||||
* ⚠️ 平台自己的动作(开通/停用学校这类)写的是 `tenant_id = 0`,**不是 null** ——
|
||||
* 直接显示会变成莫名其妙的 `#0`。
|
||||
*/
|
||||
const tenantText = computed(() => {
|
||||
const id = log.value?.tenantId
|
||||
if (id === 0) {
|
||||
return '平台'
|
||||
}
|
||||
if (id == null) {
|
||||
return '—'
|
||||
}
|
||||
return props.tenantNames[id] ?? `#${id}`
|
||||
})
|
||||
|
||||
/** 操作人:详情里**带上 `#id`**(与列表相反)—— 排查时要能对上库里那一行 */
|
||||
const operatorText = computed(() => {
|
||||
const row = log.value
|
||||
if (!row) {
|
||||
return '—'
|
||||
}
|
||||
const id = row.operatorId != null ? `#${row.operatorId}` : ''
|
||||
return [row.operatorName, id].filter(Boolean).join(' ') || '—'
|
||||
})
|
||||
|
||||
/** 模块:查不到就**原样回显那个码**,不编中文名(否则和后端日志里的码对不上) */
|
||||
const moduleText = computed(() => {
|
||||
const m = log.value?.module
|
||||
if (!m) {
|
||||
return '—'
|
||||
}
|
||||
return MODULE_OPTIONS.find((o) => o.value === m)?.label ?? m
|
||||
})
|
||||
|
||||
const targetText = computed(() => {
|
||||
const row = log.value
|
||||
if (!row?.targetType) {
|
||||
return '—'
|
||||
}
|
||||
return `${row.targetType} #${row.targetId ?? '—'}`
|
||||
})
|
||||
|
||||
/**
|
||||
* `extraJson` 美化。
|
||||
*
|
||||
* 解析失败就**原样展示**:日志里最忌讳"这一段我帮你藏起来了" ——
|
||||
* 后端将来多写一种格式时,原样展示至少还能被人肉读出来。
|
||||
*/
|
||||
const extraPretty = computed(() => {
|
||||
const raw = log.value?.extraJson
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})
|
||||
|
||||
/** 时间到**秒**:排查时"12:00"与"12:00:31"是两件事 */
|
||||
function fmtTime(v?: string): string {
|
||||
return v ? v.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
|
||||
async function copy(text: string | undefined, label: string): Promise<void> {
|
||||
if (!text) {
|
||||
message.warning(`${label}为空,没有可复制的内容`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success(`已复制${label}`)
|
||||
} catch {
|
||||
message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
}
|
||||
}
|
||||
|
||||
function open(row: OperationLog): void {
|
||||
log.value = row
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer v-model:show="visible" :width="760" placement="right">
|
||||
<NDrawerContent :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span>日志详情</span>
|
||||
<NTag v-if="log" class="logd__head-tag" size="small" :bordered="false" :type="actTag.type">
|
||||
{{ actTag.text }}
|
||||
</NTag>
|
||||
<span v-if="log" class="logd__head-sub">{{ fmtTime(log.createTime) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="log">
|
||||
<div class="section-title logd__sec">
|
||||
基本信息
|
||||
</div>
|
||||
<NDescriptions :column="2" size="small" bordered>
|
||||
<NDescriptionsItem label="时间">
|
||||
{{ fmtTime(log.createTime) }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="日志 ID">
|
||||
{{ log.id }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="单位">
|
||||
{{ tenantText }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="操作人">
|
||||
{{ operatorText }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="模块">
|
||||
{{ moduleText }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="动作">
|
||||
<NTag size="small" :bordered="false" :type="actTag.type">
|
||||
{{ actTag.text }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="对象" :span="2">
|
||||
{{ targetText }}
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<div class="section-title logd__sec">
|
||||
变更前后
|
||||
<span class="section-title__note">
|
||||
证件号/手机号已在服务端脱敏;超过 512 字符的值在服务端就被截断了
|
||||
</span>
|
||||
</div>
|
||||
<div class="logd__diff">
|
||||
<div class="logd__col">
|
||||
<div class="logd__col-head">
|
||||
<span class="logd__col-label">变更前</span>
|
||||
<NButton text size="small" :disabled="!log.beforeVal" @click="copy(log.beforeVal, '变更前')">
|
||||
复制
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="logd__val" :class="{ 'logd__val--empty': !log.beforeVal }">
|
||||
{{ log.beforeVal || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="logd__col">
|
||||
<div class="logd__col-head">
|
||||
<span class="logd__col-label">变更后</span>
|
||||
<NButton text size="small" :disabled="!log.afterVal" @click="copy(log.afterVal, '变更后')">
|
||||
复制
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="logd__val" :class="{ 'logd__val--empty': !log.afterVal }">
|
||||
{{ log.afterVal || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title logd__sec">
|
||||
请求
|
||||
</div>
|
||||
<div class="logd__req">
|
||||
<span class="logd__method">{{ log.requestMethod || '—' }}</span>
|
||||
<span class="logd__uri">{{ log.requestUri || '—' }}</span>
|
||||
</div>
|
||||
<!-- 如实说没采集:空着会被当成"这条恰好没采到",然后一直等一个不存在的字段 -->
|
||||
<div class="logd__note">
|
||||
请求参数 / 耗时:本版本未采集
|
||||
</div>
|
||||
|
||||
<div class="section-title logd__sec">
|
||||
来源
|
||||
</div>
|
||||
<NDescriptions :column="1" size="small" bordered>
|
||||
<NDescriptionsItem label="IP">
|
||||
{{ log.ip || '—' }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="User-Agent">
|
||||
<!-- 整条展示、不截断:兼容性问题全在 UA 的尾巴上 -->
|
||||
<span class="logd__ua">{{ log.userAgent || '—' }}</span>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<div class="section-title logd__sec">
|
||||
排查信息
|
||||
</div>
|
||||
<NDescriptions :column="2" size="small" bordered>
|
||||
<NDescriptionsItem label="traceId">
|
||||
<span class="logd__code">{{ log.traceId || '—' }}</span>
|
||||
<NButton v-if="log.traceId" text size="small" class="logd__copy" @click="copy(log.traceId, 'traceId')">
|
||||
复制
|
||||
</NButton>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="bizCode">
|
||||
{{ log.bizCode ?? '—' }}
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<!-- 没有 extraJson 就整块不显示:一个空标题只会让人以为"内容加载失败" -->
|
||||
<template v-if="extraPretty">
|
||||
<div class="section-title logd__sec">
|
||||
补充上下文
|
||||
</div>
|
||||
<pre class="logd__json">{{ extraPretty }}</pre>
|
||||
</template>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logd__head-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.logd__head-sub {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/* 全局 `.section-title` 只有下间距,抽屉里的分区会与上一块内容贴在一起 */
|
||||
.logd__sec {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* 变更前后:两块并排,各占一半(窄屏下会自动变窄,值本身换行不截断) */
|
||||
.logd__diff {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logd__col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.logd__col-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.logd__col-label {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
|
||||
.logd__val {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
/* 不截断:整段展示,长串靠 break-all 折行 */
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.logd__val--empty {
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.logd__req {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.logd__method {
|
||||
font-family: ui-monospace, sfmono-regular, menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--xt-primary, #0052d9);
|
||||
}
|
||||
|
||||
.logd__uri {
|
||||
font-family: ui-monospace, sfmono-regular, menlo, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.logd__note {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.logd__ua {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.logd__code {
|
||||
font-family: ui-monospace, sfmono-regular, menlo, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.logd__copy {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 补充上下文:等宽 + 可滚动(值可能很长,撑高抽屉不如让它自己滚) */
|
||||
.logd__json {
|
||||
max-height: 320px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, sfmono-regular, menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
+491
-106
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { OperationLog } from '@/apis/xt/log'
|
||||
import type { LoginLog, OperationLog } from '@/apis/xt/log'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
@@ -11,54 +11,77 @@ import {
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, h, onMounted, reactive, ref, useTemplateRef } from 'vue'
|
||||
import {
|
||||
actionText,
|
||||
getLoginLogs,
|
||||
getOperationLogs,
|
||||
LOGIN_TYPES,
|
||||
MODULE_OPTIONS,
|
||||
OPERATION_ACTIONS,
|
||||
SENSITIVE_ACTIONS
|
||||
SENSITIVE_ACTIONS,
|
||||
TARGET_TYPE_OPTIONS
|
||||
} from '@/apis/xt/log'
|
||||
import { getTenantList } from '@/apis/xt/tenant'
|
||||
import { USER_TYPE_NAMES } from '@/apis/xt/user'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import LogDetailDrawer from './LogDetailDrawer.vue'
|
||||
|
||||
/**
|
||||
* 操作日志。
|
||||
* 日志(操作日志 / 登录日志)。
|
||||
*
|
||||
* ## 为什么把「对象 ID」放在筛选栏最左边
|
||||
* ## 为什么模式切换用两个按钮,而不是 `NTabs`
|
||||
*
|
||||
* 因为这是这一屏**唯一不可替代**的查询方式。合规问题问的是
|
||||
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠 `target_type + target_id` 定位;
|
||||
* 而"搜关键字"会把「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
|
||||
* 这是本仓踩过的两个坑叠在一起,刻意绕开:
|
||||
* ① 页签内容需要额外 flex 化才能维持 `.page-card` 的高度链,否则表体高度算成 **0** ——
|
||||
* 行在 DOM 里、数据也查得到,屏幕上一行都看不见;
|
||||
* ② naive 2.45.3 的 `NTabs` 用 `tab="文字"` **属性**只渲染一个空注释节点(必须用 `#tab` 插槽)。
|
||||
* 两个按钮在视觉上就是分段控件,且完全不动高度链(与「租户」页的状态筛选同一个写法)。
|
||||
*
|
||||
* ## 「变更前 → 变更后」单独一列
|
||||
* ## 两个模式的状态**完全独立**
|
||||
*
|
||||
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于每次查看多一次点击,
|
||||
* 而合规核查往往是几十条连着看的。
|
||||
* 各自的 `list/total/pageNum/pageSize/loading/筛选条件`,切换时把要进入的那个模式重置到第 1 页 ——
|
||||
* 否则会出现"分页器停在第 5 页、而这张表只有 2 页"的空表,看起来就像"这个模式没数据"。
|
||||
*
|
||||
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
|
||||
*
|
||||
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定。⚠️ 平台看到的每条日志属于哪个单位,
|
||||
* 靠「租户」这一列 —— 它**只对平台显示**(学校管理员永远是本租户,列没有意义)。
|
||||
* 靠两张表里那一列「单位」(`0` → 「平台」)—— 它**只对平台显示**:
|
||||
* 学校管理员永远是本租户,整列都是同一个值,加了是噪音。
|
||||
*
|
||||
* 两张表的这一列**用同一个词**(「单位」),与筛选栏那两个下拉的占位符一致。
|
||||
* 不叫「租户」是因为它要能装下 `tenantId = 0` 的「平台」—— 平台不是租户,
|
||||
* 表头写「租户」而格子里写着「平台」,读起来是矛盾的。
|
||||
*/
|
||||
defineOptions({ name: 'OperationLogs' })
|
||||
defineOptions({ name: 'Logs' })
|
||||
|
||||
type LogMode = 'operation' | 'login'
|
||||
|
||||
const message = useMessage()
|
||||
const user = useUserStore()
|
||||
const detailRef = useTemplateRef('detailRef')
|
||||
|
||||
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
|
||||
/** 平台运营:看全量日志,因此要显示「单位」列并把 id 翻成单位名 */
|
||||
const isPlatform = computed(() => user.isPlatform)
|
||||
/** 平台运营的权限码口径(与后端 `logScopeTenantId` 同一个码):多一个「单位」筛选 */
|
||||
const canViewAll = computed(() => user.has('log:viewAll'))
|
||||
|
||||
const tenantNames = ref<Record<number, string>>({})
|
||||
const tenantOptions = ref<Array<{ label: string, value: number }>>([])
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<OperationLog[]>([])
|
||||
const total = ref(0)
|
||||
const mode = ref<LogMode>('operation')
|
||||
|
||||
const fromDate = ref<string | null>(null)
|
||||
const toDate = ref<string | null>(null)
|
||||
// ── 操作日志 ─────────────────────────────────────────
|
||||
const opLoading = ref(false)
|
||||
const opList = ref<OperationLog[]>([])
|
||||
const opTotal = ref(0)
|
||||
const opFrom = ref<string | null>(null)
|
||||
const opTo = ref<string | null>(null)
|
||||
|
||||
const query = reactive<{
|
||||
const opQuery = reactive<{
|
||||
tenantId: number | null
|
||||
targetId: number | null
|
||||
targetType: string | null
|
||||
action: string | null
|
||||
@@ -66,6 +89,7 @@ const query = reactive<{
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({
|
||||
tenantId: null,
|
||||
targetId: null,
|
||||
targetType: null,
|
||||
action: null,
|
||||
@@ -74,24 +98,45 @@ const query = reactive<{
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const targetTypeOptions = [
|
||||
{ label: '名册项', value: 'ROSTER' },
|
||||
{ label: '任务', value: 'TASK' },
|
||||
{ label: '填报单', value: 'SUBMISSION' },
|
||||
{ label: '导出', value: 'EXPORT' },
|
||||
{ label: '账号', value: 'USER' }
|
||||
]
|
||||
// ── 登录日志 ─────────────────────────────────────────
|
||||
const loginLoading = ref(false)
|
||||
const loginList = ref<LoginLog[]>([])
|
||||
const loginTotal = ref(0)
|
||||
const loginFrom = ref<string | null>(null)
|
||||
const loginTo = ref<string | null>(null)
|
||||
|
||||
const loginQuery = reactive<{
|
||||
tenantId: number | null
|
||||
userType: number | null
|
||||
result: number | null
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({
|
||||
tenantId: null,
|
||||
userType: null,
|
||||
result: null,
|
||||
pageNum: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const actionOptions = OPERATION_ACTIONS.map((a) => ({ label: a.label, value: a.value }))
|
||||
|
||||
/** 加入申请(JOIN)已随需求收敛下线,这里不再列它 */
|
||||
const moduleOptions = [
|
||||
{ label: '名册', value: 'ROSTER' },
|
||||
{ label: '任务', value: 'TASK' },
|
||||
{ label: '审核', value: 'AUDIT' },
|
||||
{ label: '导出', value: 'EXPORT' }
|
||||
/**
|
||||
* 登录结果的筛选值用 `1 / 0`,**不用 `true / false`** ——
|
||||
* naive 的 `SelectOption.value` 只接受 `string | number`,传布尔会在 `vue-tsc` 里报类型错;
|
||||
* 翻成后端要的 `Boolean` 只在调接口那一处做一次。
|
||||
*/
|
||||
const resultOptions = [
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 0 }
|
||||
]
|
||||
|
||||
/** 用户类型映射复用 `@/apis/xt/user`(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生) */
|
||||
const userTypeOptions = Object.entries(USER_TYPE_NAMES).map(([code, name]) => ({
|
||||
label: name,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
function fmt(v?: string): string {
|
||||
return v ? v.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
@@ -100,16 +145,36 @@ function isSensitive(action?: string): boolean {
|
||||
return !!action && SENSITIVE_ACTIONS.includes(action)
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
/**
|
||||
* 单位显示(操作日志、登录日志两张表共用同一个函数,口径必须一致)。
|
||||
*
|
||||
* ⚠️ 平台自己的动作(开通/停用学校)写的是 `tenant_id = 0`,**不是 null** ——
|
||||
* 照直渲染就是 `#0`,看不出"这是平台自己干的"。
|
||||
*/
|
||||
function tenantLabel(id?: number): string {
|
||||
if (id == null) {
|
||||
return '—'
|
||||
}
|
||||
if (id === 0) {
|
||||
return '平台'
|
||||
}
|
||||
return tenantNames.value[id] ?? `#${id}`
|
||||
}
|
||||
|
||||
function openDetail(row: OperationLog): void {
|
||||
detailRef.value?.open(row)
|
||||
}
|
||||
|
||||
const operationColumns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
const cols: DataTableColumns<OperationLog> = []
|
||||
|
||||
// 「租户」列只给平台运营看
|
||||
// 「单位」列只给平台运营看
|
||||
if (isPlatform.value) {
|
||||
cols.push({
|
||||
title: '租户',
|
||||
title: '单位',
|
||||
key: 'tenantId',
|
||||
width: 150,
|
||||
render: (row) => h('span', { class: 'log__muted' }, tenantNames.value[row.tenantId ?? 0] ?? (row.tenantId != null ? `#${row.tenantId}` : '—'))
|
||||
render: (row) => h('span', { class: 'log__muted' }, tenantLabel(row.tenantId))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,7 +190,8 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
key: 'operatorName',
|
||||
width: 130,
|
||||
// ⚠️ 只显示账号/姓名,不显示用户 ID(客户走查要求):
|
||||
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"
|
||||
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"。
|
||||
// 需要 ID 时点「详情」—— 那里带 `#id`,与库里那一行对得上
|
||||
render: (row) => row.operatorName || '—'
|
||||
},
|
||||
{
|
||||
@@ -167,6 +233,105 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
h('div', { class: 'log__muted' }, row.ip || '—'),
|
||||
h('div', { class: 'log__ua', title: row.userAgent || '' }, row.userAgent || '')
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'ops',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
// 固定在右侧:列宽合计超过容器时,横向滚动不该把「详情」滚出可视区
|
||||
fixed: 'right',
|
||||
render: (row) => h(
|
||||
NButton,
|
||||
{ text: true, size: 'small', onClick: () => openDetail(row) },
|
||||
{ default: () => '详情' }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return cols
|
||||
})
|
||||
|
||||
const loginColumns = computed<DataTableColumns<LoginLog>>(() => {
|
||||
const cols: DataTableColumns<LoginLog> = []
|
||||
|
||||
cols.push({
|
||||
title: '时间',
|
||||
key: 'loginTime',
|
||||
width: 160,
|
||||
render: (row) => h('span', { class: 'log__muted' }, fmt(row.loginTime))
|
||||
})
|
||||
|
||||
// 「单位」列(与操作日志同款、同一个判断):平台看的是跨单位的登录日志,
|
||||
// 少了它分不清一行是哪所学校的;学校管理员恒为本租户,整列一个值,不加
|
||||
if (isPlatform.value) {
|
||||
cols.push({
|
||||
title: '单位',
|
||||
key: 'tenantId',
|
||||
width: 150,
|
||||
// ⚠️ 后端本次才把这个字段加进 `LoginLogVO`,旧 jar 下这里是 undefined → 显示「—」
|
||||
render: (row) => h('span', { class: 'log__muted' }, tenantLabel(row.tenantId))
|
||||
})
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
title: '用户',
|
||||
key: 'userName',
|
||||
width: 150,
|
||||
render: (row) => h('div', null, [
|
||||
h('div', null, row.userName || '—'),
|
||||
// 用户 ID 放在姓名下面:账号重名、学生用家长的手机登录时,靠它才能确认是哪个人
|
||||
h('div', { class: 'log__muted' }, row.userId != null ? `#${row.userId}` : '—')
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '用户类型',
|
||||
key: 'userType',
|
||||
width: 110,
|
||||
render: (row) => USER_TYPE_NAMES[row.userType ?? 0] ?? '—'
|
||||
},
|
||||
{
|
||||
title: '登录方式',
|
||||
key: 'loginType',
|
||||
width: 120,
|
||||
// 认不出就原样回显那个码(后端将来加登录方式时,界面不会显示成空白)
|
||||
render: (row) => LOGIN_TYPES[row.loginType ?? ''] ?? row.loginType ?? '—'
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
key: 'result',
|
||||
width: 90,
|
||||
render: (row) => {
|
||||
if (row.result !== true && row.result !== false) {
|
||||
return h('span', { class: 'log__muted' }, '—')
|
||||
}
|
||||
return h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: row.result ? 'success' : 'error' },
|
||||
{ default: () => (row.result ? '成功' : '失败') }
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
key: 'ip',
|
||||
width: 140,
|
||||
render: (row) => h('span', { class: 'log__muted' }, row.ip || '—')
|
||||
},
|
||||
{
|
||||
title: '设备',
|
||||
key: 'device',
|
||||
width: 180,
|
||||
// 与操作日志的 UA 列同款:单行 + 省略号,完整内容放 `title`(鼠标悬停可看)
|
||||
render: (row) => h('span', { class: 'log__ua', title: row.device || '' }, row.device || '—')
|
||||
},
|
||||
{
|
||||
title: '失败原因',
|
||||
key: 'failReason',
|
||||
width: 200,
|
||||
// 登录成功的那一行本来就没有失败原因,留空比整列铺满「—」好读
|
||||
render: (row) => (row.failReason ? h('span', null, row.failReason) : '')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -180,84 +345,189 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
* 同时处理 `@update:page` 与 `@update:page-size`(改每页条数一律回到第 1 页重新取数,
|
||||
* 否则会出现"分页器说 50 条/页、表里还是上一次那 20 条")。
|
||||
*/
|
||||
const pagination = computed(() => listPagination({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
itemCount: total.value,
|
||||
const opPagination = computed(() => listPagination({
|
||||
page: opQuery.pageNum,
|
||||
pageSize: opQuery.pageSize,
|
||||
itemCount: opTotal.value,
|
||||
pageSizes: [20, 50, 100]
|
||||
}))
|
||||
|
||||
function onPage(page: number): void {
|
||||
query.pageNum = page
|
||||
void load()
|
||||
}
|
||||
const loginPagination = computed(() => listPagination({
|
||||
page: loginQuery.pageNum,
|
||||
pageSize: loginQuery.pageSize,
|
||||
itemCount: loginTotal.value,
|
||||
pageSizes: [20, 50, 100]
|
||||
}))
|
||||
|
||||
function onPageSize(size: number): void {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
async function loadOperations(): Promise<void> {
|
||||
opLoading.value = true
|
||||
try {
|
||||
const page = await getOperationLogs({
|
||||
targetId: query.targetId ?? undefined,
|
||||
targetType: query.targetType ?? undefined,
|
||||
action: query.action ?? undefined,
|
||||
module: query.module ?? undefined,
|
||||
// ⚠️ `?? undefined` 而不是 `||`:0 是「平台」这个**有效值**,用 `||` 会被当成"没选"
|
||||
tenantId: opQuery.tenantId ?? undefined,
|
||||
targetId: opQuery.targetId ?? undefined,
|
||||
targetType: opQuery.targetType ?? undefined,
|
||||
action: opQuery.action ?? undefined,
|
||||
module: opQuery.module ?? undefined,
|
||||
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
|
||||
from: fromDate.value || undefined,
|
||||
to: toDate.value || undefined,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
from: opFrom.value || undefined,
|
||||
to: opTo.value || undefined,
|
||||
pageNum: opQuery.pageNum,
|
||||
pageSize: opQuery.pageSize
|
||||
})
|
||||
list.value = page.list ?? []
|
||||
total.value = page.total ?? 0
|
||||
opList.value = page.list ?? []
|
||||
opTotal.value = page.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
opLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search(): void {
|
||||
query.pageNum = 1
|
||||
void load()
|
||||
async function loadLogins(): Promise<void> {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const page = await getLoginLogs({
|
||||
tenantId: loginQuery.tenantId ?? undefined,
|
||||
userType: loginQuery.userType ?? undefined,
|
||||
// 筛选值是 1/0(见 `resultOptions`),接口要的是 Boolean
|
||||
result: loginQuery.result == null ? undefined : loginQuery.result === 1,
|
||||
from: loginFrom.value || undefined,
|
||||
to: loginTo.value || undefined,
|
||||
pageNum: loginQuery.pageNum,
|
||||
pageSize: loginQuery.pageSize
|
||||
})
|
||||
loginList.value = page.list ?? []
|
||||
loginTotal.value = page.total ?? 0
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
query.targetId = null
|
||||
query.targetType = null
|
||||
query.action = null
|
||||
query.module = null
|
||||
fromDate.value = null
|
||||
toDate.value = null
|
||||
search()
|
||||
/**
|
||||
* 切换模式。
|
||||
*
|
||||
* ⚠️ 进入哪个模式,就把它**自己**的页码重置到第 1 页:
|
||||
* 两个模式各自记着上次翻到第几页,不重置就会出现"切过来是第 5 页、而这张表只有 2 页"的空表 ——
|
||||
* 那看起来就是"登录日志一条都没有"。筛选条件保留(切回来还是刚才那批条件),只有页码归零。
|
||||
*/
|
||||
function switchMode(next: LogMode): void {
|
||||
if (next === mode.value) {
|
||||
return
|
||||
}
|
||||
mode.value = next
|
||||
if (next === 'operation') {
|
||||
opQuery.pageNum = 1
|
||||
void loadOperations()
|
||||
} else {
|
||||
loginQuery.pageNum = 1
|
||||
void loadLogins()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
|
||||
if (isPlatform.value) {
|
||||
function searchOperations(): void {
|
||||
opQuery.pageNum = 1
|
||||
void loadOperations()
|
||||
}
|
||||
|
||||
function searchLogins(): void {
|
||||
loginQuery.pageNum = 1
|
||||
void loadLogins()
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
if (mode.value === 'operation') {
|
||||
opQuery.tenantId = null
|
||||
opQuery.targetId = null
|
||||
opQuery.targetType = null
|
||||
opQuery.action = null
|
||||
opQuery.module = null
|
||||
opFrom.value = null
|
||||
opTo.value = null
|
||||
searchOperations()
|
||||
} else {
|
||||
loginQuery.tenantId = null
|
||||
loginQuery.userType = null
|
||||
loginQuery.result = null
|
||||
loginFrom.value = null
|
||||
loginTo.value = null
|
||||
searchLogins()
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (mode.value === 'operation') {
|
||||
void loadOperations()
|
||||
} else {
|
||||
void loadLogins()
|
||||
}
|
||||
}
|
||||
|
||||
function onOperationPage(page: number): void {
|
||||
opQuery.pageNum = page
|
||||
void loadOperations()
|
||||
}
|
||||
|
||||
function onOperationPageSize(size: number): void {
|
||||
opQuery.pageSize = size
|
||||
opQuery.pageNum = 1
|
||||
void loadOperations()
|
||||
}
|
||||
|
||||
function onLoginPage(page: number): void {
|
||||
loginQuery.pageNum = page
|
||||
void loadLogins()
|
||||
}
|
||||
|
||||
function onLoginPageSize(size: number): void {
|
||||
loginQuery.pageSize = size
|
||||
loginQuery.pageNum = 1
|
||||
void loadLogins()
|
||||
}
|
||||
|
||||
/**
|
||||
* 单位下拉的选项。
|
||||
*
|
||||
* ⚠️ 除了租户列表,必须**额外加一项「平台」(`tenantId = 0`)**:
|
||||
* 平台自己的动作(开通/停用学校)写的是 `tenant_id = 0`,而租户表里没有 0 这一行 ——
|
||||
* 少了这一项,「平台做了哪些事」就永远筛不出来。放在最前面,因为它最常用。
|
||||
*/
|
||||
async function loadTenants(): Promise<void> {
|
||||
if (!canViewAll.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const page = await getTenantList({ pageNum: 1, pageSize: 200 })
|
||||
tenantNames.value = Object.fromEntries((page.list ?? []).map((t) => [t.id, t.tenantName]))
|
||||
const tenants = page.list ?? []
|
||||
tenantNames.value = Object.fromEntries(tenants.map((t) => [t.id, t.tenantName]))
|
||||
tenantOptions.value = [
|
||||
{ label: '平台', value: 0 },
|
||||
...tenants.map((t) => ({ label: t.tenantName, value: t.id }))
|
||||
]
|
||||
} catch {
|
||||
// 取不到也不影响看日志:租户列退回显示 #id,下拉至少还能按「平台」筛
|
||||
tenantNames.value = {}
|
||||
tenantOptions.value = [{ label: '平台', value: 0 }]
|
||||
message.warning('单位名称没取到,租户列先显示编号')
|
||||
}
|
||||
}
|
||||
await load()
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTenants()
|
||||
await loadOperations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard class="page-card log" :bordered="false" size="small">
|
||||
<template #header>
|
||||
操作日志
|
||||
日志
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="log__hint">
|
||||
<span v-if="mode === 'operation'" class="log__hint">
|
||||
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
|
||||
</span>
|
||||
<span v-else class="log__hint">
|
||||
登录日志用来回答「谁在什么时候从哪里登了进来」;IP 归属地一期没有采集,所以没有这一列
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
@@ -269,62 +539,136 @@ onMounted(async () => {
|
||||
否则换行后每行会被"两端对齐"抻开,看起来像随机的空档。
|
||||
-->
|
||||
<div class="toolbar log__filters">
|
||||
<!--
|
||||
模式切换:用两个按钮做的分段控件(刻意不用 NTabs,原因见文件头注释)。
|
||||
右侧那条竖线把它与「筛选条件」分开 —— 一个是"看哪张表",一个是"表里筛什么"。
|
||||
-->
|
||||
<div class="log__seg">
|
||||
<NButton :type="mode === 'operation' ? 'primary' : 'default'" @click="switchMode('operation')">
|
||||
操作日志
|
||||
</NButton>
|
||||
<NButton :type="mode === 'login' ? 'primary' : 'default'" @click="switchMode('login')">
|
||||
登录日志
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志的筛选条件 -->
|
||||
<template v-if="mode === 'operation'">
|
||||
<!-- 单位:只有平台能跨租户查(学校管理员恒为本租户,服务端会 40301) -->
|
||||
<NSelect
|
||||
v-if="canViewAll"
|
||||
v-model:value="opQuery.tenantId"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="单位"
|
||||
:options="tenantOptions"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<!-- 对象 ID 放最左:合规问题只能靠 target_type + target_id 定位 -->
|
||||
<NInputNumber
|
||||
v-model:value="query.targetId"
|
||||
v-model:value="opQuery.targetId"
|
||||
class="log__num"
|
||||
:min="1"
|
||||
clearable
|
||||
placeholder="对象 ID"
|
||||
@update:value="search"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.targetType"
|
||||
v-model:value="opQuery.targetType"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="对象类型"
|
||||
:options="targetTypeOptions"
|
||||
@update:value="search"
|
||||
:options="TARGET_TYPE_OPTIONS"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.action"
|
||||
v-model:value="opQuery.action"
|
||||
class="log__sel2"
|
||||
clearable
|
||||
placeholder="动作"
|
||||
:options="actionOptions"
|
||||
@update:value="search"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.module"
|
||||
v-model:value="opQuery.module"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="模块"
|
||||
:options="moduleOptions"
|
||||
@update:value="search"
|
||||
:options="MODULE_OPTIONS"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<!-- 起止日期用两个独立日期框,不用 range:两个框各自能单独清空,核查时更好用 -->
|
||||
<NDatePicker
|
||||
v-model:formatted-value="fromDate"
|
||||
v-model:formatted-value="opFrom"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="开始日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="search"
|
||||
@update:formatted-value="searchOperations"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:formatted-value="toDate"
|
||||
v-model:formatted-value="opTo"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="结束日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="search"
|
||||
@update:formatted-value="searchOperations"
|
||||
/>
|
||||
<NButton @click="reset">
|
||||
</template>
|
||||
|
||||
<!-- 登录日志的筛选条件:**没有**「归属地」—— 一期 location 恒为 null,不给一个永远筛不出东西的框 -->
|
||||
<template v-else>
|
||||
<NSelect
|
||||
v-if="canViewAll"
|
||||
v-model:value="loginQuery.tenantId"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="单位"
|
||||
:options="tenantOptions"
|
||||
@update:value="searchLogins"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="loginQuery.userType"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="用户类型"
|
||||
:options="userTypeOptions"
|
||||
@update:value="searchLogins"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="loginQuery.result"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="结果"
|
||||
:options="resultOptions"
|
||||
@update:value="searchLogins"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:formatted-value="loginFrom"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="开始日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="searchLogins"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:formatted-value="loginTo"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="结束日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="searchLogins"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<NButton @click="resetFilters">
|
||||
重置
|
||||
</NButton>
|
||||
<NButton @click="load">
|
||||
<NButton @click="refresh">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
@@ -332,23 +676,52 @@ onMounted(async () => {
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
高度链:`.page-card__body` → `n-data-table[flex-height]`(见 styles/index.scss 的 .page-card 注释)。
|
||||
两个表格用 `v-if / v-else` 而不是同时挂着 —— 只有一张表是 DOM 里的直接子元素,
|
||||
`.page-card__body > .n-data-table` 那条全局规则才不会失效(失效的表现就是"一行都看不见")。
|
||||
-->
|
||||
<div class="page-card__body">
|
||||
<!--
|
||||
`:scroll-x` 是**列宽合计**:横向滚动条按它算。
|
||||
两张表都是"平台比学校管理员多一列「单位」(150)",所以两个数各有一对;
|
||||
操作日志末尾还含固定的「操作」列 90(登录日志没有这一列)。
|
||||
-->
|
||||
<NDataTable
|
||||
v-if="mode === 'operation'"
|
||||
flex-height
|
||||
style="height: 100%"
|
||||
remote
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:columns="operationColumns"
|
||||
:data="opList"
|
||||
:loading="opLoading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="isPlatform ? 1240 : 1090"
|
||||
:pagination="pagination"
|
||||
:scroll-x="isPlatform ? 1330 : 1180"
|
||||
:pagination="opPagination"
|
||||
size="small"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
@update:page="onOperationPage"
|
||||
@update:page-size="onOperationPageSize"
|
||||
/>
|
||||
<NDataTable
|
||||
v-else
|
||||
flex-height
|
||||
style="height: 100%"
|
||||
remote
|
||||
:columns="loginColumns"
|
||||
:data="loginList"
|
||||
:loading="loginLoading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="isPlatform ? 1300 : 1150"
|
||||
:pagination="loginPagination"
|
||||
size="small"
|
||||
@update:page="onLoginPage"
|
||||
@update:page-size="onLoginPageSize"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<!-- 详情不发请求:整行数据已经查回来了,抽屉只负责把被截断/没展示的字段摊开 -->
|
||||
<LogDetailDrawer ref="detailRef" :tenant-names="tenantNames" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -384,6 +757,18 @@ onMounted(async () => {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模式切换(分段控件)。
|
||||
* 只放 2 个按钮,所以它自己不需要换行 —— 后面那条竖线用来与"筛选条件"分区。
|
||||
*/
|
||||
.log__seg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-right: 12px;
|
||||
margin-right: 4px;
|
||||
border-right: 1px solid var(--xt-card-border-weak);
|
||||
}
|
||||
|
||||
.log__num {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import { NAlert, NButton, NForm, NFormItem, NInput } from 'naive-ui'
|
||||
import { NAlert, NButton, NForm, NFormItem, NInput, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getCaptcha } from '@/apis/xt'
|
||||
@@ -25,6 +25,7 @@ defineOptions({ name: 'Login' })
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const user = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -111,11 +112,36 @@ function handleFailure(e: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
|
||||
* 插槽存在时 `renderExplains` 永不渲染)。
|
||||
* 本页的「租户码」有一句常驻提示,而它恰好是**必填**的 —— 漏填时
|
||||
* 界面上那句提示原样留着、只多一个红框,所以必须自己把第一条错误弹出来。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) {
|
||||
return null
|
||||
}
|
||||
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
|
||||
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
|
||||
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
|
||||
for (const entry of errors.flat(2)) {
|
||||
const message = (entry as { message?: unknown } | null)?.message
|
||||
if (typeof message === 'string' && message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
// 校验失败时 Naive 会把每条错误显示在对应字段下,这里不需要再弹一次
|
||||
} catch (errors) {
|
||||
// 没有 `#feedback` 的字段 Naive 会自己把错误显示在字段下,这里多弹一次的收益是:
|
||||
// 「租户码」那句常驻提示不会把必填错误吃掉(见 firstError 的注释)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -105,10 +105,35 @@ function edit(row: OrgTreeVO): void {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
|
||||
* 插槽存在时 `renderExplains` 永不渲染)。本表单的「组织类型」既带校验又带
|
||||
* `#feedback` 提示,所以校验失败必须自己把第一条错误弹出来 ——
|
||||
* 否则界面上只剩一个红框加一句无关的提示,用户看到的是"点了确定没反应"。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) {
|
||||
return null
|
||||
}
|
||||
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
|
||||
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
|
||||
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
|
||||
for (const entry of errors.flat(2)) {
|
||||
const message = (entry as { message?: unknown } | null)?.message
|
||||
if (typeof message === 'string' && message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function confirm(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -131,10 +131,35 @@ function edit(row: RoleVO): void {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
|
||||
* 插槽存在时 `renderExplains` 永不渲染)。本表单有 3 个字段带 `#feedback`,
|
||||
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
|
||||
* 用户看到的是"点了确定没反应"。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) {
|
||||
return null
|
||||
}
|
||||
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
|
||||
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
|
||||
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
|
||||
for (const entry of errors.flat(2)) {
|
||||
const message = (entry as { message?: unknown } | null)?.message
|
||||
if (typeof message === 'string' && message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function confirm(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +247,7 @@ defineExpose({ add, edit })
|
||||
<NSelect v-model:value="form.templateCode" :options="templateOptions" />
|
||||
<template #feedback>
|
||||
<span class="rf__hint">
|
||||
复制它的权限码与菜单;建好后在右侧授权矩阵里增删。新角色至少要有一项权限与一个菜单
|
||||
复制它的权限码与菜单。新角色至少要有一项权限与一个菜单
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
/**
|
||||
* 建 / 改任务弹窗(《03-接口设计》§7.2)。
|
||||
*
|
||||
* 三处刻意的设计:
|
||||
* 四处刻意的设计:
|
||||
* 1. **命名模板带实时预览**:光看 `{姓名}-{证件号}-{材料名}` 很难想象最终长什么样,
|
||||
* 而它直接决定导出包里每个文件的名字。用样例数据渲染出来一眼就能判断。
|
||||
* 2. **收集模式与认定类型做成只读**:它们决定审核流与材料清单,本期也不开放其它取值。
|
||||
* 可编辑只会让人以为能改。
|
||||
* 3. **变量用按钮插入而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
|
||||
* 2. **不展示收集模式与认定类型**:本期它们恒为「学校收集 + 自主认定」,
|
||||
* 界面上开放不了别的取值,写出来只是让人多读一段与自己无关的话。
|
||||
* 取值在 `confirm()` 里写死进请求体(后端 `assertScopeSupported` 会校验)。
|
||||
* 3. **变量用按钮追加而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
|
||||
* 服务端只会在发布时给一个 40020,而那时已经配了一堆别的东西。
|
||||
* 4. **批次号在"修改"时置灰**:`task_code` = 批次号 + 4 位序号,且它就是文件目录名;
|
||||
* 服务端 `update` 会无条件写入 `batch_no`,没有"编码已生成"的保护 —— 由前端守住。
|
||||
*/
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import type { TaskUpsertReq, TaskVO } from '@/apis/xt/task'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDatePicker,
|
||||
NDivider,
|
||||
@@ -41,18 +43,20 @@ const message = useMessage()
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
/** 当前在编辑的任务编码,只在"修改"时有值 —— 用来告诉用户批次号为什么不能改 */
|
||||
const detailTaskCode = ref('')
|
||||
|
||||
const formRef = useTemplateRef<FormInst>('formRef')
|
||||
|
||||
const isEdit = computed(() => !!detailId.value)
|
||||
const title = computed(() => (isEdit.value ? '修改任务' : '新建收集任务'))
|
||||
const title = computed(() => (isEdit.value ? '修改收集任务' : '新建收集任务'))
|
||||
|
||||
/**
|
||||
* 表单自己的类型。
|
||||
*
|
||||
* 与 {@link TaskUpsertReq} 的差别是这里的 `jobName` / `jobLevel` 用空串表示"没填"
|
||||
* (接口那边用 `null` 表示清空);另外收集模式与认定类型**界面上没有输入项**,
|
||||
* 构造请求体时写死为 1(本期只有学校收集 + 自主认定)。
|
||||
* 构造请求体时写死为 1(后端只接受这两个值,传别的返回 40044)。
|
||||
*
|
||||
* `startTime` / `endTime` 允许 `null`:Naive 的日期选择器清空时给的是 `null`。
|
||||
*/
|
||||
@@ -138,6 +142,19 @@ const namingPreview = computed(() => {
|
||||
return out || '—'
|
||||
})
|
||||
|
||||
/**
|
||||
* 批次号提示分两态。
|
||||
*
|
||||
* 修改时只读:`task_code` = 批次号 + 4 位序号(后端 `TaskService.nextTaskCode`),
|
||||
* 而它同时是文件目录名。服务端 `update` 会无条件写入 `batch_no`、没有"编码已生成"的保护,
|
||||
* 所以由前端守住 —— 不改批次号就不会出现"列表里的批次号与任务编码前缀对不上"。
|
||||
*/
|
||||
const batchNoHint = computed(() =>
|
||||
isEdit.value
|
||||
? `任务编码 ${detailTaskCode.value || '—'} 已按它生成,不可再改`
|
||||
: '4~8 位数字。任务编码 = 批次号 + 4 位序号(如 202609 → 2026090001)'
|
||||
)
|
||||
|
||||
/**
|
||||
* 变量按钮的文案。
|
||||
*
|
||||
@@ -154,12 +171,14 @@ function appendVariable(v: string) {
|
||||
|
||||
function add() {
|
||||
detailId.value = null
|
||||
detailTaskCode.value = ''
|
||||
Object.assign(form, getInitForm())
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function edit(row: TaskVO) {
|
||||
detailId.value = row.id
|
||||
detailTaskCode.value = row.taskCode ?? ''
|
||||
Object.assign(form, getInitForm())
|
||||
form.taskName = row.taskName
|
||||
form.batchNo = row.batchNo ?? ''
|
||||
@@ -176,18 +195,42 @@ function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
|
||||
* 插槽存在时 `renderExplains` 永不渲染)。本表单有 3 个字段带 `#feedback`,
|
||||
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
|
||||
* 用户看到的是"点了保存没反应"。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) {
|
||||
return null
|
||||
}
|
||||
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
|
||||
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
|
||||
// 所以不能直接读 `errors[0].message`(那样恒为 undefined,toast 只会显示兜底文案)。
|
||||
for (const entry of errors.flat(2)) {
|
||||
const message = (entry as { message?: unknown } | null)?.message
|
||||
if (typeof message === 'string' && message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// ⚠️ Naive 的 `validate()` 在不过时是 **reject**(不是返回 false),所以必须 catch
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
const end = form.endTime ?? ''
|
||||
const payload: TaskUpsertReq = {
|
||||
taskName: form.taskName.trim(),
|
||||
// 本期只开放这两个取值,界面上也没有输入项 —— 写死而不是从表单里读
|
||||
// 界面上没有输入项 —— 写死而不是从表单里读(后端 assertScopeSupported 只认这两个值)
|
||||
collectMode: 1,
|
||||
certType: 1,
|
||||
batchNo: (form.batchNo ?? '').trim(),
|
||||
@@ -238,9 +281,19 @@ defineExpose({ add, edit })
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="10" label="批次号" path="batchNo">
|
||||
<NInput v-model:value="form.batchNo" placeholder="如 202609" :maxlength="8" clearable />
|
||||
<!--
|
||||
修改时置灰:`task_code` = 批次号 + 4 位序号,而它同时是文件目录名 ——
|
||||
服务端 `update` 会无条件写入 `batch_no`,没有"编码已生成"的保护,所以由前端守住。
|
||||
-->
|
||||
<NInput
|
||||
v-model:value="form.batchNo"
|
||||
placeholder="如 202609"
|
||||
:maxlength="8"
|
||||
:disabled="isEdit"
|
||||
clearable
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="task-form__hint">4~8 位数字,同时作为任务编码前缀</span>
|
||||
<span class="task-form__hint">{{ batchNoHint }}</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
@@ -272,28 +325,16 @@ defineExpose({ add, edit })
|
||||
class="task-form__date"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="task-form__hint">只选日期时按当天 23:59:59 处理</span>
|
||||
<span class="task-form__hint">选到日即可,系统按当天 23:59 计算</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
本期范围
|
||||
</NDivider>
|
||||
|
||||
<NGrid :cols="2" :x-gap="16">
|
||||
<NFormItem label="收集模式">
|
||||
<NInput value="学校收集" disabled />
|
||||
</NFormItem>
|
||||
<NFormItem label="认定类型">
|
||||
<NInput value="自主认定" disabled />
|
||||
</NFormItem>
|
||||
</NGrid>
|
||||
|
||||
<NAlert type="info" :show-icon="false" :bordered="false" class="task-form__scope">
|
||||
本期只做 <strong>学校收集 + 自主认定</strong>:收集模式与认定类型由系统固定,
|
||||
界面上不开放其它取值。
|
||||
</NAlert>
|
||||
<!--
|
||||
⚠️ 这里**故意**不放「收集模式 / 认定类型」的只读展示,也不再写"本期只做学校收集 + 自主认定":
|
||||
它们的取值在系统里是恒定的,写出来只是让建任务的人多读一段与自己无关的话。
|
||||
请求体里 `collectMode: 1` / `certType: 1` 由 confirm() 写死 —— 那才是后端要的东西。
|
||||
-->
|
||||
|
||||
<NDivider title-placement="left">
|
||||
命名规则
|
||||
@@ -303,14 +344,13 @@ defineExpose({ add, edit })
|
||||
<NInput v-model:value="form.namingTemplate" :maxlength="256" clearable />
|
||||
<template #feedback>
|
||||
<span class="task-form__hint">
|
||||
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code> ——
|
||||
缺了任一项,文件名就无法唯一归属到人。
|
||||
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code>。用下面的按钮追加变量。
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<div class="task-form__vars">
|
||||
<span class="task-form__vars-label">插入变量</span>
|
||||
<span class="task-form__vars-label">追加变量</span>
|
||||
<NSpace :size="6">
|
||||
<NButton
|
||||
v-for="opt in namingVariableOptions"
|
||||
@@ -324,8 +364,17 @@ defineExpose({ add, edit })
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NFormItem label="预览(以「张同学 / 361029200912100015 / 身份证」为例)">
|
||||
<NFormItem label="文件名预览">
|
||||
<!--
|
||||
⚠️ 必须包一层块级容器:`.n-form-item-blank` 是 **flex 行**(form-item 是 grid、
|
||||
blank 区是 flex),直接放两个子元素会被排成同一行、各自被压缩 ——
|
||||
实测这行提示被挤到 191px、折成 3 行("样例"根本读不出是一句话)。
|
||||
包成块级容器后 note 独占一行(容器 603px,需要 312px)。
|
||||
-->
|
||||
<div class="task-form__preview-box">
|
||||
<span class="task-form__preview-note">以「张同学 / 361029200912100015 / 身份证」为例</span>
|
||||
<code class="task-form__preview">{{ namingPreview }}</code>
|
||||
</div>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
@@ -335,7 +384,7 @@ defineExpose({ add, edit })
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="confirm">
|
||||
保存
|
||||
{{ isEdit ? '保存修改' : '创建任务' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
@@ -357,11 +406,6 @@ defineExpose({ add, edit })
|
||||
}
|
||||
}
|
||||
|
||||
&__scope {
|
||||
margin-top: -6px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -375,6 +419,22 @@ defineExpose({ add, edit })
|
||||
|
||||
&__vars-label {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
/**
|
||||
* 样例提示 + 文件名预览:**必须整体占满 blank 区**。
|
||||
* `.n-form-item-blank` 是 flex 行,不包这一层的话两个子元素会被并排压缩
|
||||
* (实测提示被挤到 191px、折成 3 行)。
|
||||
*/
|
||||
&__preview-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__preview-note {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ const extraColumns = computed<DataTableColumns<EditField>>(() => [
|
||||
onPositiveClick: () => onDeleteExtra(row)
|
||||
}, {
|
||||
trigger: () => h(NButton, { text: true, size: 'small', type: 'error' }, { default: () => '删除' }),
|
||||
default: () => '删除后学生端不再显示这一项。确认删除?'
|
||||
default: () => '删除后学生端不再显示这一项。注意:要等「保存为新版本」后才真正生效。确认删除?'
|
||||
})
|
||||
])
|
||||
}
|
||||
@@ -427,11 +427,35 @@ function onDeleteExtra(row: EditField) {
|
||||
message.info('已从列表移除,点「保存为新版本」后生效')
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
|
||||
* 插槽存在时 `renderExplains` 永不渲染)。扩展字段弹窗的「字段标识」就带 `#feedback`,
|
||||
* 它的格式校验(只能小写字母/数字/下划线)**唯一**的提示路径就是这里 ——
|
||||
* 不弹出来,用户看到的就是"点了确定没反应"。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) {
|
||||
return null
|
||||
}
|
||||
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
|
||||
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
|
||||
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
|
||||
for (const entry of errors.flat(2)) {
|
||||
const message = (entry as { message?: unknown } | null)?.message
|
||||
if (typeof message === 'string' && message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function saveExtra() {
|
||||
try {
|
||||
await extraFormRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
const opts = needOptions.value
|
||||
@@ -520,16 +544,17 @@ defineExpose({ open })
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NDivider title-placement="left">
|
||||
内置字段(官方 21 项,不可删除)
|
||||
内置字段
|
||||
</NDivider>
|
||||
<!--
|
||||
为什么内置字段不能删:删掉之后"这个值到底该不该有"就永远说不清了
|
||||
(服务端 40026)。不想收的信息用「条件显示 → 不收集」隐藏,
|
||||
这样它仍然在定义里,只是学生看不到、也不参与必填校验。
|
||||
⚠️ 标题里**不要**再写"(官方 21 项,不可删除)":数量与"不可删"下面这句已经说了,重复就是噪音。
|
||||
-->
|
||||
<div class="fd__hint">
|
||||
这些是认定机构要的官方字段。<strong>不想要某一项时不要删</strong>(也删不掉),
|
||||
把它的「条件显示」设为「不收集」即可。带「只读」的字段由老师在名册里填,学生看不到输入框。
|
||||
不想收的字段不用删(也删不掉):把它的「条件显示」设为「不收集」。
|
||||
带「只读」的字段由老师在名册里填,学生看不到输入框。
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
@@ -543,6 +568,12 @@ defineExpose({ open })
|
||||
<NDivider title-placement="left">
|
||||
扩展字段(自定义,仅展示与导出,不参与统计)
|
||||
</NDivider>
|
||||
<!--
|
||||
为什么 key 必须带 `extra_` 前缀、值落在 `extras_json`……都是实现细节,
|
||||
界面上不写:`suggestExtraKey()`(apis/xt/form-schema.ts:187)已经按字段名称自动生成
|
||||
合法 key,人不需要知道前缀规则。前缀本身是防重名的护栏 ——
|
||||
否则"这个值到底是内置的还是扩展的"永远说不清。
|
||||
-->
|
||||
<NSpace class="fd__tool" :size="10" align="center">
|
||||
<NButton v-if="!readonly" type="primary" @click="onAddExtra">
|
||||
<template #icon>
|
||||
@@ -550,10 +581,6 @@ defineExpose({ open })
|
||||
</template>
|
||||
新增扩展字段
|
||||
</NButton>
|
||||
<span class="fd__hint fd__hint--inline">
|
||||
扩展字段的值落在 <code>extras_json</code>,key 必须带 <code>extra_</code> 前缀 ——
|
||||
前缀是防重名的护栏,否则"这个值到底是内置的还是扩展的"永远说不清
|
||||
</span>
|
||||
</NSpace>
|
||||
|
||||
<NEmpty v-if="!extras.length" description="没有扩展字段" />
|
||||
@@ -581,11 +608,18 @@ defineExpose({ open })
|
||||
<NFormItemGi :span="12" label="字段名称" path="label">
|
||||
<NInput v-model:value="extraForm.label" placeholder="如 家长联系电话" :maxlength="64" clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="字段标识(key)" path="key">
|
||||
<NFormItemGi :span="12" label="字段标识" path="key">
|
||||
<!--
|
||||
⚠️ 这里**不能**写"按字段名称自动生成":中文标签没法可靠转拼音,
|
||||
`suggestExtraKey()`(apis/xt/form-schema.ts:187-198)对中文一律落到 `extra_field`,
|
||||
说"自动生成"会让人以为它是从"家长联系电话"推出来的。
|
||||
写"能填什么 + 改了有什么后果"才是有用的;`extra_` 前缀的规则交给校验器(:379-395),
|
||||
失败时由 saveExtra 弹 toast(这个 #feedback 会把错误文案吃掉)。
|
||||
-->
|
||||
<NInput v-model:value="extraForm.key" placeholder="extra_xxx" :maxlength="46" clearable />
|
||||
<template #feedback>
|
||||
<span class="fd__hint fd__hint--inline">
|
||||
必须以 <code>extra_</code> 开头,只含小写字母、数字、下划线
|
||||
只能用小写字母、数字、下划线;导出列名跟着变
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
@@ -94,7 +94,7 @@ const templates = computed(() => catalog.value.templates)
|
||||
const visibleWhenOptions = matApi.VISIBLE_WHEN_OPTIONS
|
||||
const maxSizeMin = computed(() => catalog.value.maxSizeRange?.[0] ?? 307)
|
||||
const maxSizeMax = computed(() => catalog.value.maxSizeRange?.[1] ?? 5120)
|
||||
const maxSizeHint = computed(() => `可配范围 ${maxSizeMin.value}~${maxSizeMax.value}KB(认定机构的接收能力)`)
|
||||
const maxSizeHint = computed(() => `可配 ${maxSizeMin.value}~${maxSizeMax.value} KB(0.3M~5M)`)
|
||||
|
||||
const visibleWhenIndex = matApi.visibleWhenIndex
|
||||
const OCR_TYPE_NAMES = matApi.OCR_TYPE_NAMES
|
||||
@@ -359,11 +359,35 @@ function closeForm() {
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
|
||||
* 插槽存在时 `renderExplains` 永不渲染)。本表单多数字段都带 `#feedback`,
|
||||
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
|
||||
* 用户看到的是"点了保存没反应"。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) {
|
||||
return null
|
||||
}
|
||||
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
|
||||
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
|
||||
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
|
||||
for (const entry of errors.flat(2)) {
|
||||
const message = (entry as { message?: unknown } | null)?.message
|
||||
if (typeof message === 'string' && message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
if (!taskId.value) {
|
||||
@@ -489,16 +513,16 @@ defineExpose({ open })
|
||||
刷新
|
||||
</NButton>
|
||||
<!--
|
||||
模板是幂等的(已存在的材料码会跳过),所以先说清楚"会跳过已存在的",
|
||||
否则管理员点两次会以为第一次没生效。
|
||||
模板是幂等的(已存在的材料码会跳过),所以要说清楚"已有的不会重复添加",
|
||||
否则管理员点两次会以为第一次没生效。界面上不写"幂等"这个词 —— 那是实现语言。
|
||||
-->
|
||||
<span class="mat-drawer__hint">按模板初始化是幂等的,已有的材料不会重复添加</span>
|
||||
<span class="mat-drawer__hint">按模板初始化只补缺失的材料,已有的不会重复添加</span>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NEmpty v-if="!rows.length" description="还没有材料要求,请先「按模板初始化」或「新增材料」">
|
||||
<div class="mat-drawer__empty-hint">
|
||||
必传材料一项都没有时,任务<strong>发布不了</strong>(服务端会返回 40018)。
|
||||
必传材料一项都没有时,任务<strong>发布不了</strong>。
|
||||
</div>
|
||||
</NEmpty>
|
||||
|
||||
@@ -549,8 +573,7 @@ defineExpose({ open })
|
||||
<NDynamicTags v-model:value="form.aliases" :max="6" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">
|
||||
输入后回车,如:身份证正面、身份证人像面。
|
||||
用于从历史文件名里认出这是哪一项材料 —— 别名不全,该认出来的就认不出来
|
||||
输入后回车,如:身份证正面、身份证人像面。用于从历史文件名认出材料,别名不全就认不出来
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
@@ -598,7 +621,7 @@ defineExpose({ open })
|
||||
<NFormItemGi :span="8" label="压缩档位" path="compressProfile">
|
||||
<NSelect v-model:value="form.compressProfile" :options="profileOptions" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">档位决定分辨率与清晰度,一般不用改</span>
|
||||
<span class="mat-form__hint">影响清晰度,一般不用改</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
@@ -618,6 +641,10 @@ defineExpose({ open })
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="24" label="来源引导语" path="tipText">
|
||||
<!--
|
||||
这里**不要**再加 `#feedback`:它与 placeholder 说的是同一件事,写了就是同一句话出现两遍;
|
||||
而多一个 feedback 只会让这一格更高。
|
||||
-->
|
||||
<NInput
|
||||
v-model:value="form.tipText"
|
||||
type="textarea"
|
||||
@@ -626,18 +653,10 @@ defineExpose({ open })
|
||||
show-count
|
||||
placeholder="显示在学生端这一项下面,如:拍身份证人像面,四角要拍全"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">
|
||||
写得越具体,学生传错的比例越低 —— 这句话省下的是班主任逐人纠错的时间
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="12" label="自动识别" path="ocrType">
|
||||
<NSelect v-model:value="form.ocrType" clearable placeholder="不做识别" :options="ocrTypeOptions" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">识别结果只用于提示,不会自动改动材料归属</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="排序号" path="sortNo">
|
||||
<NInputNumber v-model:value="form.sortNo" :min="0" class="mat-form__num" />
|
||||
|
||||
@@ -280,7 +280,7 @@ defineExpose({ open })
|
||||
:disabled="readonly"
|
||||
/>
|
||||
<div class="org-drawer__fallback">
|
||||
留空表示不比对,填了才与名册比
|
||||
留空 = 不比对
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from 'naive-ui'
|
||||
import type { TenantUsage, TenantVO } from '@/apis/xt/tenant'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NEmpty,
|
||||
NSelect,
|
||||
NSpin,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import * as tenantApi from '@/apis/xt/tenant'
|
||||
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
||||
import { fmtTime } from '@/utils/format'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
|
||||
/**
|
||||
* 租户详情抽屉(平台运营)。
|
||||
*
|
||||
* ## 为什么打开时要**同时调两个接口**
|
||||
*
|
||||
* `getTenantDetail(id)` **必须**调,不是"顺手也拉一下":
|
||||
* 列表接口的 `contactPhone` 恒为 `null`(见 `apis/xt/tenant.ts` 的注释)——
|
||||
* **脱敏后的联系电话只有详情接口会给**。少了它,抽屉里的"联系电话"永远是空的,
|
||||
* 运营会以为客户没留电话。
|
||||
*
|
||||
* ## 用量区必须写清"哪一项是实时值"
|
||||
*
|
||||
* 服务端口径(`TenantUsageService` 的类注释):`studentCount` / `storageMb` 是**实时**算的
|
||||
* (它们决定"还能不能再收人"),`ocrCount` / `smsCount` 才是**按期**算的(计费项)。
|
||||
* 不写这句话,运营会以为"我选了 2026Q3,所以看到的是三季度末的学生数"——
|
||||
* 而它其实是"此刻"。周期选择器只影响计费项。
|
||||
*/
|
||||
defineOptions({ name: 'TenantDetailDrawer' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 用 `v-model:show` 与父页面同步 */
|
||||
show: boolean
|
||||
tenantId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(false)
|
||||
const usageLoading = ref(false)
|
||||
/** 错误态:http 层已经弹过提示,但抽屉里不能只剩一片空白(用户会以为"这个单位没用量") */
|
||||
const loadError = ref(false)
|
||||
const usageError = ref(false)
|
||||
const detail = ref<TenantVO | null>(null)
|
||||
const usage = ref<TenantUsage | null>(null)
|
||||
|
||||
/**
|
||||
* "当前这份详情属于哪个租户"的账。
|
||||
*
|
||||
* 父页面点「详情」时是先改 `tenant-id` 再开 `show`(同一批更新里两个都变),
|
||||
* 下面「开抽屉」与「换租户」两个 watch 会**各触发一次** ——
|
||||
* 没有这道判断就会白拉两轮接口。
|
||||
*/
|
||||
let drawId: number | null = null
|
||||
|
||||
/** 小程序码参数:孩子在手机上扫的那个码里就是这一串(`t{id}`,与开通成功弹窗给的是同一个值) */
|
||||
const scene = computed(() => (props.tenantId === null ? '—' : `t${props.tenantId}`))
|
||||
|
||||
const statusTag = computed(() => tagOf(TENANT_STATUS, detail.value?.status))
|
||||
const typeText = computed(() => {
|
||||
const t = detail.value?.tenantType
|
||||
return t === null || t === undefined ? '—' : (TENANT_TYPE_NAMES[t] ?? '—')
|
||||
})
|
||||
|
||||
/**
|
||||
* 到期提示。
|
||||
*
|
||||
* `expireTime` 是**日期**(不含时间),所以"还剩几天"按天算;
|
||||
* 已过期 / 30 天内到期分别给红 / 橙 —— 与列表里到期列的颜色是同一套口径
|
||||
* (列表只有 120px 宽,放不下文字,只能靠颜色)。
|
||||
*/
|
||||
const expireState = computed<'expired' | 'soon' | 'ok' | 'none'>(() => {
|
||||
const t = detail.value?.expireTime
|
||||
if (!t) {
|
||||
return 'none'
|
||||
}
|
||||
const end = Date.parse(`${t.slice(0, 10)}T23:59:59`)
|
||||
if (Number.isNaN(end)) {
|
||||
return 'none'
|
||||
}
|
||||
const days = (end - Date.now()) / 86400000
|
||||
if (days < 0) {
|
||||
return 'expired'
|
||||
}
|
||||
return days <= 30 ? 'soon' : 'ok'
|
||||
})
|
||||
|
||||
// ── 周期选择:只给三个固定选项 ──
|
||||
|
||||
interface PeriodOption extends SelectOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** 当月的接口口径(`2026-09`)。不传就是当月,但显式传更好排查 */
|
||||
function thisMonth(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
|
||||
}
|
||||
|
||||
function lastMonth(): string {
|
||||
const d = new Date()
|
||||
// `new Date(y, m - 1, 1)` 会自动跨年(1 月 → 上一年 12 月),不用自己判
|
||||
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
|
||||
return `${prev.getFullYear()}-${pad(prev.getMonth() + 1)}`
|
||||
}
|
||||
|
||||
/** 本季(`2026Q3`)。客户的结算周期是季度,所以这个必须给 */
|
||||
function thisQuarter(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}Q${Math.floor(d.getMonth() / 3) + 1}`
|
||||
}
|
||||
|
||||
/** ⚠️ 只给这三个固定选项,**不做自由文本输入**:手输的格式错一次,接口就回一句业务错误 */
|
||||
const periodOptions: PeriodOption[] = [
|
||||
{ label: '本月', value: thisMonth() },
|
||||
{ label: '上月', value: lastMonth() },
|
||||
{ label: '本季', value: thisQuarter() }
|
||||
]
|
||||
|
||||
const period = ref<string | number | null>(thisMonth())
|
||||
const periodText = computed(() => periodOptions.find((o) => o.value === period.value)?.label ?? '')
|
||||
|
||||
// ── 占用比例(学生数 / 存储)──
|
||||
|
||||
interface QuotaRow {
|
||||
used: number
|
||||
limit: number
|
||||
percent: number
|
||||
/** 超 80% 标橙、超 100% 标红 —— 与服务端 `alerts` 的阈值(0.8 / 1.0)同一套账 */
|
||||
level: 0 | 1 | 2
|
||||
}
|
||||
|
||||
function quotaRow(used: number, limit: number): QuotaRow {
|
||||
if (limit <= 0) {
|
||||
return { used, limit, percent: 0, level: 0 }
|
||||
}
|
||||
const ratio = used / limit
|
||||
const percent = Math.min(Math.round(ratio * 1000) / 10, 100)
|
||||
const level: 0 | 1 | 2 = ratio >= 1 ? 2 : ratio >= 0.8 ? 1 : 0
|
||||
return { used, limit, percent, level }
|
||||
}
|
||||
|
||||
const studentQuota = computed(() => quotaRow(usage.value?.studentCount ?? 0, usage.value?.quota?.studentLimit ?? 0))
|
||||
const storageQuota = computed(() => quotaRow(usage.value?.storageMb ?? 0, usage.value?.quota?.storageLimitMb ?? 0))
|
||||
|
||||
function quotaClass(row: QuotaRow): string {
|
||||
if (row.level === 2) {
|
||||
return 'tdraw__quota--over'
|
||||
}
|
||||
return row.level === 1 ? 'tdraw__quota--warn' : ''
|
||||
}
|
||||
|
||||
function loadDetail(): void {
|
||||
const id = props.tenantId
|
||||
if (id === null) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
drawId = id
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
// 先清空:避免上一次看的那所学校的联系人 / 电话留在抽屉里被误读成这一所的
|
||||
detail.value = null
|
||||
void tenantApi.getTenantDetail(id)
|
||||
.then((vo) => {
|
||||
detail.value = vo
|
||||
})
|
||||
.catch(() => {
|
||||
detail.value = null
|
||||
loadError.value = true
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function loadUsage(): void {
|
||||
const id = props.tenantId
|
||||
if (id === null) {
|
||||
usage.value = null
|
||||
return
|
||||
}
|
||||
usageLoading.value = true
|
||||
usageError.value = false
|
||||
usage.value = null
|
||||
const p = typeof period.value === 'string' ? period.value : undefined
|
||||
void tenantApi.getTenantUsage(id, p)
|
||||
.then((vo) => {
|
||||
usage.value = vo
|
||||
})
|
||||
.catch(() => {
|
||||
usage.value = null
|
||||
usageError.value = true
|
||||
})
|
||||
.finally(() => {
|
||||
usageLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
async function copyScene(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(scene.value)
|
||||
message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
// 与开通成功弹窗同一处理:http 之外的权限问题,别静默失败
|
||||
message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
}
|
||||
}
|
||||
|
||||
function onPeriodChange(v: string | number | null): void {
|
||||
period.value = v
|
||||
loadUsage()
|
||||
}
|
||||
|
||||
// 打开时同时拉两个接口;换一个租户时周期回到"本月"(否则会带着上一所学校的选项)
|
||||
watch(() => props.show, (v) => {
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
period.value = thisMonth()
|
||||
loadDetail()
|
||||
loadUsage()
|
||||
})
|
||||
|
||||
/**
|
||||
* 父页面在抽屉开着时点了另一行的「详情」——两个接口都要重拉。
|
||||
* 与上面共用 `drawId` 那道账,避免同一次更新里拉两遍。
|
||||
*/
|
||||
watch(() => props.tenantId, (id) => {
|
||||
if (props.show && id !== drawId) {
|
||||
loadDetail()
|
||||
loadUsage()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer
|
||||
:show="props.show"
|
||||
:width="900"
|
||||
placement="right"
|
||||
:mask-closable="false"
|
||||
@update:show="(v: boolean) => emit('update:show', v)"
|
||||
>
|
||||
<NDrawerContent :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span>{{ detail?.tenantName || '租户详情' }}</span>
|
||||
<span v-if="detail" class="tdraw__head-id">#{{ detail.id }}</span>
|
||||
</template>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<!-- 空态 / 错误态:不要只留一片空白(用户会读成"这个单位什么都没有") -->
|
||||
<NAlert v-if="!loading && loadError" type="error" :show-icon="false" class="tdraw__alert">
|
||||
没有拿到这个租户的信息,请关闭后重试。
|
||||
</NAlert>
|
||||
<NEmpty v-else-if="!loading && !detail" description="没有拿到这个租户的信息" class="tdraw__empty" />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!--
|
||||
顶部告警按"最严重的先说"排序:已停用 → 已过期 → 30 天内到期。
|
||||
停用与过期是两件事(停用的租户就算没过期也登不进来),所以并列显示而不是 else-if。
|
||||
-->
|
||||
<NAlert
|
||||
v-if="detail.status === 0"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
class="tdraw__alert"
|
||||
>
|
||||
该单位<strong>已停用</strong>:所有学生与老师<strong>立刻无法登录</strong>。
|
||||
恢复要在「编辑」里把状态改回正常或试用。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="expireState === 'expired'"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="tdraw__alert"
|
||||
>
|
||||
该单位<strong>已过期</strong>(到期时间 {{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }})。
|
||||
过期后账号将无法登录,请联系客户续期。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-else-if="expireState === 'soon'"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
class="tdraw__alert"
|
||||
>
|
||||
该单位将在 <strong>30 天内到期</strong>({{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }}),
|
||||
可以提前联系客户续期。
|
||||
</NAlert>
|
||||
|
||||
<NDescriptions :column="2" size="small" label-placement="left" bordered>
|
||||
<NDescriptionsItem label="单位名称">{{ detail.tenantName }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="租户 ID">{{ detail.id }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="租户编码">{{ detail.tenantCode }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="类型">{{ typeText }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="状态">
|
||||
<NTag :type="statusTag.type" size="small" :bordered="false">
|
||||
{{ statusTag.text }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="到期时间">{{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="联系人">{{ detail.contactName || '—' }}</NDescriptionsItem>
|
||||
<!--
|
||||
脱敏后的联系电话**只有详情接口会给**(列表接口恒为 null),
|
||||
所以这一行能不能有值,取决于上面有没有真的调 getTenantDetail。
|
||||
-->
|
||||
<NDescriptionsItem label="联系电话(脱敏)">
|
||||
{{ detail.contactPhone || '未登记' }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="套餐 ID">
|
||||
{{ detail.packageId === null ? '未配套餐' : detail.packageId }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="创建时间">{{ fmtTime(detail.createTime) }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="小程序码参数" :span="2">
|
||||
<span class="tdraw__scene">{{ scene }}</span>
|
||||
<NButton text size="small" type="primary" class="tdraw__copy" @click="copyScene">
|
||||
复制
|
||||
</NButton>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<!-- ── 用量 ── -->
|
||||
<div class="section-title tdraw__usage-title">
|
||||
用量
|
||||
<span class="section-title__note">学生数与存储是实时值,周期只影响计费项</span>
|
||||
</div>
|
||||
|
||||
<div class="tdraw__band">
|
||||
<div class="tdraw__period">
|
||||
<span class="tdraw__period-label">统计周期</span>
|
||||
<NSelect
|
||||
:value="period"
|
||||
:options="periodOptions"
|
||||
class="tdraw__period-select"
|
||||
@update:value="onPeriodChange"
|
||||
/>
|
||||
<span class="tdraw__period-hint">
|
||||
只影响计费项;学生数与存储永远是"此刻"的值
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NSpin :show="usageLoading">
|
||||
<NAlert v-if="!usageLoading && usageError" type="error" :show-icon="false">
|
||||
没有拿到用量数据,换个周期或稍后重试。
|
||||
</NAlert>
|
||||
<NEmpty v-else-if="!usageLoading && !usage" description="没有拿到用量数据" class="tdraw__empty" />
|
||||
|
||||
<template v-else-if="usage">
|
||||
<div class="tdraw__quotas">
|
||||
<div class="tdraw__quota">
|
||||
<div class="tdraw__quota-label">
|
||||
学生数
|
||||
</div>
|
||||
<div class="tdraw__quota-value" :class="quotaClass(studentQuota)">
|
||||
{{ studentQuota.used }} / {{ studentQuota.limit }} 人
|
||||
<span class="tdraw__quota-pct">{{ studentQuota.percent }}%</span>
|
||||
</div>
|
||||
<div class="tdraw__quota-note">
|
||||
实时值(按身份证去重),不限周期
|
||||
</div>
|
||||
</div>
|
||||
<div class="tdraw__quota">
|
||||
<div class="tdraw__quota-label">
|
||||
存储
|
||||
</div>
|
||||
<div class="tdraw__quota-value" :class="quotaClass(storageQuota)">
|
||||
{{ storageQuota.used }} / {{ storageQuota.limit }} MB
|
||||
<span class="tdraw__quota-pct">{{ storageQuota.percent }}%</span>
|
||||
</div>
|
||||
<div class="tdraw__quota-note">
|
||||
实时值(材料规范件合计),不限周期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tdraw__counts">
|
||||
<span>OCR 识别 {{ usage.ocrCount }} 次</span>
|
||||
<span>短信 {{ usage.smsCount }} 条</span>
|
||||
<span class="tdraw__counts-period">({{ periodText }} {{ usage.period }})</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
告警逐条展示。level 2 = 已超限(红),1 = 接近上限(橙)——
|
||||
与服务端 `addAlert` 的阈值(0.8 / 1.0)对应。
|
||||
-->
|
||||
<ul v-if="usage.alerts.length" class="tdraw__alerts">
|
||||
<li
|
||||
v-for="(a, i) in usage.alerts"
|
||||
:key="`${a.type}-${i}`"
|
||||
:class="a.level === 2 ? 'tdraw__alert-item--over' : 'tdraw__alert-item--warn'"
|
||||
>
|
||||
<strong>{{ a.type === 'STUDENT' ? '学生数' : '存储' }}</strong>
|
||||
已占用 {{ a.ratio }}({{ a.used }} / {{ a.limit }}),
|
||||
{{ a.level === 2 ? '已超出套餐额度' : '接近套餐额度' }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!--
|
||||
notes 如实全部展示(灰色小字),不做折叠、不吞掉:
|
||||
它解释的是"为什么 ocrCount / smsCount 是 0"——
|
||||
藏起来运营就会以为那是"真的没用",然后拿假数据去谈续费。
|
||||
-->
|
||||
<ul v-if="usage.notes.length" class="tdraw__notes">
|
||||
<li v-for="(n, i) in usage.notes" :key="i">
|
||||
{{ n }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tdraw__head-id {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tdraw__empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.tdraw__scene {
|
||||
padding: 2px 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
background: #fff;
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tdraw__copy {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tdraw__usage-title {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用量区的外框。
|
||||
*
|
||||
* ⚠️ 这里**刻意不用 `NCard`**:抽屉里再套一张卡会出现两层"浮起 + 浅灰头",
|
||||
* 比不分组还乱(与开通弹窗的「首个管理员」块同一套写法)。
|
||||
*/
|
||||
.tdraw__band {
|
||||
padding: 14px 16px 16px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tdraw__period {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tdraw__period-label {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__period-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.tdraw__period-hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__quotas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tdraw__quota {
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tdraw__quota-label {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__quota-value {
|
||||
margin-top: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 接近上限(≥80%)标橙、超限(≥100%)标红 —— 与列表到期列的配色同一套口径 */
|
||||
.tdraw__quota--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tdraw__quota--over {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.tdraw__quota-pct {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tdraw__quota-note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__counts {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
|
||||
.tdraw__counts span + span {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.tdraw__counts-period {
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__alerts,
|
||||
.tdraw__notes {
|
||||
padding-left: 18px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tdraw__alerts li,
|
||||
.tdraw__notes li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tdraw__alert-item--over {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.tdraw__alert-item--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tdraw__notes {
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormRules } from 'naive-ui'
|
||||
import type { TenantUpdateReq, TenantVO } from '@/apis/xt/tenant'
|
||||
import { NButton, NDatePicker, NForm, NFormItem, NInput, NModal, NSelect, NSpace, useMessage } from 'naive-ui'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import * as tenantApi from '@/apis/xt/tenant'
|
||||
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
||||
import { fmtDate } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 租户编辑(平台运营)。
|
||||
*
|
||||
* ## 三个"不显然"的地方
|
||||
*
|
||||
* 1. **联系电话留空 = 不修改,不是清空**。库里的 `contact_phone` 是 AES 密文列,
|
||||
* 而**列表接口返回的 `contactPhone` 恒为 null**(脱敏值只有详情接口给)——
|
||||
* 所以这里既不能回填、也不能把"看起来是空的"提交上去:
|
||||
* 一旦提交空白,服务端虽然按"空 = 不修改"跳过,但界面上必须说清,
|
||||
* 否则运营会以为自己在"清空电话"。要改就整条重填。
|
||||
* 2. **租户编码不可修改**,只能只读展示。它出现在小程序扫码落地页与对接文档里,
|
||||
* 改了会让已经发出去的文档和配好的域名全部失效 —— 所以连输入框都不给,
|
||||
* 而不是"给个禁用输入框"(禁用输入框会让人以为"申请一下就能解锁")。
|
||||
* 3. **状态能改成"试用"(2)**。后端 `TenantUsageService.update` 里放行 0/1/2,
|
||||
* 少给一个选项运营就没法把客户从试用转正,也没法退回试用。
|
||||
*/
|
||||
defineOptions({ name: 'TenantEditModal' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 用 `v-model:show` 与父页面同步;父页面负责传当前行的租户 */
|
||||
show: boolean
|
||||
/** 要编辑的租户(列表行即可 —— `expireTime` / `status` 列表接口都给) */
|
||||
tenant: TenantVO | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const formRef = ref<InstanceType<typeof NForm> | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
/**
|
||||
* 表单模型。
|
||||
*
|
||||
* `expireTime` 声明成 `string | null`:`n-date-picker` 清空时回传的是 `null`,
|
||||
* 声明成 `string` 运行期没事、但 vue-tsc 会在 `v-model` 上报类型不兼容。
|
||||
*/
|
||||
interface TenantEditForm {
|
||||
tenantName: string
|
||||
tenantType: number
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
expireTime: string | null
|
||||
status: number
|
||||
}
|
||||
|
||||
function emptyForm(): TenantEditForm {
|
||||
return {
|
||||
tenantName: '',
|
||||
tenantType: 1,
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
expireTime: null,
|
||||
status: 1
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive<TenantEditForm>(emptyForm())
|
||||
|
||||
/**
|
||||
* ⚠️ 类型 / 状态这两个数字下拉的规则**必须**写 `type: 'number'`。
|
||||
*
|
||||
* 只写 `required` 时 async-validator 会把 `rule.type` 默认成 `'string'`
|
||||
* (`Schema.validate` 里的 `rule.type = this.getType(rule)`),接着按
|
||||
* `typeof value !== rule.type` 判错 —— 而选项的 value 是数字。
|
||||
* 结果是"看起来填了、就是提交不出去",且错误文案被 `#feedback` 插槽挡着看不见。
|
||||
*
|
||||
* `contactPhone` **刻意没有** required:它留空的含义是"不修改",
|
||||
* 加了必填就等于逼运营重填一遍电话(那是密文,我们拿不到明文回填)。
|
||||
*/
|
||||
const rules: FormRules = {
|
||||
tenantName: [{ required: true, message: '请填写单位名称', trigger: ['blur', 'input'] }],
|
||||
tenantType: [{ type: 'number', required: true, message: '请选择类型', trigger: ['change'] }],
|
||||
status: [{ type: 'number', required: true, message: '请选择状态', trigger: ['change'] }]
|
||||
}
|
||||
|
||||
const typeOptions = Object.entries(TENANT_TYPE_NAMES).map(([code, name]) => ({
|
||||
label: name,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
/** 状态三项:0 停用 / 1 正常 / 2 试用(与后端放行的取值一字不差) */
|
||||
const statusOptions = Object.entries(TENANT_STATUS).map(([code, item]) => ({
|
||||
label: item.text,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
/** 把列表行的值灌进表单(打开时调用) */
|
||||
function fillFrom(row: TenantVO): void {
|
||||
Object.assign(form, emptyForm())
|
||||
form.tenantName = row.tenantName
|
||||
form.tenantType = row.tenantType
|
||||
form.contactName = row.contactName ?? ''
|
||||
// 刻意不回填:列表接口的 contactPhone 恒为 null(脱敏值只在详情接口给),
|
||||
// 回填一个空串会让人以为"这个单位没留电话",而其实是"我们看不到明文"
|
||||
form.contactPhone = ''
|
||||
form.expireTime = row.expireTime ? fmtDate(row.expireTime) : null
|
||||
form.status = row.status
|
||||
}
|
||||
|
||||
watch(() => props.show, (v) => {
|
||||
if (v && props.tenant) {
|
||||
fillFrom(props.tenant)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (插槽存在时 naive 只渲染插槽内容,不再渲染错误文案)。
|
||||
* 所以校验失败必须在这里自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
|
||||
* 用户看到的是"点了保存没反应"。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors) || !errors.length) {
|
||||
return null
|
||||
}
|
||||
const first = errors[0] as { message?: unknown } | undefined
|
||||
return typeof first?.message === 'string' ? first.message : null
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch (errors) {
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
const tenant = props.tenant
|
||||
if (!tenant) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体按"只传真的改了的语义"组装:
|
||||
* · `contactPhone` 留空就**不带这个字段**(后端"非空才写",带空串等于没传,但显式省略更清楚);
|
||||
* · `expireTime` 也只在有值时才带 —— 日期控件清空时为 null,
|
||||
* 传 `''` 后端解析会炸,传 null 又会被 MyBatis-Plus 的默认更新策略忽略(清不掉)。
|
||||
* 所以"清空到期日"这件事本轮做不到,界面上也不承诺能清。
|
||||
*/
|
||||
const payload: TenantUpdateReq = {
|
||||
tenantName: form.tenantName.trim(),
|
||||
tenantType: form.tenantType,
|
||||
contactName: form.contactName.trim(),
|
||||
status: form.status
|
||||
}
|
||||
const phone = form.contactPhone.trim()
|
||||
if (phone) {
|
||||
payload.contactPhone = phone
|
||||
}
|
||||
if (form.expireTime) {
|
||||
payload.expireTime = fmtDate(form.expireTime)
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await tenantApi.updateTenant(tenant.id, payload)
|
||||
message.success('已保存')
|
||||
// 先让父页面重新拉列表,再关弹窗:否则关掉的一瞬间列表还是旧值
|
||||
emit('saved')
|
||||
emit('update:show', false)
|
||||
} catch {
|
||||
// 提示已由 http 层给出(如"状态只能是 0(停用)/ 1(正常)/ 2(试用)")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="props.show"
|
||||
preset="card"
|
||||
title="编辑租户"
|
||||
:style="{ width: 'min(620px, calc(100vw - 32px))' }"
|
||||
:mask-closable="false"
|
||||
@update:show="(v: boolean) => emit('update:show', v)"
|
||||
>
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<div class="tedit__grid">
|
||||
<NFormItem label="单位名称" path="tenantName">
|
||||
<NInput v-model:value="form.tenantName" placeholder="如 江西机电职业技术学院" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="类型" path="tenantType">
|
||||
<NSelect v-model:value="form.tenantType" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="联系人" path="contactName">
|
||||
<NInput v-model:value="form.contactName" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="联系电话" path="contactPhone">
|
||||
<NInput v-model:value="form.contactPhone" placeholder="留空则不修改" clearable />
|
||||
<!--
|
||||
⚠️ 用了 `#feedback` 之后,naive **不再渲染校验错误文案**
|
||||
(插槽存在就以插槽为准)—— 所以 `onSubmit` 的 catch 里必须自己
|
||||
`message.error(...)` 把第一条错误弹出来,否则校验失败就是"点了没反应"。
|
||||
-->
|
||||
<template #feedback>
|
||||
<span class="tedit__fb">留空 = 不修改;填了就整条替换(库里存的是密文,看不到原文)</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
<NFormItem label="到期时间" path="expireTime">
|
||||
<NDatePicker
|
||||
v-model:formatted-value="form.expireTime"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
class="tedit__full"
|
||||
clearable
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="状态" path="status">
|
||||
<NSelect v-model:value="form.status" :options="statusOptions" />
|
||||
</NFormItem>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
租户编码只读展示。刻意用 NDescriptions 而不是禁用的 NInput:
|
||||
禁用输入框会让人以为"权限够了就能改",而这条编码是**永远不能改**的 ——
|
||||
它出现在小程序扫码落地页与对接文档里,改了会让已经发出去的文档、
|
||||
配好的域名全部失效,而那是客户那边的事,我们无法逐个通知到。
|
||||
-->
|
||||
<div class="tedit__readonly">
|
||||
<div class="tedit__readonly-label">
|
||||
租户编码
|
||||
</div>
|
||||
<div class="tedit__readonly-value">
|
||||
{{ props.tenant?.tenantCode || '—' }}
|
||||
</div>
|
||||
<div class="tedit__readonly-note">
|
||||
编码出现在小程序扫码落地页与对接文档里,开通后不可修改。
|
||||
</div>
|
||||
</div>
|
||||
</NForm>
|
||||
|
||||
<div class="tedit__footer">
|
||||
<NSpace justify="end">
|
||||
<NButton @click="emit('update:show', false)">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="onSubmit">
|
||||
保存
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tedit__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.tedit__full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tedit__fb {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读的租户编码块。
|
||||
*
|
||||
* ⚠️ 这里**刻意不用 `NCard`**:弹窗本身就是一张卡片,卡里套卡会出现两层"浮起 + 浅灰头"。
|
||||
* 改用浅灰底 + 弱描边的小格子(与开通弹窗的「首个管理员」块同一套写法)。
|
||||
*/
|
||||
.tedit__readonly {
|
||||
padding: 12px 14px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tedit__readonly-label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tedit__readonly-value {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tedit__readonly-note {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tedit__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -13,18 +13,22 @@ import {
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
|
||||
import * as tenantApi from '@/apis/xt/tenant'
|
||||
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { fmtDate } from '@/utils/format'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
import TenantDetailDrawer from './TenantDetailDrawer.vue'
|
||||
import TenantEditModal from './TenantEditModal.vue'
|
||||
|
||||
/**
|
||||
* 租户管理(仅平台运营)。
|
||||
@@ -35,11 +39,29 @@ import { tagOf } from '@/utils/tag'
|
||||
* 2. **租户列表是跨租户的**(`sys_tenant` 没有 `tenant_id`),权限把关全在服务端;
|
||||
* 学校管理员调它会拿到 40301,左侧菜单也不会给他这一项。
|
||||
*
|
||||
* ⚠️ 列表**没有操作列**:不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)。
|
||||
* ⚠️ **口径变更(本轮,覆盖 2026-10 走查的旧结论)**:列表**有操作列**了 ——
|
||||
* 详情 / 编辑 / 停用·启用 三个入口。
|
||||
* **旧口径**是"列表不提供编辑 / 停用入口";推翻它的原因是运营的真实诉求:
|
||||
* ① 学校到期日变了,运营要能自己改,而不是每次找开发;
|
||||
* ② 客户欠费 / 停止合作时要能**当场停掉**(停用会立刻挡住这所学校的
|
||||
* 学生与老师登录,服务端还会为此单独记一条 WARN 日志);
|
||||
* ③ 每周核对"谁快到期了"时,光看列表不够,要能点进详情看用量。
|
||||
* 所以旧注释不是 rebase 丢的,是**被本轮需求明确改掉的**。
|
||||
* 权限门控:`tenant:view` 管「详情」,`tenant:manage` 管「编辑 / 停用 / 启用 / 开通」;
|
||||
* 拿不到 `tenant:manage` 的人操作列显示"只读",而不是留一个点了报 403 的按钮。
|
||||
*/
|
||||
defineOptions({ name: 'Tenant' })
|
||||
|
||||
const message = useMessage()
|
||||
const userStore = useUserStore()
|
||||
|
||||
/**
|
||||
* 权限门控(**不是**权限码的"定义",字符串必须与后端 `PermissionDef` 逐字一致)。
|
||||
*
|
||||
* 没有 `v-hasPerm` 指令,判断就是一次函数调用;`computed` 是为了模板里别每次渲染都算。
|
||||
*/
|
||||
const canView = computed(() => userStore.has('tenant:view'))
|
||||
const canManage = computed(() => userStore.has('tenant:manage'))
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -48,6 +70,28 @@ const total = ref(0)
|
||||
const statusFilter = ref<number | undefined>(undefined)
|
||||
const keyword = ref('')
|
||||
|
||||
/**
|
||||
* 「即将到期」筛选(默认 30 天内)。
|
||||
*
|
||||
* ⚠️ 它与状态筛选是**互斥的两个维度**:同时下发 `status` 与 `expireBefore`
|
||||
* 会让结果集小到运营以为"这批学校都停用了",而且界面上两个按钮同时高亮,
|
||||
* 谁也说不清到底在按什么筛。所以点一个就清掉另一个,并在工具条右侧
|
||||
* 用一行小字把"当前按什么筛的"写出来。
|
||||
*
|
||||
* 布尔量而不是 `expireBefore` 字符串:判断"是不是在按到期筛"只需要一个开关,
|
||||
* 存日期字符串反而要在多处比较是否等于"今天+30 天",第二天就失效。
|
||||
*/
|
||||
const expireOnly = ref(false)
|
||||
|
||||
/** `yyyy-MM-dd`(**含当天**:服务端 `parseExpireBefore` 的语义是 ≤ 这个日期) */
|
||||
function todayPlus30(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 30)
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
const query = reactive({ pageNum: 1, pageSize: 10 })
|
||||
|
||||
const visible = ref(false)
|
||||
@@ -55,6 +99,32 @@ const created = ref<TenantCreateResult | null>(null)
|
||||
const createdName = ref('')
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
// 详情抽屉与编辑弹窗各自的状态(都靠 `v-model:show` 与子组件同步)
|
||||
const detailVisible = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
const editVisible = ref(false)
|
||||
const editing = ref<TenantVO | null>(null)
|
||||
|
||||
/**
|
||||
* 「首个管理员的登录账号」默认值**按租户编码生成**(`admin_jxjd`),不是写死的 `admin`。
|
||||
*
|
||||
* ⚠️ 为什么不能用固定默认值:账号唯一性是 `(tenant_id, username)`,即"跨租户重名"
|
||||
* 本身是被允许的(登录时靠租户码区分)。但**默认值**固定成 `admin` 的后果是
|
||||
* "运营不动手,每开一所学校就多一个 `admin`" —— 于是不带租户码登录会撞上
|
||||
* `40012 该账号存在于多个单位`,平台侧看账号也分不清是哪所学校的。
|
||||
* 默认值跟着编码走,开一所学校天然不重名;运营仍可以改成自己想要的账号名。
|
||||
*/
|
||||
const ADMIN_USERNAME_PREFIX = 'admin_'
|
||||
/** 人有没有手动改过登录账号:改过就不再被编码覆盖(否则会把人填的值冲掉) */
|
||||
const adminUsernameTouched = ref(false)
|
||||
/** 上一次**自动填出来**的值。用它区分"人改的"与"我们填的"(两者改的是同一个字段) */
|
||||
let autoUsername = ''
|
||||
|
||||
function deriveAdminUsername(code: string): string {
|
||||
const c = code.trim().toLowerCase()
|
||||
return c ? `${ADMIN_USERNAME_PREFIX}${c}` : ''
|
||||
}
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
tenantName: '',
|
||||
@@ -65,7 +135,7 @@ function emptyForm() {
|
||||
// 用 null 而不是 '':n-date-picker 清空时会回传 null,
|
||||
// 声明成 string 会在 v-model 上产生类型打架(运行期没事,但 vue-tsc 会红)
|
||||
expireTime: null as string | null,
|
||||
adminUsername: 'admin',
|
||||
adminUsername: '',
|
||||
adminRealName: '',
|
||||
adminPhone: ''
|
||||
}
|
||||
@@ -114,6 +184,27 @@ const statusChips = computed(() => [
|
||||
{ label: '已停用', value: 0 }
|
||||
])
|
||||
|
||||
/**
|
||||
* 「即将到期」的阈值与状态筛选的 30 天必须是同一个数:
|
||||
* 列表里的橙色与按钮筛出来的行要对得上,否则会出现"筛出来的行一半不是橙的"。
|
||||
*/
|
||||
const EXPIRE_WARN_DAYS = 30
|
||||
|
||||
/**
|
||||
* 工具条右侧那行小字:把"当前在按什么筛"写出来。
|
||||
*
|
||||
* 为什么必须有:状态筛选与「即将到期」是互斥的两个维度,靠按钮高亮区分不够 ——
|
||||
* 两组按钮并排放着,用户很容易以为"我全选上了"。
|
||||
*/
|
||||
const filterHint = computed(() => {
|
||||
if (expireOnly.value) {
|
||||
// 把具体的截止日写出来:只说"即将到期",运营不知道到底包到哪一天
|
||||
return `当前:${todayPlus30()} 前到期(含当天)`
|
||||
}
|
||||
const chip = statusChips.value.find((c) => c.value === statusFilter.value)
|
||||
return statusFilter.value === undefined ? '' : `当前:${chip?.label ?? ''}`
|
||||
})
|
||||
|
||||
const columns: DataTableColumns<TenantVO> = [
|
||||
{
|
||||
title: '单位名称',
|
||||
@@ -134,8 +225,16 @@ const columns: DataTableColumns<TenantVO> = [
|
||||
{
|
||||
title: '到期',
|
||||
key: 'expireTime',
|
||||
width: 120,
|
||||
render: (row) => fmtDate(row.expireTime)
|
||||
width: 130,
|
||||
/**
|
||||
* 到期列的颜色提示:已过期(红)/ 30 天内到期(橙)/ 正常(灰)。
|
||||
*
|
||||
* 为什么只上色、不加文字:这一列只有 130px,塞进"还有 12 天"会把编码列挤走;
|
||||
* 而"快到期了"这件事运营是扫一眼列表找的,颜色足够,细节在详情抽屉里。
|
||||
* ⚠️ 阈值必须与工具条「即将到期」的 30 天一致,否则会出现
|
||||
* "按钮筛出来的行里有一半不是橙的"这种自相矛盾的画面。
|
||||
*/
|
||||
render: (row) => h('span', { class: expireClass(row.expireTime) }, fmtDate(row.expireTime))
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -145,9 +244,87 @@ const columns: DataTableColumns<TenantVO> = [
|
||||
const s = tagOf(TENANT_STATUS, row.status)
|
||||
return h(NTag, { size: 'small', bordered: false, type: s.type }, { default: () => s.text })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: actionCell
|
||||
}
|
||||
]
|
||||
|
||||
/** 一行里可用的操作数最多 3 个(详情/编辑/停用),横排间距小一点不折行 */
|
||||
const S_ACTIONS = 'display: flex; flex-wrap: wrap; gap: 4px 10px; justify-content: center'
|
||||
const S_MUTED = 'font-size: 12px; color: #8a8a8a'
|
||||
|
||||
/**
|
||||
* 到期列的样式类。
|
||||
*
|
||||
* 空值走 `expire--none`(与"正常"同一个灰)—— 没填到期日的租户不是"已过期",
|
||||
* 把它标红会让运营去追一个根本不存在的续费问题。
|
||||
*/
|
||||
function expireClass(expireTime: string | null | undefined): string {
|
||||
if (!expireTime) {
|
||||
return 'tenant__expire--none'
|
||||
}
|
||||
const end = Date.parse(`${expireTime.slice(0, 10)}T23:59:59`)
|
||||
if (Number.isNaN(end)) {
|
||||
return 'tenant__expire--none'
|
||||
}
|
||||
const days = (end - Date.now()) / 86400000
|
||||
if (days < 0) {
|
||||
return 'tenant__expire--over'
|
||||
}
|
||||
return days <= EXPIRE_WARN_DAYS ? 'tenant__expire--soon' : 'tenant__expire--none'
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内动作:`详情` / `编辑` / `停用`或`启用`。
|
||||
*
|
||||
* 三个刻意的地方:
|
||||
* · 按钮用 `text` + `size="small"`(本仓行内动作的统一写法,见「收集任务」页);
|
||||
* · 停用 / 启用走 `NPopconfirm` 二次确认,**停用的文案要写清后果** ——
|
||||
* 它一下挡住的是这所学校所有学生与老师的登录,不是一次普通的"改状态";
|
||||
* · 没有 `tenant:manage` 时显示"只读"而不是干脆不给这一列:
|
||||
* 平台运营里有只读账号(`tenant:view`),"什么都没有"会让人以为是页面坏了。
|
||||
*/
|
||||
function actionCell(row: TenantVO) {
|
||||
const items = []
|
||||
if (canView.value) {
|
||||
items.push(h(NButton, {
|
||||
text: true,
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => onDetail(row)
|
||||
}, { default: () => '详情' }))
|
||||
}
|
||||
if (canManage.value) {
|
||||
items.push(h(NButton, {
|
||||
text: true,
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => onEdit(row)
|
||||
}, { default: () => '编辑' }))
|
||||
// 已停用显示「启用」(主色),正常/试用显示「停用」(警示色)
|
||||
items.push(h(NPopconfirm, {
|
||||
onPositiveClick: () => onToggleStatus(row)
|
||||
}, {
|
||||
trigger: () => h(NButton, {
|
||||
text: true,
|
||||
size: 'small',
|
||||
type: row.status === 0 ? 'primary' : 'warning'
|
||||
}, { default: () => (row.status === 0 ? '启用' : '停用') }),
|
||||
default: () => (row.status === 0
|
||||
? '恢复这所学校的登录?学生与老师马上就能重新登录。'
|
||||
: '停用后该校所有学生与老师会立刻无法登录,确认停用?')
|
||||
}))
|
||||
}
|
||||
if (!items.length) {
|
||||
return h('span', { style: S_MUTED }, '只读')
|
||||
}
|
||||
return h('div', { style: S_ACTIONS }, items)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页器统一走 `listPagination`(见 `@/composables/pagination`)——
|
||||
* 手写 `prefix` 的类型最容易写错(naive 给的 `itemCount` 是 `number | undefined`)。
|
||||
@@ -178,7 +355,8 @@ async function load(): Promise<void> {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: statusFilter.value
|
||||
status: statusFilter.value,
|
||||
expireBefore: expireOnly.value ? todayPlus30() : undefined
|
||||
})
|
||||
list.value = page.list ?? []
|
||||
total.value = page.total ?? 0
|
||||
@@ -192,13 +370,61 @@ function search(): void {
|
||||
void load()
|
||||
}
|
||||
|
||||
/** 点状态筛选:清掉「即将到期」,否则两个维度叠加后结果集小得让人以为数据丢了 */
|
||||
function setStatus(v: number | undefined): void {
|
||||
expireOnly.value = false
|
||||
statusFilter.value = v
|
||||
search()
|
||||
}
|
||||
|
||||
/** 点「即将到期」:这是"切换",再点一次取消;同时清掉状态筛选(互斥的另一半) */
|
||||
function toggleExpireOnly(): void {
|
||||
expireOnly.value = !expireOnly.value
|
||||
if (expireOnly.value) {
|
||||
statusFilter.value = undefined
|
||||
}
|
||||
search()
|
||||
}
|
||||
|
||||
function onDetail(row: TenantVO): void {
|
||||
detailId.value = row.id
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function onEdit(row: TenantVO): void {
|
||||
editing.value = row
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用 / 启用。
|
||||
*
|
||||
* 只发 `status` 一个字段:`PUT /tenants/{id}` 是**部分更新**(非 null 才写),
|
||||
* 所以不需要把整行原样回传 —— 回传反而会把 `contactName` 这类
|
||||
* "列表里是空、库里其实有值"的字段覆盖成空。
|
||||
* 也**不能**用 `updateTenant` 去改 `expireTime`:那是编辑弹窗的事。
|
||||
*/
|
||||
async function onToggleStatus(row: TenantVO): Promise<void> {
|
||||
const next = row.status === 0 ? 1 : 0
|
||||
try {
|
||||
await tenantApi.updateTenant(row.id, { status: next })
|
||||
message.success(next === 0 ? '已停用,该校用户已无法登录' : '已启用')
|
||||
await load()
|
||||
} catch {
|
||||
// 提示已由 http 层给出
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑保存成功:重新拉列表(到期日 / 状态都可能变了) */
|
||||
function onEdited(): void {
|
||||
void load()
|
||||
}
|
||||
|
||||
function onAdd(): void {
|
||||
Object.assign(form, emptyForm())
|
||||
// 每次打开弹窗都重新开始"跟着编码走"的状态,否则第二次打开时人会发现自己改不动账号名
|
||||
adminUsernameTouched.value = false
|
||||
autoUsername = ''
|
||||
created.value = null
|
||||
visible.value = true
|
||||
}
|
||||
@@ -251,6 +477,23 @@ function closeCreated(): void {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// ── 登录账号跟着租户编码走(默认值,人一改就交还给填写者) ──
|
||||
watch(() => form.tenantCode, (code) => {
|
||||
if (adminUsernameTouched.value) {
|
||||
return
|
||||
}
|
||||
autoUsername = deriveAdminUsername(code)
|
||||
form.adminUsername = autoUsername
|
||||
})
|
||||
|
||||
watch(() => form.adminUsername, (v) => {
|
||||
// 只有"值不是我们刚填进去的那个"才算人改的 —— 少了这一层,
|
||||
// 自动填充本身就会把字段标成"已手改",编码再变一次就再也不跟了
|
||||
if (v !== autoUsername) {
|
||||
adminUsernameTouched.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -267,22 +510,33 @@ onMounted(load)
|
||||
工具条:主操作在左、筛选在右(与收集任务页同一口径)。
|
||||
同一行里所有控件统一**默认尺寸** —— 全是 28px 的小控件挤在 52px 的一行里,
|
||||
白底小按钮贴在白底卡片上几乎看不出是按钮。
|
||||
|
||||
「即将到期」与状态按钮组是**互斥的两个筛选维度**(点一个清另一个),
|
||||
所以它们之间用一条竖线分开,右侧再用一行小字说明"当前在按什么筛"——
|
||||
光靠按钮高亮区分不够,用户会以为两组是叠加的。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<NSpace :size="8">
|
||||
<NButton type="primary" @click="onAdd">
|
||||
<NButton v-if="canManage" type="primary" @click="onAdd">
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="16" />
|
||||
</template>
|
||||
开通租户
|
||||
</NButton>
|
||||
<NButton
|
||||
:type="expireOnly ? 'primary' : 'default'"
|
||||
@click="toggleExpireOnly"
|
||||
>
|
||||
即将到期
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace :size="8" align="center">
|
||||
<span v-if="filterHint" class="tenant__filter-hint">{{ filterHint }}</span>
|
||||
<NButton
|
||||
v-for="c in statusChips"
|
||||
:key="String(c.value)"
|
||||
:type="statusFilter === c.value ? 'primary' : 'default'"
|
||||
:type="!expireOnly && statusFilter === c.value ? 'primary' : 'default'"
|
||||
@click="setStatus(c.value)"
|
||||
>
|
||||
{{ c.label }}
|
||||
@@ -302,6 +556,8 @@ onMounted(load)
|
||||
<!--
|
||||
⚠️ Naive 的表格**没有** `scroll` prop(那是 Arco 的 API):
|
||||
横向用 `:scroll-x`(数值),纵向靠 `flex-height` + 高度链。
|
||||
加了「操作」列(200px)后总宽从 800 涨到 1040,`:scroll-x` 要跟着改 ——
|
||||
不改的话窄屏下按钮会被压成竖排的两三个字。
|
||||
-->
|
||||
<NDataTable
|
||||
flex-height
|
||||
@@ -311,7 +567,7 @@ onMounted(load)
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="800"
|
||||
:scroll-x="1040"
|
||||
:pagination="pagination"
|
||||
size="small"
|
||||
@update:page="onPage"
|
||||
@@ -319,6 +575,15 @@ onMounted(load)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
详情抽屉 / 编辑弹窗。
|
||||
两个都用 `v-model:show`(而不是像审核详情那样的 `defineExpose({ open })` 命令式打开):
|
||||
详情要跟着"点的是哪一行"走,`tenant-id` 一变就重拉 —— 命令式在那个场景下
|
||||
要么多一个 open(row) 的参数,要么在父组件里手工把 id 与开关对齐,更容易错。
|
||||
-->
|
||||
<TenantDetailDrawer v-model:show="detailVisible" :tenant-id="detailId" />
|
||||
<TenantEditModal v-model:show="editVisible" :tenant="editing" @saved="onEdited" />
|
||||
|
||||
<!-- 开通租户:成功态与表单态共用一个弹窗,弱化"关掉就看不到密码了"的风险 -->
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
@@ -399,7 +664,11 @@ onMounted(load)
|
||||
</div>
|
||||
<div class="tenant__grid tenant__grid--3">
|
||||
<NFormItem label="登录账号" path="adminUsername">
|
||||
<NInput v-model:value="form.adminUsername" clearable />
|
||||
<NInput
|
||||
v-model:value="form.adminUsername"
|
||||
placeholder="按租户编码自动生成,如 admin_jxjd"
|
||||
clearable
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="姓名" path="adminRealName">
|
||||
<NInput v-model:value="form.adminRealName" clearable />
|
||||
@@ -456,6 +725,30 @@ onMounted(load)
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
/* 「当前按什么筛」那行小字。放在按钮组左边,读起来像"这批数据的条件" */
|
||||
.tenant__filter-hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 到期列的三档颜色。
|
||||
*
|
||||
* 用 `:deep()` 是因为它们在表格 render 出来的节点上(`scoped` 的属性选择器
|
||||
* 加不到 `h()` 生成的元素上 —— 这是本仓列表页的通用坑,不写就静默不上色)。
|
||||
*/
|
||||
:deep(.tenant__expire--over) {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
:deep(.tenant__expire--soon) {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
:deep(.tenant__expire--none) {
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
|
||||
.tenant__id,
|
||||
:deep(.tenant__id) {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -190,10 +190,35 @@ function closeCreated(): void {
|
||||
emit('save-success')
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
|
||||
* 插槽存在时 `renderExplains` 永不渲染)。本表单有 4 个字段带 `#feedback`,
|
||||
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
|
||||
* 用户看到的是"点了确定没反应"。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) {
|
||||
return null
|
||||
}
|
||||
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
|
||||
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
|
||||
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
|
||||
for (const entry of errors.flat(2)) {
|
||||
const message = (entry as { message?: unknown } | null)?.message
|
||||
if (typeof message === 'string' && message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function confirm(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
} catch (errors) {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -303,7 +328,7 @@ defineExpose({ add, edit })
|
||||
<NFormItem label="登录账号" path="username">
|
||||
<NInput v-model:value="form.username" :disabled="isEdit" placeholder="如 zhangsan" clearable />
|
||||
<template v-if="isEdit" #feedback>
|
||||
<span class="uf__hint">登录名不可修改:改了会让历史记录与导出包里的账号对不上</span>
|
||||
<span class="uf__hint">不可修改:会让历史记录与导出包对不上</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
@@ -315,7 +340,7 @@ defineExpose({ add, edit })
|
||||
<NInput v-model:value="form.phone" placeholder="11 位手机号" :maxlength="11" clearable />
|
||||
<template #feedback>
|
||||
<span class="uf__hint">
|
||||
{{ isEdit ? '列表只返回脱敏值,这里必须重填一次(加密存储)' : '加密存储,列表只显示脱敏值' }}
|
||||
{{ isEdit ? '必须重填一次(列表只给脱敏值)' : '加密存储,列表只显示脱敏值' }}
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
+71
-1
@@ -21,7 +21,7 @@
|
||||
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
|
||||
而不是靠下次再想一遍)。
|
||||
|
||||
## 查九件事
|
||||
## 查十件事
|
||||
|
||||
| # | 查什么 | 没接上的后果 |
|
||||
|:--:|---|---|
|
||||
@@ -34,6 +34,7 @@
|
||||
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
|
||||
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
|
||||
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
|
||||
| 10 | **后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案** | 日志页原样回显英文码(见下) |
|
||||
|
||||
### 第 7 项是 2026-10-09 补的(自己踩了一次)
|
||||
|
||||
@@ -58,6 +59,20 @@
|
||||
后两项严格说不是"声明没接线",而是**同一族:坏了但没有任何工具会红**。
|
||||
放这里是因为它想要的就是"一条命令把这一族扫一遍"。
|
||||
|
||||
### 第 10 项是 2026-10 补的(做租户管理与日志详情时)
|
||||
|
||||
日志页的 `actionText()` **查不到就原样回显那个码**:界面上出现 `AUTO_CLOSE`
|
||||
这种没翻译的英文,编译 / lint / typecheck / review 全都不红。
|
||||
|
||||
加这一项时顺带发现了一件更值得记的事:`AUTO_CLOSE` **根本不在常量表里** ——
|
||||
它在 `TaskService.autoClose()` 里是用字符串字面量写的。也就是说,
|
||||
"后端常量 ↔ 前端清单"这种检查**本来就扫不到它**;写这个检查的同时
|
||||
得先把字面量提成常量(`ACTION_AUTO_CLOSE`),检查才真的有覆盖。
|
||||
**这一族检查的价值,取决于被检查的那一侧有没有被规范化。**
|
||||
|
||||
⚠️ 与第 7 项同一个教训:**加完必须反向验证**(删掉前端一条文案,它得红),
|
||||
否则又是一个"扫不到却判为通过"。
|
||||
|
||||
|
||||
## 它**不查**什么(免得被当成"全绿就没事")
|
||||
|
||||
@@ -108,6 +123,10 @@ PERM_NO_ROLE = {}
|
||||
# PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
|
||||
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
|
||||
FE_PERM_EXCEPTION = {}
|
||||
|
||||
# 第 ⑩ 项:后端会写的日志动作码,却**有意**不在 PC 端给中文文案的。
|
||||
# 现在没有例外 —— 少一个文案就会在日志页上原样回显英文码,没有"故意不翻译"的理由。
|
||||
ACTION_TEXT_EXCEPTION = {}
|
||||
ERROR_CODE_UNUSED = {
|
||||
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
|
||||
'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
|
||||
@@ -642,6 +661,57 @@ def main():
|
||||
if not svg_bad:
|
||||
say(' ✓ 全部能被解析为合法 XML')
|
||||
|
||||
# ── ⑩ 后端会写的日志动作码 ↔ PC 端的中文文案 ──────────────
|
||||
#
|
||||
# ⚠️ 为什么属于这一族:PC 端的 `actionText()` **查不到就原样回显那个码**
|
||||
# (不是空白,也不报错)。于是日志页上会出现一个没翻译的英文码,
|
||||
# 而编译 / lint / typecheck / review **全都不红** —— 只有人盯着那一列才看得出来。
|
||||
#
|
||||
# ⚠️ 加这一项时顺带发现的:`AUTO_CLOSE`(定时任务自动截止)**根本不在常量表里**,
|
||||
# 是字符串字面量写的 —— 所以"常量 ↔ 前端清单"这种检查本来就扫不到它。
|
||||
# 已提成 `OperationLogService.ACTION_AUTO_CLOSE`,这条检查才真的有覆盖。
|
||||
#
|
||||
# ⚠️ 刻意**只从 `OPERATION_ACTIONS` 这个数组里取**前端清单:同文件里
|
||||
# `MODULE_OPTIONS` / `TARGET_TYPE_OPTIONS` 也是 `{ value: 'TASK' }` 的形状,
|
||||
# 整文件扫会把 `FILE` / `TENANT` 这些模块名当成动作码收进来(假阳性)。
|
||||
LOG_SERVICE = ('backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/'
|
||||
'service/OperationLogService.java')
|
||||
PC_LOG_API = 'frontend-pc/src/apis/xt/log.ts'
|
||||
be_actions = set(re.findall(r'public static final String ACTION_\w+ = "([A-Z_]+)";',
|
||||
strip_java_comments(read(LOG_SERVICE))))
|
||||
blk = re.search(r'export const OPERATION_ACTIONS[^=]*=\s*\[(.*?)\n\]',
|
||||
strip_js_comments(read(PC_LOG_API)), re.S)
|
||||
fe_actions = set(re.findall(r"value:\s*'([A-Z_]+)'", blk.group(1) if blk else ''))
|
||||
|
||||
say('\n【⑩】日志动作码:后端常量 %d 个 / PC 端文案 %d 个'
|
||||
% (len(be_actions), len(fe_actions)))
|
||||
if not be_actions or not fe_actions:
|
||||
# 解析失败必须**报红**,不能静默通过 —— 第 ⑦ 项就是栽在这里
|
||||
# (重搭后写法变了,它"一个码都扫不到却判为通过")。
|
||||
problems.append(('日志动作码', '解析失败',
|
||||
'后端解析出 %d 个 / PC 端解析出 %d 个 —— 0 就是检查器自己失效了,'
|
||||
'不是"没问题"' % (len(be_actions), len(fe_actions))))
|
||||
say(' ✗ 解析失败(后端 %d / PC %d):0 表示检查器失效,先修检查器'
|
||||
% (len(be_actions), len(fe_actions)))
|
||||
else:
|
||||
for code in sorted(be_actions - fe_actions):
|
||||
if code in ACTION_TEXT_EXCEPTION:
|
||||
notes.append('日志动作码 %s —— 已知例外:%s' % (code, ACTION_TEXT_EXCEPTION[code]))
|
||||
say(' — %-18s %s' % (code, ACTION_TEXT_EXCEPTION[code]))
|
||||
else:
|
||||
problems.append(('日志动作码', code,
|
||||
'后端会写它,但 PC 端 OPERATION_ACTIONS 没有文案'
|
||||
'(日志页会原样显示这个英文码)'))
|
||||
say(' ✗ %-18s 后端会写,PC 端没文案 → 界面回显英文码' % code)
|
||||
for code in sorted(fe_actions - be_actions):
|
||||
problems.append(('日志动作码', code,
|
||||
'PC 端有文案,但后端没有任何 ACTION_* 常量等于它'
|
||||
'(选中它永远筛不到东西)'))
|
||||
say(' ✗ %-18s PC 端有文案,后端没这个码 → 选了永远筛不到' % code)
|
||||
if be_actions == fe_actions:
|
||||
say(' ✓ %d 个动作码两端逐字一致:%s'
|
||||
% (len(be_actions), '、'.join(sorted(be_actions))))
|
||||
|
||||
# ── 汇总 ────────────────────────────────────────────────
|
||||
print('\n' + '=' * 88)
|
||||
if notes:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""临时脚本(只读):解出本轮走查夹具(e2e-m3-flow.py 建的 taskMain)名册行身份证明文。
|
||||
|
||||
用途:L3-B 浏览器走查要在门禁界面里**手动输入姓名 + 身份证号**,
|
||||
而明文只在建夹具的那一刻存在过(库里是 AES 密文 / 脱敏 / HMAC 索引)。
|
||||
dev 的 AES 密钥在 application-dev.yml 里(提交进仓库的开发专用密钥),格式见 AesGcmCipher:
|
||||
Base64( IV(12) || ciphertext || tag(16) )。
|
||||
|
||||
**只读,不写库。** 走查完请连同夹具任务一起清掉(`python scripts/e2e-m3-flow.py --clean <taskId>`)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
TASK_ID = int(sys.argv[1]) if len(sys.argv) > 1 else 2311
|
||||
|
||||
DEV_YML = r"D:\IDEA_Project\xiaotutongxue\backend\xt-server\src\main\resources\application-dev.yml"
|
||||
TEXT = open(DEV_YML, encoding="utf-8").read()
|
||||
AES_KEY = base64.b64decode(re.search(r"aes-key:\s*(\S+)", TEXT).group(1))
|
||||
CIPHER = AESGCM(AES_KEY)
|
||||
|
||||
|
||||
def psql(sql: str) -> list[list[str]]:
|
||||
p = subprocess.run(
|
||||
["docker", "exec", "xt-pg", "psql", "-U", "fill", "-d", "filldb",
|
||||
"-A", "-F", "\t", "-P", "pager=off", "-t", "-c", sql],
|
||||
capture_output=True,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
raise SystemExit(p.stderr.decode("utf-8", "replace"))
|
||||
# ⚠️ 不能对整段输出 .strip() —— psql 的 -A 模式在**末尾字段为空**时会留下结尾制表符,
|
||||
# 整段 strip 会把它吃掉,于是"未绑定"的那一行少一列、解包直接报
|
||||
# `ValueError: not enough values to unpack`(而这个报错看起来像解密失败)。
|
||||
out = p.stdout.decode("utf-8", "replace")
|
||||
return [ln.split("\t") for ln in out.splitlines() if ln.strip()]
|
||||
|
||||
|
||||
def decrypt(cipher_b64: str) -> str:
|
||||
raw = base64.b64decode(cipher_b64.strip())
|
||||
return CIPHER.decrypt(raw[:12], raw[12:], None).decode("utf-8")
|
||||
|
||||
|
||||
rows = psql(f"""
|
||||
SELECT id, real_name, COALESCE(id_card_no,''), COALESCE(id_card_mask,''),
|
||||
COALESCE(bind_user_id::text,'')
|
||||
FROM collect_roster
|
||||
WHERE deleted = 0 AND task_id = {TASK_ID}
|
||||
ORDER BY id
|
||||
""")
|
||||
|
||||
print(f"任务 {TASK_ID} 的名册(走查用;只打印,不入库)")
|
||||
print("名册行 | 姓名 | 身份证(明文) | 脱敏 | 绑定用户")
|
||||
for r in rows:
|
||||
rid, name, enc = r[0], r[1], r[2]
|
||||
mask = r[3] if len(r) > 3 else ""
|
||||
bind = r[4] if len(r) > 4 else ""
|
||||
try:
|
||||
plain = decrypt(enc)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"{rid} | {name} | [解不开:{type(e).__name__}] | {mask} | {bind or '未绑定'}")
|
||||
continue
|
||||
print(f"{rid} | {name} | {plain} | {mask} | {bind or '未绑定'}")
|
||||
+94
-2
@@ -16,7 +16,14 @@ M0 验收测试。
|
||||
|
||||
退出码:0 = 全部通过;1 = 有失败项(可直接用于 CI)。
|
||||
|
||||
依赖:cryptography
|
||||
依赖:cryptography;且需要能 `docker exec xt-pg`(**清理本脚本造出的测试租户与测试组织**)。
|
||||
|
||||
⚠️ 关于"跑完要清干净"(2026-10 补)
|
||||
本脚本以前**完全不清理**:每跑一次就留下一个 `e2e######` 租户(里面有一个叫 `admin`
|
||||
的管理员)+ demo 租户下的 3 个测试组织。跑两轮之后,库里同一时刻就存在两个
|
||||
`admin` —— 而"账号跨单位重名"这条保护(40012)一旦被别的东西当作前提,
|
||||
清理残留这一个动作就会把那个脚本**静默反转**(见 `e2e-m1-tenant-domain.py` 文件头)。
|
||||
所以:跑完(含中途崩溃)必须把自己造的东西删干净,且**只删自己建的**。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,6 +31,7 @@ import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -35,9 +43,18 @@ if hasattr(sys.stdout, "reconfigure"):
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||||
|
||||
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||||
PG_USER = os.environ.get("PG_USER", "fill")
|
||||
PG_DB = os.environ.get("PG_DB", "filldb")
|
||||
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[tuple[str, str]] = []
|
||||
|
||||
#: 本脚本造出来的东西。放模块级 —— 脚本中途抛异常时也要能被清掉。
|
||||
CREATED_TENANT_IDS: list[int] = []
|
||||
CREATED_ORG_IDS: list[int] = []
|
||||
|
||||
|
||||
# ── HTTP ──────────────────────────────────────────────
|
||||
|
||||
@@ -129,6 +146,58 @@ def finish() -> int:
|
||||
return 0 if not FAILED else 1
|
||||
|
||||
|
||||
# ── 清理 ──────────────────────────────────────────────
|
||||
|
||||
def sql(stmt: str) -> tuple[bool, str]:
|
||||
"""直接执行 SQL —— 只用于清理测试夹具。"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
|
||||
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
|
||||
capture_output=True, text=True, timeout=40)
|
||||
return r.returncode == 0, (r.stdout or r.stderr).strip()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)
|
||||
|
||||
|
||||
_TENANT_TABLES: list[str] | None = None
|
||||
|
||||
|
||||
def tables_with_tenant_id() -> list[str]:
|
||||
"""
|
||||
带 `tenant_id` 的表 —— **动态枚举**,不手抄表名。
|
||||
|
||||
手抄的清单在"新增了一张业务表"那天就开始漏,而漏掉的那张表恰好很难被注意到
|
||||
(租户没了、行还在)。`dev-clean-e2e.py` 是同一口径。
|
||||
"""
|
||||
global _TENANT_TABLES
|
||||
if _TENANT_TABLES is None:
|
||||
_, out = sql("SELECT table_name FROM information_schema.columns "
|
||||
"WHERE table_schema='public' AND column_name='tenant_id'")
|
||||
_TENANT_TABLES = [t.strip() for t in out.splitlines() if t.strip()]
|
||||
return _TENANT_TABLES
|
||||
|
||||
|
||||
def cleanup_all() -> None:
|
||||
"""
|
||||
删掉本脚本造出来的数据。**幂等**,可以重复调用。
|
||||
|
||||
⚠️ 只删**自己建的**(按本次跑出来的 id):这里刻意不做
|
||||
`tenant_code LIKE 'e2e%'` 那种"顺手全清" —— 那是 `dev-clean-e2e.py` 的活。
|
||||
测试脚本越界删别人建的东西,就会让"跑一个脚本把另一个脚本的在途数据删了"
|
||||
这种事变得可能。
|
||||
"""
|
||||
for oid in list(CREATED_ORG_IDS):
|
||||
sql(f"DELETE FROM sys_org WHERE id = {oid}")
|
||||
CREATED_ORG_IDS.remove(oid)
|
||||
|
||||
for tid in list(CREATED_TENANT_IDS):
|
||||
for table in tables_with_tenant_id():
|
||||
sql(f"DELETE FROM {table} WHERE tenant_id = {tid}")
|
||||
sql(f"DELETE FROM sys_tenant WHERE id = {tid}")
|
||||
CREATED_TENANT_IDS.remove(tid)
|
||||
|
||||
|
||||
# ── 用例 ──────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
@@ -216,6 +285,8 @@ def main() -> int:
|
||||
expect_code("B2 平台运营开通租户成功", b, 0)
|
||||
created = data_of(b)
|
||||
new_tenant_id = created.get("id")
|
||||
if isinstance(new_tenant_id, int):
|
||||
CREATED_TENANT_IDS.append(new_tenant_id) # 登记,收尾与崩溃路径都要清它
|
||||
new_pwd = created.get("adminInitialPassword")
|
||||
check("B3 返回初始密码(仅此一次)", bool(new_pwd), json.dumps(created, ensure_ascii=False)[:200])
|
||||
check("B4 返回小程序码参数", created.get("miniProgramScene") == f"t{new_tenant_id}",
|
||||
@@ -262,6 +333,10 @@ def main() -> int:
|
||||
b = mk_org(demo_token, dept_id, f"E2E 24数控1班 {stamp}", 3, f"CLS{stamp}")
|
||||
expect_code("C3 建班级节点", b, 0)
|
||||
class_id = b.get("data")
|
||||
# 登记(C1/C2/C3 若失败,对应的 data 是 None,不能登记成 0/None)
|
||||
for oid in (school_id, dept_id, class_id):
|
||||
if isinstance(oid, int):
|
||||
CREATED_ORG_IDS.append(oid)
|
||||
|
||||
expect_code("C4 同级重复组织编码被拒(40001)",
|
||||
mk_org(demo_token, school_id, "重复编码", 2, f"DEPT{stamp}"), 40001)
|
||||
@@ -321,8 +396,25 @@ def main() -> int:
|
||||
check("E4 参数校验失败返回 40001 且带字段级信息", b.get("code") == 40001,
|
||||
f"code={b.get('code')}")
|
||||
|
||||
# ── F. 清理 ───────────────────────────────────────
|
||||
print("\n[F] 清理本次造出的测试数据")
|
||||
n_tenant, n_org = len(CREATED_TENANT_IDS), len(CREATED_ORG_IDS)
|
||||
cleanup_all()
|
||||
print(f" 已清理 {n_tenant} 个测试租户(连它的角色与账号)"
|
||||
f"、{n_org} 个挂在 demo 下的测试组织")
|
||||
check("F1 测试租户与测试组织都删掉了", not CREATED_TENANT_IDS and not CREATED_ORG_IDS,
|
||||
f"tenant={CREATED_TENANT_IDS} org={CREATED_ORG_IDS}")
|
||||
|
||||
return finish()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
# 崩溃也要清:否则每次失败的运行都往库里丢一个 e2e 租户和一个叫 admin 的账号
|
||||
# (跨租户同名账号就是这么攒出来的,见文件头 ⚠️)
|
||||
try:
|
||||
_code = main()
|
||||
except BaseException:
|
||||
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
|
||||
cleanup_all()
|
||||
raise
|
||||
raise SystemExit(_code)
|
||||
|
||||
+133
-12
@@ -14,8 +14,20 @@
|
||||
所以这条比第 ① 条更重要。
|
||||
|
||||
跑法:python scripts/e2e-m1-tenant-domain.py
|
||||
前提:后端 8080 在跑,且 `fill.tenant-domain` 已启用(dev 默认
|
||||
enabled=true / base=localhost —— 只有 *.localhost 会走这条路,不影响其它域名)。
|
||||
前提:后端 8080 在跑;能 `docker exec xt-pg`(造/清"跨租户同名账号"夹具)。
|
||||
开关开与关都能跑(dev 默认 enabled=false,脚本自己探测)。
|
||||
|
||||
⚠️ 「账号跨单位重名」(40012)这个前提,本脚本**自己造、自己清**(2026-10 改)
|
||||
40012 只有在**真的有两个租户用同一个账号名**时才会触发。以前这里靠的是
|
||||
"E2E 残留的测试租户凑巧也建了一个 admin" —— 那是个隐式依赖:
|
||||
跑一次 `python scripts/dev-clean-e2e.py` 把残留清掉,本脚本就会**静默反转**
|
||||
(账号变唯一 → 不填租户码也能直接登录 → 一整套 40012 断言全反,
|
||||
连 `detect_mode()` 都会把关闭态误判成开启态,报 8 条莫名其妙的 FAIL)。
|
||||
所以现在:跑之前建一个 `e2edup######` 租户 + 里面的 admin,跑完(含中途崩溃)删掉。
|
||||
夹具在,40012 才是可复现的;夹具不在,dev 库里就不该有同名账号。
|
||||
|
||||
顺带修掉了同一族的第二处隐式依赖:原脚本假定 `t14.localhost` 是"e2e 建的、
|
||||
存在且启用的另一个租户",而租户 14 早就被清掉了 —— 改成用夹具租户自己的 id。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,16 +36,26 @@ import base64
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||||
|
||||
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||||
PG_USER = os.environ.get("PG_USER", "fill")
|
||||
PG_DB = os.environ.get("PG_DB", "filldb")
|
||||
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[tuple[str, str]] = []
|
||||
|
||||
#: 本脚本造出来的「跨租户同名账号」夹具租户 id。模块级 —— 脚本中途抛异常也要能清掉。
|
||||
FIXTURE_TENANT_IDS: list[int] = []
|
||||
|
||||
|
||||
def ok(name: str) -> None:
|
||||
PASSED.append(name)
|
||||
@@ -53,6 +75,93 @@ def check(name: str, cond: bool, detail: str = "") -> bool:
|
||||
return cond
|
||||
|
||||
|
||||
# ── 重名夹具:造 / 清 ────────────────────────────────────
|
||||
#
|
||||
# 为什么必须自己造(而不是"库里本来就有"):见文件头那段 ⚠️。
|
||||
# 一句话 —— **靠残留数据凑前提的测试,会被"清理残留"这一个动作静默反转**。
|
||||
|
||||
def sql(stmt: str) -> tuple[bool, str]:
|
||||
"""
|
||||
直接执行 SQL —— 只用于造/清夹具。
|
||||
|
||||
⚠️ 造数据时用 `WITH … AS (INSERT … RETURNING id) SELECT id FROM …` 的写法,
|
||||
**不要**直接 `INSERT … RETURNING id`:后者 psql 会把返回值与命令标签
|
||||
(`INSERT 0 1`)一起打出来,解析的人一不留神就把 `INSERT 0 1` 当成 id 了。
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
|
||||
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
|
||||
capture_output=True, text=True, timeout=40)
|
||||
return r.returncode == 0, (r.stdout or r.stderr).strip()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def create_duplicate_fixture() -> int | None:
|
||||
"""
|
||||
造一个「另一个租户里也有一个叫 admin 的账号」。
|
||||
|
||||
只造登录定位真正需要的两行:`sys_tenant` + `sys_user`。
|
||||
**故意不给密码** —— 40012 在比对密码之前就抛了(`AuthService.locateUser`),
|
||||
给密码只会让人误以为这个账号能登进去。
|
||||
|
||||
租户编码用 `e2edup` 前缀:万一脚本被强杀(SIGKILL,`finally` 跑不到),
|
||||
`scripts/dev-clean-e2e.py`(按 `tenant_code LIKE 'e2e%'` 收)还能兜底清掉。
|
||||
"""
|
||||
code = f"e2edup{str(int(time.time()))[-6:]}"
|
||||
okc, out = sql(
|
||||
"WITH ins AS (INSERT INTO sys_tenant "
|
||||
"(tenant_code, tenant_name, tenant_type, contact_name, status) "
|
||||
f"VALUES ('{code}', 'E2E 重名夹具', 1, '测试联系人', 1) RETURNING id) "
|
||||
"SELECT id FROM ins")
|
||||
# 取**第一行**判 id:psql 可能把命令标签(`INSERT 0 1`)也打在同一份输出里。
|
||||
# ⚠️ 这里踩过一次:整串 isdigit() 判不过 → 直接 return None,
|
||||
# 而那时 id 还没登记进 FIXTURE_TENANT_IDS → 租户行留在库里没人清。
|
||||
first = out.strip().splitlines()[0].strip() if out.strip() else ""
|
||||
if not okc or not first.isdigit():
|
||||
print(f" ✗ 造重名夹具失败:{out}")
|
||||
return None
|
||||
tid = int(first)
|
||||
# 拿到 id 立刻登记:后面任何一步失败,它都能被清掉
|
||||
FIXTURE_TENANT_IDS.append(tid)
|
||||
okc, out = sql(
|
||||
"WITH ins AS (INSERT INTO sys_user "
|
||||
"(tenant_id, username, real_name, user_type, status) "
|
||||
f"VALUES ({tid}, 'admin', 'E2E 重名夹具', 2, 1) RETURNING id) "
|
||||
"SELECT id FROM ins")
|
||||
if not okc:
|
||||
print(f" ✗ 造重名夹具账号失败:{out}")
|
||||
return None
|
||||
print(f" 已造重名夹具:租户 id={tid}({code}),里面也有一个 admin")
|
||||
return tid
|
||||
|
||||
|
||||
def cleanup_fixture() -> None:
|
||||
"""删掉夹具。**幂等** —— main 的 finally 与函数内的显式调用各来一次。"""
|
||||
for tid in list(FIXTURE_TENANT_IDS):
|
||||
sql(f"DELETE FROM sys_user WHERE tenant_id = {tid}")
|
||||
sql(f"DELETE FROM sys_tenant WHERE id = {tid}")
|
||||
FIXTURE_TENANT_IDS.remove(tid)
|
||||
|
||||
|
||||
def cleanup_and_verify() -> None:
|
||||
"""
|
||||
用完就删,并**断言"同名账号"这个前提真的消失了**。
|
||||
|
||||
这条断言守的是本脚本自己的副作用:夹具没清干净的话,
|
||||
下一轮跑起来前提就是假的,而 dev 库里会多出两个同名 admin ——
|
||||
正是"不同租户不要出现相同账号名"要清掉的那种垃圾。
|
||||
"""
|
||||
print("\n[清理] 重名夹具用完即删")
|
||||
cleanup_fixture()
|
||||
again = login("localhost", None)
|
||||
check("夹具删除后 demo 的 admin 又变回唯一 → 不填租户码可直接登录",
|
||||
again.get("code") == 0,
|
||||
f"code={again.get('code')} msg={again.get('msg')}"
|
||||
"(库里还有别的同名 admin:先跑 python scripts/dev-clean-e2e.py 清残留)")
|
||||
|
||||
|
||||
def rsa_encrypt(plain: str) -> str:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
@@ -94,7 +203,7 @@ def login(host: str, tenant_code: str | None = None, account: str = "admin",
|
||||
return call("POST", "/api/v1/auth/login", host, body=body)
|
||||
|
||||
|
||||
def run_on() -> int:
|
||||
def run_on(fixture_tid: int) -> int:
|
||||
ap = argparse.ArgumentParser(description="租户域名 E2E")
|
||||
ap.add_argument("--password", default="Admin@123")
|
||||
args = ap.parse_args()
|
||||
@@ -144,9 +253,10 @@ def run_on() -> int:
|
||||
check("拿 t1 的 token 访问 t1 域名 → 正常",
|
||||
call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t1.localhost", token1).get("code") == 0,
|
||||
"同租户被误拒")
|
||||
# 14 号租户是 e2e 建的(status=1,存在且启用),所以 t14.localhost 能被解析成一个真实租户
|
||||
cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token1)
|
||||
check("**拿 t1 的 token 访问 t14 域名 → 403(拒绝)**",
|
||||
# 夹具租户就是"存在、启用、且不是 t1"的另一个真实租户,
|
||||
# 所以 t{夹具id}.localhost 能被解析成一个真实租户
|
||||
cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", f"t{fixture_tid}.localhost", token1)
|
||||
check(f"**拿 t1 的 token 访问 t{fixture_tid} 域名 → 403(拒绝)**",
|
||||
cross.get("code") == 40301 or cross.get("_http") == 403
|
||||
or "另一个单位" in (cross.get("msg") or ""), f"{cross}")
|
||||
check("拒绝时给出可理解的提示(不是一句「无权限」)",
|
||||
@@ -164,6 +274,8 @@ def run_on() -> int:
|
||||
check("无关域名(example.com)不解析",
|
||||
login("example.com", None).get("code") == 40012, "无关域名被误当租户")
|
||||
|
||||
cleanup_and_verify()
|
||||
|
||||
print()
|
||||
print("=" * 76)
|
||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||
@@ -190,7 +302,7 @@ def detect_mode() -> str:
|
||||
raise SystemExit(f" ✗ 探测失败(既不是 0 也不是 40012):{r}")
|
||||
|
||||
|
||||
def run_off() -> int:
|
||||
def run_off(fixture_tid: int) -> int:
|
||||
"""
|
||||
关闭态(当前默认):验"关掉之后,行为与从前**完全一致**"。
|
||||
|
||||
@@ -214,13 +326,15 @@ def run_off() -> int:
|
||||
d.get("code") == 40012, f"{d}")
|
||||
|
||||
token = (b.get("data") or {}).get("token")
|
||||
e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token)
|
||||
check("关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t14 域名照常通过",
|
||||
e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", f"t{fixture_tid}.localhost", token)
|
||||
check(f"关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t{fixture_tid} 域名照常通过",
|
||||
e.get("code") == 0,
|
||||
f"{e}(这是关闭前的既有行为;开启态下会被 403 拒掉,见 run_on)")
|
||||
|
||||
ok("要验开启态:以 XT_TENANT_DOMAIN_ENABLED=true 重启后端,再跑本脚本")
|
||||
|
||||
cleanup_and_verify()
|
||||
|
||||
print()
|
||||
print("=" * 76)
|
||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||
@@ -232,10 +346,17 @@ def run_off() -> int:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# 夹具必须在**探测模式之前**造好:detect_mode() 正是靠"不填租户码会不会 40012"
|
||||
# 判断开关的,而没有同名账号时那条路会直接登录成功 → 关闭态被误判成开启态(见文件头 ⚠️)
|
||||
fixture_tid = create_duplicate_fixture()
|
||||
if fixture_tid is None:
|
||||
return 1
|
||||
try:
|
||||
mode = detect_mode()
|
||||
if mode == "ON":
|
||||
return run_on()
|
||||
return run_off()
|
||||
return run_on(fixture_tid) if mode == "ON" else run_off(fixture_tid)
|
||||
finally:
|
||||
# 中途崩溃也要清 —— 否则"清残留"这件事自己开始制造残留
|
||||
cleanup_fixture()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+143
-2
@@ -14,6 +14,11 @@
|
||||
断言要看的是"**它承认自己没接**",而不是"字段存在"。
|
||||
③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。
|
||||
返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。
|
||||
④ **租户用量必须只算"这一所学校"**。平台运营的 `tenantId` 是 0,而租户拦截器
|
||||
对它**整张表关掉过滤**(`FillTenantLineHandler.ignoreTable`)—— 于是
|
||||
`/tenants/{id}/usage` 会算成**全平台**的学生数与存储量,挂在某一所学校的名下。
|
||||
⚠️ 这个 bug 在"库里只有一所学校有数据"时**与正确值完全相等**,光断言
|
||||
"有值、非 null"永远发现不了 —— 所以本脚本要**先造出别的租户的数据**再断言。
|
||||
|
||||
## 还有一条不能省的边界
|
||||
|
||||
@@ -24,6 +29,7 @@
|
||||
跑法:python scripts/e2e-m8-orgfile.py
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -38,6 +44,8 @@ PASSED = []
|
||||
FAILED = []
|
||||
CREATED_TASK_IDS = []
|
||||
CREATED_ORG_NAMES = []
|
||||
CREATED_TENANT_IDS = []
|
||||
USAGE_FIXTURE_HASH_PREFIX = "e2e-usage-scope-"
|
||||
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-of-roster.xlsx")
|
||||
TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx")
|
||||
TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-of.jpg")
|
||||
@@ -250,6 +258,103 @@ def main():
|
||||
check("学生看租户用量 → 403", b.get("code") in (40101, 40301, 403),
|
||||
"code=%s" % b.get("code"))
|
||||
|
||||
# ── 2b. 详情脱敏 / 用量口径 / 变更留痕 / 到期日边界 ──
|
||||
print("\n[2b] 租户:详情脱敏 / 用量口径 / 变更留痕 / 到期日边界")
|
||||
|
||||
# ① 详情接口必须可用,且联系电话是**脱敏值**。
|
||||
# ⚠️ 修复前这条会 500:`update()` 把手机号**明文**写进了加密列,
|
||||
# 而 `AesGcmCipher.decrypt` 对非 Base64 的输入直接抛 —— 库里 id=1 就是那种行。
|
||||
det = body_of("GET", "/api/v1/tenants/1", ops_token)
|
||||
check("⭐ 租户详情可用(历史明文手机号不能让详情页打不开)",
|
||||
det.get("code") == 0, json.dumps(det, ensure_ascii=False)[:250])
|
||||
check("详情给出**脱敏**后的联系电话(形如 139****1111)",
|
||||
"****" in str((det.get("data") or {}).get("contactPhone")),
|
||||
"contactPhone=%r" % ((det.get("data") or {}).get("contactPhone"),))
|
||||
|
||||
# ② 改手机号:落库必须是密文,并且能读回脱敏值(写得进 + 解得开)
|
||||
upd = body_of("PUT", "/api/v1/tenants/1", ops_token, {"contactPhone": "13712345678"})
|
||||
check("改联系电话成功", upd.get("code") == 0, json.dumps(upd, ensure_ascii=False)[:250])
|
||||
stored_phone = sql("select contact_phone from sys_tenant where id=1;").strip()
|
||||
check("⭐ 联系电话以**密文**落库(明文落库会让详情页从此永远打不开)",
|
||||
stored_phone != "13712345678" and len(stored_phone) > 20, "stored=%r" % stored_phone)
|
||||
det2 = body_of("GET", "/api/v1/tenants/1", ops_token)
|
||||
check("⭐ 新写的号能读回脱敏值(证明密文与解密对得上,不是「写进去就算了」)",
|
||||
(det2.get("data") or {}).get("contactPhone") == "137****5678",
|
||||
"contactPhone=%r" % ((det2.get("data") or {}).get("contactPhone"),))
|
||||
|
||||
# ③ 到期日筛选:格式不对要**明确报错**,不能静默忽略
|
||||
bad_exp = body_of("GET", "/api/v1/tenants?expireBefore=" + urllib.parse.quote("去年"), ops_token)
|
||||
check("⭐ expireBefore 写错 → 明确报错(静默忽略会让人以为「我按到期日筛过了」)",
|
||||
bad_exp.get("code") != 0, "code=%s msg=%s" % (bad_exp.get("code"), bad_exp.get("msg")))
|
||||
|
||||
# ④ 后面几条用一个**新建的**租户(不动 demo,免得影响别的用例)
|
||||
today = datetime.date.today().isoformat()
|
||||
_, created = R.call("POST", BASE + "/api/v1/tenants", ops_token, {
|
||||
"tenantName": "E2E用量学校 " + suffix,
|
||||
"tenantCode": "e2eusage" + suffix, # ⚠️ 必须以 e2e 开头,dev-clean-e2e.py 按它清
|
||||
"tenantType": 1, "contactName": "E2E用量联系人", "contactPhone": "13900139009",
|
||||
"expireTime": today, "adminUsername": "admin",
|
||||
"adminRealName": "E2E用量管理员", "adminPhone": "13900139010",
|
||||
})
|
||||
ntid = ((created or {}).get("data") or {}).get("id")
|
||||
check("建一所 E2E 租户(后面的断言靠它,收尾会清掉)", isinstance(ntid, int),
|
||||
json.dumps(created, ensure_ascii=False)[:250])
|
||||
|
||||
if isinstance(ntid, int):
|
||||
CREATED_TENANT_IDS.append(ntid)
|
||||
|
||||
# 开通就要留痕(F-1 点名「租户开通」是优先留痕点,而本模块原先一条都不写)
|
||||
row = sql("select action || '|' || tenant_id from sys_operation_log "
|
||||
"where target_type='TENANT' and target_id=%d order by id desc limit 1;" % ntid).strip()
|
||||
check("⭐ 开通租户留痕(target_type=TENANT & target_id=租户 id)",
|
||||
row.startswith("CREATE_TENANT|"), "最新一行=%r" % row)
|
||||
check("⭐ 留痕的 tenant_id 是 **0**(平台身份),不是被操作的那所学校 —— "
|
||||
"所以查「这所学校是谁开通的」要用 target,不能用 tenant_id 筛",
|
||||
row.split("|")[-1] == "0", "row=%r" % row)
|
||||
|
||||
# 状态流转:2 试用 / 0 停用 / 1 启用,各自留痕(停用与启用不能并成一个动作)
|
||||
for st, act in ((2, "UPDATE_TENANT"), (0, "DISABLE_TENANT"), (1, "ENABLE_TENANT")):
|
||||
r = body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"status": st})
|
||||
check("status=%d 能改(⚠️ status=2「试用」原先被拒:只放行 0/1,而前端有「试用」)" % st,
|
||||
r.get("code") == 0, json.dumps(r, ensure_ascii=False)[:200])
|
||||
got = sql("select action from sys_operation_log where target_type='TENANT' "
|
||||
"and target_id=%d order by id desc limit 1;" % ntid).strip()
|
||||
check("status→%d 的留痕动作是 %s" % (st, act), got == act, "实际=%r" % got)
|
||||
|
||||
r4 = body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"tenantType": 4})
|
||||
check("⭐ tenantType=4(企业)能改(原先只放行 1~3,而前端下拉有 4 项)",
|
||||
r4.get("code") == 0, json.dumps(r4, ensure_ascii=False)[:200])
|
||||
|
||||
# 到期日**含当天**:把到期日设成今天,`expireBefore=今天` 必须能查到它
|
||||
body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"expireTime": today})
|
||||
lst = body_of("GET", "/api/v1/tenants?expireBefore=%s&pageSize=200" % today, ops_token)
|
||||
hit = [t.get("id") for t in ((lst.get("data") or {}).get("list") or [])]
|
||||
check("⭐ expireBefore=今天 能查到「今天到期」的租户"
|
||||
"(原先用 atStartOfDay() 比,差一天,查不到)",
|
||||
ntid in hit, "今天=%s 命中 id=%s" % (today, hit[:10]))
|
||||
|
||||
# ⭐ 用量口径 —— 本段最重要的一条。
|
||||
# 平台身份会让租户拦截器**整张表关掉过滤**,所以 `/tenants/{id}/usage`
|
||||
# 曾经返回的是**全平台**的学生数与存储量。库里只有一所学校有数据时
|
||||
# 它与正确值完全相等,所以这里必须先往**另一个租户**塞一行名册。
|
||||
R.sql("insert into collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
|
||||
"values (%d, -1, 'E2E用量范围', 'x', '%s%s');"
|
||||
% (ntid, USAGE_FIXTURE_HASH_PREFIX, suffix))
|
||||
own = int(sql("select count(distinct id_card_hash) from collect_roster "
|
||||
"where tenant_id=1 and deleted=0;").strip() or 0)
|
||||
allc = int(sql("select count(distinct id_card_hash) from collect_roster "
|
||||
"where deleted=0;").strip() or 0)
|
||||
u1 = (body_of("GET", "/api/v1/tenants/1/usage", ops_token).get("data") or {})
|
||||
check("⭐ 平台看到的「1 号学校学生数」= 库里 tenant_id=1 的去重人数,**不是全平台**",
|
||||
u1.get("studentCount") == own,
|
||||
"接口=%s 库里 tenant_id=1 去重=%s 全平台去重=%s(接口等于后者就说明过滤被算丢了)"
|
||||
% (u1.get("studentCount"), own, allc))
|
||||
un = (body_of("GET", "/api/v1/tenants/%d/usage" % ntid, ops_token).get("data") or {})
|
||||
check("⭐ 新租户看到的是**它自己**那一行(顺带证明不是恒 0)",
|
||||
un.get("studentCount") == 1, "新租户 studentCount=%s" % un.get("studentCount"))
|
||||
check("全平台确实不止 1 个(否则上面那条断言是空转的)", allc > own,
|
||||
"全平台=%s 一号学校=%s" % (allc, own))
|
||||
|
||||
# ── 3. 文件夹具 ──
|
||||
print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)")
|
||||
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
|
||||
@@ -476,6 +581,24 @@ def _make_jpeg():
|
||||
f.write(base64.b64decode(b64))
|
||||
|
||||
|
||||
_TENANT_TABLES = None
|
||||
|
||||
|
||||
def _tables_with_tenant_id():
|
||||
"""
|
||||
带 `tenant_id` 的表 —— **动态枚举**,不手抄表名。
|
||||
|
||||
手抄的清单在"新增了一张业务表"那天就开始漏,而漏掉的那张表恰好很难被注意到
|
||||
(租户没了、行还在)。`dev-clean-e2e.py` 是同一口径。
|
||||
"""
|
||||
global _TENANT_TABLES
|
||||
if _TENANT_TABLES is None:
|
||||
out = sql("SELECT table_name FROM information_schema.columns "
|
||||
"WHERE table_schema='public' AND column_name='tenant_id'")
|
||||
_TENANT_TABLES = [t.strip() for t in out.splitlines() if t.strip()]
|
||||
return _TENANT_TABLES
|
||||
|
||||
|
||||
def cleanup():
|
||||
for tid in CREATED_TASK_IDS:
|
||||
R.sql("delete from collect_validation_issue where task_id=%d;" % tid)
|
||||
@@ -489,12 +612,30 @@ def cleanup():
|
||||
R.sql("delete from collect_task where id=%d;" % tid)
|
||||
for name in CREATED_ORG_NAMES:
|
||||
R.sql("delete from sys_org where org_name = '%s';" % name)
|
||||
R.sql("update sys_tenant set contact_name='张校长' where id=1;")
|
||||
|
||||
# 用量夹具(本脚本往 E2E 租户里插的那一行名册)
|
||||
R.sql("delete from collect_roster where id_card_hash like '%s%%';" % USAGE_FIXTURE_HASH_PREFIX)
|
||||
|
||||
# 本脚本建的 E2E 租户:连带它的用户 / 角色 / 组织一起删。
|
||||
# ⚠️ 日志行要单独删 —— 它们的 `tenant_id` 是 **0**(平台身份),
|
||||
# 用 tenant_id 清是清不掉的,得按 target 清。
|
||||
for tid in CREATED_TENANT_IDS:
|
||||
R.sql("delete from sys_operation_log where target_type='TENANT' and target_id=%d;" % tid)
|
||||
for table in _tables_with_tenant_id():
|
||||
R.sql("delete from %s where tenant_id=%d;" % (table, tid))
|
||||
R.sql("delete from sys_tenant where id=%d;" % tid)
|
||||
del CREATED_TENANT_IDS[:]
|
||||
|
||||
# 联系人 / 联系电话复原:本条用例改过它们(手机号改完是**密文**,
|
||||
# 所以复原成改动前那个明文值 —— 顺带保留"库里有一行历史明文"这个现状,
|
||||
# 那正是详情接口的容错分支要处理的输入)
|
||||
R.sql("update sys_tenant set contact_name='张校长', contact_phone='13800138000' where id=1;")
|
||||
R.sql("delete from collect_message where user_id in (997201, 997202, 997299);")
|
||||
for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
print(" 已清理:任务 / 材料文件 / 测试组织 / 租户联系人复原 / 临时文件")
|
||||
print(" 已清理:任务 / 材料文件 / 测试组织 / E2E 租户与用量夹具 / "
|
||||
"租户联系人复原 / 临时文件")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -24,11 +24,12 @@
|
||||
|
||||
```bash
|
||||
python scripts/verify-all.py # 全部(跳过需要外部前置的,并说明)
|
||||
python scripts/verify-all.py --with-e2e # 连 37 个脚本的接口断言一起跑(需要后端在跑)
|
||||
python scripts/verify-all.py --with-e2e # 连 38 个脚本的接口断言一起跑(需要后端在跑)
|
||||
python scripts/verify-all.py --quick # 只跑不需要任何前置的五项
|
||||
```
|
||||
|
||||
> 脚本数与断言总数会随需求变化(2026-10 需求收敛后是 **37 个脚本**,断言总数以本命令
|
||||
> 脚本数与断言总数会随需求变化(2026-10 需求收敛后是 **37 个脚本**,加上
|
||||
> `e2e-m3-flow.py` 后是 **38 个**;断言总数以本命令
|
||||
> 实际输出为准 —— 别再往文档里抄一个会过期的固定数字)。
|
||||
|
||||
退出码:0 = 全过;1 = 有失败(**跳过不算失败**,但会明确列出来)。
|
||||
@@ -130,7 +131,7 @@ def main():
|
||||
results.append(("接口断言(E2E)", None, msg))
|
||||
print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg))
|
||||
elif not args.with_e2e:
|
||||
msg = "**跳过**:要跑就加 `--with-e2e`(37 个脚本、约 10 分钟)"
|
||||
msg = "**跳过**:要跑就加 `--with-e2e`(38 个脚本、约 10 分钟)"
|
||||
results.append(("接口断言(E2E)", None, msg))
|
||||
print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg))
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user