623e6393d5b222266da8ebec9200fdb6bc36d6d5
76 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b9373d6ebd |
fix(config): 本地私有配置改用 profile 机制(spring.config.import 优先级不够)
起因:用户把七牛 AK/SK 填进了 application-dev.yml(**会提交的文件**)。 已把密钥挪到 jar 外的 backend/application-local.yml(gitignore), 并把 dev 文件还原成空占位符 —— 确认过待提交差异里没有密钥。 ⚠️ 排查中发现一个**沉默的坑**:一开始用 spring.config.import: optional:file:./application-local.yml 加载本地密钥,结果**启动正常、日志不报错、但配的 provider 完全没生效** (还是本地磁盘)。三步定位: ① 确认 YAML 合法(结构/编码/缩进都对)→ 不是文件问题 ② 换 --spring.config.additional-location=file:./... → **生效了** → 文件与内容没问题,问题在 import 这条路 ③ 把 import 改成**非可选 + 指向不存在的文件** → 启动失败并报 "Config data resource ... does not exist" → 证明 import **确实被处理了**,是**优先级**问题 根因:spring.config.import 导入的文件排在 **profile 文件之后(优先级更低)**, 于是 application-dev.yml 里的 storage.provider 把导入文件里的 provider: qiniu 盖掉了 —— 而这一切没有任何报错。 修法:改用标准机制 —— 文件恢复成 profile 名 application-local.yml, application.yml 里加 spring.profiles.include: local。 profile 文件后加载、优先级更高。启动日志现在打印 The following 2 profiles are active: "local", "dev" 那个坑已写进 application.yml 的注释里,免得后人再踩。 教训:**"配置了但没生效、且不报错"是最难查的一类问题。** 判断某个配置机制到底有没有被执行,有个通用办法: **把它改成一个必然会失败的值** —— 如果照样跑起来,说明它根本没被处理。 ⛔ 仍阻塞:用户填的 AK/SK **验证无效**(七牛 401 bad token)。 两个互相独立的签名实现都失败,所以不是代码问题: ① 七牛官方 SDK 的 Auth.uploadToken(自检走的就是它) ② 手写的管理 API 签名(GET https://rs.qiniu.com/buckets) 线索:AK 40 字符正常、**SK 41 字符**(七牛的 AK/SK 都是 40 字符的 URL 安全 base64); 但逐字符试"去掉首/尾字符"三种组合都被拒 → 不是收尾多一个字符。 需要用户到七牛控制台 → 密钥管理重新完整复制一对。 为不阻塞本地开发,application-local.yml 里 provider 先留 local(密钥保留着, 换成有效的之后去掉一行注释即可)。回归:e2e-m3-file 29/29、m3-submit 31/31。 |
||
|
|
65ebf8de90 |
feat(storage): 对象存储接七牛云 —— 解除"不能上多副本"
文件以前直接写本地磁盘:两个实例各存各的,学生这次传到 A、下次请求落到 B,
文件就"不见了"。这不是性能问题,是正确性问题 —— 它一直卡着不上多副本。
新增:
· module/file/storage/{StorageService,LocalStorageService,QiniuStorageService}
· config/StorageConfig(装配 + 可选启动自检)
· FillProperties 新增 fill.storage
· MaterialFileController 新增 GET /{id}/url(签名链接)与 GET /{id}/content
· scripts/e2e-m1-storage.py(存储自检,没配七牛时跳过而非失败)
关键设计:
① 业务只认 path(std/1/17/张同学-…-身份证.jpg),各实现自己映射成真实位置:
本地 ./data/uploads/std/...、七牛 xiaotutongxue/std/...
**换存储不用改数据库、不用改业务代码**
② 空间私有 → 下载必须签名。GET /{id}/url 每次现签(默认 30 分钟),
所以这个 URL 绝不能长期保存:写进库、塞进导出的 Excel、发给学生明天再打开,都是 403
③ 对象键里有中文,拼 URL **必须逐段编码** —— 整串 encode 会把 / 编成 %2F,
那在七牛那边是另一个键,表现是"传上去了却打不开",且报错什么都没有
④ 区域默认 auto:华东-浙江在新旧控制台对应 z0 与 cn-east-2,
写错了的症状是**上传超时**——一个从报错完全看不出原因的失败
⑤ AK/SK 不进仓库也不进 jar:dev.yml 只放占位符;
真密钥在 backend/application-local.yml(已 gitignore),
用 spring.config.import: optional:**file**:./application-local.yml
—— 刻意用 file: 而不是 classpath:,后者会被打进 jar,密钥就跟着构建产物走了
⑥ 配了 qiniu 却没密钥 → 启动就炸(fail-fast):起得来的话第一个受害的是
正在填材料的学生,而排查要从"文件保存失败"一路追到环境变量
已验证:存储抽象改造没破坏任何现有链路 —— 本地磁盘路径下
e2e-m3-file 29/29、m3-submit 31/31、m1-materials 68/68、m3-draft 27/27 全绿;
启动日志打出「本地磁盘(多副本部署下会丢文件)」;git check-ignore 确认
application-local.yml 被忽略而 .example 不被忽略。
⏳ 未验证:**七牛的真实链路没跑过** —— 用户给了域名/空间/区域/私有/目录前缀,
但没给 AccessKey/SecretKey,没有它生成不了上传凭证。
补上后跑 python scripts/e2e-m1-storage.py 即可(它会验:配置齐全 →
上传处理落进七牛 → 取签名链接 → 真的下载回来比字节 → 不带签名被拒
→ 对象键带前缀 → 原始件一字不差)。没配时脚本跳过并 exit 0,不误报失败。
|
||
|
|
ef903a55c3 |
feat(task): 表单设计器(M1-08)—— 填报表单在界面上也能配了
"能动态设置填报表单"是本产品的核心承诺之一,而在这之前表单定义只能用 SQL 改。
更要紧的是它有一个**不可逆的方向**:内置字段删不掉(40026),正确用法是
"用条件显示隐藏"—— 而只有界面上给这条路留了位置,用户才会照着做。
新增:
· apis/xt/form-schema.ts —— 表单定义接口 + 字段类型 + 条件选项 + 扩展字段 key 生成
· views/task/TaskFormSchemaDrawer.vue —— 21 项内置字段(必填/条件/排序/提示语就地改)
+ 扩展字段增删改 + 保存为新版本 + 版本冲突处理
· task/index.vue 操作列加「表单设计」,240→300px
浏览器实测:
· 版本标签「未保存过(当前为官方默认表单)」——服务端对没存过的任务返回内置默认定义,
让设计器从一份完整的官方表单开始,而不是从空白开始
· **21 项全在,且「条件显示」反查正确**:补考三科→仅补考时、
在读学校/专业/入学时间→仅在校生、本职业工作年限必填开关是关的。
把服务端任意 visibleWhen 对象映射回人话标签,是本界面最容易做错的一处
· 保存→库里 v1 current=1 fields=21;三次保存得到 v1/v2/v3,
旧的 is_current=0 全部保留(**版本只增不改**实测)
· 「不收集」= {field:"_never_",...}(内置字段删不掉,用一条永远不成立的条件隐藏);
保存后原有条件(is_retake/is_student)都保住了
⭐ 端到端:学生端 字段 21→17(已获证书编号消失、补考三科消失、在校生三项保留)、
材料 4→3(工作证明消失)
⚠️ 顺带修掉一个真 bug:**材料侧的条件没求值**
上面这次验证暴露的:表单字段的条件前端有求值(21→17),材料的没有 ——
「工作证明(仅非在校生)」照样显示给在校生;而后端校验是排除它的,
于是表现成"系统让我传,我不传也不报错",学生只能自己猜。
修法:把 visible() 抽成 whenVisible(),**字段与材料共用同一份**。
教训:同一套规则出现在两处时必须共用同一个函数 ——
抄一份的代价不是多几行,而是两边行为不一致,而两边各自看都没错。
修掉一条过期的 E2E 断言:e2e-m1-form.py 断言「联系电话不锁定」,
与实现刻意的只读相反(BuiltinFields L88-93:collect_submission 没有 phone 列,
所以一期只读;反过来做"前端允许改、后端悄悄丢掉"最糟 ——
学生以为改成功,实际提交的还是老号码)。改断言并写明理由,48/48 恢复。
frontend-mp 类型检查修干净:原先 fill.vue 还有 3 条(输入框事件、日期选择、
FormData 缺 submissionStatus),现在只剩 config/index.ts 的 2 条 ——
那是 uni-app 的 #ifdef H5/#ifndef H5 声明同一变量,vue-tsc 看不懂条件编译,设计如此。
验收:e2e-m1-form 48/48、materials 68/68、task 71/71、orgs 48/48、
m3-submit 31/31、file 29/29、draft 27/27、gate 29/29(合计 351 条断言全绿)
|
||
|
|
a4406f545a |
feat(task): 材料规则界面(M1-04 前端)—— 动态收集要求在界面上真的能配了
发布任务的 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 全绿。
|
||
|
|
edb47e8557 |
verify(h5): 提交页问题清单浮层真浏览器验证通过
两条分支都跑通了(穿透单次要 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()(重进页面能看到上次传的材料与体积并能删)
⚠️ git add -A 差点把 backend/data/uploads/ 下的**运行时上传文件**提交进来。
那是数据不是代码 —— 已加进 .gitignore(backend/data/)并从暂存区撤回。
新增 scripts/dev-seed-warn-state.py:把某份草稿快速摆到"只剩黄色警告"的状态,
用来验浮层/审核端的"只有警告"分支,省掉"手填 7 个字段 + 等穿透"。
它**不**替代 E2E —— 逻辑仍由 e2e-m3-submit.py 断言,这个脚本只负责摆状态。
演示态:collect_submission.id=21 现在是「已提交」(本次验证留下)。
它是很好的演示态,但要重跑填报流程必须先删掉它(见交接摘要 §一·十三)。
名册(task 17, id=38)绑定 sys_user.id=151,保持不动。
|
||
|
|
6620b024cf | docs(handoff): 追加 §一·十三(材料上传 + 提交校验)—— 五个坑、两处契约偏差、新增 VAL-22 | ||
|
|
9911ff01f1 |
feat(submit): 提交 + 红黄绿校验(PRD §5.7 的 VAL 系列)
一次给全,不"改一条再拒一次":学生一次可能同时缺 3 项材料、2 个字段没填,
逐条拒绝会让他改 5 次提交 5 次。所以 validate 收全部问题后一次性返回
{ruleCode, severity, title, detail},ruleCode 用 PRD 的 VAL-xx 编号。
两段式提交:只有黄色警告时先返回 submitted=false 让学生确认,
再带 confirmWarnings=true 才真的提交。为什么不前端自己判断 ——
只有服务端的校验结果算数,两次请求之间学生可能又改坏了什么。
实现的规则:VAL-01 证件号 / VAL-05 材料完整性 / VAL-06 格式 /
VAL-07 体积 / VAL-08 清晰度 / VAL-09 重复文件 / VAL-13 报考三项 /
VAL-15 补考科目数按等级 / VAL-16 在校生三项 / VAL-17 工作年限 /
VAL-18 未规范化 / VAL-20 体积下限 / VAL-22 表单必填(**新增**:
PRD 的 VAL-01~21 是具体业务规则,没覆盖动态表单的必填字段,按系列顺延补的)。
═══ 本轮抓到的四个真问题(都记在代码注释里了)═══
① **校验器不能用列名当字段 key**。字段 key 是 snake_case
(work_unit / job_level / enroll_date),不是列名;而且报考三项
存在 ext_json 里、**不是独立列**(契约 §2.4)。写 camelCase 的表现是
"明明填了却说没填"。更糟的是 VAL-16 读列、VAL-22 读表单装配值 ——
**两条规则对同一份数据给出相反结论**,学生不知道该信哪一条。
现在统一用 FillService.getForm 装配出来的那一份作为唯一取值口径。
② **VAL-22 没判可见性**。不判可见就会要求一个在校生填「补考理论」——
而那个字段在他页面上根本不存在。求值口径已与前端 fill.vue 的 visible()
逐字对齐(norm 归一 true/false↔1/0、未知 op 一律可见)。
③ **VAL-01 读错了列**:collect_roster.id_card_no 是 **AES 密文**
(列注释写明的),明文只在导入与门禁时短暂存在。一开始拿它算校验位,
于是每一份填报单都被判"身份证号未通过校验" —— 100% 阻断。
这种"规则很对但读错列"的错,反而容易被当成"校验太严"而不是 bug。
现在只确认名册里有证件号(用脱敏列),校验位交给导入与门禁。
④ **静态资源没有 Cache-Control/ETag**(新增 StaticCacheHeaderFilter)。
没有它浏览器会走启发式缓存:旧 index.html → 旧 chunk →
**学生发版后看到的是上个月的页面**,而开发机硬刷新一次就没了,
极容易被当成"偶发"。现在 /assets/** 一年 immutable(文件名带内容哈希)、
*.html no-cache(每次校验)、/api/** no-store。
排查过程里还确认了一件事:材料上传那轮的"浏览器加载旧 chunk"
不是构建问题 —— 从 8080 下载的 chunk 与磁盘文件 SHA-256 一致、里面确实有新代码,
是缓存头缺失导致的浏览器行为。
验收:e2e-m3-submit.py **30/30**(含"该拦的确实拦了"和"修好后确实放过")。
回归:草稿 27 / 门禁 29 / 材料上传 29 / 材料目录 68 全绿。
|
||
|
|
8ef09733b1 |
feat(file): 材料上传的前端界面 + 列出/删除接口
后端:GET /mp/files/submissions/{id}(列出我的材料)+ DELETE /mp/files/{id}(软删)。
两者都把归属条件(submitter_id)写进 SQL 的 WHERE —— 与门禁、草稿同一套写法,
少一个条件就是"能看/能删别人的材料"。
前端(填报页的材料区):
· 按材料项列出:名称、必传标记、格式白名单与体积上限、来源引导语
· 已传的显示「原名 → 规范名」与「压缩前 → 压缩后」体积(学生要能看见系统做了什么)
· 传完**立刻处理**并显示结果
⚠️ 为什么上传后马上处理而不是留到提交时:学生要能看到"压到了多大、叫什么名字"
才算完成。若把处理留到提交时,他上传时会以为"传上去了",提交时才发现体积超标 ——
而那时他可能已经在回家的路上。
· 学生能自己删(传错了不该只能找班主任)。已提交的填报单不给增删,与草稿同口径。
⚠️ 本轮**未完成浏览器肉眼验证**:构建产物里确实有材料区的代码
(产物 grep 到「需要上传的材料」),但浏览器加载的是**缓存的旧 chunk**
(实测 .fill__wrap 只有 4 个子元素,没有材料卡),我用带时间戳的方式绕缓存没成功。
代码与产物是对的,需要一次硬刷新(Ctrl+Shift+R)或清缓存后再确认。
这条记下来,不要当成"已验证"。
|
||
|
|
80a4050e53 |
feat(file): 材料上传与规范化(压缩到 1M 内 + 按规则改名)
这是产品的核心资产。实测那批真实材料里 **52.7% 的原始文件名是违规的**
(`曾伊豪-...110身份证.jpg.jpg` 这种),而认定机构只看两件事:
**体积有没有超 1M**、**名字对不对得上人**。这两件事做不对,
材料会被整批退回 —— 而退回时往往已经截止了。
两步走,不能合成一步:
① upload:先原样存下来。这一步必须快、必须成功 —— 学生的手机可能是慢网络,
上传中断是最常见的失败。先落盘,学生就不必重传。
② process:压缩 + 改名。慢(要解码、试多档质量),但在服务端跑,学生不用等网络。
压缩策略:**先保清晰度,再压质量,最后才缩尺寸**。顺序不能反 ——
证件类的可读性是底线(机构要能看清号码),缩过的证件翻拍件数字就糊了。
实测:11.6MB 的 2400x3200 原图 → **712KB**,1020x1360(P2 长边限 1600),q70。
命名策略:模板 + 白名单 + 非法字符清理。
⚠️ 非法字符清理不能省:学生的原始文件名里 `/` 很常见("学籍表(1/2)"),
而带 `/` 的名字**根本写不进磁盘** —— 表现是"保存失败",
而错误信息不会告诉你是因为文件名里有个斜杠。
原始文件**绝不覆盖**(另存 std/):认定机构退件时我们要能拿出原始件,
覆盖掉原图就再也说不清了("是不是你们压坏的")。E2E 断言了原图一字不差。
⚠️ 与契约的一处偏差(已知、有意):契约写的是 POST /mp/files/upload-token
(OSS 直传凭证),当前**后端代理接收**(multipart)。原因:后端还没接对象存储,
直传凭证要先有 bucket 与签名机制。硬做一个"返回假凭证"的接口只会让人以为能直传。
接入对象存储后可以并存:小文件走代理、大文件走直传。
⚠️ 当前把文件存在**本地磁盘**(fill.upload.dir)。多实例部署时各存各的会丢文件,
所以接入对象存储前**不能上多副本**。这一点必须记住,别当成"已经解决了"。
E2E 里修了一个夹具陷阱(值得记):用**裸 SQL** 插材料项会绕过 MaterialCatalog
的默认值(DDL 列默认是 AUTO / 1 份),于是"档位没生效""份数不对" ——
而这两条都会被误当成代码 bug。实测就踩了:IDCARD 期望 P2 + 2 份,
裸 SQL 插出来是 AUTO + 1 份,AUTO 的行为(不缩放、先降质量)被当成了"P2 没生效"。
细节修正:文件名里的脱敏号用 X 而不是下划线。`*` 在 Windows 文件名里非法,
被 sanitize 换成下划线后 `361029******0015` 变成 `361029_0015` ——
没人看得出那是脱敏号。现在是 `361029XXXXXX0015`。
验收:e2e-m3-file.py 29/29(含体积、档位、命名、原图保留、幂等、白名单、份数)。
|
||
|
|
465700969a |
feat(h5): 填报页(三态 + 条件级联 + 失焦自动存草稿)
学生终于能在手机上真正填一份表了。 三态渲染(契约 §9.2): locked=true → 只读灰底 + 「如有误请联系班主任」 prefilled=true,locked=false → 可编辑 + 橙字「老师已填,请核对」 其余 → 普通空白 实测:姓名/身份证号/联系电话/报考职业/工种/等级 只读,学历可改并标注来源。 条件级联(visibleWhen)在前端求值,实测正确: 「是否在校生=是」→ 在读学校/所学专业/入学时间 出现; 「是否补考」未填 → 补考三科不出现。 **隐藏 ≠ 清空**:学生把「是否补考」改回"否",已填的补考科目仍留在草稿里 (万一他改回来)。要不要清空由后端在提交时判断 —— 前端不该替用户做不可逆的事。 保存策略:**失焦即存单个字段**。不做"整表提交"——学生填到一半切出去接电话, 回来整表丢失是最让人恼火的失败。单字段保存的代价是一次请求, 远小于"白填一遍"。 进页面时若还没有草稿会先建一份(否则第一次失焦保存会因 submissionId 为空而静默不存)。 前端拦住只读字段的保存请求(能省一次必然失败的请求),但**服务端那道拒绝才是真的**。 顺带:核验页的「开始填写材料」接上了(带 taskId + scene —— H5 跳转不保留 URL 参数)。 验收:构建产物含 pages-fill-fill;用无头浏览器实测渲染, 三态与两处级联均正确,抬头显示「请在 2026-10-15 前提交」。 |
||
|
|
5de9ccc100 |
feat(fill): 草稿保存(§9.3 / §9.4)
POST /mp/submissions 取草稿(幂等)+ PUT /mp/submissions/{id} 保存草稿。
学生填了一半能存下来,再打开还在。
四条守住的规则:
1) **填报单必须属于调用者** —— 这个条件写进 SQL 的 WHERE(selectMineById),
不是"查出来再判断"。后者一旦判断漏写就是越权。
2) **锁定字段服务端再拒一次**:前端已经渲染成只读,但请求可以伪造,
而「报考工种」被改错的后果是机构直接不受理。两层防护里只有服务端这层是真的。
3) **改动留痕**(changed_fields):老师填的值被学生改过要记下来 ——
审核时看到的是一个数字,无法判断该找谁(老师填错找老师,学生改错直接驳回)。
4) 已提交的填报单不能直接改,要先撤回。
本轮修掉的 4 个真问题(全是"不报错但结果错"的类型):
1) **changed_fields 是 jsonb,我当 varchar 用了**。存了逗号分隔字符串,
PostgreSQL 报 `invalid input syntax for type json`,而错误信息里的
"Token xxx is invalid" 完全指不到是这一列。已改为存 JSON 数组。
⚠️ 这已经是第三次被"列的真实类型与我的假设不符"咬到
(前两次:expected_count NOT NULL、min_long_side NOT NULL)。
教训:**写列之前查 information_schema 的实际类型,不要只读 DDL 文档。**
2) **changed_fields 把没动过的字段也标成"改过"**。原因是我拿"提交表的列值"
和"名册值"比,而锁定字段的列**永远不写初值**、姓名/证件号/电话**根本没有列** ——
全都读成 null,于是全被判成"改过"。留痕一旦充满噪音,真正的改动就没人看了。
已改为:排除锁定字段(按定义不可能被改)与没有列的字段。
3) **学生自己填的"非预填字段"根本没被读回来**。我的合并只遍历了"名册有的键",
于是在读学校填了、保存成功、再打开却没了。已改为按**表单字段全集**遍历,
并明确三种情形:动过的预填字段用草稿值(空也算,那是清空)、
自己填的用草稿值、其余用名册值。
4) **建草稿时没做初值,导致三种状态无法区分**(从没动过 / 改成了别的 / 清空了)——
列值都是 null。表现是改动判不出、清空会被名册值顶回来。
已在建草稿时把名册值写入"可改的预填字段"做初值,状态就自明了。
顺带修正一处**字段目录与 DDL 的真不一致**:
表单定义把「联系电话」标为可改(PRD 把它列在「预填可改」里),
但 collect_submission **没有 phone 列** —— 学生改了没地方存。
一期先设为**只读**,并在代码里写明要真正开放修改需要加一列。
不用"前端允许改、后端悄悄丢掉"糊过去 —— 那会让学生以为改成功了,
实际提交的还是老号码。
另一处实现选择修正:锁定字段(报考职业/工种/等级)**要写进提交单**。
它们恰恰是最重要的统计维度(按工种统计报名人数),不落列就统计不出来 ——
而架构约束正是"内置字段落列才可统计"。写进去还带来一个好处:
它是**提交时的快照**,老师之后改了名册,这份单子仍记得"他当时报的是四级"。
顺带修 E2E 的两条过时期望(回归抓到的):
· 写死了 127.0.0.1,而 H5 应用地址已改成穿透域名 → 改为从配置读期望值;
· fragment 改成 hash 路由格式后,token 解析没跟上。
验收:e2e-m3-draft 27/27、e2e-m3-gate 29/29、e2e-m3-h5-auth 23/23。
|
||
|
|
eff5a761bf |
feat(fill): 表单结构接口(学生端填报的起点)
GET /api/v1/mp/tasks/{id}/form —— 按 §9.2 装配表单结构 + 当前值。
两道前置门槛(缺一不可):
1) 任务在收集中(草稿/暂停/截止/归档都不给填);
2) **这个人已过门禁**(有绑定关系)。没过门禁就给表单 = "没核对身份也能填",
门禁就白设了。
字段三态(§9.2)实测正确:
real_name/id_card_no/occupation/worktype/job_level → 只读 + 老师已填
education/phone/is_student → 可改 + 「老师已填,请核对」
其余 → 空
其中 occupation/worktype/job_level 只读是刻意的:它们由老师按机构报考策略确定,
学生不懂也不该改 —— 而报错工种在机构那边是直接不受理,等发现时报名已结束。
为什么 locked 必须是真的只读、而不是"前端灰一下":让它看起来可编辑(哪怕服务端
会拒绝)只会让人白改一次。所以前端渲染成只读,服务端再拒绝一次(两层)。
身份证号与手机号只回**脱敏值**:表单上回显完整证件号完全没有必要(学生自己知道),
而页面上多一处明文就多一处泄露面(截图、录屏、分享)。
两处实现说明:
· prefilled 与 locked 独立:prefilled=true,locked=false → 可改但标注(如联系电话,
名册里常是家长的老号码);prefilled=true,locked=true → 只读灰底。
· FormFieldVO 补了 value / prefilled 两个字段(契约 §9.2 的字段对象里有)。
放在同一个类而不是另建 VO:两者结构完全一致,拆开只会让前端写两套解析。
本轮范围:只做"看得到、读得对"。**草稿保存(POST/PUT /mp/submissions)与
前端表单页是下一段** —— 已在 FillService 里留了 TODO。
验收:用真实会话实测 GET /mp/tasks/17/form,21 个字段分两组返回,三态与脱敏均正确。
|
||
|
|
aa2f7f175c |
feat(h5): 身份核验页(门禁)—— 学生流程首个可用闭环
学生填材料之前的唯一一道门:姓名 + 身份证号与老师提交的名单核对, 通过后把微信账号与名单里那一行钉死。后面所有材料都归属那个人。 交付: · pages/verify/verify.vue —— 核验页 · 落地页「开始填报」→ 核验页(带 scene) · 两页都加 max-width 居中(桌面查看/自动化验证时不至于拉满全屏) 三处刻意的设计: 1) 失败提示不区分"姓名错"还是"号码错"(后端统一 40007),前端也只显示 后端那一句话 —— **不在前端加"可能是身份证号错了"这类推断**。 分开提示等于给出一个身份证号枚举器。 2) 预填项标注「老师已填,请核对」并明确「学生不能改」:报考职业/工种/等级 由老师按报考计划确定(名单里实测 28/28 都有值)。让学生重填只会制造错误, 而报错工种在机构那边是直接不受理。 3) 前端只做"非空 + 长度"这类明显笔误的提示,真正的判定(校验位、名册匹配、 绑定状态)全交给后端。 修掉一个 H5 特有的坑: **H5 的页面跳转不保留 URL 参数** —— 直接 navigateTo 到核验页的话, 那边读不到 scene、就不知道是哪个任务(小程序没有这个问题)。 所以落地页跳转时显式拼上 scene,核验页再从自己的 URL 里读出来。 验收(全程无头浏览器自动完成,除"微信授权"那一步)。 现在 task 17 已发布(收集中)并带测试名册,手机上可走完整流程。 注:task 17 现在是「收集中」并带 1 行测试名册(张同学 / 361029200912100015), 是我为验证留的演示数据。 |
||
|
|
2659cd5a8c |
feat(h5): H5 交给后端托管 + 修掉"相对地址在 uni.request 下失败"
背景:配好了花生壳穿透 http://394nb68658le.vicp.fun → 本机 8080。 但 H5 原来跑在 127.0.0.1:5174,手机访问不到 —— 所以把 H5 静态文件 交给后端一起服务(同一个域名)。这样顺带也不再需要 CORS, 而且正是正式部署的形态。 改动: · application-dev.yml 加 spring.web.resources.static-locations, 默认指向 frontend-mp/dist/build/h5/,可用 H5_STATIC_DIR 覆盖。 ⚠️ 前缀必须是 spring.(我一开始写成了 fill.,表现是"页面 404 而应用 照常启动、没有任何报错")。 · fill.mp-h5.callback-url / h5-base-url 改成穿透域名。 · config/index.ts:API 地址按端区分(api 地址两端策略不同,写死一个值 一定会在某一边出错): H5 → location.origin(绝对同源) 小程序 → http://localhost:8080(没有"同域"概念,必须绝对地址) 用 uni-app 条件编译区分,而不是运行期判断(后者会把两套地址都打进包里)。 ⚠️ 修掉一个只在真实穿透下才会暴露的 bug: · 原来 H5 硬编码 http://localhost:8080。从公网域名打开时变成跨域请求 → 被 CORS 拦 → 页面显示"网络请求失败",而**从本机打开却是好的**。这个 bug 只有走真实 穿透/真机才会暴露 —— 这正是"要在真环境验证"的价值。 · 改成相对路径后**仍然失败**(request:fail),但同样地址用原生 XHR / fetch 都是 200。也就是说这是 uni.request 自身对相对路径的处理差异,不是服务端 或跨域问题 —— 而它长得像"网络不通",很容易往 CORS / 域名 / 后端去查,白绕一大圈。 最终用 location.origin(绝对同源)绕开。 验证:经公网 http://394nb68658le.vicp.fun/?scene=t1-k17 → 显示「示范职业学校 / 2026年9月机电专业认定材料收集 / 尚未发布」, 无头浏览器截图确认(不依赖人工)。 ※ 首次加载约 27 秒 —— 穿透的带宽限制(花生壳免费档),不是代码问题。 |
||
|
|
6e7597531a |
feat(mp): M3-02 H5 微信网页授权(公众号 OAuth)
方向决定了 H5 优先:资质门槛低得多、且前端可自动化测试。
小程序端代码不浪费 —— uni-app 一套代码多端,build:h5 直接出 H5。
交付:
· GET /mp/h5/authorize —— 生成微信授权地址(公众号 appid,snsapi_base 静默授权)
· GET /mp/h5/callback —— code 换 openid → 找/建学生 → 下发会话 → 302 回 H5
· GET /mp/h5/stub-login —— 【仅开发/测试】跳过微信授权的登录,供自动化测试
· fill.mp-h5 配置段(公众号凭据)
关键事实:H5 必须用**公众号**的 appid —— 网页授权与小程序码是两套凭据、两套接口,
不能混用。所以配置分成 fill.mini-program(小程序码用)与 fill.mp-h5(网页授权用)。
授权流程的三处设计:
1) token 放在 URL 的 fragment(#)里,不放 query。fragment 不发给服务器 ——
不会进 Nginx access log、不会进 Referer、不会进浏览器历史。
放 query 等于把 token 写进四五处日志。
2) scene 不裸放在 state 里,而是存 Redis 用一次性随机串指代 ——
state 会进 URL、浏览器历史与日志,放业务参数容易被人拿来构造请求。
3) 回调必须"永远重定向回应用",绝不抛业务错误。那是浏览器直接跳转的地址,
抛错用户看到的是一屏 {"code":40001,...} JSON —— 一个学生完全无法理解、
也不知道该怎么办的画面。
打桩(为了自动化)与四道约束:
微信授权那一步必须真人在微信里点,脚本永远做不了,所以要有打桩;
而打桩是危险动作 —— 一个"跳过微信授权"的开关漏进生产就是
"任何人可登录任何学生"的后门。约束:
① 配置开关默认 false,只有 dev 打开;
② 代码里再判一次 profile 不含 prod(配置写错也进不来,双保险);
③ 接口路径独立(/mp/h5/stub-login),不是"真实回调里加个 if"——
那种写法一旦判断写反就是灾难;
④ 每次调用都 WARN 日志,不留静默通道。
本轮修掉 2 个真问题:
1) RoleProvisioner.bindRole 在匿名请求下不可用:它内部走 MP 的 BaseMapper,
插入时租户拦截器拿不到 tenantId、直接抛「未登录」。症状是 500 且角色没建上
(用户建了却没角色,后面每个接口都会以"无权限"拒绝他,排查毫无头绪)。
已改为自己写:@InterceptorIgnore + 显式 tenant_id。
2) 授权回调抛业务错误 → 用户看到一屏 JSON。已改成 try/catch 后重定向回应用,
把 e.getMessage() 作为 authError 带上(那句话本来就是写给人看的)。
E2E 实测:用户看到的是「网页授权换 openid失败:code 无效或已使用过
(code 只能用一次,且 5 分钟过期)(微信原文:40029 invalid code...)」。
⚠️ 上线前必须解决:H5 与小程序是**两个 openid**
同一个人的小程序 openid 与公众号 openid 是不同的(不同 appid 各自签发),
只有 union_id 一致 —— 而它需要两个账号都绑到同一个微信开放平台账号。
后果:学生先用 H5 填、后来又用小程序填,系统当成两个用户,
第二个渠道撞 40072(已被登记)。一期只走 H5 不会碰到,
但两条腿都上线前必须解决(按 union_id 认人)。
已知缺口:
· H5 核验页未做(落地页的「开始填报」仍是"开发中"提示)。
· H5 桌面全宽铺开,该加 max-width 居中。
· 真实微信授权未走通:需要公众号后台填「授权回调页面域名」+ 域名解析到能跑服务的
机器(或内网穿透)。打桩跳过的是微信那一步,后面全走真逻辑。
· /mp/login(小程序登录)仍未做,可与 H5 授权共用 resolveUser。
顺带确认:frontend-mp 的 build:h5 已能出 H5 产物,
已在无头浏览器里跑通「URL 带 scene → 读 scene → 跨域调后端 → 显示学校/任务/状态」,
CORS(后端早已配 localhost:*)也通。这条是选 H5 的核心收益:前端交互层可自动化验证。
验收:e2e-m3-h5-auth.py 22/22;受影响的四条回归全绿
(落地页 31 / 门禁 29 / M0 账号 55 / 二维码 32)。
|
||
|
|
15b819746b |
feat(mp): M3-01 门禁核验(名单是闸门)
M3 的第一块:学生必须先过名单核验,才能开始填报任何东西。
交付:
· POST /api/v1/mp/roster/match(§5.4)—— 姓名+证件号与名册匹配,命中才放行并绑定
· common/util/IdCardUtil —— 身份证校验位(三态裁决)
· support/ClientIp —— 取真实调用方 IP(限流用,X-Forwarded-For 第一段)
· module/roster/entity/CollectRoster —— 名册实体(M2 会继续用)
四条守住的规则:
1) 失败提示必须统一(40007),不区分"姓名错"还是"号码错"。
分开提示等于交付了一个身份证号枚举器:用任意姓名+目标号码试,
若提示"姓名不对"就说明号码在名单里,一个个试就能把全校号码试出来。
这不是体验取舍,是安全底线。E2E 专门断言两种失败的 msg 字符串完全一致。
2) 绑定并发安全:UPDATE ... WHERE bind_user_id IS NULL + 看影响行数。
不要改成"先 SELECT 判空再 UPDATE"——中间有窗口,两人会同时通过检查,
后绑的人静默覆盖先绑的人,而先绑的学生之后再也进不去。
E2E 用两个线程同时冲同一行,断言"恰好一个成功、一个 40072"(实测 [0, 40072])。
3) 证件号明文只在 match() 里出现一次,失败日志只记 HMAC 与 uid(日志被翻出来
同样是泄露);响应里永远只有 idCardMask,E2E 断言整个响应 JSON 不含明文。
4) 已绑定自己的人重复核验是幂等的 —— 学生刷新页面不该看到"该信息已被登记"。
IdCardUtil 的价值在三态而不在对错:
真实数据集实测:名册 28 人校验位全通过(可信基准);某两个学生的文件名证件号
校验位失败(确定是号码错,可自动改正);另一学生的两个号码都通过(是两个真人,
串档实锤,必须人工介入)。后两种看起来都是"号码对不上",处理策略却完全相反——
一个能放心自动修,另一个自动修就是把两个真人的材料合并。区分它们的唯一办法
就是校验位。所以对外暴露 Verdict{VALID,CHECKSUM_FAILED,MALFORMED} 而非 boolean。
1900 年下界是业务判断,不是国标要求:网上流传的"示例有效身份证号"里有一批
出生年份在 1800 年代(如常被引用的 440524188001010014 → 1880 年),它们校验位
合法但不可能是认定考生。只算校验位会把它们判成 VALID。这个坑写 E2E 时真踩到了。
E2E 怎么拿 token:门禁要求已登录的学生,而登录(/mp/login)需要真实的 wx.login
code,脚本拿不到。所以用 dev 配置里的 JWT 密钥自己签一个 MP token(HS512,
claims 与 JwtService.issue 同构)—— 与 e2e-m0 用 dev RSA 公钥加密登录密码是同一类
做法:用开发配置里的凭据手搓测试夹具,不是绕过认证逻辑。prod 密钥不在仓库里,
这个手法自然失效。顺带:读配置要读两个文件,issuer 在主 application.yml 里,
只读 dev 会漏掉它,而症状是 401,完全指不到"配置没读到"。
回归抓到我一条脆断言:
e2e-m1-qrcode.py 的"token TTL 是 7000 秒"在连续第二次运行时失败(TTL=4633,
正好是两次运行的时间差)——它假设 token 是刚取的,实际沿用了缓存。
已改成"先清缓存强制重取,再断言 TTL∈[6900,7000]",连跑两次都稳定通过。
教训与前面几次同类:断言要确定性地反映被测行为,而不是碰巧在某种顺序下通过。
已知缺口(写进交接摘要):
· /mp/login 未做:它需要真实 wx.login code,脚本测不了,只能在小程序里测,
下一轮连同小程序核验页一起做。
· 形近字候选(FR-504c)未实现,needConfirmName 恒为 false。
· 名册导入未做(M2),E2E 用 SQL 直接造名册行。
· 小程序端还没有核验页(落地页的「开始填报」目前是明确的"开发中"提示)。
验收:e2e-m3-gate.py 29/29(含并发绑定与限流);
九条 E2E 合计 418 项断言全通过。
|
||
|
|
0bc7ee02dc |
feat(mp): 小程序端骨架 + 扫码落地页(scene 识别租户)
小程序端从零建起来了,并在微信开发者工具里真机模拟跑通: 扫 scene=t1-k17 → 显示「示范职业学校 / 2026年9月机电专业认定材料收集 / 尚未发布」。 交付: · frontend-mp/(uni-app,官方 uni-preset-vue#vite-ts 模板),appid 已填 · src/pages/index/index.vue 扫码落地页:读 scene → 调后端 → 显示 「哪个学校 / 填什么 / 现在能不能填」 · GET /api/v1/mp/scene-context(匿名):登录前的落地页查询 · 主题色与 PC 管理端同一套(腾讯云 TDesign 色板),两端视觉一致 为什么落地页必然是匿名接口: 学生扫开的第一屏要回答"这是哪个学校、要填什么、能不能填", 而这发生在绑定身份之前。它与契约里的 POST /mp/login(code+scene 换 session) 不是一回事 —— 那个要换 openid,属 M3。 ⚠️ 一个必踩的坑(撞上并绕开了): FillTenantLineHandler.getTenantId() 在**匿名请求下直接抛 40101**, 所以不能用 taskMapper.selectById()。正确写法是专用 Mapper + 每条查询 @InterceptorIgnore(tenantLine="true") 并显式带 tenant_id(项目注释称此为 一次代码审查点)。见 module/mp/mapper/MpSceneMapper.java —— 把这几条集中在 一个文件里,正是为了让"关掉框架保护后由人保证隔离"这件事有地方审。 ⚠️ scene 可穷举(t1-k1、t1-k2…),所以匿名接口: ① 只返回落地页必须的最少信息,不往里加"顺带能查出来"的东西; ② 按 IP 限流(20 次/分钟,Redis INCR,复用项目的 GATE_RATE_LIMITED 40071)。 E2E 专门验了"前 20 次通过、第 21 次起被限、清掉计数器立刻恢复"。 状态文案分五种而不是合并成"当前不可填报":学生的下一步动作完全不同 —— 「已截止」要找班主任、「已暂停」要等通知、「尚未发布」是根本不该现在扫。 顺带记两个环境坑(写进交接摘要): · 微信开发者工具的「服务端口」默认关闭,HBuilderX 运行时报 `IDE service port disabled` 然后退出,**调不起开发者工具**。 解法:开发者工具 → 设置 → 安全设置 → 服务端口 → 开启; 或直接「导入项目」dist/dev/mp-weixin 绕过。 · HBuilderX 把这个项目当 CLI 型项目,产物在 dist/dev/mp-weixin, **不是** unpackage/。模拟扫码要在「编译模式」里设 进入场景=扫描二维码、 启动参数 scene=t1-k17。 · <style lang="scss"> 需要装 sass(模板没带),已 pnpm add -D sass。 ⚠️ 对上线计划最重要的一条结论(已写进交接摘要): 开发版(develop)只有开发者能扫、且会过期;体验版(trial)只有体验成员能扫; **只有正式版(release)学生才能扫**,而它需要提交审核,涉及小程序类目与资质。 所以 C1「小程序资质法务确认」不是"以后再说",它直接决定学生能不能开始填报。 已知缺口: · 填报主流程(/mp/login 门禁核验 表单填写)属 M3; 现在点「开始填报」给的是明确的"开发中"提示,不是静默无反应。 · frontend-mp 还没套项目既有的 ESLint/Stylelint 规范。 验收:e2e-m1-scene.py 31/31;八条 E2E 合计 389 项断言全通过; 微信开发者工具里人工确认页面渲染与 scene 传递正确。 |
||
|
|
eb352e3537 |
feat(qrcode): M1-06 填报二维码(接入真实微信小程序)
学生端的入口终于通了:班主任把码发到班级群,学生扫码进填报页。
这条 E2E 是本仓库唯一打外网的测试 —— 真实调微信生成小程序码并取回图片。
交付:
· GET /tasks/{id}/qrcode?type=MINI[&orgId] (§7.12)
· GET /qrcodes/{scene} 匿名图片接口(<img src> 带不了 Authorization 头,
必须匿名。图里只有一串场景值,不含学生信息)
· 微信小程序凭据接入 + access_token 与二维码的 Redis 缓存
微信账号(已实测确认,不是靠猜):
· wxf01d3fa60f87f0b7 是【小程序】:小程序码可生成、小程序订阅消息可用、
公众号的用户管理接口返回 48001(交叉验证)
· wx5d3ae5c1c21fef24 是【公众号测试号】:做不了小程序码,但能做网页授权
+ JS-SDK —— 正好是「H5 保底」需要的能力。两条路都有号了
三处刻意的设计:
1) access_token 走 Redis 缓存(TTL 7000 秒,比官方 7200 少 200 避开边界)。
token 获取接口有日调用限额(约 2000 次),每次生成码都取一次,
几十个班一起用就会撞限额 —— 表现是"早上还能用,下午全班的码都生成不出来"。
2) 同一个 scene 的图缓存 30 天:图是一样的,而生成接口本身也有日限额,
列表页每渲染一次就可能请求一次。
3) 微信 errcode 翻译成人话:40164(IP 不在白名单)与 40001(appsecret 错)
是完全不同的排查方向,只给一个数字等于让运维从错的方向查起。
匿名图片接口**只读缓存、绝不现场调微信**:否则任何人都能用构造出来的 scene
刷爆当天额度,而额度用完是全租户的码都生成不出来 —— 等于整个填报入口瘫痪。
开发期 env-version 设成 develop(配置项 WX_MP_ENV_VERSION)。这个小程序若尚未
发布,用 release 生成的码扫开提示「小程序未发布」,而接口不会报错 ——
只会让人以为"码生成得不对"。
本轮踩的 3 个坑,都是"假设错了但不报错":
1) 微信返回的是 JPEG 不是 PNG。探测脚本按 PNG 魔数判断,把一次成功报成了失败
(接口 200、91KB、看着一切正常)。服务代码里按 Content-Type/是不是图片判断。
2) 业务错误返回 HTTP 200 —— 项目约定只有 401/403/429/5xxxx 才占用真 HTTP 状态码,
业务码在响应体的 code 里(ErrorCode.httpStatus())。我的 E2E 按 HTTP 状态码判
业务失败,于是把"业务失败"当成了"成功"。
3) 我写的断言又一次恒为真:check(..., "max-age" in (...) or True, "")。
这与上一轮刚记进摘要的"断言要能真的失败"是同一类错 —— 说明那条教训我只是
写下来了,没有真的在写断言时自查。已改成真的读响应头来判。
另外 fetch_* 辅助函数成功时返回 err=None,而断言在成功路径上写 err[:120],
把"本该通过的用例"崩掉了;已改成成功时返回空串。
⚠️ 凭据位置与安全:
app-secret 放在 application-dev.yml(会入库),用 ${WX_MP_APP_SECRET:...} 形式,
test/prod 必须用环境变量覆盖。它与 jwt/crypto/rsa 那几把自研密钥性质不同:
那几个泄露只影响本系统,appsecret 泄露等于别人能以这个小程序身份调用所有接口。
生产环境务必单独申请凭据 + 走环境变量。
已知缺口:
· shortUrl 恒为 null:短链需要短链服务,而「H5 保底」路线还没定。
不返回一个"其实很长"的链接冒充短链 —— 字段叫 shortUrl 却给 80 个字符,
会让调用方以为可以直接打印。
· 图存在 Redis,没走对象存储(后端还没接 MinIO,容器在跑代码没接)。
· 小程序正式使用还需两步人工操作:服务器域名白名单加 API 域名、确认小程序已发布。
· 前端还没有二维码入口(属 M1-07~M1-10)。
验收:e2e-m1-qrcode.py 32/32(真实调微信);七条 E2E 合计 358 项断言全通过。
|
||
|
|
870c388287 |
feat(form): M1-03 表单 schema 版本化与冲突检测
学生端看到的表单终于可以按任务配置了。按计划表这一项是后端
(4 人日),表单设计器界面属 M1-07。
交付:
· GET/PUT /tasks/{id}/form-schema(§7.9 / §3.5)
· BuiltinFields:官方《考生线下报名信息导入模板》的 21 个内置字段(A~U 列)
—— 选项集逐字照抄,级联规则照官方原文
四条守住的规则:
1) 版本只增不改:每次保存插一行新版本,旧的保留(is_current=0)。
学生是按某一版填的,事后必须能回答"他当时看到的是什么"。表上没有 deleted
列也是这个道理 —— 表单版本不需要软删,它本来就不该删。
顺带:collect_form_schema 没有 deleted 列,实体刻意不继承基类
(collect_task_org 踩过同一个坑)。
2) baseVersion 是乐观锁,不符则 40025 并把最新定义一起回传。
设计器是"打开 → 改很多处 → 保存",中间可能好几分钟;直接覆盖会静默丢掉
别人刚加的字段,而且丢失发生在"另一个人"那里。只说"版本冲突"而不给最新内容,
等于把合并推给一个信息不全的调用方 —— 所以 40025 的 data 里带着最新 schema。
3) 内置字段不可删(40026)的判断依据是「内置目录」而不是「上一版定义」:
拿上一版当依据,客户端只要让上一版先缺一个就绕过去了。E2E 也验了
把 builtin 传成 false 无法绕过 —— builtin 与 source 一律以内置目录为准。
4) 扩展字段键必须带 extra_ 前缀、不可与内置重名:扩展字段的值落 extras_json,
与内置列同处一份 form_data,一个叫 education 的扩展字段会让"这个值到底是
内置的还是扩展的"永远说不清。
契约之外必须补的一个字段 source:
§3.5 说"内置字段与 collect_submission 列名一致",但「姓名」「身份证号」
「联系电话」「序号」根本不在 submission 里,在名册里(collect_roster)。
不标来源,填报引擎就得靠硬编码名单去猜"这个 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」)。
· 工作单位保留官方原文提示「没有工作单位请填「无」」。
⚠️「无」是合法业务值,不是空值,校验时不能把它当成"没填"拦掉。
· 锁定项(姓名/身份证号/报考职业/工种/等级)与 PRD 一致,另把「序号」一并锁定
(来自名册,让学生编辑"自己是第几号"没有意义)。
顺带:V6 更正 collect_form_schema.schema_json 的列注释(V1 写的是"字段定义数组",
实际存 {"fields":[...],"extras":[...]})。合成一个文档而不是拆两列,是因为
一版表单就是一份快照 —— 拆开就存在"只写成功一半"的可能,而"学生看到的那版"
必须是原子的。
本轮踩的坑(记进交接摘要):
· 我的 E2E 又写错一条:测「已截止不可改表单」时先调 close,但**草稿是不能直接
截止的**(close 只允许 收集中/已暂停)。改成先用 SQL 夹具把发布前置备齐 →
发布 → 再截止。这反而验证了一件对的事:没配齐就发布,学生填了也没人审,
所以 close 把草稿挡住是合理的。
· 写 Java 时把 Map 声明成了 String,编译器直接拦住,没有代价。
已知缺口:
· 表单设计器界面未做(M1-07)。
· 报考职业/工种/等级的 options 为空:它们来自"评价计划"(由名册聚合,M2),
不是固定选项。属预期,等 M2 接上计划级联。
· 库里没有字典表,所以官方选项内联在 options 里,dictCode 暂未启用。
· 条件显示的求值引擎属 M3-10,本轮只做存取与形状校验。
验收:e2e-m1-form.py 48/48;六条 E2E 合计 326 项断言全通过;Flyway 迁移 1~6 success。
|
||
|
|
d32969d766 |
feat(material): M1-04 材料项定义与内置模板
发布前置校验里最后缺的一项(40018「未配置必传材料项」)终于能配了。
按计划表这一项是后端(4 人日),材料规则的界面属 M1-09。
交付:
· 材料项 CRUD(《03-接口设计》§7.10):GET/POST /tasks/{id}/materials、
PUT/DELETE /materials/{id}
· 三个内置模板(TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG),幂等应用
· GET /material-catalog:材料码/档位/格式白名单/模板的静态清单,
供界面下拉使用(接口是契约外的,理由见 Controller 注释:把这份常量在前端
再抄一份会造成两份真相,而别名正是识别历史文件名用的)
落在代码里的三件事:
1) 默认值按两个维度分别取(MaterialCatalog):
· 按材料码定 名字/别名/默认压缩档/份数/是否合并
· 按压缩档定 固定尺寸(P1)/长边下限(P2)/目标DPI(P3)/输出格式
把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",
而"身份证要正反面"是这件材料的事实,不是档位的规则。
2) WORK_PROOF 的名字就是「工作证明」。机构模板里「职业技能等级个人申报表」
的规范文件名就是「工作证明」,这是机构既有习惯,不要"修正" —— 改了会让
导出包与该机构一直以来的归档口径对不上,带教老师只认这个名字。
3) 关联键是 (task_id, material_code),不是 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 直接 500。而更根本的是:契约 §3.4 明确写着
outputFormat 的 null = 保持原格式 —— 这一列本就不该是 NOT NULL。
已由 V5__material_profile_params_nullable.sql 把四个档位参数统一成
"不适用时为空"(另两个本来就可空),并同步更正两份 DDL 文档。
⚠️ 这与上一轮的 expected_count 是同一类问题:NOT NULL DEFAULT x 看起来无害,
但它让"未设置/不适用"无法表达,而契约往往又要求能表达。
动"可选数字/字符串"字段前,先查 information_schema.columns 的 is_nullable。
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「一寸照片阈值标定」,
没有实测数据。编一个数会让它看起来"已实现",卡住真材料时没人说得清为什么。
· 条件显示只做存取与形状校验,求值在填报引擎 M3-10。
· 40030(审核中不可删材料)属 M5,当前删除只查 40027。
验收:e2e-m1-materials.py 68/68;五条 E2E 合计 278 项断言全通过;
Flyway 迁移 1~5 全部 success。
|
||
|
|
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 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
|
||
|
|
daf42608d9 |
feat(task): M1-01 收集任务 CRUD 与状态机
后端:/tasks 建改删查 + publish/pause/resume/close/archive 六个动作, 按角色过滤可见范围(班主任只看自己带的班级的任务)。 前端:收集任务列表页 + 建/改任务弹窗(命名模板带实时预览与变量插入)。 验收:e2e-m1-task.py 71/71;e2e-m0.py 36/36、e2e-m0-user.py 55/55 回归通过; 前端 typecheck 0 错 / build 通过 / 浏览器肉眼确认。 三条硬约束(改动时当成规则): 1) 「暂停」与「截止」不是一回事。暂停:学生不能交、已提交的照常审、 且可恢复;截止不可逆。做成一个会让"临时冻一下"变成不可逆操作。 2) 发布前置 5 项全是阻断级,force 绕不过:名册非空(40016)、名册含姓名与 证件号(40017)、至少 1 项必传材料(40018)、已设置覆盖班级(40019)、 命名模板可用(40020)。只有 P1 级提醒(某班未指定初审人)可 force 跳过。 3) collectMode / certType 发布后不可改 —— 它们决定审核流与材料清单, 改了会让已填过的学生面对另一份表单。 本轮修掉的 3 个真问题: 1) collect_task_org 没有 deleted 列,而我让它继承了带 @TableLogic 的基类 —— MyBatis-Plus 会给每条查询自动加 deleted = 0,运行时报"列不存在", 编译期完全看不出来。改为不继承基类、显式声明字段; "移除"语义走 status = 0,不要对它调 deleteById(会变成物理删除)。 2) 任务编码查重被逻辑删除遮蔽:用 MP 的 selectCount 会自动过滤 deleted = 0, 但数据库的唯一约束不看软删标记 —— 软删掉的任务仍占着那个编码, 于是"查重说没有、插进去说重复",报 duplicate key(500)。 另外原用 COUNT+1 也错:删掉中间一个后计数会退回去,算出已占用的号。 改为 MAX(序号)+1,且查重语句刻意不加 deleted = 0(与唯一约束同口径); 编码一次用过就不复用(它同时是文件目录名,复用会把两批文件混进同一目录)。 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)。 补依赖,但任务配置的 JSON 读写仍不用 Jackson —— Boot 4 用 Jackson 3 而 jjwt-jackson 带来 Jackson 2,打包后两个 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。 - stat 恒为 null(依赖名册与提交单)。故意不返回全 0 的对象 —— 那会让界面显示"完成率 0%",而真相是"还没有分母";界面显示「待导入名册」。 - 任务编码并发撞号由唯一约束兜底 + 可读提示(不再 500),未做分布式锁。 顺带: - 两个列表页的列宽按实测可用宽度重算(1366 屏:收集任务 824px、 账号管理因左侧组织树只有 574px),并把"操作列必须 fixed + 列宽口径" 写进注释 —— 之前把「状态」列整个遮住、把操作链接挤成竖排。 - 状态与最后登录并成一列,给窄屏省出 150px。 |
||
|
|
8480f431ba |
feat(user): 账号管理(CRUD、Excel 导入、改密)并修掉 5 处缺陷
后端补齐 /users 全套(列表/新增/修改/软删/启停/重置密码/批量导入)与
PUT /auth/password;前端加「组织与账号 → 账号管理」一屏(左组织树 + 右列表、
新增编辑弹窗、导入体检报告、初始密码只显示一次的强确认)。
验收:e2e-m0-user.py 55/55;e2e-m0.py 回归 36/36;typecheck 0 错、build 通过。
账号管理页已在浏览器肉眼确认(首屏与切页均可见、数据与分页正确)。
本轮修掉的 5 个真问题 —— 都不是功能没做完,而是做完了但错的:
1) 学校租户也发放 PLATFORM_ADMIN 角色行
学校管理员在新增账号时传 roleCodes=[PLATFORM_ADMIN] 就能造出带
tenant:manage 的账号,而 /tenants 只校验权限码、不校验是否真在平台租户下
→ 任何学校管理员都能开通租户、拉全平台租户列表(租户级越权到平台级)。
RoleProvisioner 不再给非平台租户发该角色行 + 服务端按租户白名单校验。
2) UserUpsertReq.username 的 @NotBlank 对修改也生效
PUT /users/{id} 永远返回 40001,且明细只说存在阻断性校验问题,
看不出是登录名 —— 改账号功能整体不可用。去掉该注解,
必填性改由 UserService.create 判定。
3) Main.vue 的 <transition appear>
首次渲染被当成一次过渡,元素被加上 zoom-fade-enter-from(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(班主任,最高频的账号)被当成社会人员(二期)直接拒掉,
建班主任账号会失败;前端还把学校管理员显示成填报人(学生)。
改法:后端从角色反推用户类型再比对;新增 V3 迁移更正库注释
(V1 已应用不能改,Flyway 会校验 checksum);更正 PRD 与 schema DDL 的同一行;
前端「用户类型」从手选改为由角色推导,从根上消除矛盾组合;
E2E 加 3 条回归断言锁住这个语义。
顺带修掉:
· components/index.ts 里 6 个指向已删组件的悬空导出(构建不报错、用到才炸)
· tsconfig 的 lib 停在 ES2020 而代码用了 Array.prototype.at
· 两处 .at() 的空值风险(useBreakpoint / useTabsStore)
· dashboard 的 userStore.menus 拼错,导致平台判断恒为 false
· http.ts 里两个永不命中的错误码常量
· 账号列表的 fixed 列缺 :scroll 配置,会浮在其它列上面
· .gitignore 补 __pycache__
新增脚本:
· scripts/e2e-m0-user.py 账号管理 55 项验收(含越权与自锁的负向断言)
· scripts/dev-clean-test-users.py 清理 E2E 残留的测试账号
|
||
|
|
87ece5a971 |
feat(frontend): 管理端改用 Gi Admin Pro 框架 + 腾讯云主题
前端此前只有本地实现、从未入库,本次首次纳入版本管理。
框架切换
· 以 Gi Admin Pro(Vue3 + Vite + TS + Arco Design Vue + Pinia)为底
· 剥离 14 个演示页面目录 / 121 个文件(demo 28、file 20、form 19、system 18…)
· 依赖 dependencies 35 → 19,devDependencies 31 → 27
· 保留框架核心:layout、Gi* 组件库、hooks、权限指令、错误页、登录页
主题:腾讯云 / TDesign 官方色板
· 主色 #0052D9(腾讯蓝)、成功 #00A870、警告 #ED7B2F、危险 #E34D59
· 页面底色 #f3f3f3、文字 rgba(0,0,0,90/60/40/26%)
· 覆盖 Arco 底层色板 + 显式语义别名(primary/success/warning/danger/link/neutral)
· 三个改了不生效也不报错的坑已记入注释:
1) Arco 色值是逗号分隔 RGB 三元组,不是 #RRGGBB
2) Arco 把色板定义在 body 而非 :root,必须写 `:root, body`
3) setting.json 的 themeColor 会以内联样式写进 body,优先级高于样式表
对接后端(三处契约差异在 @/utils/http 统一适配)
· 响应信封 {code,msg,data} → {code,success,message,data}
· Token 头 token → Authorization: Bearer
· 分页 {list,total} → 补一份 records,list 原样保留
· 40101 → 登录页;登录密码 RSA 加密(公钥走 /auth/public-key,轮换无需重建前端)
· 路由:后端只下发菜单键,前端在本地路由表上过滤(有哪些页面归代码,谁能看归后端)
已实现页面(浏览器实测通过)
· 登录:RSA 加密 + 自适应验证码/租户码 + 账号锁定提示
· 工作台:真实数据(组织 6 个 / 角色 1 个 / 权限 25 项)
· 组织架构:三级树 + CRUD + 占用校验
· 租户管理:开通租户 + 初始密码只显示一次的强确认
· 待建页:未实现菜单显示所属里程碑
· 权限菜单:学校管理员看不到「租户管理」
视觉资产按腾讯云主题重做
· logo.svg(渐变圆角方 + 填)、favicon.svg(小尺寸做减法)、login-hero.svg(材料归集)
· 登录页点阵纹理走主题变量,换主色自动跟随
· 文案改为产品实际能力:自动改名 / 强制压缩 / 隐私保护
清理模板遗留(约 17MB + 三处外部依赖)
· 百度统计脚本(上报到 gi-demo 作者的统计账号)
· 用户菜单「项目地址」(跳转模板作者的 Gitee 仓库)
· 两份 8.8MB mp3、388KB favicon.ico、57KB 模板插画、未引用字体与 loading.gif
· 「个人中心」指向已删除的 /system/account(点了 404)
· 头部头像在无 avatarUrl 时渲染成裂图 → 改为姓名首字兜底
后端修复
· 新增 GET /auth/public-key(前端加密登录密码用)
· DataSeeder.ancestor_path 生成错误:子节点本该是 /16/17/ 却写成 /17/
该字段是「按院系统计」的索引,错了会漏人/多人且不报错 —— 改为从库里读父节点路径
· seedDemoOrgs 曾放在租户作用域外 + admin 早退分支之后,导致启动失败 / 永不执行
· 示范租户补一套三级组织树(1 学校 / 2 院系 / 3 班级),供界面演示
|
||
|
|
9d7a8ca845 |
feat(m0): 后端骨架 + 账号体系(M0 验收 36/36 通过)
技术栈(已在 4.1.1 上实测跑通,非纸面选型)
Java 21 + Spring Boot 4.1.1 + MyBatis-Plus 3.5.17(boot4) + PostgreSQL 16.15
+ Redis 7 + Flyway 12.4 + springdoc 3.1.1;前端定 Vue 3 + Element Plus + uni-app
交付内容
M0-01 基础设施 deploy/docker-compose.yml(PG + Redis + MinIO,容器名与端口避让本机既有项目)
M0-02 后端骨架 xt-common + xt-server;统一响应 / 全局异常 / 58 个错误码 /
多租户拦截 / 软删除 / 字段自动填充 / AES-GCM / HMAC 索引 / RSA / JWT
M0-05 租户 开通租户(一次完成建租户 + 发 6 个内置角色 + 建首个管理员)、列表、详情
M0-06 组织 CRUD + 组织树 + ancestor_path 自动维护 + 占用校验
M0-07 PC 登录 RSA 解密 + 图形验证码 + 失败锁定 + 等时比较防账号枚举 + 审计日志
M0-08 登出/当前用户 Token 黑名单(存 SHA-256,TTL 到自然过期)
M0-01 数据库 Flyway 2 个迁移 → 26 张表(20 业务 + 5 标准 + 1 迁移记录)
安全姿态
· 租户拦截器【默认拒绝】:匿名请求触及租户表直接抛 40101,不做「不加条件放行」
· 权限码定义在代码里(PermissionDef / BuiltinRole),不建 sys_permission 表,杜绝前后端漂移
· 匿名接口只靠 @PublicApi 放行,不做路径白名单,避免两套机制各说各话
· 账号不存在时也跑一次 BCrypt(假哈希),拉平响应时间防枚举
· 登录密码 RSA 传输,防止网关/APM 把明文密码记进日志
脚本
scripts/sync-ddl.py 文档 DDL → Flyway 迁移,带 --check 防漂移
scripts/e2e-m0.py M0 验收 36 项,可进 CI
scripts/verify-docs.py 文档一致性(接口数【逐节】核对、错误码、跨文档数字、表格列数)
scripts/dev-login.py RSA 加密登录助手
scripts/dev-clean-e2e.py 清理 E2E 测试数据
文档修正(实测暴露,非人工审查)
· DDL 缺陷:sys_tenant.contact_phone / sys_user.phone / collect_roster.phone
原 varchar(20) 按明文长度建,装不下 AES-GCM 密文(约 52 字符),改 varchar(128)(PRD V2.4)
· 错误码 40007b/40007c 带字母后缀与「code 为数字」的约定冲突,规范化为 40071/40072
· 07-开发实施计划 新增 §9 版本兼容踩坑记录(10 条实录)与 §10 M0 完成情况
|
||
|
|
63aec55329 |
docs: 产品文档与交互原型(P1-P7 交付物)
一期范围:学校收集 + 自主认定(4 类材料全部有实测样本) - 主 PRD 190KB:125 条 FR / 21 条 VAL / 20 张表 DDL / 121 个接口 - 功能逻辑卷 88KB:不含代码模块 - 接口设计 116KB:58 个错误码 / 12 个 VO - 核心模块设计:M1 命名 / M2 压缩 / M3 识别 / M4 门禁 / M5 校验 / M6 导出 - 开发任务拆解:100 任务 / 387 人日 / 15 周 - 验收测试清单:36 条 Golden 断言 / 111 条功能验收 - PostgreSQL DDL:已在 PG 16.15 实测零错误 - 交互原型 31 屏:学生端 11 / 班主任端 9 / 管理端 11 |