Files
xiaotutongxue/docs/交接摘要.md
T
lovife 7523ebe789 feat(task): M1-02 覆盖班级与班主任配置
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。

后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用);
前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示
「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引
(BLOCKING_HINTS),而不是只说"未设置覆盖班级"。

三条守住的规则:
1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除",
   不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己
   的任务上,B 校的班主任就出现在 A 校的审批流里。
2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权,
   那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把
   "谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。
3) 移除是软移除(status=0),且「移除再加回」必须复用原行。
   后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status
   条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。

本轮修掉的 3 个真问题:
1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而
   CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。
   基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active
   和一段基于错误前提的代码,已全部撤掉并更正注释。
   教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。
2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾:
   DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在
   PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs
   名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职
   表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警
   变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉,
   而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值,
   并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。
3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package,
   target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行
   了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4,
   于是"两个 v4"→"checksum 冲突",排查绕了好几圈。

顺带修掉:
· 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进
  「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。
· defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。
  我此前写的 5 个组件都是被禁的 tuple 形式,一并改正:
  OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。
· e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally
  里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号
  留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。
  其余三条 E2E 脚本仍是老样子,下一轮一并改。

验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
2026-09-29 09:32:48 +08:00

492 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 交接摘要
> 用途:**压缩后的唯一状态源**。新会话读这一份即可接续,不必回看历史对话。
> 更新:2026-09-28
---
## 一、项目一句话
职业技能等级认定**报名材料收集 SaaS**。学生在微信小程序填报、班主任在手机初审、管理员在 PC 终审与导出。核心价值:**动态表单 + 规则引擎(命名/压缩/识别)+ 门禁核验 + 一键规范导出**。
工作目录 `D:\IDEA_Project\xiaotutongxue`(git 仓库,**已有 3 次提交**:`63aec55` 文档 / `9d7a8ca` M0 后端 / `87ece5a` 前端换框架;**其后又有一批未提交改动**,见 §一·四),交付物在 `docs/`。
---
## 一·二、一期范围(2026-09-28 客户确认收窄)
> **一期只做「学校收集 + 自主认定」一条流程。** 详见 PRD「一期范围声明」。
| 维度 | 字段 | 一期 | 二期后置 |
|---|---|---|---|
| 收集模式 | `collect_mode` | `1` 学校收集 | `2` 社会收集 |
| 认定类型 | `cert_type` | `1` 自主认定 | `2` 社会评价 / `3` 专项职业能力 |
**一期材料只有 4 类,全部有实测样本**:一寸照片 / 身份证 / 学籍表 / 工作证明
**二期才做的 2 类材料零样本**:`XJW_BA` 学信网备案表 / `OTHER_PROOF` 其它佐证材料
**数据结构保留不启用**(二期直接开,不需 DB 迁移)——因为这三项本来就是**配置驱动**的
(`needClassAudit` 是开关、字段隐藏走 `visibleWhen`、材料差异是材料项配置),
所以**省下的主要是测试成本与一个外部依赖,而不是代码量**。
**工期**:392 → **387 人日**(−5);**关键路径不变,仍 15 周**。
> ⚠️ **二期启动前必做**:索取社会评价样本(D4)→ 标定 `XJW_BA`/`OTHER_PROOF` → 补社会收集审核流与专项职业能力字段隐藏。
---
## 一·三、开发进展(2026-09-28 起)
> **代码位置**:`backend/`(Java 21 + Spring Boot 4.1.1)、`deploy/`(基础设施)、`scripts/`(工具)
| 项 | 状态 |
|---|---|
| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7<br>PC: Vue3 + **Gi Admin Pro(Arco Design Vue 2.58)**;小程序: uni-app |
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** |
| M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** |
| **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** |
| **M1-02 覆盖班级与班主任** | ✅ `GET/PUT /tasks/{id}/orgs`(整体替换 + 软移除 + 历史行复用)+ 前端「配置班级」抽屉(勾选班级、主/副班主任、应填人数);**`e2e-m1-orgs.py` → 48/48** |
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** |
| 待做 | M1 剩余(M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 |
---
## 一·四、账号管理竖切(2026-09-28 晚,未提交)
> 范围:`GET/POST/PUT/DELETE /users`、`PUT /users/{id}/status`、`POST /users/{id}/reset-password`、
> `POST /users/import`、`PUT /auth/password`;前端「组织与账号 → 账号管理」一屏。
**验收**:`python scripts/e2e-m0-user.py` → **55/55**;`python scripts/e2e-m0.py` 回归 **36/36**。
前端 `pnpm typecheck` **0 错**、`pnpm build` 通过、新增文件 eslint/stylelint 干净。
账号管理页**已在浏览器肉眼确认**(首屏与切页均可见、8→7 列、数据与分页正确)。
### 本轮修掉的 5 个真问题(都不是"功能没做完",是"做完了但错的")
| # | 问题 | 后果 | 修法 |
|:--:|---|---|---|
| 1 | **学校租户也发放 `PLATFORM_ADMIN` 角色行** | 学校管理员在新增账号时传 `roleCodes:["PLATFORM_ADMIN"]` 即可造出带 `tenant:manage` 的账号;而 `/tenants` 只校验权限码、不校验"是否真在平台租户下" → **任何学校管理员都能开通租户、拉全平台租户列表**(租户级越权到平台级) | ① `RoleProvisioner` 不再给非平台租户发该角色行;② `UserService.resolveAssignableRoles` 按租户白名单校验,越界返回 `40301`。两道都留 |
| 2 | `UserUpsertReq.username` 上的 `@NotBlank` 对**修改**也生效 | `PUT /users/{id}` 永远返回 `40001`,而错误明细只说"存在阻断性校验问题",看不出是登录名 —— 改账号功能整体不可用 | 去掉该注解(`@Pattern` 对 null 是通过的),"新增时必填"改由 `UserService.create` 判定 |
| 3 | `Main.vue` 的 `<transition appear>` | 首次渲染被当成一次过渡,元素被加上 `zoom-fade-enter-from/-active`(`opacity:0`)后**永不清理** → **DOM 内容齐全、接口数据正常、文字可选中,但整块内容区肉眼不可见**。极易误判成"页面没渲染"而往路由/接口方向排查 | 去掉 `appear`(首次加载本就不需要入场动画)。**已浏览器复验通过** |
| 4 | 我写的 `assertAnotherAdminExists` **在停用/删除路径上不可达** | 能调这两个接口的人自己必然是活跃管理员,所以"除目标外还有管理员"恒成立。而真正会锁死租户的路径——**把管理员改成普通角色**(目标可以是操作者自己)——当时没有拦 | 新增 `losingAdminRole()` + `UserMapper.countAdminRoles()`,在改角色时判定;方法注释里写明"哪条路径真生效、哪条是防御性的" |
| 5 | ⭐ **`sys_user.user_type` 有两套互相冲突的枚举** | DDL/实体注释写「1教师/管理员 2填报人(学生) 3社会人员」,而**权威契约**(《03-接口设计》§2.7)与 `BuiltinRole.UserType` 是「1平台运营 2学校管理员 3班主任 4学生」。两套的 **2 和 3 完全错位**。我照错的注释写了新代码,结果是:**`userType=3`(班主任,最高频的账号)被当成"社会人员(二期)"直接拒掉** —— 建班主任账号会失败;前端「类型」列还把学校管理员显示成"填报人(学生)" | ① 后端改成**从角色反推用户类型**再比对(`ROLE_TO_USER_TYPE`),不再逐条 `if`;② 新增 `V3__fix_user_type_comment.sql` 更正库注释(**不能改 V1,Flyway 会校验 checksum**);③ 更正 PRD 与 `schema-postgresql.sql` 的同一行注释;④ 前端「用户类型」从手选改为**由角色推导**,从根上消除矛盾组合;⑤ E2E 加 3 条回归断言锁住这个语义 |
> ⚠️ **第 5 条是最值得记住的一条**:不是"漏了个校验",而是**同一份资料里有两套定义,且都对不上**。
> 教训:看到"枚举注释"时,要去**权威契约**(这里是《03-接口设计》)核对,不能就近抄实体或 DDL 的注释。
> 这条也解释了为什么 `V1` 迁移里的错误注释只能靠 `V3` 覆盖 —— 已应用的迁移**一个字都不能改**。
### 已知缺口(有意留着,别当成漏做)
| 项 | 说明 |
|---|---|
| **改密后旧 token 仍有效** | 直到自然过期。要立刻踢人需要 JWT 带签发时间 + 与"最近改密时间"比较,涉及 `JwtService`/`AuthInterceptor`,不适合混在这一刀里。已在 `AuthService.changePassword` 注释里写明 |
| `hasActiveTask()` 恒为 `false` | `collect_task` 是 M1 的表且当前无"任务负责人"字段,`40014` 这条约束**目前不生效**。方法注释已标红,M1 必须补 |
| 手机号**模糊**搜不了 | AES-GCM 每次密文不同,`LIKE` 在原理上不可行。完整号码走 `phone_hash` 等值匹配;代价是"只记得中间几位"搜不到,属加密的必然取舍。E2E 里有一条专门断言这个行为(避免后人当 bug 改掉) |
| **浏览器端验证** | ✅ 已完成。账号管理页首屏与切页均 `opacity: 1`(`appear` 修复生效)、7 列、数据与分页正确、组织树正常;demo 租户已清理干净(1 个账号 `admin`、6 个组织节点)。⚠️ 下一个新页面上线时容易忽略的一点:**新增 `views/` 目录后必须重启 dev server**,见下方环境坑 3 |
### 本轮踩到的三个环境坑
1. **Maven 的本地仓库不在 `C:\Users\<u>\.m2\repository`**,而在 `D:\maven_repositiory`。在那里查依赖才准。
2. **`easyexcel-4.0.3.jar` 只有 3KB** —— 它是聚合包,真类在 `easyexcel-core`(528KB)+ `poi 5.2.5`。只看聚合包的大小会误判成"依赖是空的"。这正是本项目"依赖在、功能不在"那条教训的又一例。
3. **`import.meta.glob('@/views/**/*.vue')` 在 dev server 里对"新增目录"不总失效**:`views/user/` 建好并 HMR 重载后,路由仍拿不到组件(component 为 `undefined` → 内容区空白)。**新增页面目录后要重启 dev server**,不能只靠 HMR。
---
## 一·五、M1-01 任务 CRUD 与状态机(2026-09-28 夜,未提交)
> 范围:`GET/POST /tasks`、`GET/PUT/DELETE /tasks/{id}`、
> `POST /tasks/{id}/{publish|pause|resume|close|archive}`;前端「收集管理 → 收集任务」一屏。
**验收**:`python scripts/e2e-m1-task.py` → **71/71**;
`e2e-m0.py` 36/36、`e2e-m0-user.py` 55/55 回归通过;
前端 `pnpm typecheck` 0 错、`pnpm build` 通过。
### 三条硬约束(改动时当成规则,不是"可以商量的校验")
| # | 约束 | 为什么 |
|:--:|---|---|
| 1 | **「暂停」与「截止」是两件事** | 暂停:学生不能交,**已提交的照常审**,且**可恢复**(名单要改、规则要调时用)。截止:不可交、仍可审,**不可逆**。做成一个就等于把"临时冻一下"变成不可逆操作,现场没人敢点 |
| 2 | **发布前置 5 项全是阻断级**,`force` 绕不过 | 名册非空(40016)、名册含姓名与证件号(40017)、≥1 项必传材料(40018)、已设置覆盖班级(40019)、命名模板可用(40020)。可绕过就会出现"学生填了却没人审"或"文件名根本生成不出来"。只有 P1 级提醒(某班未指定初审人)可用 `force` 跳过 |
| 3 | **`collectMode` / `certType` 发布后不可改** | 它们决定审核流与材料清单;改了会让已经填过的学生面对另一份表单。归档后仍可改名称,只锁这两个 |
### 本轮修掉的 3 个真问题
| # | 问题 | 后果 | 修法 |
|:--:|---|---|---|
| 1 | **`collect_task_org` 没有 `deleted` 列** | 我起初让它继承 `TenantEntity`(基类带 `@TableLogic deleted`),MyBatis-Plus 会给**每条**查询自动加 `deleted = 0` → 运行时报「列不存在」。而这**编译期完全看不出来** | 该实体改成不继承基类、显式声明字段;"移除"语义走 `status = 0`(DDL 注释即如此),**不要对它调 `deleteById`**(会变成物理删除) |
| 2 | **任务编码查重被逻辑删除遮蔽** | 用 MP 的 `selectCount` 查重会自动过滤 `deleted = 0`,但**数据库的唯一约束不看软删标记** —— 软删掉的任务仍占着那个编码。表现为 `duplicate key value violates unique constraint "uk_task_tenant_code"`(500)。另外原用 `COUNT+1` 也错:删掉中间一个任务后计数会退回去,算出已占用的号 | 改为 `MAX(序号) + 1`,且**查重语句刻意不加 `deleted = 0`**(与唯一约束同口径);编码一次用过就不复用(它同时是**文件目录名**,复用会把两批任务的文件混进同一目录)。并发撞号由唯一约束兜底并返回可读提示,不再 500 |
| 3 | **Boot 4 的 Jackson 自动配置在 `spring-boot-starter-jackson`,项目没引** | 两个症状:① 容器里**没有 `ObjectMapper` bean**(`@Autowired` 直接启动失败);② `application.yml` 里 `spring.jackson.*` **静默失效** —— 其中 `time-zone: Asia/Shanghai` 不生效最危险:本机时区恰好是上海时看不出来,部署到 UTC 服务器后所有时间会整体偏 8 小时,且只表现为"时间看着不对"。**注意 `spring-boot-starter-json` 在 Boot 4 已无 4.x 版本**(仓库里最高 3.5.6),照老习惯找它是找不到的 | 在 `xt-common` 补 `spring-boot-starter-jackson`。但**任务配置的 JSON 读写仍不用 Jackson** —— Boot 4 用 Jackson 3(`tools.jackson`)而 `jjwt-jackson` 带来 Jackson 2,打包后 jar 里两个 `jackson-databind` 并存(2.21.5 + 3.1.5),import 哪个都是赌;改用项目已直接声明的 `hutool-all` |
### 已知缺口(有意留着)
| 项 | 说明 |
|---|---|
| **发布在前置数据齐备前不可达** | 发布要求名册(M2)、材料项(M1-04)、覆盖班级(M1-02)。所以 E2E 用 **SQL 夹具**造这三样,才能走到"已发布→暂停⇄恢复→截止→归档"并验证状态机。这是测试夹具,不是产品捷径 —— **M1-02/M1-04/M2 落地后要回来把夹具换成真实接口** |
| 命名规则试跑目前是**静态校验** | 只查"变量名是否合法"与"是否含姓名/证件号/材料名"。完整形态(拿真实名册前若干行跑一遍,检查生成的名字非空且两两不重复)需要名册数据,属 M1-09 / M3-01。当前这条已能拦住绝大多数错误(变量名写错、漏必备变量),且名册为空时发布本就会被 `40016` 先拦 |
| `stat` 恒为 `null` | 依赖名册(M2)与提交单(M3)。**故意不返回全 0 的对象** —— 那会让界面显示"完成率 0%",而真相是"还没有分母"。界面显式显示「待导入名册」 |
| 任务编码并发撞号 | 两个请求同时到达可能算出同一个号。唯一约束兜底 + 可读提示(不再 500)。一学期几十个任务,撞号概率极低,为此引独立序列表或分布式锁不划算 —— 但**别当成已解决** |
| `taskOrgMapper` 尚未用到 | M1-01 只用到 `TaskReadinessMapper` 里的计数;覆盖班级的增删改属 M1-02 |
### 前端踩到的坑
- **不要在校验/修复工具会碰的模板里拼花括号**。我写 `{{ '{' + v + '}' }}` 渲染命名变量按钮,`eslint --fix` 把它"帮忙"改成模板字符串 `` `{${v}}` `` —— 而里面的 `}}` 会截断 Vue 的插值解析器,报
`Error parsing JavaScript expression: Unexpected token, expected "}"`。
**这个错只在 `pnpm build` / 编译时出现,编辑器里看不出来。**
正确做法:在 `<script>` 里算好 `{ value, label }`,模板只做纯取值。
- **列宽要先量可用宽度,不能按像素凑总宽**(实测数据):
| 页面 | 可用宽度 | 原因 |
|---|---|---|
| 收集任务 | 约 **824px** | 只有 200 左侧栏 |
| 账号管理 | 约 **574px** | 200 左侧栏 + 约 250 左侧组织树卡 |
Arco 会按容器**重新分配**列宽:声明 120 的列被拉到 188,而 `fixed="right"` 的操作列被压到 105 —— 105px 装不下「编辑 / 重置密码 / 停用」,**竖排成了「编 辑 / 重 置 密 码 / 停 用」**。
更关键的是:列宽合计超过可用宽度时必然横向滚动,而**钉住的操作列会盖住紧邻它的那一列**(实测把「状态」整个遮住了,表头挤成「进度操作」)—— 这是固定列的固有行为,不是配置错误。
**踩过两次的教训:去掉 `fixed` 看似不压盖了,实际是把操作列滚出视野,用户根本看不到操作入口 —— 比压盖更糟。**
真正的解法只有一条:把列数减到能在可用宽度内放下(账号管理目前 5 列 800px,还需要再减一列)。
- ⚠️ **改中文内容一律用 Python 脚本文件,不要用 PowerShell 内联命令**(本项目已写进规则,我又犯了一次):
用 `python -c @"..."@` 内联改这两个 vue 文件时,PowerShell 把 `fixed="right"` 的 `f` 吃成了一个**不可见字符**(变成 `ixed="right"`),同时引入"不规则空白"。
肉眼完全看不出来,只有 eslint 的 `no-irregular-whitespace` 能发现。
---
## 一·六、M1-02 覆盖班级与班主任(2026-09-29 上午,未提交)
> 范围:`GET/PUT /api/v1/tasks/{id}/orgs`(《03-接口设计》§7.6);
> 前端「收集任务 → 操作 → 配置班级」抽屉(勾选班级 + 主/副班主任 + 应填人数)。
**验收**:`python scripts/e2e-m1-orgs.py` → **48/48**;
`e2e-m1-task.py` 71/71、`e2e-m0.py` 36/36、`e2e-m0-user.py` 55/55 回归通过;
前端 `typecheck` / `eslint` / `lint:style` 0 错、`build` 通过。
### 三条守住的规则(改动时当成规则)
| # | 规则 | 为什么 |
|:--:|---|---|
| 1 | **班级与初审人都必须在本租户内** | 跨租户的 id 一律当作"不存在或已删除",不透露"它存在但不属于你"。漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,B 校的班主任就出现在 A 校的审批流里 |
| 2 | **初审人必须持有班主任/副班主任角色** | 「指定初审人」本质上是一次**授权** —— 那个人将能看到该班学生的身份证号与学籍材料。不做角色校验就等于把"谁能看学生隐私"变成任意授权。报错要给出下一步(去账号管理分配角色),不能只说"校验失败" |
| 3 | **移除是软移除**(`status = 0`),且**"移除再加回"必须复用原行** | 名册与提交单挂在覆盖关系下,物理删会让历史数据失去归属。而复用是**必须**的(不是优化):`uk_task_org UNIQUE (task_id, org_id)` 不带 status 条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束 |
### 本轮修掉的 3 个真问题
| # | 问题 | 后果与修法 |
|:--:|---|---|
| 1 | **我误判 `uk_task_org` 不存在** | 先前的 grep 只匹配含 `collect_task_org` 字样的行,而 `CONSTRAINT uk_task_org UNIQUE (task_id, org_id)` 那行**没有表名** —— 于是漏掉了,还基于"没有唯一约束"的错误前提写了一个多余的部分唯一索引 `uk_task_org_active`。已撤掉(DROP INDEX + 删 flyway 历史行 + 删文件),并更正了注释:**"复用已移除的行"不是优化而是必须**。教训:**查约束要查 `CREATE TABLE` 整块,不能只 grep 表名** |
| 2 | **`collect_task_org.expected_count` 是 `NOT NULL DEFAULT 0`,与接口契约矛盾** | DDL 注释写的是"冗余,由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在 **PUT 请求体**里,且说"用于**与名册实际人数比对**,不一致时告警" —— 只有"管理员填的期望值 vs 名册实际值"才需要比对。而 `0` 是有意义的业务值("预期 0 人"),拿它兼职表示"没填"会让**每个没填的班**都触发"应填 0 人、实际 31 人"的假告警;告警变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉 —— 而这条告警的全部价值就在那一次。已由 `V4__task_org_expected_count_nullable.sql` 改为**可空 + 去掉默认值**,并同步更正了 `docs/db/schema-postgresql.sql` 与 PRD 里的内联 DDL。**NULL ≠ 0 这条规则适用于所有"可选数字"字段** |
| 3 | **删掉迁移文件后必须 `mvn clean`,否则它会"复活"** | 我删掉 `V4__task_org_active_unique.sql` 后只跑了 `mvn package`(没有 clean):`target/classes/db/migration/` 里**还留着旧副本**,被打进 jar,Flyway 发现历史里没有 v4 就**又执行了一遍** —— 索引回来了、历史行填回去了。之后 I 又建了同名版本号的新 V4,启动就报 `Found more than one migration with version 4`;清了旧副本后又变成 `Migration checksum mismatch for migration version 4`。**排查绕了好几圈,根源就是一行 `mvn package` 少了 `clean`。** 规则:**动过 `db/migration/` 里的文件(新增、删除、改名),一律 `mvn clean package`** |
顺带修掉:`views/task/index.vue` 的操作列从 3 个链接涨到 4 个(编辑/配置班级/发布/删除),
160px 会挤成竖排 —— 把「进度」并进「状态」列腾出 40px,操作列给到 200px,合计 820px
(可用约 824px)。发布被拦时现在会**告诉用户去哪里补**(`BLOCKING_HINTS`),
而不是只说"未设置覆盖班级"。
### E2E 脚本必须"崩了也清理"
`e2e-m1-orgs.py` 第一次运行时在准备阶段抛了 `TypeError`(我自己写错了参数),
**脚本直接退出、清理段没执行**,把两个测试账号留在了库里 —— 直到我在浏览器里
看到「班主任」下拉里有个 `E2E班主任688ed2` 才发现。
已改成:清理逻辑抽成模块级 `cleanup_all()`(幂等),既在 `main()` 末尾调用,
也在 `__main__` 的 `try/except BaseException` 里兜一次。
**其余三条 E2E 脚本(`e2e-m0.py` / `e2e-m0-user.py` / `e2e-m1-task.py`)还是老样子,
崩了会漏数据,下一轮一并按这个模式改。**
> 另外记一笔:清理残留账号时我写的用户名前缀过滤(`adm`)**把 `admin` 也匹配进去了**,
> 是服务端的「不能停用当前登录的账号,请换一个管理员账号操作」把它挡下来的。
> 那条护栏当初加的时候是为了防自锁,这次意外挡住了一次误删 —— 护栏的价值往往
> 不是在被设计的场景里体现的。
### ⚠️ 新增两条前端硬规则(来自 `frontend-pc/AGENTS.md` 与 `.agents/skills/`)
1. **未经用户明确要求,不要 `git commit` / `push`**(AGENTS.md 常驻规则)。
本轮改动**未提交**,工作区里留着。
2. **`defineEmits` 必须用函数调用签名**,禁止 tuple/对象形式:
```ts
// ✅
const emit = defineEmits<{ (e: 'save-success'): void }>()
// ❌ const emit = defineEmits<{ 'save-success': [] }>()
```
框架自带组件(GiForm / GiButton / GiTable)用的都是前者 —— 这是本项目已确立的写法。
我此前写的 5 个组件(`OrgFormModal` / `TaskFormModal` / `TaskOrgDrawer` /
`UserFormModal` / `UserImportModal`)都是被禁的 tuple 形式,**本轮已全部改正**。
改文件前先按 `frontend-pc/AGENTS.md` 的表格加载对应 skill(`.vue` → `code-vue`,样式 → `code-css`,
`.ts` → `code-ts`,CRUD → `project-crud`)。
---
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |
|---|---|
| **权限码定义在代码里**(`PermissionDef` / `BuiltinRole`),不建 `sys_permission` 表 | 权限码是前后端契约,放 DB 必然与代码漂移;一期无"权限配置界面"需求 |
| **租户拦截器默认拒绝**:匿名请求触及租户表直接抛 40101 | 漏加租户条件 = 越租户泄露全校身份证号,不可接受的失败模式;宁可让开发者立刻撞墙 |
| 匿名接口只靠 `@PublicApi` 放行,**不做路径白名单** | 两套机制会让"哪些接口公开"出现两个来源,改一处漏一处 |
| **登录时账号不存在也跑一次 BCrypt**(假哈希) | 否则响应时间差会泄露"账号是否存在",直接把密码喷洒成功率提高几个数量级 |
| 密码 RSA 加密传输 | 不是替代 HTTPS,而是防止网关/APM/链路追踪把明文密码记进日志 |
| 登出黑名单**存 token 的 SHA-256**,TTL 到自然过期 | token 本身是凭证,明文写进 Redis 等于复制了一份到别处 |
**四个必须实测的坑**(详见 `07-开发实施计划.md` §9,10 条实录)
Boot 4 / Flyway 10 / MP 3.5.9 这一批版本都在"把单体拆成模块",
导致**「依赖在、功能不在」的静默失败变多**。典型:只加 `flyway-core` 时迁移**不跑也不报错**。
**规则:每加一个中间件,必须实测它的核心动作真的发生了,不能只看依赖解析成功。**
---
## 一·五、客户回执(2026-09-28 · 需求已冻结)
> **客户全部 23 项按建议方案确认。** 唯一功能增量是 **B1:名单加入申请通道**。
| 组 | 需开发 | 说明 |
|---|:--:|---|
| A 认定机构 6 项 | **0** | 与现有设计完全一致(`min_long_side=1400`、`min_quality=70`、PNG 保留等都是当初按实测设计的) |
| B 校方业务 9 项 | **1** | **B1** 已落地:FR-509 系列 + `collect_join_request` 表 + 5 接口 + 2 屏原型 |
| C 商务法务 5 项 | 0 | C1 小程序资质推进中,H5 保底已列入 M0-13 预研 |
| D 内部技术 7 项 | 0 | **D4 已后置为二期**(一期不涉及零样本材料);**D7 已降级**(文件名识别准确率不影响任何交付项,残留只是上传页文案校准,班主任群里问两句即可)。**均已清除,无外部依赖** |
**规模变化**:FR 115 → **125** / 接口 117 → **121** / 库表 19 → **20** / VAL 20 → **21** / 错误码 50 → **57** / Golden 断言 29 → **36** / 功能验收 85 → **111** / 原型 29 → **31** 屏 / 任务 94 → **100** / 人日 380 → **392** → **387**(一期收窄 −5)/ 工期 14 → **15 周**。
**B1 的设计要点(三条边界,不要在后继开发中放水)**
| # | 边界 | 理由 |
|:--:|---|---|
| 1 | **只解决"进名单",不绕过门禁** | 申请期间仍不能填报;审批通过后才自动绑定放行 |
| 2 | **证件号与手机号仅存 HMAC + 脱敏值** | 与门禁口径一致 |
| 3 | **驳回必须逐条填原因;批量通过不提供"全选"** | 身份认定环节误操作代价极高 |
---
## 二、已锁定的铁律(不要重开讨论)
| # | 决策 | 关键理由 |
|---|---|---|
| 1 | **扫描件强制压到 ≤1M,无例外** | 认定机构硬要求 |
| 2 | 默认参数 **300dpi / q85** | 实测 28/28 达标,平均 806KB,清晰度零损失 |
| 3 | 材料来源只有 **聊天记录 / 相册 / 手机文件**,「直接拍照」默认关闭 | 实测 4 类材料无一来自现场拍照 |
| 4 | **必须先导名单**,姓名+身份证匹配不通过只提示、不放行(学校与社会收集皆然) | 门禁是产品根基 |
| 5 | 报考范围**从名单聚合**,不存在独立"计划清单"文件 | 真实名单验证 |
| 6 | 动态表单 = **内置字段(固定列,可统计)+ 扩展字段(extras_json,仅展示导出)** | 兼顾统计能力与灵活性 |
| 7 | 数据库 **PostgreSQL**(纯 PG);软删除字段名为 **`deleted`** | 客户已确认 |
| 8 | 班主任端**只做右滑通过**,驳回必须点按钮 | 误触后果不对称 |
| 9 | 批量通过**只带无问题项**,红黄一律排除 | 防"手快全选" |
| 10 | 有不合格文件时**直接禁用导出按钮**,不允许带问题打包 | 铁律 |
| 11 | 识别策略 **规则为主 + VLM 兜底**(触发率 2%~10%) | 成本可控 |
| 12 | 识别失败**一律放行**,不得阻断提交 | 可用性优先 |
| 13 | 原型**不用 emoji**(`→` `↔` `·` 等排版字符保留) | 设计约束 |
---
## 三、已完成交付物
| 文件 | 版本/大小 | 状态 |
|---|---|---|
| `docs/数据信息填报系统PRD.md` | V2.4 / 189KB / 3019 行 | ✅ 含 **125 条 FR**、**21 条 VAL**、**121 个接口**、**20 表 DDL**、实测附录 |
| `docs/数据信息填报系统PRD-功能逻辑卷.md` | V1.9 / 88KB | ✅ 纯功能逻辑,**不含库表/接口/代码** |
| `docs/db/schema-postgresql.sql` | 37KB | ✅ **PG 16.15 实测零错误**(20 表 / 177 列注释 / 65 索引 / 20 表注释) |
| `docs/prototype/student-app.html` | **11 屏** / 57KB | ✅ 含 STEP 03B「申请加入名单」 |
| `docs/prototype/teacher-app.html` | 9 屏 / 63KB | ✅ |
| `docs/prototype/admin-web.html` | **11 屏** / 114KB | ✅ 含表单设计器、材料规则配置、历史整理器、**申请审批** |
| `docs/prototype/index.html` | 7KB | ✅ 三端入口 |
| `docs/00-交付计划.md` | — | ✅ 7 项计划与进度记录 |
| `docs/01-客户与机构确认函.md` | 23KB | ✅ **P1** 23 项**已全部确认**(客户按建议执行,需求冻结) |
| `docs/02-需求追溯矩阵.md` | 10KB | ✅ **P2** 审计报告,含 10 个接口缺口 |
| `docs/03-接口设计.md` | **115KB / 2883 行** | ✅ **P3 121 个接口**字段级定义 + 枚举全集 + 12 个 VO + **58 个错误码**;§4 逐节声称数与表行数一致 |
| `docs/06-验收测试清单.md` | **33KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** |
| `../README.md` | **10KB** | ✅ **P7 文档导航**,按角色读法 + 快速上手 |
| `docs/04-核心模块设计说明.md` | **69KB / 1453 行** | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 |
| `docs/05-开发任务拆解.md` | **31KB / 506 行** | ✅ **P5 100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与核算口径 |
---
## 四、待办计划
| # | 任务 | 产出 | 状态 |
|---|---|---|---|
| ~~P1~~ | 客户与认定机构确认函 | `01-客户与机构确认函.md` | ✅ |
| ~~P2~~ | 需求追溯矩阵 | `02-需求追溯矩阵.md` | ✅ |
| ~~P2.5~~ | 管理端补 3 屏原型 | 并入 `admin-web.html` | ✅ |
| **P3** | **接口设计(OpenAPI)** | `03-接口设计.md` | ✅ **已完成**(117 个接口) |
| P4 | 核心模块设计说明(伪代码级) | `04-核心模块设计说明.md` | ✅ **已完成**(6 个模块) |
| P5 | 开发任务拆解 WBS(M0–M7) | `05-开发任务拆解.md` | ✅ **已完成**(94 任务 / 380 人日 / 14 周) |
| P6 | 验收测试清单(Golden Test Set) | `06-验收测试清单.md` | ✅ **已完成**(29 条 Golden 断言 + 85 条功能验收) |
| P7 | 文档导航 README | `../README.md` | ✅ **已完成** |
### P3 已补齐的缺口(21 个接口,原审计发现 10 项)
```
✅ I1 报考范围读取/确认 GET/PUT /api/v1/tasks/{id}/plan-scope
✅ I2 历史数据整理器 POST /api/v1/cleanup/jobs(共 5 个)
✅ I3 字典管理 /api/v1/dicts(4 个)
✅ I4 操作日志 GET /api/v1/logs/operations
✅ I5 登录日志 GET /api/v1/logs/logins
✅ I6 消息中心 /mp/messages、/mp/messages/read、/mp/teacher/messages
✅ I7 订阅额度登记 POST /api/v1/mp/subscribe
✅ I8 审核撤回 POST /api/v1/audit/submissions/{id}/revoke
✅ I9 任务暂停/恢复 POST /api/v1/tasks/{id}/pause、/resume
✅ I10 材料识别结果读取 已并入 GET /api/v1/audit/submissions/{id} 响应
➕ 文件规范化 process 原仅在 PRD §8.9 详述、未登记进清单,已补登
```
**接口总数:96(PRD 原有)+ 21 = 117**
---
## 五、关键事实速查
### 真实基准数据集(Golden Test Set)
`D:\项目\202609机电填报系统\24中技数控1班\` —— **28 人 × 4 类材料 = 112 文件**
| 实测项 | 结果 |
|---|---|
| 原始总量 | 475 MB |
| 命名违规 | 59 / 112(52.7%) |
| 双扩展名 | 56 |
| 体积超标(>1M) | 31 |
| 证件号异常 | 5 例(3 可自动、2 待人工) |
| 疑似串档 | 1(**唯一必须人工判断的**) |
| 压缩后 | 300dpi/q85 → 平均 806KB、最大 831KB、**0 张超标** |
| 材料识别 | 文件名层 112/112;形态层 99.1%;OCR 内容层 32/32 |
### 官方模板要点
- 21 列(A–U):**红=必填 11 项、绿=级联 6 项、黑=选填 4 项**
- **「报考职业」「报考工种」在 Excel 里没有下拉,是自由填写** ← 我方产品的核心增量
- 「职业技能等级个人申报表」的规范文件名却是 **「工作证明」**(机构既有习惯)
- **表头行与工作表名都不固定**(实测一份在第 1 行、一份在第 6 行)
### 三个易错实现陷阱
1. **「无」是合法业务值,不是空值**
2. **专项职业能力:无工种 / 无等级 / 无补考科目(0 科)**
3. 补考约束:三/四/五级最多 1 科,一/二级最多 2 科,专项 0 科
### 压缩五档(PRD §6.3)
A4 扫描件 300dpi(2480×3508)/q85 · 一寸照片固定 295×413 · 学籍表为 16:9 截图**保持 PNG 不缩放**
### 教师端数字口径(原型必须自洽)
28 人 = 已通过 15 + 待审 4 + 待核对 3 + 有问题 1 + 未提交 5;**完成率 82% 是已提交率(23/28)**
---
## 六、环境与工具
| 项 | 内容 |
|---|---|
| PG 验证容器 | `docker exec -it pg-verify psql -U postgres -d filldb`;清理 `docker rm -f pg-verify` |
| 原型校验脚本 | `python C:\Users\93292\AppData\Local\Temp\opencode\check2.py <html路径>` |
| DDL 同步方式 | 从主 PRD 的 ` ```sql ` 代码块自动抽取生成,改文档后重跑抽取脚本 |
| 原型约束 | 单文件自包含、零外部依赖、内联 SVG(`<symbol>`+`<use>`,1.7px 描边 currentColor)、无 emoji |
| 原型校验基线 | 四份全通过:标签配对 ✓ / CSS 变量 ✓ / SVG 无冗余 ✓ / emoji 0 ✓ |
---
## 七、重要教训
### 教训一:脚本审计前必须先验证脚本本身
P2 审计时脚本因未匹配加粗编号 ``| **FR-614** |``,误报 3 个"需求条目问题"(实为假警报);若直接采信并"修复",会引入 3 处新错误。同时 FR 总数由误算的 102 修正为 **115**。
> **⚠️ 这条教训在 2026-09-28 写开发实施计划时又复现了一次。**
> 我为交接摘要重算交付物体量,用了三个正则去数"接口数",分别得到 **74 / 95 / 121**——
> 三个都"看起来对",但没有一个经过验证。
>
> 定位过程:**逐节对比「标题声称数」与「实际表行数」**,发现
> ① 我漏了 `| ⭐ GET | ... |` 这种 ⭐ 在方法**前面**的行;
> ② §4.9 / §4.11 是**索引节**(交叉引用别处已计数的接口),本就不该计入。
> 修正后逐节声称数与表行数**全部为 0 差**,合计 **121**。
>
> **加固**:`scripts/verify-docs.py` 现在会做「**逐节声称数 vs 实际行数**」的一致性检查,
> 而不是只看总数对不对——总数相等也可能是个别节多算、个别节少算恰好抵消。
>
> **可复用的规则**:
> 1. 数字类断言必须**交叉核对至少两种独立方法**;
> 2. 核对要**分块**(逐节/逐表),不能只看总和;
> 3. 相互矛盾的中间值**一个都不要先写进文档**——写错再改,比一开始就空着更容易误导人。
### 教训二:写新文档必须回查已有编号体系,不能凭记忆
P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权威清单整体错位**:
| P4 原写法 | 我以为的含义 | PRD 实际定义 |
|---|---|---|
| VAL-01 | 姓名与名册一致 | **证件号格式与校验位** |
| VAL-03 | 证件号格式 | **姓名与名册一致** |
| VAL-06 | 双扩展名 | **文件格式白名单** |
| VAL-07 | 多扩展名冲突 | **文件体积上限** |
**后果**:若不修正,开发按 P4 实现会把规则完全接错,且两份文档互相矛盾。
**修正**:已全部重映射到 PRD 体系(单次扫描替换避免 01↔03 碰撞),并给 PRD 补了 P4 需要的三条新规则 **VAL-18 文件名规范化 / VAL-19 疑似串档 / VAL-20 体积下限保护**。
**规则**:新增文档引用任何已有编号(FR/VAL/错误码/接口)前,**先脚本抽取权威清单核对**,不凭记忆。
### 教训三:系统里的数据不会错,错的一定是「归属」
客户指出:**"系统应该不存在名称错误的情况。是先录入了有这个姓名和身份证,然后才能填写的。"**
这指出一个真实的设计缺陷:
| 位置 | 会不会错 | 说明 |
|---|---|---|
| **填报单里的姓名/证件号** | **永远不会错** | 门禁已核验 + 字段锁定。客户说得对 |
| 文件名里的姓名/证件号 | 可能不同 | **但这不是"名称错误",而是"这份文件可能不属于这个人"** |
**原设计的错误**:把"文件名姓证件号与名册不符"处理为「自动按名册修正」(信息级),
这会把"A 传了 B 的材料"**静默洗白**成 A 的材料——审核时完全看不出来,事后无从追溯。
```
学生 A 在微信里误选了同学 B 的材料
↓ 文件名:李四-3610...(B 的号码,校验位通过)
错误做法:判"与名册不符" → 改成 张三-3610... → 信息级,A 看不到
↓ 审核:班主任看到一份名字正确的材料 → 通过
↓ 最终:B 的工作证明被认定成 A 的 ← 灾难
```
**修正(客户已确认)**:
1. **判定优先级重排**为 `证件号反查名册 > OCR 内容 > 姓名(仅辅助)`
2. 新增 **VAL-21 文件归属校验**:归属存疑一律**阻断提交**,且**不提供"仍然继续"**
3. **姓名不再作为独立规则**——只在证件号佐证时按名册取值,仅留痕不打扰学生
4. 核心判据:**文件名证件号能否在名册里反查到"另一个人"**(一次查询,走唯一索引,< 1ms)
**精髓在于两条的区分**:
- **校验位失败** = 不是一个真实号码 → 只是写错了 → **可以自动改**
- **校验位通过** = 一个真实存在的自然人 → 改了就是改了身份 → **必须阻断**
### 教训五:联系方式不进系统
客户指出:**"学生本来就有班主任联系方式,不需要系统里面体现。"**
原设计在门禁失败页放了一个「联系老师」按钮(脱敏手机号 + 一键拨打),并为此专门开了一个接口 `GET /mp/roster/contact`。
已移除:
| 移除项 | 原因 |
|---|---|
| `GET /mp/roster/contact` 接口 | 不必要(用户已知道怎么找人)+ 有维护成本(换人/换号要同步)+ 多一处隐私暴露面 |
| 门禁失败页「联系老师」按钮 | 同上。改为只保留两条**系统内动作**:重新填写 / 申请加入名单 |
| 功能逻辑卷中的联系方式展示描述 | 同步 |
**保留**:纯文案提示(如"如有疑问请联系老师")——**不涉及任何联系方式数据**。
**接口总数**:122 → **121**。
### 教训四:「社会评价」与「社会收集」只差一个字
`cert_type`(认定类型:自主认定 / **社会评价** / 专项职业能力)决定**填什么材料**;
`collect_mode`(收集模式:学校收集 / **社会收集**)决定**谁审核**。二者可任意组合,已在 PRD §5.3 与接口 §2.3 分别写明。
另外补全了 `cert_type` 的第三个取值(专项职业能力)在 DDL 注释与附录中的遗漏。
### 附带修正
- **体积口径**:原型导出中心曾写"475MB → 22.4MB(95%)",与 PRD 及历史整理器屏(37MB/92%)矛盾。实为混淆——**444→22MB 是"工作证明"单项,475→37MB 是全班 112 文件合计**。已统一为 37MB / 92%。
---
## 八、外部依赖(未回)
`01-客户与机构确认函.md` 已发出待回,其中**最高优先级 3 项**:
- **A1** 认定机构是否书面接受 300dpi 扫描件
- **B1** 名单外的人怎么处理(建议加"申请加入名单→管理员审批"通道)
- **B3** 是否允许学生修改老师预填字段(建议:姓名/证件号/报考三项锁定,其余可改留痕)
未回复则按文档内建议方案推进。