From db6a51d617492c95816792ad55c19dd01cc00852 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Wed, 30 Sep 2026 09:10:34 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B8=85=E6=8E=89=E4=B8=89=E5=A4=84"?= =?UTF-8?q?=E4=BC=9A=E9=9D=99=E9=BB=98=E8=BF=87=E6=9C=9F"=E7=9A=84?= =?UTF-8?q?=E5=B0=8F=E8=B4=A6=20+=20check-test-profile=20=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=EF=BC=88F-98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、附录 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 项全部通过 --- AGENTS.md | 4 +- docs/03-接口设计.md | 28 ++++- docs/08-完成度盘点与问题清单.md | 79 +++++++----- docs/交接摘要.md | 44 ++++++- .../.agents/skills/project-api/SKILL.md | 11 +- frontend-pc/AGENTS.md | 7 +- scripts/check-api-parity.py | 24 ++++ scripts/check-test-profile.py | 119 +++++++++++++++--- 8 files changed, 258 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5ea2960..f709649 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,9 @@ pnpm typecheck # vue-tsc pnpm lint # eslint - 用 **pnpm**(仓库里是 `pnpm-lock.yaml`;别 `npm install`,会多出 package-lock.json) - 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 模板自带的,别往那儿加 - **PC 前端有独立的规范来源:`frontend-pc/.agents/`**(`AGENTS.md` + `skills/*`)。 改那里的代码前按它的表加载对应 skill,不要另起一套写法 diff --git a/docs/03-接口设计.md b/docs/03-接口设计.md index 29f061f..4277c7a 100644 --- a/docs/03-接口设计.md +++ b/docs/03-接口设计.md @@ -256,11 +256,22 @@ | `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 | | `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 | | `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` 已过期 | | `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 | | `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 通用数据结构 @@ -461,9 +472,19 @@ | `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) | | `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` | | `issues` | array | — | 该文件的 `ValidationIssueVO[]` | -| `source` | int | `source` | `1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传 | +| `source` | int | `source` | `1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传 ⚠️ 见下方注 | | `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 归属裁决矩阵) | verdict | 含义 | blockSubmit | 学生端表现 | @@ -1784,7 +1805,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手 | `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) | | `fileSize` | int64 | ✅ | 字节 | | `contentType` | string | ✅ | MIME | -| `source` | int | — | `1` 聊天记录 / `2` 相册 / `3` 手机文件 | +| `source` | int | — | `1` 聊天记录 / `2` 相册 / `3` 手机文件(⚠️ 落库口径见 §3.6 注:实际存的是「谁传的」= 1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入) | | `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`)而非密文,宽度足够,无需改动。 | | **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 错误码全集 | diff --git a/docs/08-完成度盘点与问题清单.md b/docs/08-完成度盘点与问题清单.md index f01c230..3a0b0e6 100644 --- a/docs/08-完成度盘点与问题清单.md +++ b/docs/08-完成度盘点与问题清单.md @@ -163,10 +163,15 @@ 2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子; 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 的声称数)。 ### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析" @@ -175,11 +180,19 @@ - **影响**:本地演示/截图时列表里全是 `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"。 - **影响**: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 · 一致性】撤回的动作码与适用状态 @@ -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-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-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 个**(下方逐条已核对; -> 首次盘点 19 个 + M6 导出新增的 A20/A21)。 +> **这份清单是跑出来的,不是抄出来的:** +> +> ```bash +> python scripts/check-api-parity.py --extra +> ``` +> +> 当前口径:代码共注册 **161** 个接口,契约 §4 声称 **121** 个(其中 **120** 个已在代码里), +> 差集 **41** 个。 +> +> ⚠️ 上一版这里是"人工做一次差集、手抄成表"(写着 83 / 62 / 21)—— +> 此后每加一个接口它就悄悄过期一次,最后差了近一倍,而且**没人会记得回来改它**。 +> 手抄件会过期,命令不会。所以下面只留"为什么它们不在契约里"的分类结论; +> **要逐条清单时现跑那条命令。** -| # | 接口 | 说明 | -|:--:|---|---| -| 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 加密传输用的公钥 | -| A4 | `GET /api/v1/mp/h5/authorize` | H5 微信网页授权跳转(代替 `POST /mp/login` 的学生入口) | -| A5 | `GET /api/v1/mp/h5/callback` | 授权回调(token 放 URL fragment) | -| A6 | `GET /api/v1/mp/h5/stub-login` | **仅 dev** 打桩登录(`stub-enabled` + profile 双重保护) | -| A7 | `PUT /api/v1/users/{id}/status` | 启用/停用(清单里只有 `DELETE` 停用) | -| A8 | `GET /api/v1/tenants/{id}` | 租户详情(清单只列了列表/开通/修改/用量) | -| A9 | `POST /api/v1/mp/files/upload` | 实际的上传入口(替代 `upload-token` + OSS 直传) | -| A10 | `GET /api/v1/mp/files/{id}/url` | 取签名预览链接(学生端) | -| A11 | `GET /api/v1/mp/files/{id}/content` | 直出内容(学生端预览) | -| 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 只定义了取下载信息;真正下文件走这条) | +### 9.1 这 41 个按其「为什么不在契约里」分成六类 + +| 类 | 个数 | 为什么契约里没有 | 该怎么办 | +|:--|:--:|---|---| +| **A 材料通道:一步 multipart / 审核端独立一条** | 7 | 契约写的是"先传文件拿 `fileId` 再挂载"与 `upload-token` + OSS 直传;实现是**一步 multipart**,且审核端与学生端**各一条通道**(审核端要看防伪纹,学生端只看自己的) | §17 注明"实现按此写法"。**不要**改实现去迁就文档:直传要把密钥交给前端,反而更不安全 | +| **B 微信授权链路代替 `POST /mp/login`** | 4 | H5 走网页授权跳转(`authorize` → `callback`,token 放 URL fragment)、匿名落地页要 `scene-context`、dev 打桩登录只用于联调 | §17 注明;`stub-login` 要写明**仅非生产可用**(代码里另有一道 profile 判断) | +| **C 签名 URL:契约只定义了"取下载信息"** | 1 | `GET /exports/{id}/file` 才是真正下文件那条;§12.6 只定义了取下载信息 | 补进 §17,并写明 **URL 有有效期** —— 不能入库、不能发出去让人明天再打开 | +| **D 界面与接口必需、清单漏列** | 17 | 按钮要先知道"能不能点"(`exports/precheck`、`exports/preview`、`material-catalog`)、匿名出图、批量通过、教师端原因字典、名册项"标记不参与"这个可逆替代(`roster/{rid}/deactivate`)…… | **直接补进 §17 清单**,这类没有口径争议 | +| **E 契约链条本身断在第一步(要客户确认)** | 8 | `§12.7` 模板:`GET /notify/templates` 返回的内置模板**没有 id**,而 `PUT …/{id}` 要 id → 用户点"编辑"时根本拿不出 id(F-52);`§14` 的**接入方管理整块没写**(F-67) | 这 8 条**不是补文档能解决的**,要客户定口径(见 F-51 / F-52 / F-67) | +| **F 运维与调试动作** | 4 | 定时任务在测试里"等不起",所以留了手工入口(F-37):`schedules/run`、`exports/cleanup-run`、`cleanup/jobs/json`、`cleanup/jobs/parse-preview` | 补进 §17,标注"运维/调试用"(权限走运营侧的 `log:view:all`) | + +> 合计 7 + 4 + 1 + 17 + 8 + 4 = **41**。 +> 分类是按"谁去改什么"分的 —— 需要客户回答的只有 **E** 那一栏,其余三类是文档侧的补记。 --- @@ -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-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 | **对外待决事项归拢 + 一键交付自检**:`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) | diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 927585a..88ce85b 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -2286,7 +2286,7 @@ M1 规则模板与任务克隆 → M2 报考范围与名册改删 → M3 提交 **§14 开放接口(认定机构对接,HMAC)** → 小程序登录与组织导入 → 工作台数据看板 → CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。 -**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 97 条编号问题(F-1~F-97)。 +**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 98 条编号问题(F-1~F-98)。 ### 这 20 多轮里最该记住的几条 @@ -2337,3 +2337,45 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略 ⚠️ **不要在没有这些输入的情况下去"顺手实现"**:M4 的识别链路一旦按某家供应商写死, 换供应商要重写;`union_id` 没拿到之前,"按 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 逐条写明"没测就是没测")。 diff --git a/frontend-pc/.agents/skills/project-api/SKILL.md b/frontend-pc/.agents/skills/project-api/SKILL.md index b329cbb..d5e7c87 100644 --- a/frontend-pc/.agents/skills/project-api/SKILL.md +++ b/frontend-pc/.agents/skills/project-api/SKILL.md @@ -1,10 +1,19 @@ --- name: project-api -description: 在本模板项目中新增或修改 API 接口、类型定义与 vite-plugin-mock 模拟数据。涉及 getBaseApi、http、baseAPI、mock 目录、分页响应格式时使用。 +description: 在本模板项目中新增或修改 API 接口与类型定义(本仓库已不用 Mock,见开头说明)。涉及 http、baseAPI、分页响应格式、响应体适配时使用。 --- # 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`: diff --git a/frontend-pc/AGENTS.md b/frontend-pc/AGENTS.md index c870ffd..ec33923 100644 --- a/frontend-pc/AGENTS.md +++ b/frontend-pc/AGENTS.md @@ -39,7 +39,7 @@ ## 快速入口 ```bash -pnpm dev # 开发(Mock,/mock 前缀) +pnpm dev # 开发(/api 代理到 http://localhost:8080,没有 Mock) pnpm build # 生产构建 pnpm typecheck # vue-tsc pnpm lint # ESLint @@ -47,5 +47,8 @@ pnpm lint:style # Stylelint ``` - 路径别名:`@` → `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) diff --git a/scripts/check-api-parity.py b/scripts/check-api-parity.py index 8c166d8..eef12bf 100644 --- a/scripts/check-api-parity.py +++ b/scripts/check-api-parity.py @@ -15,6 +15,7 @@ 用法: python scripts/check-api-parity.py # 打印逐分类实现度 + 缺口清单 python scripts/check-api-parity.py --quiet # 只打印汇总(CI 里用) + python scripts/check-api-parity.py --extra # 反向:代码有、契约 §4 清单没有的(F-4 底稿) """ from __future__ import annotations @@ -88,9 +89,11 @@ def code_endpoints() -> list[tuple[str, str, str]]: def main() -> int: quiet = "--quiet" in sys.argv only_missing = "--missing" in sys.argv + only_extra = "--extra" in sys.argv doc = doc_overview() code = code_endpoints() 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 missing: list[tuple[str, str, str]] = [] @@ -104,6 +107,27 @@ def main() -> int: missing += [(cat, m, p) for m, p in 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: print("=" * 78) print("代码注册接口:%d 个" % len(code)) diff --git a/scripts/check-test-profile.py b/scripts/check-test-profile.py index 57b2cea..007b955 100644 --- a/scripts/check-test-profile.py +++ b/scripts/check-test-profile.py @@ -16,8 +16,17 @@ 不验任何业务 —— 业务由 `scripts/e2e-*.py` 打真实接口验。 这里只回答一个问题:**"CI 那套配置能不能把服务拉起来"**。 +## 两个"时好时坏"的坑(都踩过) + +1. **就绪判据不能用日志行**:日志行会随版本变(改个措辞就失效), + 而且子进程输出走管道时是块缓冲的,`readline()` 一阻塞就轮不到超时判断。 + 所以判据是 **HTTP 探活**,并且子进程输出**落盘**(`backend/_test-profile.txt`)—— + 失败时能看见它停在哪一行,而不是只剩一句"180 秒没起来"。 +2. **随机端口带来一个反直觉的副作用**:残留 JVM 永远不会撞端口,于是会静静攒着, + 直到某一次"怎么都起不来"。所以开跑前按 jar + profile 清残留(**保留 dev 后端**)。 + 跑法:python scripts/check-test-profile.py -(前提:本地 PG/Redis 在跑、已 `mvn package`) +(前提:本地 PG/Redis 在跑、已 `mvn package`;可用 `XT_TEST_PROFILE_WAIT` 调超时) """ import base64 import os @@ -92,6 +101,42 @@ def preclean_port(port): except Exception: 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: """ 要一个空闲端口。 @@ -108,24 +153,48 @@ def free_port() -> int: PORT = free_port() print("用端口 %d(每次随机取,避免重复跑时撞端口)" % PORT) +preclean_stale_jvms() 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( ["java", "-jar", JAR, "--spring.profiles.active=test", "--server.port=%d" % PORT], cwd=os.path.join(ROOT, "backend"), env=env, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, encoding="utf-8", errors="replace") + stdout=logf, stderr=subprocess.STDOUT) + + +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 reason = "" body = "" try: - # ⚠️ 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行": - # 子进程 stdout 在管道里是**块缓冲**的,启动日志常常不足一个缓冲区, - # 于是一行都读不到;而且 readline() 一旦阻塞,超时的判断就轮不到。 - # 实测:单独跑能过、紧跟在几个 JVM 之后跑就失败 —— 时好时坏正是它的特征。 - deadline = time.time() + 180 + # 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行": + # 日志行会随版本变(改个措辞就失效),而"接口能回答"才是这件事本身。 + WAIT = float(os.environ.get("XT_TEST_PROFILE_WAIT", "180")) + deadline = time.time() + WAIT + next_report = time.time() + 15 while time.time() < deadline: if p.poll() is not None: reason = "进程自己退了(退出码 %s)" % p.returncode @@ -138,37 +207,51 @@ try: break except Exception as e: # noqa: BLE001 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) if not ok and not reason: - reason = "180 秒内没探活成功" + reason = "%d 秒内没探活成功" % int(WAIT) finally: # ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程, # 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。** kill_tree(p) try: p.wait(timeout=15) - except Exception: + except Exception: # noqa: BLE001 pass + logf.close() + +text = "\n".join(log_lines()) if ok: + took = time.time() - started 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 **能起来**") else: print("\n结果:test profile **起不来**") print(" 原因:" + reason) - try: - text = (p.stdout.read() or "") if p.stdout else "" - except Exception: - text = "" + print(" 完整日志:%s(%d 行)" % (os.path.relpath(LOG, ROOT), len(log_lines()))) if "already in use" in text or ("Port" in text and "in use" in text): 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"): if kw in ln: - print(" " + ln.strip()[:160]) + print(" [%s] %s" % (kw, ln.strip()[:150])) break - print("---- 尾部 ----") - print(text[-1500:]) + print("---- 日志尾部 20 行 ----") + for ln in log_lines()[-20:]: + print(" " + ln[:150]) sys.stdout.flush() raise SystemExit(0 if ok else 1)