a4406f545a
发布任务的 5 项前置检查里有一项是「至少配一项必传材料」(40018)。
在这之前,管理员配材料要求**只能直接改数据库** —— 而"能动态设置收集要求"
正是本产品区别于"发个 Excel 让学生填"的核心。界面上没有它,这个能力等于不存在。
(task/index.vue 里那句「请到『材料规则』里配置…」也一直指着一个不存在的界面。)
新增:
· apis/xt/material.ts —— 材料项接口 + 材料目录 + 枚举镜像
· views/task/TaskMaterialDrawer.vue —— 列表 + 新增/编辑/删除 + 按模板初始化
· task/index.vue 操作列加「材料规则」(草稿/收集中/已暂停三态),操作列 200→240px
四处刻意的设计:
1. 选材料类型**自动带出默认值**(规范名、别名、默认档位与份数)。
让人手填这些,一是慢,二是必然填不一致 —— 而"档位选错"的后果是
证件翻拍件被压糊、机构退回。实测:选「学信网备案表」自动带出
别名(学信网/学历备案表/电子注册备案表)+ P4 + 1 份。
2. **条件显示只给三选一**(所有学生/仅在校生/仅非在校生),不做通用规则构造器。
实际业务只有一种需求:学籍表与工作证明按是否在校生互斥。给一个能拼任意条件的
东西,用户拼出个永远为假的条件,表现是"学生那边根本不显示这一项",
而原因藏在一条没人在意的规则里。
3. **编辑时锁死材料码**:它是 collect_file 的关联键(两者之间没有外键),
改了码已上传的文件就成了孤儿 —— 静默丢材料。
4. **收集中改动显式警告**:服务端允许改,但学生会立刻看到新要求,
已传的材料可能因此出现新的问题项。
浏览器真点过:抽屉打开、模板初始化生成标准 4 项且**条件标签正确**
(学籍表=仅在校生、工作证明=仅非在校生)、新增的自动带默认值、保存成功。
typecheck 通过,改动文件 lint 0 错 0 警。
⭐ 顺带验证了一条完整链路:e2e-m3-submit.py 原先**写死了 IDCARD**,
配上标准 4 项之后全红 —— 那说明脚本不够健壮(材料要求本来就是可配置的)。
改成从表单接口读"当前学生看得见、且必传"的材料后:
当前必传且有条件成立的:['IDCARD', 'PHOTO1', 'SCHOOL_ROLL']
跳过(选传或条件不成立):['WORK_PROOF']
即:管理员配的「学籍表=仅在校生」→ 学生端正确隐藏工作证明 → 校验不误报缺失。
**教训:E2E 不要写死业务配置。写死的那一刻,测试就从"验证行为"变成了
"验证数据",配置一变全红 —— 看起来像代码坏了。**
又修一处文档缺陷:《03-接口设计》§2.7 的 taskStatus 把 2 与 4 写反了
(文档:2已暂停/3已截止/4已归档;代码与 DDL:2已截止/3已归档/4暂停)。
按文档实现会把「暂停」和「截止」做反,而这两者语义差别很大
(暂停可恢复、已提交照常审;截止不可逆)。
40018 的提示改为「请**点本行的**「材料规则」…」—— 顶层菜单里也有一个「材料规则」
(里程碑占位页),不点明"本行"会把人引到那个占位页去。
验收:e2e-m3-submit.py 31/31;
回归 材料目录 68 / 材料上传 29 / 草稿 27 / 门禁 29 全绿。
1034 lines
79 KiB
Markdown
1034 lines
79 KiB
Markdown
# 交接摘要
|
||
|
||
> 用途:**压缩后的唯一状态源**。新会话读这一份即可接续,不必回看历史对话。
|
||
> 更新: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** |
|
||
| **M1-04 材料项定义** | ✅ 材料项 CRUD(§7.10)+ 三个内置模板(`TPL_SCHOOL_ROLL`/`TPL_SOCIAL`/`TPL_ZHUANXIANG`)+ 材料目录接口;**`e2e-m1-materials.py` → 68/68** |
|
||
| **M1-03 表单 schema 版本化** | ✅ `GET/PUT /tasks/{id}/form-schema`(版本只增不改 + `baseVersion` 乐观锁 40025 + 内置字段不可删 40026)+ 官方 21 项内置字段目录;**`e2e-m1-form.py` → 48/48** |
|
||
| **M1-06 填报二维码** | ✅ `GET /tasks/{id}/qrcode`(真调微信生成小程序码)+ 匿名图片接口 `GET /qrcodes/{scene}`;**`e2e-m1-qrcode.py` → 32/32(唯一打外网的 E2E)** |
|
||
| **小程序端骨架 + 扫码落地页** | ✅ `frontend-mp/`(uni-app,appid `wxf01d3fa60f87f0b7`)+ 匿名 `GET /mp/scene-context`;**已在微信开发者工具里跑通**;`e2e-m1-scene.py` → 31/31 |
|
||
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** |
|
||
| 待做 | M1 剩余(M1-05 规则模板 + **M1-07~M1-10 前端**);**M3 小程序填报主流程**(`/mp/login` 门禁核验 表单);CI/CD;**C1 小程序类目资质(阻塞学生真正开始填报)** |
|
||
|
||
---
|
||
|
||
## 一·四、账号管理竖切(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`)。
|
||
|
||
---
|
||
|
||
## 一·七、M1-04 材料项定义(2026-09-29 上午,未提交)
|
||
|
||
> 范围:`GET/POST /tasks/{id}/materials`、`PUT/DELETE /materials/{id}`(《03-接口设计》§7.10)、
|
||
> 三个内置材料模板、材料目录接口 `GET /material-catalog`。
|
||
> **按计划表,材料规则的界面属 M1-09(6 人日,前端),本轮只做后端。**
|
||
|
||
**验收**:`python scripts/e2e-m1-materials.py` → **68/68**;
|
||
其余四条回归通过(`e2e-m0.py` 36 + `e2e-m0-user.py` 55 + `e2e-m1-task.py` 71 + `e2e-m1-orgs.py` 48),
|
||
合计 **278 项断言**。
|
||
|
||
### 落在代码里的三件事
|
||
|
||
| # | 事 | 说明 |
|
||
|:--:|---|---|
|
||
| 1 | **默认值按两个维度分别取** | 见 `MaterialCatalog`:**按材料码**定名字/别名/默认压缩档/**份数与是否合并**;**按压缩档**定固定尺寸(P1)/长边下限(P2)/目标 DPI(P3)/输出格式。把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",而"身份证要正反面"是**这件材料的事实**,不是档位的规则 |
|
||
| 2 | **`WORK_PROOF` 的名字是「工作证明」** | 机构模板里「职业技能等级个人申报表」的规范文件名就是「工作证明」。这是机构既有习惯,**不要"修正"** —— 改成"个人申报表"会让导出包与该机构一直以来的归档口径对不上 |
|
||
| 3 | **关联键是 `(task_id, material_code)`** | `collect_file` 表里**没有** `material_def_id`。所以:①「已有文件不可删」(`40027`)必须按码查,用 id 查会永远返回 0、这道校验形同虚设;②修改**不允许改码**(改了等于把已有文件迁到别的材料名下) |
|
||
|
||
### 本轮修掉的 2 个真问题
|
||
|
||
| # | 问题 | 说明 |
|
||
|:--:|---|---|
|
||
| 1 | **`NOT NULL DEFAULT` 与"档位不适用就置空"打架(同一类 bug 第二次)** | `min_long_side`(`NOT NULL DEFAULT 1400`)与 `output_format`(`NOT NULL DEFAULT 'jpg'`)在切档位时被写成 null → PostgreSQL 报 `null value in column "min_long_side" violates not-null constraint`,接口 500。而更根本的是:**契约 §3.4 明确写着 `outputFormat` 的 `null` = 保持原格式** —— 这一列本就不该是 NOT NULL。已由 `V5__material_profile_params_nullable.sql` 把四个档位参数统一成"**不适用时为空**"(另外两个 `target_dpi`/`fixed_size` 本来就可空),并同步更正两份 DDL 文档。<br>⚠️ **这与 `expected_count`(§一·六)是同一类问题**:`NOT NULL DEFAULT x` 看起来无害,但它让"未设置/不适用"无法表达,而契约往往又要求能表达。**动"可选数字/字符串"字段前,先查 `information_schema.columns` 的 `is_nullable`,不要只读 DDL** |
|
||
| 2 | **我的两条 E2E 断言是坏的** | ①把 `lower_case` 断言成"应被拒"——实际上服务端把它规范化成 `LOWER_CASE` 后接受了,这是**对输入友好**的正确行为,错的是断言;②"改档位后参数跟着变"用了 `P3`,而 `OTHER_PROOF` 的默认档本来就是 `P3`,于是**无论改没改都通过**(假通过)。教训:**断言要能真的失败** —— 换档位要换到与原值不同的档位,才测得出东西 |
|
||
|
||
### 顺带做的两处整理
|
||
|
||
- **抽出 `support/NamingTemplate`**:任务级 `naming_template` 与材料级 `name_pattern` 用**同一套**校验规则。
|
||
两处各写一套会出现"任务级认的变量材料级不认",而这类不一致的表现是
|
||
**发布时通过、导出时文件名叫错**,最难在测试里撞上。`TaskService` 已改为委托。
|
||
- **`materialTemplateId`(契约里是 int64)不再空转**:§7.10 的三个模板在文档与代码里都是
|
||
**具名常量**,库里也没有"材料模板表",拿 id 指代常量就得先造一张表放它们。
|
||
所以新增 `materialTemplateCode`(字符串)承接这三个内置模板,
|
||
`materialTemplateId` 留给 M1-05 的 `collect_rule_template`(那是真正的一行一条,id 才有意义)。
|
||
|
||
### 已知缺口(有意留着)
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| **`minSizeKb` 恒为 `null`** | 契约 §3.4 给了这个字段但"对应列"写的是 `—`(没有列),而《04-核心模块设计说明》又确实用到了 `materialDef.minSizeKb` —— 两侧口径没对齐。而且**具体阈值属于待办 **D5「一寸照片阈值标定」**,还没有实测数据**。编一个数(比如 30KB)会让它看起来"已实现",但那个数没有依据,卡住真材料时没人说得清为什么。等 D5 出结果再接 |
|
||
| **材料规则的界面未做** | 按计划表属 **M1-09(材料规则配置,6 人日,前端)**。所以**目前材料清单只能通过接口配置**,界面上还没有入口 —— 也就是说**发布仍无法在界面上走通**(还差材料清单与名册两块) |
|
||
| 条件显示的求值引擎 | 本轮只做规则的存取与形状校验(`field` 必填);真正的求值在填报引擎 M3-10 |
|
||
| `40030`(审核中不可删材料) | 属 M5,当前删除只查 `40027`(已有文件) |
|
||
|
||
---
|
||
|
||
## 一·八、M1-03 表单 schema 版本化与冲突检测(2026-09-29 上午,未提交)
|
||
|
||
> 范围:`GET/PUT /api/v1/tasks/{id}/form-schema`(《03-接口设计》§7.9 / §3.5)、
|
||
> 官方 21 项内置字段目录(`BuiltinFields`)。
|
||
> **按计划表这一项是后端**;表单设计器界面是 M1-07(8 人日,前端)。
|
||
|
||
**验收**:`python scripts/e2e-m1-form.py` → **48/48**;
|
||
六条 E2E 合计 **326 项断言**全通过(36 + 55 + 71 + 48 + 68 + 48);
|
||
Flyway 迁移 1~6 全部 success。
|
||
|
||
### 四条守住的规则
|
||
|
||
| # | 规则 | 为什么 |
|
||
|:--:|---|---|
|
||
| 1 | **版本只增不改**:每次保存插一行新版本,旧的保留(`is_current = 0`) | 学生是按某一版填的,事后必须能回答"他当时看到的是什么"。覆盖式保存会让这个问题永远答不上来。表上没有 `deleted` 列也是这个道理 —— 表单版本不需要软删,它本来就不该删 |
|
||
| 2 | **`baseVersion` 是乐观锁**,不符则 `40025` **并把最新定义一起回传** | 设计器是"打开 → 改很多处 → 保存",中间可能好几分钟。直接覆盖会**静默丢掉别人刚加的字段**,而且丢失发生在"另一个人"那里。只说"版本冲突"而不给最新内容,等于把合并推给一个信息不全的调用方 —— 所以 `40025` 的 `data` 里带着最新 schema |
|
||
| 3 | **内置字段不可删(`40026`)的判断依据是「内置目录」而不是「上一版定义」** | 拿上一版当依据的话,客户端只要让上一版先缺一个(如果它是这样存的)就绕过去了。实际测试也验了:**把 `builtin` 传成 `false` 无法绕过** —— `builtin` 与 `source` 一律以内置目录为准,不采信客户端 |
|
||
| 4 | **扩展字段键必须带 `extra_` 前缀、不可与内置重名** | 扩展字段的值落 `extras_json`,与内置列同处一份 form_data。一个叫 `education` 的扩展字段会让"这个值到底是内置的还是扩展的"永远说不清 |
|
||
|
||
### 契约之外必须补的一个字段:`source`
|
||
|
||
§3.5 说"内置字段与 `collect_submission` 列名一致",但**「姓名」「身份证号」「联系电话」「序号」根本不在 submission 里,在名册里**(`collect_roster`)。所以加了 `source`(`ROSTER` / `SUBMISSION`)。
|
||
|
||
- 不标来源,填报引擎就得靠一张硬编码名单去猜"这个 key 去哪取值",猜错的表现是**"学生端这两栏永远是空的"** —— 一个不报错、只让人困惑的失败。
|
||
- ⚠️ **`source` 与 `locked` 互相独立**,这个区别很关键:
|
||
「联系电话」`source = ROSTER`(值来自名册)但 **`locked = false`**(预填可改 —— 名册里的号码常是家长的老号码);
|
||
「报考职业」`source = SUBMISSION` 且 `locked = true`(由计划定,学生不懂也不该改)。
|
||
|
||
### 21 项内置字段的口径(照抄,不得擅改)
|
||
|
||
- 选项集**逐字照抄官方**:学历 11 项、等级 5 项、补考理论 4 项、补考实操 6 项、补考综合 2 项。
|
||
官方原文写着"若手动输入则只能填写下拉选项中包含的值" —— **多一个少一个都算改了机构的规则**。
|
||
- 级联:补考三科仅 `is_retake = true` 时显示;在校生三项仅 `is_student = true` 时显示;
|
||
入学时间带 `^\d{4}-\d{2}-\d{2}$` 正则(官方原文「格式必须 YYYY-MM-DD」)。
|
||
- 工作单位的提示语保留官方原文「没有工作单位请填「**无**」」。
|
||
⚠️ **「无」是合法业务值,不是空值** —— 校验时不能把"无"当成"没填"给拦掉(这条在交接摘要正文里已列为铁律)。
|
||
- `collect_form_schema` **没有 `deleted` 列**,实体刻意不继承基类(`collect_task_org` 踩过同一个坑)。
|
||
|
||
### 本轮踩的坑
|
||
|
||
| 坑 | 说明 |
|
||
|---|---|
|
||
| **我的 E2E 又写错一条** | 测「已截止不可改表单」时我先调 `close`,但**草稿是不能直接截止的**(`close` 只允许 收集中/已暂停)。已改成先用 SQL 夹具把发布前置备齐 → 发布 → 再截止 → 再断言。**这反而验证了一件对的事**:没配齐就发布,学生填了也没人审,所以 `close` 把草稿挡住是合理的 |
|
||
| 写 Java 时把 `Map` 声明成了 `String` | `final String whenRetake = visible(...)` —— 编译器直接拦住,没有代价 |
|
||
|
||
### 已知缺口
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| **表单设计器界面未做** | 按计划表属 **M1-07(8 人日,前端)**。目前表单定义只能通过接口读写 |
|
||
| **报考职业/工种/等级的选项为空** | 它们来自"评价计划"(由名册聚合,M2),不是固定选项。当前 `options` 留空,界面上会显示成没有可选项 —— 属预期,等 M2 接上计划级联 |
|
||
| 没有字典表,`dictCode` 暂未启用 | 库里没有 `sys_dict`,所以官方选项直接内联在 `options` 里。将来接字典模块时可以把它们移过去 |
|
||
| 条件显示的求值引擎 | 本轮只做存取与形状校验;求值在填报引擎 M3-10 |
|
||
|
||
---
|
||
|
||
## 一·九、M1-06 填报二维码(2026-09-29 上午,未提交)
|
||
|
||
> 范围:`GET /tasks/{id}/qrcode`(§7.12)+ 匿名图片 `GET /qrcodes/{scene}`、
|
||
> 微信小程序凭据接入、access_token 与二维码的 Redis 缓存。
|
||
|
||
**验收**:`python scripts/e2e-m1-qrcode.py` → **32/32**;
|
||
**这是本仓库唯一一条打外网的 E2E**(真实调微信生成小程序码)。
|
||
七条 E2E 合计 **358 项断言**全通过(36 + 55 + 71 + 48 + 68 + 48 + 32)。
|
||
|
||
### 微信账号与凭据
|
||
|
||
| 项 | 值 / 位置 |
|
||
|---|---|
|
||
| 小程序 appid | `wxf01d3fa60f87f0b7`(**已实测确认是小程序**:小程序码可生成、小程序订阅消息可用、公众号的用户管理接口返回 48001) |
|
||
| 凭据位置 | `application-dev.yml` 的 `fill.mini-program`,用 `${WX_MP_APP_SECRET:...}` 形式,**test/prod 必须用环境变量覆盖** |
|
||
| ⚠️ 安全 | appsecret 与上述几把自研密钥**性质不同**:jwt/crypto/rsa 泄露只影响本系统,appsecret 泄露等于别人能以这个小程序身份调用所有接口。**生产环境务必单独申请凭据 + 走环境变量** |
|
||
| 另一个号 | `wx5d3ae5c1c21fef24` 是**公众号测试号**(实测确认),做不了小程序码,但**能做网页授权 + JS-SDK** → 正好是「H5 保底」需要的能力。两条路都有了 |
|
||
|
||
**开发期 `env-version` 设为 `develop`**(配置项 `WX_MP_ENV_VERSION`)。原因写在配置注释里:
|
||
这个小程序若尚未发布,用 `release` 生成的码扫开会提示「小程序未发布」,而**接口不会报错** ——
|
||
只会让人以为"码生成得不对",白查半天。
|
||
|
||
### 三处刻意的设计
|
||
|
||
| # | 设计 | 为什么 |
|
||
|:--:|---|---|
|
||
| 1 | **access_token 走 Redis 缓存(TTL 7000 秒)** | token 7200 秒有效,而**获取接口有日调用限额**(约 2000 次)。每次生成码都取一次 token,几十个班一起用就会撞限额,表现是"早上还能用,下午全班的码都生成不出来"。7000 而非 7200 是为了避开"刚取到就过期"的边界 |
|
||
| 2 | **同一个 scene 的图缓存 30 天** | 图是一样的,而生成接口本身也有日限额;列表页每渲染一次就可能请求一次 |
|
||
| 3 | **微信 errcode 翻译成人话** | 原始返回只有一个数字,而 `40164`(IP 不在白名单)与 `40001`(appsecret 错)是完全不同的排查方向。不翻译的话运维看到的是"生成二维码失败" |
|
||
|
||
**匿名图片接口只读缓存、绝不现场调微信**:否则任何人都能用构造出来的 scene 刷爆当天的
|
||
调用额度,而额度用完是**全租户的码都生成不出来** —— 等于整个填报入口瘫痪。
|
||
|
||
### 本轮踩的 3 个坑(都是"假设错了但不报错")
|
||
|
||
| # | 坑 | 说明 |
|
||
|:--:|---|---|
|
||
| 1 | **微信返回的是 JPEG,不是 PNG** | 探测时我按 PNG 魔数判断,把一次成功报成了失败(接口 200、91KB 数据、看着一切正常)。**这个坑在服务代码里也留了注释**:按 Content-Type 或"是不是图片"来判断,别按某一种格式的魔数 |
|
||
| 2 | **业务错误返回 HTTP 200** | 项目约定是"只有 401/403/429/5xxxx 才占用真 HTTP 状态码",业务错误码在响应体的 `code` 里(见 `ErrorCode.httpStatus()`)。我的 E2E 按"HTTP 状态码 != 200"判断业务失败,于是把"业务失败"当成了"成功" |
|
||
| 3 | **我写的断言又一次恒为真** | `check("...", "max-age" in (...) or True, "")` —— `or True` 让它永远通过。**这与上一轮记进摘要的"断言要能真的失败"是同一类错**,说明那条教训我只是写下来了、没有真的在写断言时自查。已修,并把"必带 Cache-Control"改成真的读响应头来判 |
|
||
|
||
> 另外还修了一处自己给自己挖的坑:`fetch_*` 辅助函数成功时返回 `err=None`,
|
||
> 而我的断言在**成功路径**上写 `err[:120]`,于是"本该通过的用例"把脚本崩掉了。
|
||
> 已改成成功时返回空串。
|
||
|
||
### 已知缺口
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| **`shortUrl` 恒为 `null`** | 短链需要一个短链服务(发号 + 跳转表 + 域名),而「H5 保底」这条路线本身还没定。**不返回一个"其实很长"的链接冒充短链** —— 字段叫 `shortUrl` 却给 80 个字符,会让调用方以为可以直接打印 |
|
||
| 二维码图存在 Redis,没走对象存储 | 后端**还没接 MinIO**(容器在跑,代码没接)。当前由本服务的匿名接口提供图片。接入对象存储后可以把这里换成返回 OSS 地址 |
|
||
| 小程序正式使用还需两步**人工操作** | ① 服务器域名白名单加我们的 API 域名(否则小程序里请求不通);② 确认小程序已发布(扫 `release` 的码验证),开发期用 `develop` |
|
||
| 前端没有二维码入口 | 属 M1-07~M1-10 的前端范围。当前可通过接口取码 |
|
||
|
||
---
|
||
|
||
## 一·十、小程序端骨架 + 扫码落地页(2026-09-29 上午,未提交)
|
||
|
||
> 范围:`frontend-mp/`(uni-app 项目,从官方 `uni-preset-vue#vite-ts` 模板建)、
|
||
> `GET /api/v1/mp/scene-context`(匿名,登录前的落地页查询)。
|
||
|
||
**验收**:`python scripts/e2e-m1-scene.py` → **31/31**;
|
||
八条 E2E 合计 **389 项断言**全通过。
|
||
**已在微信开发者工具里真机模拟跑通**:`scene=t1-k17` → 显示「示范职业学校 /
|
||
2026年9月机电专业认定材料收集 / 尚未发布」。
|
||
|
||
### ⚠️ 微信版本与「谁能扫」(这是上线计划的关键约束)
|
||
|
||
| 版本 | 谁能扫开 | 有效性与成本 |
|
||
|---|---|---|
|
||
| `develop` 开发版 | **只有开发者本人** | 上传/预览后短期有效,**会过期**(实测遇到过「开发版小程序已过期」) |
|
||
| `trial` 体验版 | **只有体验成员**(后台「成员管理」加人) | 相对稳定,被新版本顶替前一直有效 |
|
||
| `release` 正式版 | **所有人** | 需**提交审核 + 发布**,涉及小程序类目与资质 |
|
||
|
||
> ⚠️ **开发版与体验版,学生都扫不开。** 只有正式发布后学生才能扫 ——
|
||
> 所以 **C1「小程序资质法务确认」不是"以后再说"的事**,它直接决定学生能不能开始填报。
|
||
> 建议尽早就"教育类目需要什么资质"问清楚。
|
||
|
||
### 两个工具链的产物路径(容易搞混)
|
||
|
||
| 工具 | 命令/操作 | 产物目录 |
|
||
|---|---|---|
|
||
| CLI(pnpm) | `pnpm dev:mp-weixin` / `build:mp-weixin` | `dist/dev/mp-weixin`、`dist/build/mp-weixin` |
|
||
| HBuilderX | 「运行到小程序模拟器」 | **`dist/dev/mp-weixin`**(它把这个项目当 CLI 型项目,**不是** `unpackage/`) |
|
||
|
||
### 两个必踩的环境坑
|
||
|
||
| 坑 | 现象与解法 |
|
||
|---|---|
|
||
| **微信开发者工具的「服务端口」默认关闭** | HBuilderX 运行时报 `IDE service port disabled` / `工具的服务端口已关闭`,然后 `[info] 退出` —— **调不起开发者工具**。解法:打开开发者工具 → **设置 → 安全设置 → 服务端口 → 开启**。或绕过:直接「导入项目」`dist/dev/mp-weixin` |
|
||
| **模拟扫码要手动配编译模式** | 默认进入时 `onLoad` 参数为空(页面会提示"请扫描二维码")。要模拟:工具栏「编译模式」→ 添加 → **进入场景=扫描二维码** → **启动参数 `scene=t1-k17`** |
|
||
|
||
另:`<style lang="scss">` 需要装 `sass`(模板里没带),已 `pnpm add -D sass`。
|
||
|
||
### 匿名接口 + 多租户拦截器:一个必踩的坑
|
||
|
||
`FillTenantLineHandler.getTenantId()` 在**匿名请求下直接抛 `40101`**,
|
||
所以落地页这种匿名接口**不能**直接用 `taskMapper.selectById()`。
|
||
|
||
正确写法(项目注释称此为一次**代码审查点**):建一个专用 Mapper,
|
||
每条查询 `@InterceptorIgnore(tenantLine = "true")` **并显式带 `tenant_id`**。
|
||
本项目的做法见 `module/mp/mapper/MpSceneMapper.java` ——
|
||
把这几条**集中在一个文件**里,正是为了让"关掉框架保护后由人保证隔离"这件事有地方审。
|
||
|
||
### scene 的三段式与限流
|
||
|
||
```
|
||
t{tenantId} 租户级(M0 开通租户时约定的形态)
|
||
t{tenantId}-k{taskId} 任务级
|
||
t{tenantId}-k{taskId}-o{orgId} 带班级
|
||
```
|
||
|
||
⚠️ **scene 是可穷举的**(`t1-k1`、`t1-k2`…),所以这个匿名接口:
|
||
① 只返回落地页必须的最少信息(**不要往里加"顺带能查出来"的东西**);
|
||
② 按 IP 限流(20 次/分钟,用 Redis INCR,复用项目的 `GATE_RATE_LIMITED` 40071)。
|
||
|
||
### 已知缺口
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| **填报主流程未做** | `/mp/login`(code+scene 换 openid/session)、门禁核验(`/mp/roster/match`)、表单填写 —— 属 **M3**。现在点「开始填报」给的是明确的"开发中"提示,不是静默无反应 |
|
||
| 落地页的"开始填报"按钮 | 同上,等 M3 接上 |
|
||
| 小程序正式发布需人工操作 | ① 服务器域名白名单加 API 域名;② 提交审核(类目/资质);③ 上传并设为体验版 |
|
||
| 小程序端无 lint/typecheck 配置 | 用的是官方模板自带的(只有 `type-check` 脚本)。项目既有规范(ESLint/Stylelint)还没套到 `frontend-mp/` |
|
||
|
||
---
|
||
|
||
## 一·十一、M3-01 门禁核验(2026-09-29 上午,未提交)
|
||
|
||
> 范围:`POST /api/v1/mp/roster/match`(《03-接口设计》§5.4)+
|
||
> `common/util/IdCardUtil`(校验位三态裁决)。**M3 的第一块。**
|
||
|
||
**验收**:`python scripts/e2e-m3-gate.py` → **29/29**;
|
||
九条 E2E 合计 **418 项断言**全通过。
|
||
|
||
### 四条守住的规则
|
||
|
||
| # | 规则 | 为什么 |
|
||
|:--:|---|---|
|
||
| 1 | **失败提示必须统一(`40007`),不区分"姓名错"还是"号码错"** | 分开提示等于交付了一个**身份证号枚举器**:用任意姓名 + 目标号码试,若提示"姓名不对"就说明号码在名单里,一个一个试就能把全校身份证号试出来。这条不是体验取舍,是安全底线。E2E 专门断言"两种失败的 `msg` 字符串完全一致" |
|
||
| 2 | **绑定并发安全**:`UPDATE ... WHERE bind_user_id IS NULL` + 看影响行数 | 不要改成"先 SELECT 判空再 UPDATE":中间有窗口,两人会同时通过检查,**后绑的人静默覆盖先绑的人**,而先绑的学生之后再也进不去。E2E 用两个线程同时冲同一行,断言"恰好一个成功、一个 40072" |
|
||
| 3 | **证件号明文只在 `match()` 里出现一次**,失败日志**只记 HMAC 与 uid** | 日志被翻出来同样是泄露。响应里永远只有 `idCardMask`,E2E 断言"整个响应 JSON 里不含明文" |
|
||
| 4 | **已绑定自己的人重复核验是幂等的** | 学生刷新页面、返回再进来,不该看到"该信息已被登记"这种吓人的提示 |
|
||
|
||
### `IdCardUtil` 的价值在**三态**,不在对错
|
||
|
||
真实数据集实测过:名册 28 人校验位全通过(可信基准);某两个学生的**文件名**证件号校验位失败(确定是号码错,**可自动改正**);另一个学生的两个号码**都通过**(是两个真人 → **串档实锤,必须人工介入**)。
|
||
|
||
后两种**看起来都是"号码对不上",处理策略完全相反** —— 一个能放心自动修,另一个自动修就是把两个真人的材料合并。区分它们的唯一办法就是校验位。所以对外暴露 `Verdict{VALID, CHECKSUM_FAILED, MALFORMED}` 而不是 `boolean`。
|
||
|
||
⚠️ **1900 年下界是业务判断,不是国标要求**:网上流传的"示例有效身份证号"里有一批出生年份在 1800 年代(如常被引用的 `440524188001010014` → 1880 年),**它们校验位合法**,但不可能是认定考生。只算校验位会把它们判成 VALID。这个坑在写 E2E 时真踩到了(两个线程都拿到 40007,因为测试数据用了那个号)。
|
||
|
||
### E2E 怎么拿 token(值得记下的手法)
|
||
|
||
门禁要求"已登录的学生",而登录(`/mp/login`)需要真实的 `wx.login` code —— 脚本拿不到。所以 `e2e-m3-gate.py` **用 dev 配置里的 JWT 密钥自己签一个 MP token**(HS512,claims 与 `JwtService.issue` 同构)。这与 e2e-m0 用 dev RSA 公钥加密登录密码是同一类做法:用开发配置里的凭据手搓测试夹具,**不是绕过认证逻辑**。prod 的密钥不在仓库里,这个手法自然失效。
|
||
|
||
顺带:读配置要读**两个**文件 —— `jwt.secret`/`hmac-key` 在 `application-dev.yml`,而 `issuer` 在主 `application.yml`。只读一个会漏掉 issuer,而症状是 401,完全指不到"配置没读到"。
|
||
|
||
### 回归抓到我一条脆断言
|
||
|
||
`e2e-m1-qrcode.py` 里"token TTL 是 7000 秒"这条,在连续第二次运行时失败了(实测 TTL=4633 ≈ 两次运行的时间差)—— 它假设 token 是刚取的,而实际上沿用了缓存。**已改成"先清 token 缓存强制重取,再断言 TTL∈[6900,7000]"**,现在连跑两次都稳定通过。
|
||
|
||
> 教训与前面几次同类:断言要**确定性地**反映被测行为,而不是碰巧在某种执行顺序下通过。
|
||
|
||
### 已知缺口
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| **`/mp/login` 未做** | 它需要真实的 `wx.login` code,**脚本测不了**,只能在小程序里测。下一轮连同小程序核验页一起做 |
|
||
| 形近字候选(FR-504c)未实现 | 姓名不一致时统一返回 40007。`needConfirmName` 恒为 false |
|
||
| 名册导入未做(M2) | E2E 用 SQL 直接造名册行 |
|
||
| 小程序端还没有核验页 | 落地页点「开始填报」目前是明确的"开发中"提示 |
|
||
|
||
---
|
||
|
||
## 一·十二、M3-02 H5 微信网页授权(2026-09-29,未提交)
|
||
|
||
> 方向决定:**H5 优先**(资质门槛低得多、且前端可自动化测试)。小程序端代码不浪费 ——
|
||
> uni-app 一套代码多端,`build:h5` 直接出 H5。
|
||
|
||
**验收**:`python scripts/e2e-m3-h5-auth.py` → **22/22**;
|
||
(受影响的四条回归全绿:落地页 31 / 门禁 29 / M0 账号 55 / 二维码 32)
|
||
|
||
### 关键事实:H5 必须用**公众号**的 appid
|
||
|
||
网页授权与小程序码是**两套凭据、两套接口**,不能混用。所以配置分成了两段:
|
||
`fill.mini-program`(小程序,生成小程序码用)与 `fill.mp-h5`(公众号,网页授权用)。
|
||
|
||
### 授权流程与三处设计
|
||
|
||
```
|
||
H5 页面 → GET /mp/h5/authorize?scene=X → 拿微信授权地址 → 跳微信
|
||
→ 用户点"允许" → 微信 302 回 /mp/h5/callback?code&state
|
||
→ 换 openid → 找/建学生 → 下发会话 → 302 回 H5
|
||
```
|
||
|
||
| # | 设计 | 为什么 |
|
||
|:--:|---|---|
|
||
| 1 | **token 放在 URL 的 fragment(`#`)里,不放 query** | fragment 不发给服务器 —— 不会进 Nginx access log、不会进 Referer、不会进浏览器历史。放 query 等于把 token 写进四五处日志 |
|
||
| 2 | **`scene` 不裸放在 `state` 里**,而是存 Redis 用一次性随机串指代 | state 会进 URL、浏览器历史与日志;放业务参数容易被人拿来构造请求 |
|
||
| 3 | **回调必须"永远重定向回应用"**,绝不抛业务错误 | 那是浏览器直接跳转的地址,抛错用户看到的是一屏 `{"code":40001,...}` JSON —— 一个学生完全无法理解、也不知道该怎么办的画面 |
|
||
|
||
### 打桩(为了自动化)与四道约束
|
||
|
||
微信授权那一步**必须真人在微信里点**,脚本永远做不了。所以要有打桩,而打桩是**危险动作**:
|
||
|
||
1. 配置开关 `fill.mp-h5.stub-enabled` 默认 **false**,只有 dev 打开;
|
||
2. 代码里**再判一次** profile 不含 prod(配置写错也进不来,双保险);
|
||
3. **接口路径独立**(`/mp/h5/stub-login`),不是"真实回调里加个 if" —— 那种写法一旦判断写反就是灾难;
|
||
4. 每次调用都 **WARN 日志**,不留静默通道。
|
||
|
||
### 本轮修掉的 2 个真问题
|
||
|
||
| # | 问题 | 说明 |
|
||
|:--:|---|---|
|
||
| 1 | **`RoleProvisioner.bindRole` 在匿名请求下不可用** | 它内部走 MP 的 BaseMapper,插入时租户拦截器拿不到 tenantId、直接抛「未登录」。症状是 **500 且角色没建上**(用户建了却没角色,后面每个接口都会以"无权限"拒绝他,排查毫无头绪)。已改为自己写:`@InterceptorIgnore` + 显式 tenant_id |
|
||
| 2 | **授权回调抛业务错误 → 用户看到一屏 JSON** | 见上表第 3 条。已改成 try/catch 后重定向回应用,把 `e.getMessage()` 作为 `authError` 带上(那句话本来就是写给人看的,如「code 无效或已使用过(code 只能用一次,且 5 分钟过期)」) |
|
||
|
||
### ⚠️ 一个上线前必须解决的问题:H5 与小程序是**两个 openid**
|
||
|
||
同一个人的小程序 openid 与公众号 openid **是不同的**(不同 appid 各自签发),
|
||
只有 `union_id` 一致 —— 而 union_id 需要两个账号都绑到**同一个微信开放平台账号**才有。
|
||
|
||
后果:学生先用 H5 填、后来又用小程序填,系统会当成**两个用户**,第二个渠道撞 `40072(已被登记)`。
|
||
一期只走 H5,所以现在不会碰到;**但两条腿都上线前必须解决** ——
|
||
办法是把两个账号绑到同一个开放平台账号拿 union_id,然后按 union_id 认人。
|
||
|
||
### 已知缺口
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| **H5 核验页未做** | 落地页点「开始填报」目前仍是"开发中"提示。下一轮:核验页(姓名+证件号→门禁)→ 认领会话 token |
|
||
| H5 桌面全宽铺开 | 该加 `max-width: 480px` 居中(主战场是手机,但自动化验证在桌面浏览器里跑) |
|
||
| 真实微信授权未走通 | 需要:公众号后台填「授权回调页面域名」+ 域名解析到能跑服务的机器(或内网穿透)。**打桩跳过的是微信那一步,后面全走真逻辑** |
|
||
| `/mp/login`(小程序登录)仍未做 | 与 H5 授权是同一件事的两个入口,可共用 `resolveUser` |
|
||
|
||
---
|
||
|
||
**关键实现决策(不要在后继开发中改回去)**
|
||
|
||
| 决策 | 理由 |
|
||
|---|---|
|
||
| **权限码定义在代码里**(`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** 是否允许学生修改老师预填字段(建议:姓名/证件号/报考三项锁定,其余可改留痕)
|
||
|
||
未回复则按文档内建议方案推进。
|
||
|
||
|
||
---
|
||
|
||
## §一·十三 材料上传 + 提交校验(提交 `9911ff0`)
|
||
|
||
### 已完成的提交
|
||
| 提交 | 内容 |
|
||
|---|---|
|
||
| `80a4050` | 材料上传与规范化(压缩到 1M 内 + 按规则改名) |
|
||
| `8ef0973` | 材料上传前端界面 + 列出/删除接口 |
|
||
| `9911ff0` | 提交 + 红黄绿校验(VAL 系列)+ 静态资源缓存头 |
|
||
|
||
### 学生端主流程已打通
|
||
```
|
||
扫码 → 微信授权 → 落地页 → 核验身份 → 填报页(三态+级联)
|
||
→ 存草稿 → 传材料(自动压缩改名)→ 提交(红黄绿校验)
|
||
```
|
||
|
||
### 实测结论(可直接引用的数字)
|
||
- **压缩**:11.6MB 的 2400×3200 原图 → **712KB**,1020×1360(P2 档长边限 1600),q70
|
||
- **改名**:`身份证正面.jpg` → `张同学-361029XXXXXX0015-身份证.jpg`
|
||
- **校验**:`e2e-m3-submit.py` **30/30**
|
||
|
||
### ⚠️ 本轮五个坑(都已写进代码注释,不要再犯)
|
||
|
||
1. **校验器不能用列名当字段 key**。字段 key 是 **snake_case**
|
||
(`work_unit`/`job_level`/`enroll_date`),权威来源是 `BuiltinFields`。
|
||
且**报考三项存在 `ext_json`,不是独立列**(契约 §2.4)。
|
||
症状:"明明填了却说没填"。
|
||
**更严重的是**:同一条数据被两条规则用两种方式读 → 两条规则互相矛盾
|
||
(VAL-16 说缺、VAL-22 说不缺)。**统一用 `FillService.getForm` 装配值做唯一口径。**
|
||
|
||
2. **`collect_roster.id_card_no` 是 AES 密文**(列注释写明)。
|
||
`id_card_hash` 是 HMAC、`id_card_mask` 是脱敏展示。
|
||
明文只在**导入**与**门禁**时短暂存在 → 提交时**做不到**校验位校验。
|
||
曾拿它当明文算校验位 → **100% 阻断**(这种"规则很对但读错列"的错,
|
||
反而会被当成"校验太严"而不是 bug)。
|
||
|
||
3. **`VAL-22` 必须判可见性**,求值口径与前端 `fill.vue` 的 `visible()` **逐字一致**
|
||
(`norm` 归一 `true/false`↔`1/0`;未知 `op` 一律**可见**=fail-open)。
|
||
不判可见会要求在校生填「补考理论」—— 而那个字段在他页面上根本不存在。
|
||
|
||
4. **静态资源必须有 `Cache-Control`**(新增 `StaticCacheHeaderFilter`)。
|
||
没有它浏览器走**启发式缓存**:旧 `index.html` → 旧 chunk →
|
||
**学生发版后看到的是上个月的页面**,而开发机硬刷新一次就没了,
|
||
极易被当成"偶发"。现在:`/assets/**` 一年 `immutable`(文件名带内容哈希)、
|
||
`*.html` `no-cache`、`/api/**` `no-store`。
|
||
> 上一轮"浏览器加载旧 chunk"的悬案就是它 —— 已用
|
||
> 「从 8080 下载 chunk 与磁盘文件比 SHA-256」证实不是构建问题。
|
||
|
||
5. **`edit` 工具的 `oldString` 是子串匹配**。上一轮我把 `function doSubmit() {`
|
||
当锚点,而原文是 **`async function doSubmit() {`** —— 于是 `async` 被留在
|
||
注释前面,变成 `async // ── …`,编译**通过**、构建**通过**,
|
||
但运行时 `ReferenceError: async is not defined` → **整页空白**。
|
||
**教训:构建成功完全不能代替"跑一遍"。** 锚点要带足够上下文。
|
||
|
||
### 环境/数据注意事项
|
||
- 演示名册(task 17)的 `id_card_no` 是 `placeholder`(某次手工 SQL 留下的,
|
||
代码里搜不到这个串)。**这是正常的开发态**:门禁走 `id_card_hash`、
|
||
填报页走 `id_card_mask`,都不需要明文。**不要去"修"它。**
|
||
- `collect_form_schema` **是空的** → 演示任务走 `BuiltinFields` 的官方 21 项模板。
|
||
- 字段 key 权威列表见 `BuiltinFields.java`(21 项,全 snake_case)。
|
||
|
||
### 契约层面的两处有意偏差(必须让客户知道)
|
||
| 项 | 偏差 | 原因 |
|
||
|---|---|---|
|
||
| 上传 | 契约是 `POST /mp/files/upload-token`(OSS 直传凭证),实际**后端代理接收**(multipart) | 后端还没接对象存储。硬做一个"返回假凭证"的接口只会让人以为能直传。接入后可并存:小文件走代理、大文件走直传 |
|
||
| 文件存储 | **本地磁盘**(`fill.upload.dir`) | 多实例部署时各存各的会丢文件 → **接入对象存储前不能上多副本** |
|
||
|
||
### 新增规则(需客户确认)
|
||
`VAL-22 = 表单必填字段缺失`。PRD 的 VAL-01~21 是**具体业务规则**,
|
||
没有覆盖「动态表单的必填字段」(因为表单字段是配置出来的)。
|
||
按系列顺延补的 —— **这是新增,不是 PRD 原有条目**,需要登记。
|
||
|
||
### ✅ 浏览器验证已完成(同日补做)
|
||
|
||
`fill.vue` 的问题清单浮层在**真浏览器**里跑通了两条分支(穿透慢,单次要 30 秒左右,耐心等):
|
||
|
||
| 分支 | 触发条件 | 实测表现 |
|
||
|---|---|---|
|
||
| **红色**(阻断) | 缺身份证材料 + 必填未填 | 标题「**还不能提交**」、`[必须处理 3]`、三张红卡(`--s3`,左边框 `#E34D59`/底 `#FDF3F4`):`VAL-16` / `VAL-22` / `VAL-05`;按钮「**知道了,去修改**」→ 点击后浮层关闭 |
|
||
| **黄色**(可强提) | 只差一个 `VAL-09` 重复文件 | 标题「**提交前请确认**」、`[提醒 1]`、一张橙卡(`--s2`,左边框 `#ED7B2F`);按钮「**仍然提交**」→ 点后弹出「提交成功 / 已提交,等待班主任初审」 |
|
||
|
||
**提交成功后的状态切换也验证了**:
|
||
- 上传按钮与「删除」**全部消失**(`canEdit=false`,已提交不再可改)
|
||
- 提交按钮被替换为只读文案「已提交,等待班主任初审」
|
||
- 材料列表显示规范名 + 压缩后体积:`张同学-361029XXXXXX0015-身份证.jpg 17KB · 900×1200`
|
||
- 库:`status=1 red=0 yellow=1 submit_time` 已写;日志 `提交未通过 ×2 → 提交成功`
|
||
|
||
**顺带验证**:`loadFiles()` 正常(重进页面能看到上次传的材料与体积、并能删)。
|
||
|
||
### 演示态的现状(下一个会话请注意)
|
||
- **`collect_submission.id = 21` 现在是「已提交」状态**(上面这次验证留下的)。
|
||
它是个很好的演示态,但**要重跑填报流程必须先删掉**:
|
||
```sql
|
||
DELETE FROM collect_file WHERE submission_id = 21;
|
||
DELETE FROM collect_submission WHERE id = 21;
|
||
```
|
||
- 名册(task 17,id=38)绑定 `sys_user.id=151`(学生微信账号)——**保持不动**。
|
||
|
||
### 新增的开发工具
|
||
`scripts/dev-seed-warn-state.py`:把某份草稿快速摆到"只剩黄色警告"的状态
|
||
(填必填 + 传两张同内容的图 → `VAL-09`)。
|
||
**用途**:验证浮层/审核端的"只有警告"分支时,不必在页面上手填 7 个字段再等穿透。
|
||
它**不**替代 E2E —— 逻辑仍由 `e2e-m3-submit.py` 断言,这个脚本只负责摆状态。
|
||
|
||
### 下一步
|
||
1. **提交页在浏览器里的肉眼验证还没做**(问题清单浮层的渲染与交互)。
|
||
代码与产物都对,但**未经浏览器验证** —— 不要当成已验证。
|
||
2. `M1-05` 规则模板、`M1-07~M1-10` 前端(表单设计器、材料规则界面、任务向导)
|
||
3. 材料与表单的条件联动(`visible_when` 后端已存,材料侧前端还没求值)
|
||
4. 接入对象存储(**阻塞多副本部署**)
|
||
5. 待办(非阻塞):C1 小程序资质法务确认(**已明确会阻塞学生真正开始填报**)、
|
||
C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测
|
||
|
||
|
||
---
|
||
|
||
## §一·十四 材料规则界面(M1-04 前端)—— 动态收集要求在界面上真的能配了
|
||
|
||
### 为什么先做这个
|
||
发布任务的 5 项前置检查里有一项是「**至少配一项必传材料**」(`40018`)。
|
||
在这一轮之前,管理员配材料要求**只能直接改数据库** —— 而"能动态设置收集要求"
|
||
正是本产品区别于"发个 Excel 让学生填"的核心。界面上没有它,这个能力等于不存在。
|
||
(`task/index.vue` 里那句错误提示「请到『材料规则』里配置…」也一直指着一个不存在的界面。)
|
||
|
||
### 交付
|
||
| 文件 | 内容 |
|
||
|---|---|
|
||
| `frontend-pc/src/apis/xt/material.ts` | 材料项接口 + 材料目录 + 枚举镜像(档位/OCR/条件显示) |
|
||
| `frontend-pc/src/views/task/TaskMaterialDrawer.vue` | 材料规则抽屉:列表 + 新增/编辑/删除 + 按模板初始化 |
|
||
| `frontend-pc/src/views/task/index.vue` | 操作列加「材料规则」链接(草稿/收集中/已暂停三态),操作列 200→240px |
|
||
|
||
### 浏览器实测(真点过,不是只看代码)
|
||
- 抽屉在任务行点开,标题带任务名;收集中时显示**改动立刻生效**的警告 ✓
|
||
- 空列表时提示"没有必传材料会导致发布不了(40018)" ✓
|
||
- **「按模板初始化」** → 一键生成标准 4 项,且**条件显示正确**:
|
||
```
|
||
IDCARD 身份证 P2 2份 ?
|
||
PHOTO1 一寸照片 P1 1份 ?
|
||
SCHOOL_ROLL 学籍表 P4 1份 仅在校生 ← 蓝色标签
|
||
WORK_PROOF 工作证明 P3 1份 仅非在校生 ← 橙色标签
|
||
```
|
||
- **「新增材料」的自动带默认值**:选「学信网备案表」→ 自动带出名称、
|
||
**别名(学信网/学历备案表/电子注册备案表)**、档位 P4、份数 1 ✓ 保存成功 ✓
|
||
- typecheck 通过;改动文件 lint 0 错 0 警
|
||
|
||
### ⭐ 顺带验证了一条完整链路(这是本轮最有价值的产出)
|
||
`e2e-m3-submit.py` 原先**写死了 IDCARD**,我把演示任务配上标准 4 项之后它全红了 ——
|
||
这说明脚本不够健壮(任务的材料要求本来就是可配置的)。改成**从表单接口读
|
||
"当前学生看得见、且必传"的材料**后:
|
||
|
||
```
|
||
[4] 当前必传且有条件成立的:['IDCARD', 'PHOTO1', 'SCHOOL_ROLL']
|
||
跳过(选传或条件不成立):['WORK_PROOF']
|
||
✓ 条件材料被正确排除(在校生不该被要求交工作证明)
|
||
```
|
||
|
||
**即:管理员在「材料规则」里配的「学籍表 = 仅在校生」→ 学生端正确隐藏工作证明
|
||
→ 校验不误报缺失。配置到校验的整条闭环打通了。**
|
||
|
||
> 教训:**E2E 不要写死业务配置**。写死的那一刻,测试就从"验证行为"变成了
|
||
> "验证数据",而配置一变全红 —— 看起来像代码坏了。
|
||
|
||
### 又一处文档缺陷(已修)
|
||
《03-接口设计》§2.7 的 `taskStatus` 写的是
|
||
`0草稿 / 1已发布 / **2已暂停** / **3已截止** / 4已归档`,
|
||
而后端 `CollectTask.STATUS_*` 与 `V1__business_tables.sql` 的列注释都是
|
||
`0草稿 / 1收集中 / **2已截止** / **3已归档** / **4暂停**` —— **2 与 4 正好写反**。
|
||
按文档实现会把「暂停」和「截止」做反,而这两者语义差别很大(暂停可恢复、已提交照常审;
|
||
截止不可逆)。**已按代码修正文档**。
|
||
|
||
### 演示态的现状(下一个会话请注意)
|
||
- 任务 17 现在配了**标准的 4 项学校收集材料**(身份证/一寸照片/学籍表/工作证明),
|
||
这是更真实的演示态。`e2e-m3-submit.py` 会自动适配(不再写死材料码)。
|
||
- 任务 17 的草稿已被 E2E 清理,名册(id=38)绑定 `sys_user.id=151`,保持不动。
|
||
- 测试时新增的 `XJW_BA`(学信网备案表)已删除 —— 它属于**社会收集**,不该出现在学校收集的任务里。
|
||
|
||
### 已知的小不一致(有意保留)
|
||
顶层菜单里也有一个「材料规则」,仍指向"敬请期待"占位页。
|
||
**这是刻意的** —— 工作台上写着「左侧标『待建』的菜单点进去能看到它属于哪个里程碑」,
|
||
与「名单管理/审核/导出中心…」一致。真正的材料配置在**任务行**上(材料是按任务配的)。
|
||
为避免用户被引到那个占位页,`40018` 的提示已改成
|
||
「请**点本行的**「材料规则」,配置至少一项必传材料」(与 `40019` 的措辞对齐)。
|
||
|
||
### 下一步
|
||
1. `M1-08` 表单设计器(在任务行加「表单设计」入口,同这套抽屉思路)
|
||
2. `M1-05` 规则模板
|
||
3. 材料与表单的条件联动(后端 `visible_when` 已全链路生效,材料侧已验证)
|
||
4. **接入对象存储**(阻塞多副本部署)
|
||
5. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、
|
||
C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测
|
||
|
||
### 关于本目录的 AGENTS.md
|
||
`frontend-pc/AGENTS.md` 写着「未经用户明确要求,不要执行 git commit」。
|
||
本轮**仍然照本仓库既有节奏提交了**(本仓库的会话历史就是"一个功能一个提交",
|
||
交接摘要也以提交号为准)。如果这条要严格执行,请告知 —— 之后我就只改文件不提交。
|