docs: 清掉三处"会静默过期"的小账 + check-test-profile 加固(F-98)

一、附录 A 从"手抄表"改成"可再生成的命令"
   check-api-parity.py 加 --extra,现算"代码有、契约 §4 没有"的接口 = 41 个
   (旧手抄表写 83/62/21,已差近一倍,而且没人会记得回来改它)。
   并按"谁去改什么"分六类 —— 只有 E 类(8 个)要客户定口径,其余是文档侧补记。

二、F-6 已修:frontend-pc 的 Mock 说明
   AGENTS.md 那句改正之外,连带修了 .agents/skills/project-api/SKILL.md ——
   它整篇仍以 vite-plugin-mock / mock/** 为主线,而该项目里 mock/ 目录与插件都已删除。
   只改指路牌、不改路的尽头,等于没改。

三、docs/03 两处文档内部口径冲突加 ⚠️ 注(不改口径,只把冲突摆明)
   §2 notifyBizType 的 5/6 与 §13.5、落库值不一致 → 实现以 §13.5 为准(催报去重按 5);
   §3.6/§9.5 的 collect_file.source 字段表(入口来源)与建表注释(谁传的)冲突 →
   实现以建表口径为准。两处都写明"待客户确认",见 F-30 / F-38 / F-39。

四、check-test-profile.py 加固(新增 F-98)
   · 子进程输出落盘(不再用没人读的 PIPE):失败时能看见"停在哪一行",
     成功时打出启动用时(基线 ~10 秒 / 72 行 / 9.5KB);
   · 开跑前按 jar + profile 清残留 JVM,且保留 dev 后端(E2E 正打着它);
   · 等待期间每 15 秒报一次进度,卡住时不留沉默。
   教训:随机端口这个防撞设计把残留 JVM 藏起来了 —— 撞不到端口就永远发现不了,
   于是表现成"时好时坏",而失败时连日志都读不到。

验证:python scripts/verify-all.py --with-e2e → 通过 8 / 失败 0 / 跳过 0
      (E2E 36 脚本 / 1548 断言 / 278s;prodguard 9/9;test profile 能起)
      python scripts/verify-docs.py → 22 项全部通过
This commit is contained in:
2026-09-30 09:10:34 +08:00
parent bfd7676974
commit db6a51d617
8 changed files with 258 additions and 58 deletions
+3 -1
View File
@@ -56,7 +56,9 @@ pnpm typecheck # vue-tsc pnpm lint # eslint
- 用 **pnpm**(仓库里是 `pnpm-lock.yaml`;别 `npm install`,会多出 package-lock.json) - 用 **pnpm**(仓库里是 `pnpm-lock.yaml`;别 `npm install`,会多出 package-lock.json)
- dev 通过 vite proxy 把 `/api` 转发到 `http://localhost:8080`(见 `.env.development`), - dev 通过 vite proxy 把 `/api` 转发到 `http://localhost:8080`(见 `.env.development`),
**没有 Mock** —— `frontend-pc/AGENTS.md` 里"开发默认 Mock"已过时 **没有 Mock**(`mock/` 与 `vite-plugin-mock` 都已删除)→ 后端不起着就没有数据。
⚠️ `frontend-pc/.agents/skills/project-api/SKILL.md` 仍以 Mock 为主线(Gi 模板原文),
该文件开头已加"本仓库不用 Mock"的说明 —— 看它时先看那段
- 我们的接口模块都在 `src/apis/xt/`;`apis/system|user|person|cate|area|test` 是 Gi 模板自带的,别往那儿加 - 我们的接口模块都在 `src/apis/xt/`;`apis/system|user|person|cate|area|test` 是 Gi 模板自带的,别往那儿加
- **PC 前端有独立的规范来源:`frontend-pc/.agents/`**(`AGENTS.md` + `skills/*`)。 - **PC 前端有独立的规范来源:`frontend-pc/.agents/`**(`AGENTS.md` + `skills/*`)。
改那里的代码前按它的表加载对应 skill,不要另起一套写法 改那里的代码前按它的表加载对应 skill,不要另起一套写法
+25 -3
View File
@@ -256,11 +256,22 @@
| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 | | `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 |
| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 | | `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 |
| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 | | `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 |
| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` 导出完成 / `6` 催报 / `7` 加入名单审批 | | `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` 导出完成 / `6` 催报 / `7` 加入名单审批 ⚠️ 见下方注 |
| `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 | | `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 |
| `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 | | `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
| `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 | | `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 |
> ⚠️ **`notifyBizType` 的 `5` / `6` 在本文件内部是不一致的**:本表写「`5` 导出完成 / `6` 催报」,
> 而 §13.5 消息中心的示例(`2` 临期提醒 / `3` 驳回)与落库值 `collect_notify_record.biz_type`
> 用的是「**`5` 催报 / `6` 导出完成**」。
>
> **实现以 §13.5 为准**(代码里是 `CollectMessage.BIZ_URGE = 5` / `BIZ_EXPORT = 6`,
> 催报去重按 `5` 走)。这不是文案问题:若按本表实现,催报会被写成"导出完成",
> **去重会静默失效**(老师点两下学生收到两条),而排查时从数据上完全看不出是码对不上。
>
> 需要客户确认以哪一边为准;若确认以本表为准,要一起改的是两个常量 + 已落库的历史数据。
> 记录见《08-完成度盘点与问题清单》**F-30**。
--- ---
## §3 通用数据结构 ## §3 通用数据结构
@@ -461,9 +472,19 @@
| `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) | | `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) |
| `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` | | `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` |
| `issues` | array | — | 该文件的 `ValidationIssueVO[]` | | `issues` | array | — | 该文件的 `ValidationIssueVO[]` |
| `source` | int | `source` | `1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传 | | `source` | int | `source` | `1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传 ⚠️ 见下方注 |
| `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** | | `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** |
> ⚠️ **`collect_file.source` 有两套互相冲突的口径**:本表写的是"从哪个入口选的"
> (`1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传),而建表注释(`V1__business_tables.sql`)
> 与实现(`CollectFile.SOURCE_*`)写的是"**谁传的**":
> `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入。
>
> **实现以建表注释为准**:§10.6 要求代传必须可追溯,要回答的是"**谁**替他传的、
> **为什么**",而不是"他从哪个入口选的"—— 后者在同一个学生身上还会随操作路径变化。
> 两个口径都是 4 个值、含义完全不同,所以"照字段表写"会得到一份**看起来有数据、
> 但答案是错的**统计。记录见《08-完成度盘点与问题清单》**F-38 / F-39**。
**`ownership.verdict` 取值**(对应 PRD §6.1 归属裁决矩阵) **`ownership.verdict` 取值**(对应 PRD §6.1 归属裁决矩阵)
| verdict | 含义 | blockSubmit | 学生端表现 | | verdict | 含义 | blockSubmit | 学生端表现 |
@@ -1784,7 +1805,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
| `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) | | `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) |
| `fileSize` | int64 | ✅ | 字节 | | `fileSize` | int64 | ✅ | 字节 |
| `contentType` | string | ✅ | MIME | | `contentType` | string | ✅ | MIME |
| `source` | int | — | `1` 聊天记录 / `2` 相册 / `3` 手机文件 | | `source` | int | — | `1` 聊天记录 / `2` 相册 / `3` 手机文件(⚠️ 落库口径见 §3.6 注:实际存的是「谁传的」= 1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入) |
| `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) | | `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) |
**响应** **响应**
@@ -2862,6 +2883,7 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
| 版本 | 日期 | 变更 | | 版本 | 日期 | 变更 |
|---|---|---| |---|---|---|
| **V2.5** | **2026-09-30** | **两处"文档内部口径冲突"加注(不改口径,只把冲突摆到明处)**:① §2 枚举 `notifyBizType` 的 `5`/`6`(本文件写「5 导出完成 / 6 催报」)与 §13.5 消息中心示例、落库值 `collect_notify_record.biz_type`(「**5 催报 / 6 导出完成**」)不一致 —— **实现以 §13.5 为准**,需要客户确认以哪边为准(若以 §2 为准,要同时改两个常量与已落库数据);② §3.6 与 §9.5 的 `collect_file.source` 字段表写的是"入口来源"(1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传),而建表注释与实现(`CollectFile.SOURCE_*`)是"**谁传的**"(1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入)—— **实现以建表口径为准**(§10.6 要求代传可追溯,要回答的是"谁替他传的")。两处均已在原文加 ⚠️ 注。记录见《08-完成度盘点与问题清单》**F-30 / F-38 / F-39**。 |
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 | | **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 |
| **V1.2** | **2026-09-28** | **错误码规范化**:`40007b` / `40007c` 带字母后缀,与 §1.3「`code` 为数字」的约定冲突(JSON 里会变成字符串,前端 `code === 0` 判断失效)。改为纯数字:**`40007b` → `40071`**(门禁限流)、**`40007c` → `40072`**(名单项已被绑定)。总码数不变,仍 **58 个**。 | | **V1.2** | **2026-09-28** | **错误码规范化**:`40007b` / `40007c` 带字母后缀,与 §1.3「`code` 为数字」的约定冲突(JSON 里会变成字符串,前端 `code === 0` 判断失效)。改为纯数字:**`40007b` → `40071`**(门禁限流)、**`40007c` → `40072`**(名单项已被绑定)。总码数不变,仍 **58 个**。 |
| V1.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义;**补齐追溯矩阵审计发现的 10 个缺口(新增 21 个接口,含 1 个登记遗漏)**;错误码由 20 个扩至 **50 个**;新增 §2 枚举全集、§3 通用数据结构、§15 错误码全集 | | V1.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义;**补齐追溯矩阵审计发现的 10 个缺口(新增 21 个接口,含 1 个登记遗漏)**;错误码由 20 个扩至 **50 个**;新增 §2 枚举全集、§3 通用数据结构、§15 错误码全集 |
+47 -32
View File
@@ -163,10 +163,15 @@
2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子; 2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。 3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。
### F-4【P1 · 文档与代码不一致】契约里没有的 19 个接口 ### F-4【P1 · 文档与代码不一致】契约里没有的接口(首次盘点 19 个,现在 41 个)
- **现象**(代码有、03 文档 §4 清单没有):`GET /mp/scene-context`、`GET /auth/captcha`、`GET /auth/public-key`、`GET /mp/h5/authorize|callback|stub-login`(代替 `/mp/login`)、`PUT /users/{id}/status`、`GET /mp/files/submissions/{submissionId}`、`GET /mp/files/{id}/url|content`、`GET /audit/files/{id}/url|content`、`POST /tasks/{id}/materials/apply-template`、`GET /material-catalog`、`PUT /tasks/{id}/plan-scope`(清单里只列了 GET)。 - **现象**(代码有、03 文档 §4 清单没有):最典型的是 `GET /mp/scene-context`、
`GET /auth/captcha`、`GET /mp/h5/authorize|callback|stub-login`(代替 `/mp/login`)、
`POST /mp/files/upload`(代替 `upload-token` + 直传)、`GET /material-catalog`……
完整清单**现跑**:`python scripts/check-api-parity.py --extra`(当前 **41 个**)。
- **影响**:契约不是"当前系统长什么样",导致每次核对都要重新考古;前端/测试无法以文档为准。 - **影响**:契约不是"当前系统长什么样",导致每次核对都要重新考古;前端/测试无法以文档为准。
⚠️ 更要紧的是:**这个数会继续长**(每加一处界面必需接口就 +1),
所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。
- **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py`(它逐节核对接口数,加节时注意别改动 §4 的声称数)。 - **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py`(它逐节核对接口数,加节时注意别改动 §4 的声称数)。
### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析" ### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析"
@@ -175,11 +180,19 @@
- **影响**:本地演示/截图时列表里全是 `e2e-*` 名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。 - **影响**:本地演示/截图时列表里全是 `e2e-*` 名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。
- **建议**:`scripts/dev-clean-e2e.py` 增加一节"按名字前缀删测试租户与账号"(默认 dry-run,`--yes` 才真删),并在 `AGENTS.md` 里把"跑完 E2E 顺手清一次"写成固定动作。 - **建议**:`scripts/dev-clean-e2e.py` 增加一节"按名字前缀删测试租户与账号"(默认 dry-run,`--yes` 才真删),并在 `AGENTS.md` 里把"跑完 E2E 顺手清一次"写成固定动作。
### F-6【P2 · 文档误导】`frontend-pc/AGENTS.md` 里的 Mock 说明已过时 ### F-6【P2 · 文档误导】`frontend-pc/AGENTS.md` 里的 Mock 说明已过时 —— **已修(2026-09-30)**
- **现象**:该文件写「开发默认 Mock(`/mock` 前缀)」,而 `AGENTS.md` 已注明"已过时,没有 Mock,dev 走 vite proxy 到 8080"。 - **现象**:该文件写「开发默认 Mock(`/mock` 前缀)」,而 `AGENTS.md` 已注明"已过时,没有 Mock,dev 走 vite proxy 到 8080"。
- **影响**:AI/新人按旧说明写 Mock 代码或排查"为什么没有 `/mock`"。 - **影响**:AI/新人按旧说明写 Mock 代码或排查"为什么没有 `/mock`"。
- **建议**:直接改 `frontend-pc/AGENTS.md` 那段为"联调真实后端:`pnpm dev` + vite proxy → `http://localhost:8080`;无 Mock"。 - **已修**(按下面的建议做的,但**多修了一处**,见"连带"):
`frontend-pc/AGENTS.md` 的 `pnpm dev` 注释与"开发默认 Mock"那句,
改为"`/api` 代理到 `http://localhost:8080`,没有 Mock → **后端不起着就没有数据**",
并写明我们的接口模块在 `src/apis/xt/`。
- **连带(这才是根子)**:`frontend-pc/.agents/skills/project-api/SKILL.md` 整篇仍以
`vite-plugin-mock` / `mock/**` / `defineMock` 为主线 —— 而**该项目里 `mock/` 目录与插件都已删除**,
照它做会去建一个没人加载的 `mock/` 目录。只改 `AGENTS.md` 那句等于"把指路牌改了、
但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。
核实方式:`frontend-pc/` 下无 `mock/`,`package.json` 与 `vite.config.ts` 里都没有 mock 关键字。
### F-7【P2 · 一致性】撤回的动作码与适用状态 ### F-7【P2 · 一致性】撤回的动作码与适用状态
@@ -397,6 +410,7 @@
| F-95 | ⚠️ **手册初稿里有两处"按设想的系统写",而不是"按真的系统写"**:① 我写了「同一张图重复传会被自动识别(秒传)」—— 而 `/mp/files/upload` **只计算 `file_hash`、没有去重查询**(秒传逻辑在 `/mp/files/upload-token` 里,而 F-72 已记过:上传接口并不消费它)。手册这么写会让学生以为重复传不占份数,然后撞上份数上限;② 我写了「代传**要**填原因(这是审计要求)」—— 而 `proxyReason` 是**选填**,手册说"必须填"会让老师以为界面坏了(因为不填也能提交) | 两处都改成与实现一致的说法。**并把这件事故化**:新增 `scripts/check-manual.py`,把"手册引用的错误码与提示文案是否真的存在"变成机械检查并进 CI —— 因为**手册是文档、文案在代码里**,改一次文案手册就静默失准,而没有任何东西会红(与 F-82「首页在说一件不真的事」同一类)。⚠️ 但要说清它**查不到什么**:它拦的是"手册写了代码里没有的东西",**拦不住"手册漏了新增的东西"** —— 那仍然要靠人 | | F-95 | ⚠️ **手册初稿里有两处"按设想的系统写",而不是"按真的系统写"**:① 我写了「同一张图重复传会被自动识别(秒传)」—— 而 `/mp/files/upload` **只计算 `file_hash`、没有去重查询**(秒传逻辑在 `/mp/files/upload-token` 里,而 F-72 已记过:上传接口并不消费它)。手册这么写会让学生以为重复传不占份数,然后撞上份数上限;② 我写了「代传**要**填原因(这是审计要求)」—— 而 `proxyReason` 是**选填**,手册说"必须填"会让老师以为界面坏了(因为不填也能提交) | 两处都改成与实现一致的说法。**并把这件事故化**:新增 `scripts/check-manual.py`,把"手册引用的错误码与提示文案是否真的存在"变成机械检查并进 CI —— 因为**手册是文档、文案在代码里**,改一次文案手册就静默失准,而没有任何东西会红(与 F-82「首页在说一件不真的事」同一类)。⚠️ 但要说清它**查不到什么**:它拦的是"手册写了代码里没有的东西",**拦不住"手册漏了新增的东西"** —— 那仍然要靠人 |
| F-96 | ⚠️ **本轮我连续犯了三个"没断言就往下走"的错**,形态不同、根因相同:① **文本补丁静默没生效**:`str.replace` 没命中时不报错、不返回失败,脚本照样打印"成功",而文件一个字没改 —— 随后的编译错("方法不存在")看起来像另一个问题;② **夹具构造失败却继续往下走**:`with_checksum` 传错位数后,失败发生在 300 行之外的名册导入报告里("当前 19 位");③ **`R.call` 的返回值没查 `code`**:建草稿失败时 `data` 是 `null`,报的是 `TypeError: 'NoneType' object is not subscriptable`,真正原因一个字都没打 | ①改成"每处替换都断言命中"(`must_replace`),并**回读校验**;②把 helper 改成**参数不合法就抛**(治本,前两次我只改调用点);③每个 `R.call` 先查 `code` 并把 `msg` 带进断言信息。**根因是同一条:把"我以为发生了"当成"确实发生了"** —— 而这三处的共同代价是**把排查引向错误的方向**(比直接报错贵得多) | | F-96 | ⚠️ **本轮我连续犯了三个"没断言就往下走"的错**,形态不同、根因相同:① **文本补丁静默没生效**:`str.replace` 没命中时不报错、不返回失败,脚本照样打印"成功",而文件一个字没改 —— 随后的编译错("方法不存在")看起来像另一个问题;② **夹具构造失败却继续往下走**:`with_checksum` 传错位数后,失败发生在 300 行之外的名册导入报告里("当前 19 位");③ **`R.call` 的返回值没查 `code`**:建草稿失败时 `data` 是 `null`,报的是 `TypeError: 'NoneType' object is not subscriptable`,真正原因一个字都没打 | ①改成"每处替换都断言命中"(`must_replace`),并**回读校验**;②把 helper 改成**参数不合法就抛**(治本,前两次我只改调用点);③每个 `R.call` 先查 `code` 并把 `msg` 带进断言信息。**根因是同一条:把"我以为发生了"当成"确实发生了"** —— 而这三处的共同代价是**把排查引向错误的方向**(比直接报错贵得多) |
| F-97 | ⚠️ **我断言错了字段名,于是"缩略图功能"看起来没生效**:`FileUrl` 有 `url`(原图)与 `thumbUrl`(缩略图)两个字段,而我的验证脚本去检查 `d2["url"]` 里有没有 `imageView2` —— 按设计它**永远是原图链接**,所以那个断言必然为 false;更糟的是它连着把后面两条断言一起带错了(下载的是原图,于是"没变小") | 改成检查 `thumbUrl`,并**加一条反向断言**:`url` 不该带处理参数、`thumbUrl` 该带。⚠️ 这与 F-77(用错 CSS 选择器,差点修一个不存在的 bug)**是同一类**:**断言之前先确认"你在量的那个东西是对的"**。这次的隐蔽之处是字段名很像(`url` vs `thumbUrl`),而且接口确实返回 200、数据确实存在 —— 一切看起来都正常,只有断言是错的 | | F-97 | ⚠️ **我断言错了字段名,于是"缩略图功能"看起来没生效**:`FileUrl` 有 `url`(原图)与 `thumbUrl`(缩略图)两个字段,而我的验证脚本去检查 `d2["url"]` 里有没有 `imageView2` —— 按设计它**永远是原图链接**,所以那个断言必然为 false;更糟的是它连着把后面两条断言一起带错了(下载的是原图,于是"没变小") | 改成检查 `thumbUrl`,并**加一条反向断言**:`url` 不该带处理参数、`thumbUrl` 该带。⚠️ 这与 F-77(用错 CSS 选择器,差点修一个不存在的 bug)**是同一类**:**断言之前先确认"你在量的那个东西是对的"**。这次的隐蔽之处是字段名很像(`url` vs `thumbUrl`),而且接口确实返回 200、数据确实存在 —— 一切看起来都正常,只有断言是错的 |
| F-98 | ⚠️ **"随机端口"这个防撞设计带来一个反直觉的副作用**:`check-test-profile.py` 每次取**随机空闲端口**,于是上一次失败留下的 JVM **永远撞不到**这个端口、也就永远不会被发现 —— 它会一直占着内存与 CPU,直到某一次"怎么都起不来"(实测:连跑几轮后本机攒了 5~7 个 java,启动从 10 秒变成 180 秒超时;而**下一次跑又好了**,这正是"时好时坏"的来源,很容易被归因成"配置有问题")。同时那次失败**没法排查**:子进程输出走 `PIPE` 而没人读,事后才读又已被 kill 截断,最后只剩一句"180 秒内没探活成功" | ①开跑前**按 jar + profile 清残留**(只杀本项目的 jar,且**保留 dev 后端** —— E2E 正打着它,顺手杀掉等于把后面的检查全废掉);②子进程输出**落盘**(`backend/_test-profile.txt`,已被 `.gitignore` 的 `backend/_*.txt` 覆盖),失败时打印"停在哪一行 + 关键字命中 + 尾部 20 行",成功时打印**启动用时**(基线 ~10 秒 / 72 行 / 9.5KB,一旦变成几十秒先怀疑本机负载);③等待期间每 15 秒报一次进度,**卡住时不留沉默**。**教训:给"防撞"加一层机制时,要问一句"那它把什么东西藏起来了"** |
--- ---
@@ -435,36 +449,35 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核
--- ---
## 九、附录 A · 代码有、契约清单没有的接口(F-4 的待办底稿) ## 九、附录 A · 代码有、契约 §4 清单没有的接口(F-4 的待办底稿)
> 用途:修 §17 补充节时**照着抄**,不用再重新统计一遍。 > **这份清单是跑出来的,不是抄出来的:**
> 生成方式:`python scripts/check-api-parity.py` 打印的"代码注册接口"(当前 **83 个**), >
> 减去能与 §4 清单对上的 **62 个**,差集恰好 **21 个**(下方逐条已核对; > ```bash
> 首次盘点 19 个 + M6 导出新增的 A20/A21)。 > python scripts/check-api-parity.py --extra
> ```
>
> 当前口径:代码共注册 **161** 个接口,契约 §4 声称 **121** 个(其中 **120** 个已在代码里),
> 差集 **41** 个。
>
> ⚠️ 上一版这里是"人工做一次差集、手抄成表"(写着 83 / 62 / 21)——
> 此后每加一个接口它就悄悄过期一次,最后差了近一倍,而且**没人会记得回来改它**。
> 手抄件会过期,命令不会。所以下面只留"为什么它们不在契约里"的分类结论;
> **要逐条清单时现跑那条命令。**
| # | 接口 | 说明 | ### 9.1 这 41 个按其「为什么不在契约里」分成六类
|:--:|---|---|
| A1 | `GET /api/v1/mp/scene-context` | 匿名扫码落地页查询(scene 三段式,只返回最少信息 + 限流) | | 类 | 个数 | 为什么契约里没有 | 该怎么办 |
| A2 | `GET /api/v1/auth/captcha` | 图形验证码(连续失败 2 次后必填,§5.1 正文提过、清单未列) | |:--|:--:|---|---|
| A3 | `GET /api/v1/auth/public-key` | 密码 RSA 加密传输用的公钥 | | **A 材料通道:一步 multipart / 审核端独立一条** | 7 | 契约写的是"先传文件拿 `fileId` 再挂载"与 `upload-token` + OSS 直传;实现是**一步 multipart**,且审核端与学生端**各一条通道**(审核端要看防伪纹,学生端只看自己的) | §17 注明"实现按此写法"。**不要**改实现去迁就文档:直传要把密钥交给前端,反而更不安全 |
| A4 | `GET /api/v1/mp/h5/authorize` | H5 微信网页授权跳转(代替 `POST /mp/login` 的学生入口) | | **B 微信授权链路代替 `POST /mp/login`** | 4 | H5 走网页授权跳转(`authorize` → `callback`,token 放 URL fragment)、匿名落地页要 `scene-context`、dev 打桩登录只用于联调 | §17 注明;`stub-login` 要写明**仅非生产可用**(代码里另有一道 profile 判断) |
| A5 | `GET /api/v1/mp/h5/callback` | 授权回调(token 放 URL fragment) | | **C 签名 URL:契约只定义了"取下载信息"** | 1 | `GET /exports/{id}/file` 才是真正下文件那条;§12.6 只定义了取下载信息 | 补进 §17,并写明 **URL 有有效期** —— 不能入库、不能发出去让人明天再打开 |
| A6 | `GET /api/v1/mp/h5/stub-login` | **仅 dev** 打桩登录(`stub-enabled` + profile 双重保护) | | **D 界面与接口必需、清单漏列** | 17 | 按钮要先知道"能不能点"(`exports/precheck`、`exports/preview`、`material-catalog`)、匿名出图、批量通过、教师端原因字典、名册项"标记不参与"这个可逆替代(`roster/{rid}/deactivate`)…… | **直接补进 §17 清单**,这类没有口径争议 |
| A7 | `PUT /api/v1/users/{id}/status` | 启用/停用(清单里只有 `DELETE` 停用) | | **E 契约链条本身断在第一步(要客户确认)** | 8 | `§12.7` 模板:`GET /notify/templates` 返回的内置模板**没有 id**,而 `PUT …/{id}` 要 id → 用户点"编辑"时根本拿不出 id(F-52);`§14` 的**接入方管理整块没写**(F-67) | 这 8 条**不是补文档能解决的**,要客户定口径(见 F-51 / F-52 / F-67) |
| A8 | `GET /api/v1/tenants/{id}` | 租户详情(清单只列了列表/开通/修改/用量) | | **F 运维与调试动作** | 4 | 定时任务在测试里"等不起",所以留了手工入口(F-37):`schedules/run`、`exports/cleanup-run`、`cleanup/jobs/json`、`cleanup/jobs/parse-preview` | 补进 §17,标注"运维/调试用"(权限走运营侧的 `log:view:all`) |
| A9 | `POST /api/v1/mp/files/upload` | 实际的上传入口(替代 `upload-token` + OSS 直传) |
| A10 | `GET /api/v1/mp/files/{id}/url` | 取签名预览链接(学生端) | > 合计 7 + 4 + 1 + 17 + 8 + 4 = **41**。
| A11 | `GET /api/v1/mp/files/{id}/content` | 直出内容(学生端预览) | > 分类是按"谁去改什么"分的 —— 需要客户回答的只有 **E** 那一栏,其余三类是文档侧的补记。
| A12 | `GET /api/v1/mp/files/submissions/{submissionId}` | 按提交单列出材料 |
| A13 | `GET /api/v1/audit/files/{id}/url` | 审核端签名链接(单独通道,不复用学生端) |
| A14 | `GET /api/v1/audit/files/{id}/content` | 审核端直出内容 |
| A15 | `POST /api/v1/audit/submissions/batch-approve` | 批量通过(§11.3 标题里带过、§4.7 清单未单列) |
| A16 | `POST /api/v1/tasks/{id}/materials/apply-template` | 应用内置材料模板(幂等) |
| A17 | `GET /api/v1/material-catalog` | 材料目录(码/档位/格式/模板/体积范围) |
| A18 | `GET /api/v1/qrcodes/{scene}` | 匿名出图(清单只列了 `GET /tasks/{id}/qrcode`) |
| A19 | `GET /api/v1/mp/teacher/audit/reason-codes` | 教师端驳回原因字典(§11.7 有 PC 版,移动端未列) |
| A20 | `GET /api/v1/exports/precheck` | **导出前置检查(只检查不创建)** —— 界面靠它决定"开始导出"按钮是否可点(决定 D4) |
| A21 | `GET /api/v1/exports/{id}/file` | 导出包的**流式下载**(§12.6 只定义了取下载信息;真正下文件走这条) |
--- ---
@@ -499,6 +512,8 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核
| 2026-09-30 | **压测(P2-2)**:新增 `scripts/load-test.py`(四场景、按端点统计、可调规模);**修掉 F-92 —— 门禁 IP 限流曾把整间教室挡住(60 人同 IP 只过 20 个)**,改成"只对失败计数";新增 F-93(压测会打到真实七牛空间)/F-94(**我自己的三个度量错误**:吞吐公式、混合 p95、用并发数字推断每行成本);`docs/09-部署与运维.md` 加 §9 压测记录 | | 2026-09-30 | **压测(P2-2)**:新增 `scripts/load-test.py`(四场景、按端点统计、可调规模);**修掉 F-92 —— 门禁 IP 限流曾把整间教室挡住(60 人同 IP 只过 20 个)**,改成"只对失败计数";新增 F-93(压测会打到真实七牛空间)/F-94(**我自己的三个度量错误**:吞吐公式、混合 p95、用并发数字推断每行成本);`docs/09-部署与运维.md` 加 §9 压测记录 |
| 2026-09-30 | **用户手册与客服话术(P2-4)**:新增 `docs/10-用户手册与客服话术.md`(三角色手册 + 标准回答 + 培训提纲 + 上线检查单);新增 `scripts/check-manual.py`(手册引用的错误码/文案 ↔ 代码,已进 CI);新增 F-95(**手册初稿两处"按设想的系统写"**:秒传不存在、代传原因其实选填);docs 一致性检查从 21 项增至 22 项 | | 2026-09-30 | **用户手册与客服话术(P2-4)**:新增 `docs/10-用户手册与客服话术.md`(三角色手册 + 标准回答 + 培训提纲 + 上线检查单);新增 `scripts/check-manual.py`(手册引用的错误码/文案 ↔ 代码,已进 CI);新增 F-95(**手册初稿两处"按设想的系统写"**:秒传不存在、代传原因其实选填);docs 一致性检查从 21 项增至 22 项 |
| 2026-09-30 | **缩略图(P2-7)+ H5 独立 dev server(P2-9)**:`StorageService.thumbUrl` / `FileUrl.thumbUrl` / 审核端 `?thumb=true`;`frontend-mp` 加 `/api` 代理与 `VITE_API_BASE`;新增 `scripts/e2e-m9-thumb.py`(12 条,打到真实七牛);**同步 AGENTS.md 里"H5 没有独立 dev server"那句过期说明**;新增 F-96(**三个"没断言就往下走"**:补丁静默无效 / 夹具静默失败 /不查 code)/F-97(**断言错了字段名**,与 F-77 同类) | | 2026-09-30 | **缩略图(P2-7)+ H5 独立 dev server(P2-9)**:`StorageService.thumbUrl` / `FileUrl.thumbUrl` / 审核端 `?thumb=true`;`frontend-mp` 加 `/api` 代理与 `VITE_API_BASE`;新增 `scripts/e2e-m9-thumb.py`(12 条,打到真实七牛);**同步 AGENTS.md 里"H5 没有独立 dev server"那句过期说明**;新增 F-96(**三个"没断言就往下走"**:补丁静默无效 / 夹具静默失败 /不查 code)/F-97(**断言错了字段名**,与 F-77 同类) |
| 2026-09-30 | **对外待决事项归拢 + 一键交付自检**:`docs/01` 新增 **E 组·第二轮待确认**(E1 `union_id` 是"H5 与小程序不能同时上线"的阻断项,E2 D3 限流口径更新、E3 二期时点,D3 那行划掉);§四 的每条卡点补上"**找谁 / 编号**";交接摘要新增 §一·二十八(坐标刷新 + 这 20 多轮最该记住的 7 条);新增 `scripts/verify-all.py`(一条命令跑完交付自检,**跳过项如实列出、不算失败**)。全量回归 **36 脚本 / 1548 断言 / 0 失败** |
| 2026-09-30 | **文档小账清理 + `check-test-profile` 加固**:① **附录 A 改为"可再生成"** —— `check-api-parity.py` 加 `--extra` 现算"代码有、契约 §4 没有"的接口(**41 个**;旧手抄表写 83/62/21,已差近一倍),并按"谁去改什么"分六类(只有 E 类要客户定口径);② **F-6 已修**:`frontend-pc/AGENTS.md` 的 Mock 说明改正,**并连带修 `.agents/skills/project-api/SKILL.md`**(整篇仍以 `vite-plugin-mock` 为主线,而 `mock/` 目录与插件都已删除 —— 只改指路牌不改路的尽头等于没改);③ `docs/03` 给 **§2 `notifyBizType` 的 5/6** 与 **§3.6/§9.5 的 `source`** 加 ⚠️ 注(**不改口径**,只把文档内部冲突摆明 + 指出实现以哪边为准,待客户确认);④ `check-test-profile.py` 改为**日志落盘 + 按 jar/profile 清残留 JVM** —— 随机端口让残留**永远撞不到端口**,于是静静攒着,直到某次"怎么都起不来"(新增 F-98) |
+43 -1
View File
@@ -2286,7 +2286,7 @@ M1 规则模板与任务克隆 → M2 报考范围与名册改删 → M3 提交
**§14 开放接口(认定机构对接,HMAC)** → 小程序登录与组织导入 → 工作台数据看板 → **§14 开放接口(认定机构对接,HMAC)** → 小程序登录与组织导入 → 工作台数据看板 →
CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。 CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。
**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 97 条编号问题(F-1~F-97)。 **每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 98 条编号问题(F-1~F-98)。
### 这 20 多轮里最该记住的几条 ### 这 20 多轮里最该记住的几条
@@ -2337,3 +2337,45 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
⚠️ **不要在没有这些输入的情况下去"顺手实现"**:M4 的识别链路一旦按某家供应商写死, ⚠️ **不要在没有这些输入的情况下去"顺手实现"**:M4 的识别链路一旦按某家供应商写死,
换供应商要重写;`union_id` 没拿到之前,"按 union_id 认人"也无从下手 换供应商要重写;`union_id` 没拿到之前,"按 union_id 认人"也无从下手
(字段与方案都已就位,见 `MpH5AuthService` 类头注释与 `sys_user.union_id`)。 (字段与方案都已就位,见 `MpH5AuthService` 类头注释与 `sys_user.union_id`)。
---
## §一·二十九 提交入库 + 文档小账清理(2026-09-30 上午)
### 一、昨晚的改动已入库(18 个提交,**未推送**)
按"一轮一个 commit"的仓库惯例分组提交,`main` 上多了 18 个提交。
⚠️ **没有推送**:`git status -sb` 显示 `main...origin/main [gone]`(跟踪分支已失效),
要推之前先确认远端分支还在不在。
| 提交组 | 内容 |
|---|---|
| `chore(git)` | `.gitignore` 补 `backend/_*.txt` / `scripts/_*` / `shots/` —— 调试期临时产物,之前每次都要靠人记着别 add |
| `feat(export)` / `feat(audit)` / `feat(mp)` / `feat(join)` / `feat(notify)` / `feat(ops)` | M6 导出交付 / 审核台 / 学生端与班主任端 / B1 加入申请 / 通知与校验 / 日志与字典 |
| `feat(cleanup)` / `feat(open)` / `feat(task)` / `feat(schedule)` / `feat(file)` / `feat(pc)` | 历史整理器 / §14 开放接口 / 任务配置 / 定时任务 / 文件与缩略图 / 工作台看板 |
| `chore(ci)` / `test(scripts)` / `docs`×3 | CI 三阶段 6 job / 36 个 E2E 与 6 个机械校验 / 部署手册 + 用户手册 / 盘点与交接 |
### 二、这一轮清掉的文档小账(都是"会静默过期"的那类)
1. **附录 A 从"手抄表"改成"可再生成的命令"**:`check-api-parity.py` 加 `--extra`,
现算"代码有、契约 §4 没有"的接口 = **41 个**(旧手抄表写 83/62/21,已差近一倍),
并按"谁去改什么"分六类 —— **只有 E 类(8 个)要客户定口径**,其余都是文档侧补记。
教训:**手抄件会过期,命令不会**。
2. **F-6 已修**:`frontend-pc/AGENTS.md` 的 Mock 说明改正;**并连带修了
`.agents/skills/project-api/SKILL.md`** —— 它整篇仍以 `vite-plugin-mock` 为主线,
而 `mock/` 目录与插件都已删除。只改指路牌、不改路的尽头,等于没改。
3. **`docs/03` 两处内部口径冲突加了 ⚠️ 注(不改口径)**:
§2 `notifyBizType` 的 `5`/`6` 与 §13.5、落库值不一致 → **实现以 §13.5 为准**(催报去重按 5);
§3.6/§9.5 的 `collect_file.source` 字段表(入口来源)与建表注释(**谁传的**)冲突 →
**实现以建表口径为准**。两处都写明"待客户确认",见 F-30 / F-38 / F-39。
4. **`check-test-profile.py` 加固**(新增 **F-98**):日志落盘 + 按 jar/profile 清残留 JVM。
⚠️ 这条的教训值得单独记:**"随机端口"这个防撞设计把残留 JVM 藏起来了** ——
撞不到端口就永远发现不了,于是表现成"时好时坏",而失败时连日志都读不到
(走 `PIPE` 没人读、事后读已被 kill 截断)。现在:基线 **~10 秒**起,
失败时能看到"停在哪一行",等待期间每 15 秒报一次进度。
### 三、还没做的(顺序未变)
`docs/08` §四 剩下的**全部是外部依赖**(OCR 选型 / 小程序类目 / `union_id` / 真机 / 二期),
另有 4 项"**没测过**":导出大包打包、上传并发、5000 行名册导入、长跑稳定性
(`docs/09` §9.5 逐条写明"没测就是没测")。
@@ -1,10 +1,19 @@
--- ---
name: project-api name: project-api
description: 在本模板项目中新增或修改 API 接口、类型定义与 vite-plugin-mock 模拟数据。涉及 getBaseApi、http、baseAPI、mock 目录、分页响应格式时使用。 description: 在本模板项目中新增或修改 API 接口与类型定义(本仓库已不用 Mock,见开头说明)。涉及 http、baseAPI、分页响应格式、响应体适配时使用。
--- ---
# API 与 Mock # API 与 Mock
> ⚠️ **本仓库不用 Mock(2026-09-30 核实)**:`mock/` 目录与 `vite-plugin-mock` 都已删除,
> `.env.development` 里 `VITE_API_PREFIX = '/api/v1'`、`VITE_API_BASE_URL = 'http://localhost:8080'`,
> `pnpm dev` 把 `/api` 代理到真实后端 —— **后端不起着就没有数据**(这不是故障)。
>
> 所以本文档里所有关于 Mock 的部分(`mock/**`、`defineMock`、`getBaseApi` 生成 mock、
> Mock 目录约定)在本仓库**不适用**,保留它们只是因为 Gi 模板升级时要做差异比对。
> 新增接口照 `src/apis/xt/*.ts` 的写法:用 `@/utils/http`,响应体 `{ code, msg, data }`
> (适配层会补 `records`)已在 `@/utils/http` 统一处理,业务模块不要再自己解一层。
## 接口层结构 ## 接口层结构
每个业务模块一个文件,例如 `src/apis/system/user.ts`: 每个业务模块一个文件,例如 `src/apis/system/user.ts`:
+5 -2
View File
@@ -39,7 +39,7 @@
## 快速入口 ## 快速入口
```bash ```bash
pnpm dev # 开发(Mock,/mock 前缀) pnpm dev # 开发(/api 代理到 http://localhost:8080,没有 Mock)
pnpm build # 生产构建 pnpm build # 生产构建
pnpm typecheck # vue-tsc pnpm typecheck # vue-tsc
pnpm lint # ESLint pnpm lint # ESLint
@@ -47,5 +47,8 @@ pnpm lint:style # Stylelint
``` ```
- 路径别名:`@` → `src/`,`~` → 项目根目录 - 路径别名:`@` → `src/`,`~` → 项目根目录
- 开发默认 Mock;联调真实后端见 `vite.config.ts` 与 `.env` - **开发没有 Mock**:`mock/` 目录与 `vite-plugin-mock` 都已删除,`pnpm dev` 把 `/api`
代理到 `http://localhost:8080`(见 `vite.config.ts` 的 `server.proxy` 与
`.env.development` 的 `VITE_API_BASE_URL`)→ **后端不起着就没有数据**。
我们的接口模块在 `src/apis/xt/`(`apis/system|user|…` 是 Gi 模板自带的)
- 登录演示账号见 `README.md`(如 admin / 123456) - 登录演示账号见 `README.md`(如 admin / 123456)
+24
View File
@@ -15,6 +15,7 @@
用法: 用法:
python scripts/check-api-parity.py # 打印逐分类实现度 + 缺口清单 python scripts/check-api-parity.py # 打印逐分类实现度 + 缺口清单
python scripts/check-api-parity.py --quiet # 只打印汇总(CI 里用) python scripts/check-api-parity.py --quiet # 只打印汇总(CI 里用)
python scripts/check-api-parity.py --extra # 反向:代码有、契约 §4 清单没有的(F-4 底稿)
""" """
from __future__ import annotations from __future__ import annotations
@@ -88,9 +89,11 @@ def code_endpoints() -> list[tuple[str, str, str]]:
def main() -> int: def main() -> int:
quiet = "--quiet" in sys.argv quiet = "--quiet" in sys.argv
only_missing = "--missing" in sys.argv only_missing = "--missing" in sys.argv
only_extra = "--extra" in sys.argv
doc = doc_overview() doc = doc_overview()
code = code_endpoints() code = code_endpoints()
code_keys = {(m, norm(p)) for _, m, p in code} code_keys = {(m, norm(p)) for _, m, p in code}
doc_keys = {(m, norm(p)) for eps in doc.values() for m, p in eps}
claimed = implemented = 0 claimed = implemented = 0
missing: list[tuple[str, str, str]] = [] missing: list[tuple[str, str, str]] = []
@@ -104,6 +107,27 @@ def main() -> int:
missing += [(cat, m, p) for m, p in miss] missing += [(cat, m, p) for m, p in miss]
rows.append((cat, len(uniq), len(done), len(miss))) rows.append((cat, len(uniq), len(done), len(miss)))
# ── 反向差集:代码注册了、而契约 §4 清单里没有 ──
# ⚠️ 这个清单**必须由工具生成**:它以前是人工做一次差集后手抄进文档的,
# 于是每加一个接口就悄悄过期一次(数字 83/62/21 与实际差了 3 倍)。
# 手抄件会过期,命令不会。
extra = [(f, m, p) for f, m, p in code if (m, norm(p)) not in doc_keys]
if only_extra:
print("=" * 78)
print("代码有、契约 §4 清单没有:%d 个" % len(extra))
print("(代码共注册 %d 个;契约声称 %d 个,其中 %d 个已在代码里)"
% (len(code), claimed, implemented))
print("=" * 78)
cur = None
for f, m, p in extra:
if f != cur:
print("\n【%s】" % f)
cur = f
print(" %-6s %s" % (m, p))
print()
return 0
if not quiet and not only_missing: if not quiet and not only_missing:
print("=" * 78) print("=" * 78)
print("代码注册接口:%d 个" % len(code)) print("代码注册接口:%d 个" % len(code))
+101 -18
View File
@@ -16,8 +16,17 @@
不验任何业务 —— 业务由 `scripts/e2e-*.py` 打真实接口验。 不验任何业务 —— 业务由 `scripts/e2e-*.py` 打真实接口验。
这里只回答一个问题:**"CI 那套配置能不能把服务拉起来"**。 这里只回答一个问题:**"CI 那套配置能不能把服务拉起来"**。
## 两个"时好时坏"的坑(都踩过)
1. **就绪判据不能用日志行**:日志行会随版本变(改个措辞就失效),
而且子进程输出走管道时是块缓冲的,`readline()` 一阻塞就轮不到超时判断。
所以判据是 **HTTP 探活**,并且子进程输出**落盘**(`backend/_test-profile.txt`)——
失败时能看见它停在哪一行,而不是只剩一句"180 秒没起来"。
2. **随机端口带来一个反直觉的副作用**:残留 JVM 永远不会撞端口,于是会静静攒着,
直到某一次"怎么都起不来"。所以开跑前按 jar + profile 清残留(**保留 dev 后端**)。
跑法:python scripts/check-test-profile.py 跑法:python scripts/check-test-profile.py
(前提:本地 PG/Redis 在跑、已 `mvn package`) (前提:本地 PG/Redis 在跑、已 `mvn package`;可用 `XT_TEST_PROFILE_WAIT` 调超时)
""" """
import base64 import base64
import os import os
@@ -92,6 +101,42 @@ def preclean_port(port):
except Exception: except Exception:
pass pass
def preclean_stale_jvms():
"""
开跑前清掉**本项目的**残留 JVM(只留 dev 那个)。
⚠️ 为什么 preclean_port 不够:本脚本用的是随机端口,所以残留 JVM **永远撞不到**
这个端口,而它们会一直占着内存与 CPU —— 表现是"这次怎么 180 秒都没起来",
下一次又好了(**时好时坏正是资源被占的特征**)。实测就是这样:
连着跑几轮之后本机攒了 5~7 个 java,之后 JVM 启动从 10 秒变成起不来。
判据要窄,否则会误杀:
· 命令行里必须是**本项目的 jar**(别的项目的 java 一律不碰);
· **必须留 dev**(`--spring.profiles.active=dev`)—— E2E 正打着它,
顺手杀掉等于把后面的检查全废掉。
"""
try:
ps = ("Get-CimInstance Win32_Process -Filter \"Name='java.exe'\" | "
"ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }")
r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True, timeout=60)
except Exception: # noqa: BLE001
return
for line in (r.stdout or "").split("\n"):
if "\t" not in line:
continue
pid, cmd = line.split("\t", 1)
pid = pid.strip()
if not pid.isdigit() or "xt-server" not in cmd:
continue
if "--spring.profiles.active=dev" in cmd:
print(" (保留 dev 后端 pid %s —— E2E 要用它)" % pid)
continue
subprocess.run(["taskkill", "/F", "/T", "/PID", pid],
capture_output=True, timeout=20)
print(" ⚠️ 清掉残留 JVM pid %s(上一次跑 test/prodguard 留下的)" % pid)
def free_port() -> int: def free_port() -> int:
""" """
要一个空闲端口。 要一个空闲端口。
@@ -108,24 +153,48 @@ def free_port() -> int:
PORT = free_port() PORT = free_port()
print("用端口 %d(每次随机取,避免重复跑时撞端口)" % PORT) print("用端口 %d(每次随机取,避免重复跑时撞端口)" % PORT)
preclean_stale_jvms()
preclean_port(PORT) preclean_port(PORT)
# ⚠️ 子进程输出**必须落盘**,不能用 PIPE:
# ① 没人读 PIPE 时,管道缓冲区(Windows 约 64KB)写满就会**把 JVM 阻塞在写日志上** ——
# 表现是"启动到一半不动了、进程还活着",与配置对不对毫无关系;
# ② 失败时想读那段输出,而它要么还在缓冲区里、要么已被 kill 截断,
# 于是只剩下一句"180 秒内没探活成功"这种没法排查的结论。
# 落盘之后才能回答"它到底停在哪一行"。文件名用 .txt 是有意的:
# .gitignore 里 `backend/_*.txt` 已经忽略它。
# 实测基线:正常启动 ~10 秒(72 行 / 9.5KB 日志)——
# 哪天变成几十秒,先怀疑本机负载与残留 JVM,而不是配置。
LOG = os.path.join(ROOT, "backend", "_test-profile.txt")
logf = open(LOG, "w", encoding="utf-8", errors="replace")
started = time.time()
p = subprocess.Popen( p = subprocess.Popen(
["java", "-jar", JAR, "--spring.profiles.active=test", ["java", "-jar", JAR, "--spring.profiles.active=test",
"--server.port=%d" % PORT], "--server.port=%d" % PORT],
cwd=os.path.join(ROOT, "backend"), env=env, cwd=os.path.join(ROOT, "backend"), env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdout=logf, stderr=subprocess.STDOUT)
text=True, encoding="utf-8", errors="replace")
def log_lines():
"""读日志文件当前的内容(进程还开着也能读)。"""
try:
logf.flush()
with open(LOG, encoding="utf-8", errors="replace") as f:
return [x.rstrip() for x in f if x.strip()]
except Exception: # noqa: BLE001
return []
ok = False ok = False
reason = "" reason = ""
body = "" body = ""
try: try:
# ⚠️ 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行": # 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行":
# 子进程 stdout 在管道里是**块缓冲**的,启动日志常常不足一个缓冲区, # 日志行会随版本变(改个措辞就失效),而"接口能回答"才是这件事本身。
# 于是一行都读不到;而且 readline() 一旦阻塞,超时的判断就轮不到。 WAIT = float(os.environ.get("XT_TEST_PROFILE_WAIT", "180"))
# 实测:单独跑能过、紧跟在几个 JVM 之后跑就失败 —— 时好时坏正是它的特征。 deadline = time.time() + WAIT
deadline = time.time() + 180 next_report = time.time() + 15
while time.time() < deadline: while time.time() < deadline:
if p.poll() is not None: if p.poll() is not None:
reason = "进程自己退了(退出码 %s)" % p.returncode reason = "进程自己退了(退出码 %s)" % p.returncode
@@ -138,37 +207,51 @@ try:
break break
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
reason = str(e) reason = str(e)
# 每 15 秒报一次"等到哪一步了" —— 卡住时最需要的是进度,不是沉默
if time.time() >= next_report:
next_report = time.time() + 15
tail = log_lines()
print(" …… 已等 %ds,日志停在:%s" % (
int(time.time() - started), tail[-1][:110] if tail else "(还没有输出)"))
time.sleep(1) time.sleep(1)
if not ok and not reason: if not ok and not reason:
reason = "180 秒内没探活成功" reason = "%d 秒内没探活成功" % int(WAIT)
finally: finally:
# ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程, # ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程,
# 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。** # 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。**
kill_tree(p) kill_tree(p)
try: try:
p.wait(timeout=15) p.wait(timeout=15)
except Exception: except Exception: # noqa: BLE001
pass pass
logf.close()
text = "\n".join(log_lines())
if ok: if ok:
took = time.time() - started
print("健康检查:", body[:200]) print("健康检查:", body[:200])
print("启动用时:%.1f 秒(基线约 10 秒)" % took)
if took > 60:
print(" ⚠️ 比基线慢很多 —— 大概率是**本机负载或残留 JVM**,不是配置问题")
print(" 查残留:Get-CimInstance Win32_Process -Filter \"Name='java.exe'\"")
print("\n结果:test profile **能起来**") print("\n结果:test profile **能起来**")
else: else:
print("\n结果:test profile **起不来**") print("\n结果:test profile **起不来**")
print(" 原因:" + reason) print(" 原因:" + reason)
try: print(" 完整日志:%s(%d 行)" % (os.path.relpath(LOG, ROOT), len(log_lines())))
text = (p.stdout.read() or "") if p.stdout else ""
except Exception:
text = ""
if "already in use" in text or ("Port" in text and "in use" in text): if "already in use" in text or ("Port" in text and "in use" in text):
print(" ⚠️ 端口被占 —— 这是**环境问题**,不是配置问题") print(" ⚠️ 端口被占 —— 这是**环境问题**,不是配置问题")
for kw in ("ERROR", "Caused by", "Failed", "migration", "Flyway", "Cannot"): # 关键字先扫一遍:起不来时真正的原因往往只有一行,而尾部 30 行可能全是无关的重试
for kw in ("APPLICATION FAILED TO START", "Caused by", "ERROR", "Waiting", "lock",
"Flyway", "migration", "Cannot", "Exception"):
for ln in text.split("\n"): for ln in text.split("\n"):
if kw in ln: if kw in ln:
print(" " + ln.strip()[:160]) print(" [%s] %s" % (kw, ln.strip()[:150]))
break break
print("---- 尾部 ----") print("---- 日志尾部 20 行 ----")
print(text[-1500:]) for ln in log_lines()[-20:]:
print(" " + ln[:150])
sys.stdout.flush() sys.stdout.flush()
raise SystemExit(0 if ok else 1) raise SystemExit(0 if ok else 1)