lovife
|
b991e3d68e
|
docs(contract): V2.6 六处口径修正 + 新增 §17 实现补充与偏差记录(客户确认)
17 项决策里的 A 组:契约按已确认的口径改写,不动代码。
一、六处口径修正
· §2.7 notifyBizType:5/6 对调修正为「5 催报 / 6 导出完成」——
旧表若按写实现,催报去重会静默失效(F-30)
· §2.7 auditAction:补全 7 个动作,撤回=7(3 是批量通过;F-7);
§11.4 约束补「状态 2 可撤(撤的是初审)」、留痕改 action=7
· §3.6/§9.5 collect_file.source:改为建表口径「谁传的」
(1 本人/2 班主任代传/3 管理员代传/4 整理器;F-38/39 —— 代传可追溯)
· §9.5 重写为「上传前置检查(预检/秒传/压缩建议)」,direct:false,
明写「客户端可跳过;服务端上传本身做全部校验」(F-3/71/72);
§9.6 process 改为「有原始件时的重新规范化」;§9.9 OCR 标注一期缓做
· §12.1 validation/run 改同步并返回统计
(checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs;F-51 ——
全契约本就没有查 jobId 的接口,返回一个查不到的作业号没有意义)
二、新增 §17 实现补充与偏差记录
· 17.1:41 个「代码有、§4 清单没有」的接口按六类分组
(材料通道/微信授权/签名URL下载/界面必需/契约链条断在第一步/运维动作);
清单声明由 check-api-parity.py --extra 生成 —— 手抄件会过期,命令不会
· 17.2:12 条已确认偏离 D-1~D-12(notifyBizType、source、上传链路、process、
validation 同步、撤回动作码、§14 路径前缀、biz_type 落库字符串、归档可删、
租户域名、通知两级、OCR 缓做)—— 每条带契约原口径/实现口径/依据编号
验证:python scripts/verify-docs.py → 22 项全部通过(含表格列数一致性);
check-api-parity.py --quiet → 契约 121 / 已实现 120(§17 不影响 §4 计数)
|
2026-09-30 12:38:47 +08:00 |
|
lovife
|
28dedb4c16
|
docs: 补测结论回填与限流口径同步(docs/01 E2 / docs/08 F-99·F-100 / docs/09 §9 / 交接摘要 §一·三十)
- docs/09 §9 重排为 9.1 结果 / 9.2 结论 / 9.3 门禁两次修正 / 9.4 怎么跑 /
9.5 补测记录(上传并发、5000 行导入、导出大包、长跑)/ 9.6 仍然没测的;
补记机器规格(12 物理核 / 59.6GB,全在同一台机器上)。
- docs/01 E2 二次修正备案:成功侧 200→600,附实测证据。
- docs/08:F-99(压测工具自己撒的 8 次谎)、F-100(门禁成功上限卡年级量级)、
F-5/F-6 核销与更新,变更记录补 3 行;问题清单到 F-1~F-100。
- 交接摘要:§一·二十九(提交入库+文档小账)之后补 §一·三十(四项补测+门禁二次修正+磁盘清理)。
- AGENTS.md:清理残留一节写清 dry-run 默认与「演示态挂在 E2E 班下面」的风险。
验证:python scripts/verify-all.py --quick → 通过 5 / 失败 0
|
2026-09-30 11:10:06 +08:00 |
|
lovife
|
601714c476
|
test(scripts): 补测"没测过的四项"+ 清理工具扩展(F-99 / F-5)
一、load-test.py:把 docs/09 §9.5 里四项"没测过"的变成"测过"
· --base:另起一个 --fill.storage.provider=local 的实例(18080)量上传,
不把测试文件灌进真实七牛桶;
· --fixture-only:量名册导入本身(造 Excel 与导入接口分开计时 ——
脚本自己的成本不能算到系统头上);
· --only export-big:造 N 份已通过的单(门禁绑定→草稿→上传→提交→
初审(班主任)→终审(管理员)→前置检查→创建→轮询),再逐卷解开验证 ZIP;
· --soak/--rps:长跑,每分钟采 RSS(从系统取,不是应用自报的堆)与
PG 连接数,比较前 1/3 与后 1/3 的均值 —— 判据是趋势,不是"跑完没崩";
· 上传夹具换成真 JPEG(原来的"JPEG 头+随机字节"在服务端解码即失败,
量到的根本不是压缩成本)。
数字(docs/09 §9.5 有完整复现命令):
上传 20 并发 40/40,串行 321ms(2850KB→1329KB,47%),并发 p50 620ms;
5000 行导入 20.2s(RSS 782MB);150 人 194.6MB 导出打包 13.2s,
分卷 4 卷每卷可单独解开;长跑 51 样本 15300 请求 0 失败,
RSS 603.9→582.9MB 平稳,PG 连接定时任务高峰后正常回落。
二、F-99:这一轮压测工具自己撒了 8 次谎(都是"度量/夹具出问题时
现象长得像产品缺陷"):前置未断言(--only upload 单跑全报"没有草稿")、
假 JPEG、猜列名(part_no/file_name/file_path)、拿 HTTP 码当业务码、
跨租户同名 admin(库里 50 多个,改从 token 的 sub 取 uid)、
夹具接口不查返回码(PUT orgs 被拒→任务没发布→学生端整齐报 40008)、
PowerShell -First 截管道把"被堵"变成"卡住"、清理只删库行不删文件。
修法:bind_users/call_ok(夹具每个调用都断言业务码)、make_img 真图、
列名按 DDL、cleanup 连磁盘文件一起删。
三、dev-clean-e2e.py(F-5 落地):
· --orphan-files:清"库里没有任何一行指向"的磁盘文件(判据来自
collect_file.std_path/raw_path + collect_export_file.file_path)——
实测 uploads 里 281 个文件/276MB 而库里只剩 16 行,已清 255 个/275.4MB;
· --demo-tasks:清【演示·旧】任务(只匹配带「旧」的 ——
【演示】审核台是当前演示态在用的,原来那句 LIKE 会把它一起删掉);
· e2e 租户的业务行按 information_schema 动态枚举(写死清单必然漏新表);
· --only-orphan-files:最安全的一档,只碰磁盘不碰任何库行。
⚠️ e2e 租户与"E2E 24数控1班"组织刻意未删:当前演示态正挂在其中一个班下面。
验证:python scripts/verify-all.py --quick → 通过 5 / 失败 0 / 跳过 0;
docs/09 §9.1~§9.5 已同步(含机器规格:12 物理核 / 59.6GB)。
|
2026-09-30 11:10:05 +08:00 |
|
lovife
|
f86a999c52
|
perf(gate): 门禁成功上限 200→600 —— 250 人同时核验曾挡住 37 个(F-100)
契约 §1.6 只规定了失败侧(同 openid 10 次/小时、同 IP 20 次/分钟),
成功侧 200/分钟是本项目自己加的(防"拿匹配接口批量取数据"),
而当初注释里写的本意是"一个 200 人的年级同时扫码也不该被挡"。
实测(python scripts/load-test.py --only gate --rows 250):
250 个不同学生同时核验(全部正确)→ 213 成功、37 被挡,
提示「当前网络访问过于集中,请稍等 1 分钟再试」。
"全校在同一个出口 IP 集中扫码"(开学第一课集体填报)正是会发生的场景,
而这些人没有一个做错了什么 —— 与 F-92「教室被挡住」同一类,量级放大到一所学校。
上调为 600 的依据:一所学校一次集中填报的峰值。它仍然是限速(600 条/分钟),
而防枚举靠的是失败侧那两个计数器 —— 原样未动,防猜测强度没有降低。
验证:
250 人 → 250/250(改前 213/37)
500 人 → 500/500(500 并发 p50 1.4s / p95 2.5s,是排队不是限流)
scripts/e2e-m3-gate.py → 29/29
文档同步:docs/01 E2(二次修正备案)、docs/09 §9.3 / §9.1。
教训:自己加的"防御性限额"也要过一遍真实场景的量级 ——
否则它与它要防的东西一样,都是事故。
|
2026-09-30 11:10:05 +08:00 |
|
lovife
|
db6a51d617
|
docs: 清掉三处"会静默过期"的小账 + check-test-profile 加固(F-98)
一、附录 A 从"手抄表"改成"可再生成的命令"
check-api-parity.py 加 --extra,现算"代码有、契约 §4 没有"的接口 = 41 个
(旧手抄表写 83/62/21,已差近一倍,而且没人会记得回来改它)。
并按"谁去改什么"分六类 —— 只有 E 类(8 个)要客户定口径,其余是文档侧补记。
二、F-6 已修:frontend-pc 的 Mock 说明
AGENTS.md 那句改正之外,连带修了 .agents/skills/project-api/SKILL.md ——
它整篇仍以 vite-plugin-mock / mock/** 为主线,而该项目里 mock/ 目录与插件都已删除。
只改指路牌、不改路的尽头,等于没改。
三、docs/03 两处文档内部口径冲突加 ⚠️ 注(不改口径,只把冲突摆明)
§2 notifyBizType 的 5/6 与 §13.5、落库值不一致 → 实现以 §13.5 为准(催报去重按 5);
§3.6/§9.5 的 collect_file.source 字段表(入口来源)与建表注释(谁传的)冲突 →
实现以建表口径为准。两处都写明"待客户确认",见 F-30 / F-38 / F-39。
四、check-test-profile.py 加固(新增 F-98)
· 子进程输出落盘(不再用没人读的 PIPE):失败时能看见"停在哪一行",
成功时打出启动用时(基线 ~10 秒 / 72 行 / 9.5KB);
· 开跑前按 jar + profile 清残留 JVM,且保留 dev 后端(E2E 正打着它);
· 等待期间每 15 秒报一次进度,卡住时不留沉默。
教训:随机端口这个防撞设计把残留 JVM 藏起来了 —— 撞不到端口就永远发现不了,
于是表现成"时好时坏",而失败时连日志都读不到。
验证:python scripts/verify-all.py --with-e2e → 通过 8 / 失败 0 / 跳过 0
(E2E 36 脚本 / 1548 断言 / 278s;prodguard 9/9;test profile 能起)
python scripts/verify-docs.py → 22 项全部通过
|
2026-09-30 09:10:34 +08:00 |
|
lovife
|
bfd7676974
|
feat(roster): 名册导入模板下载(§8.5)—— 表头固定第 1 行、必填列标红、9 条填表规则页
服务端生成(POI),不放前端静态目录:表头列必须与导入解析器的已知列名表一致,
两处各写一份必然分叉,而分叉的表现是「照着模板填的表导不进来」。
三条要求都不是装饰:
- 表头在第 1 行:导入时表头是自动探测的(客户给的文件里实测有表头在第 6 行的),
但我们自己发的模板必须规矩,否则「下载模板 → 填写 → 传回」这条最正常的路径反被折腾;
- 必填列标红:填错的绝大多数原因是不知道这列必填,而错误要到导入时才报(那时已经填了 500 行);
- 规则说明页:9 条里最值钱的是「身份证号必须是真的」与「在校生/非在校生决定要交哪些材料」,
不写清就会先填完再被逐条打回。
「要交哪些材料」那一页用当前任务的材料清单推导(不写死);表头列本身固定 21 列 ——
跟着任务变的话,客户换个任务下载到的模板就不一样,而「我上次填的那份」会突然不适用。
验证:scripts/e2e-m7-taskconfig.py 69/69(模板表头与导入列定义逐项比对)。
|
2026-09-30 09:03:13 +08:00 |
|
lovife
|
8debf371bf
|
docs: 完成度盘点(F-1~F-97)、确认函 E 组、交接摘要 §一·二十八与 AGENTS 更新
docs/08 是计划与现实的对账:完成度表、未完成按「能不能上线」排序(P0/P1/P2 + 卡点→找谁→编号)、
97 条编号问题、施工记录 6.1~6.4(每条都带验证方式,不写没有证据的「已完成」)。
docs/01 新增 E 组第二轮待确认:E1 union_id(H5 与小程序不能同时上线的阻断项)、
E2 D3 限流口径更新、E3 二期时点;D3 那行划掉。
交接摘要 §一·二十八 把坐标从「M5 后端」刷新到「一期功能收口」:120/121、V17、
27 实体/33 表、36 脚本/1548 断言,以及这 20 多轮最该记住的 7 条(含 F-96:
三处「没断言就往下走」把排查引向了完全错误的方向)。
AGENTS.md:H5 那条「没有可直接联调的独立 dev server」已过时,改为三端行为的说明。
|
2026-09-30 09:02:58 +08:00 |
|
lovife
|
b94db65ec4
|
docs: 部署与运维手册 + 用户手册与客服话术
docs/09:环境与发布、密钥清单、迁移与回滚、排障、首次接入 CI 的检查单、本地能验到哪一步、
压测记录(含未测项)。
docs/10:学生/班主任/管理员三份操作手册 + 高频问题标准回答 + 30 分钟培训提纲 +
上线首日检查单;文案与错误码逐条取自代码并回核(抓到两处与实现不符),
核对固化成了 check-manual.py 并进 CI。
|
2026-09-30 09:02:57 +08:00 |
|
lovife
|
30c3714d35
|
test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。
压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。
度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。
验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
|
2026-09-30 09:02:57 +08:00 |
|
lovife
|
6945f194b5
|
chore(ci): CI/CD 三阶段 6 job + test/prod 环境 + 启动前的配置自检
.gitlab-ci.yml:契约与实体对账 / 后端编译 / 管理端类型检查与构建 / 学生端 H5 构建 /
接口断言(全套 E2E 打真库)/ 生产环境自检;密钥每次随机生成,顺带证明应用没有偷偷
依赖 dev 默认值。
ProdEnvCheck 是 EnvironmentPostProcessor,跑在任何 bean 之前,拦 6 类「配错了也能启动」;
判「没配」与「配了」要 try { getProperty } catch(F-86)——
getProperty 会解析占位符、失败就抛,只读 PropertySource 又读不到 env 的值。
删掉 Gi 模板残留的 GitHub Pages 工作流(远端是 GitLab、用 pnpm,且它发布的是没有 API 的静态页)。
验证:scripts/e2e-prodguard.py 9/9(三种误配形态都拒绝启动)。
|
2026-09-30 09:02:57 +08:00 |
|
lovife
|
168c76ed0e
|
feat(pc): 工作台换成真实数据看板 + 「待建」页不再说谎
工作台底部原来是一张写着「M0 后端已完成,界面建设中」的进度卡,而项目已是 120/121 个
接口可用——首页上说一件不真的事比空着更糟。改成真实看板:选任务 → 进度四格 +
分班进度 + 审核情况 + 驳回原因分布;提交率与通过率分两列;平均审核时长按量级换单位。
名单管理/材料规则/表单设计三个菜单项对应的功能早就做好了(入口在「收集任务」每行的
链接里),却一直显示「这个页面还没做」。给 XtMenu 加 entryAt:有入口就指路 + 给跳转,
没有才说没做(角色与授权确实没做,属二期)。并删掉那句会静默过期的「已实现页面」清单。
验证:浏览器实测四个菜单与看板出数;pnpm typecheck 通过、eslint 0 错。
|
2026-09-30 09:02:56 +08:00 |
|
lovife
|
c8837ee602
|
feat(file): 上传预检/秒传/批量处理/预览 + 缩略图(P2-7)与 H5 独立 dev server(P2-9)
缩略图:StorageService.thumbUrl(path, expire, maxLongSide),七牛在**签名之前**拼
imageView2/2/w/h(拼在签名之后必 401),本地存储回退原图,让调用方不必判断当前是什么存储。
实测 1454KB → 36KB(2.4%)、1600×1200 → 400×300。
F-73:秒传原来排在份数检查之后 → 网络重传得到「你超了」而不是「你已经传过了」。
H5 dev server:vite 加 /api 代理 + .env.development 把 VITE_API_BASE 设空串,
改一行不用再 build:h5;三端行为不同(dev→代理 / 构建产物→location.origin / 小程序→写死)。
验证:新增 e2e-m9-thumb.py 12/12(打到真实七牛)、e2e-m8-orgfile 54/54;
H5 与小程序两端构建都验过。
|
2026-09-30 09:02:56 +08:00 |
|
lovife
|
9216bd66d2
|
feat(schedule): 定时任务 8 项 + 用量快照/压缩重试/到期提醒(V17)
PRD 附录 E。每个任务都是两段式:先以平台身份(tenantId=0)扫候选,再逐个租户摆上下文
干活——写数据必须在第二段,否则 tenant_id 被写成 0,通知进平台租户且不报错。
V17 新建 sys_tenant_usage_snapshot(唯一索引保证幂等;tenant_id 必须显式写)并给
collect_file 补 process_retry_count / process_error / last_retry_time + 只索引
「还没处理出规范件」的部分索引;压缩失败重试必须封顶,否则原图损坏的文件会被每 10
分钟重试一次而永远不成功。
跨租户的候选查询全部集中在 ScheduleMapper 一个文件、一律 @InterceptorIgnore + 只读;
Map 结果的别名一律 AS "camelCase"(F-83:PG 会把未加引号的别名折成小写,
表现是任务照跑、日志一行 warn、什么都没做)。
验证:e2e-m7-schedule 38/38(含快照幂等与一致性、重试累加与封顶、到期提醒窗口)。
|
2026-09-30 09:02:55 +08:00 |
|
lovife
|
efde4627eb
|
feat(task): 规则模板、任务克隆与统计、组织导入、租户用量(V15)
克隆**配置**(表单/材料清单/命名模板/审核配置/覆盖班级)、刻意不克隆业务数据——
名册克隆过去会让同一证件号在两个任务里各有一行。
统计补上 abnormalCount 与 avgAuditHours(这两个字段以前恒为 null,因为问题表与审核记录
还不存在);规则模板可按 parts 只应用一部分、材料清单覆盖式合并。
顺带修 F-44:TaskService.create 对缺必填项抛 NPE(对外 50000「系统繁忙」),
改为集中校验并指出是哪个字段;F-45:历史整理器导入改走 TaskService.create,
避免两处各生成一次 task_code 迟早撞号。
验证:e2e-m7-taskconfig 69/69(含待办范围判据的回归防线)。
|
2026-09-30 09:02:55 +08:00 |
|
lovife
|
aa30fd2383
|
feat(open): §14 开放接口(V16)—— HMAC 签名对接认定机构
密钥存 AES 密文(HMAC 要能还原,不能只存哈希);签名 = HMAC-SHA256 + 5 分钟时间窗
+ nonce 防重放,先验签再占 nonce;数据接口一律不返回明文证件号,文件走 30 分钟签名 URL;
推送重试的 next_retry_at 落库(内存定时器重启后就没了),推送挂在 afterCommit 上。
契约漏掉的接入方管理(F-67)一并补上;规则模板两个接口修回契约路径(F-68)。
验证:e2e-m8-open 60/60。
|
2026-09-30 09:02:54 +08:00 |
|
lovife
|
0d3aae75fa
|
feat(cleanup): 历史数据整理器(§13.2,V13)—— 解压解析、体检报告、导入为正式任务
从「人长出来的」历史路径里解析姓名/证件号/材料/班级 → 与名册比对 → 按模板重打包 →
体检报告(autoHandled / needManual 由服务端算,前端只展示,不自己减)→ 幂等导入。
F-48 的教训:中文人名与业务短词无法用「包含」区分——噪声词只做精确匹配,
候选名按「完整中文串 2~4 字 + 不含数字 + 不含班级词」判定。
F-49:参数校验必须排在幂等判断之前(否则不合法的请求会因「之前导过一次」变成成功)。
验证:e2e-m7-cleanup 54/54(含「坏包要明确失败」与「无名单时串档恒为 0」)。
|
2026-09-30 09:02:54 +08:00 |
|
lovife
|
7090ee7d65
|
feat(ops): 操作日志留痕、字典管理、名册改删(V9 / V10)
PRD FR-506/FR-1202 的 P0 项:V9 给 sys_operation_log 补 target_type/target_id/
before_val/after_val,OperationLogService 显式留痕并统一强制脱敏;
已接入的敏感动作:名册改证件号/改名/删名册项、任务发布/暂停/恢复/截止/归档。
F-32 的教训落地:动作名由调用方显式给,不从 (原状态, 新状态) 反推——
「发布」与「恢复」都是 →收集中,反推必然把 resume 记成 PUBLISH_TASK。
字典:内置字典写在代码里(DictCatalog),库里只存客户自定义项与内置项停用开关,
两表分开才能「一键恢复默认」;表单下拉与字典共用同一份口径(BuiltinFields 加访问器)。
验证:e2e-m5-logs 44/44、e2e-m1-dict 53/53。
|
2026-09-30 09:02:53 +08:00 |
|
lovife
|
29c8e932ae
|
feat(notify): 消息中心、催报、通知模板与微信回调 + 校验问题清单(V8 / V14)
消息中心 + 订阅额度(原子扣减,先扣再发——订阅消息是一次性授权)+ 催报(定向/一键,
按 bizType 去重 24 小时);通知降级链只做能真正走通的两级(订阅消息 → 站内信),
公众号模板与短信如实少做并写明理由:永远失败的通道会把真正的原因淹掉。
校验问题清单对齐语义:已有→只更新内容不碰 resolved / 新增→插入 / 消失→标已解决;
上传时把「系统改过文件名」记成 VAL-06 自动修复,让 byRule.autoFixed 有真实数据。
微信回调必须偏离两条全局约定(匿名入口、响应体是 {"errcode":0}):只写推送状态不读
业务数据 + 必须验签 + 凭发出时记的 id 反查租户(查不到就报错,绝不猜一个租户去写)。
验证:e2e-m5-notify 48/48、e2e-m5-urge 40/40、e2e-m7-validation 70/70。
|
2026-09-30 09:02:53 +08:00 |
|
lovife
|
bb91515590
|
feat(join): B1 名单加入申请 —— 学生提交 + 管理端审批(V7 存 AES 密文)
按契约「服务端转 HMAC、不落明文」设计会写不进名册:collect_roster.id_card_no 是
NOT NULL 的密文列,而申请表只有 hash 与 mask(三条替代路都被排除)。
V7 给 collect_join_request 补 id_card_cipher(与名册同一把密钥/算法),审批时原样搬进名册,
明文全程只在提交请求的内存里存在过。V7 之前的老申请明确提示重新提交,不静默建一条
永远过不了校验的名册行。
另修 F-20:自述班级不能借用姓名规范化(姓名要「张 三」→「张三」,班级名里的空格有意义)。
验证:scripts/e2e-m2-join.py 50/50。
|
2026-09-30 09:02:52 +08:00 |
|
lovife
|
ad86e2d521
|
feat(mp): 学生端「我的填报 / 撤回 / 进度」与班主任端看板、审核卡片流、代传材料
学生端:待办数、状态、驳回原因、截止提醒、撤回;进度页的材料清单与进度条
分母用「这个人本次真的要交什么」(不适用的必传材料不进分母,否则永远填不满)。
班主任端:已交率与已通过分开呈现(已提交 ≠ 已通过)、右滑通过 / 左滑不绑驳回、
一键通过只带无问题项;代传走一步 multipart,三道闸门(只给自负责的班 / 只给草稿单 /
必须留痕且学生可见),V12 给 collect_file 补 proxy_by / proxy_reason。
验证:e2e-m3-mytask 42/42、e2e-m5-teacher-dashboard 24/24、e2e-m5-proxy 33/33;
H5 build:h5 产物含新增各页。
|
2026-09-30 09:02:52 +08:00 |
|
lovife
|
45271a3c08
|
feat(audit): 审核台前端与审核统计 —— 批量通过逐条给原因,驳回走附录 C 原因码
待办台四个计数 + 分级过滤 + 批量通过(被跳过的条目逐条列原因,只说「过了 3 个」
会让人反复点那个大按钮);详情抽屉含材料/问题清单/改动明细/时间轴/通过·驳回·撤回。
前端不做范围过滤:权限 ≠ 范围,由服务端用 1 = 0 守死,前端再筛一次只会制造假象。
canApprove=false 时按钮直接禁用并显示原因,而不是等他点了再弹 40032。
审核端看材料走单独一条通道(不复用学生那条),材料 URL 用 ?thumb=true 取缩略图,
默认 false —— 详情页要看防伪纹。
验证:vue-tsc / eslint 0 错;浏览器实测四个菜单与批量通过弹窗。
|
2026-09-30 09:02:51 +08:00 |
|
lovife
|
6cca97efe6
|
feat(export): 导出与交付(M6)—— 前置检查、目录树预览、分卷打包与 7 天过期清理
客户最关心的那条链:precheck(不通过就没有 force 分支,铁律 10)→ 目录树预览
(与真正打包共用同一份构建代码,预览不会说谎)→ 异步分卷(每卷都是完整可解的 ZIP)
→ 产物落表按行判可下载(过期回「已被清理、请重新导出」)。
契约没有但界面必需的 GET /exports/precheck 一并补上(否则按钮要点一次才知道能不能导)。
顺带修 F-11:severity 口径统一为 INFO=1/WARN=2/ERROR=3(三处注释曾互相矛盾,
按直觉写会把「提示」当阻断,正常数据也导不出来)。
验证:scripts/e2e-m6-export.py 60/60、e2e-m6-export-split.py 33/33;
ZIP 的 UTF-8 标志位(general purpose bit 11)与 sha256 都有断言。
|
2026-09-30 09:02:50 +08:00 |
|
lovife
|
3195dc4d7b
|
chore(git): 忽略调试期临时产物 —— _*.txt / _thumb-* / _lt-roster-* / shots/
排查问题时生成的日志与量测样本不是交付物,之前每次 git status 都要靠人记着别 add。
按仓库既有做法(backend/data/、__pycache__/)把它们写进 .gitignore。
|
2026-09-30 09:02:50 +08:00 |
|
lovife
|
5213bc214f
|
feat(audit): 审核闭环后端(M5-01~05)—— 「谁能审谁」比「怎么审」重要
一、落地
module/audit:entity(CollectAuditRecord) + 4 个 mapper + AuditService
+ RejectReasonCatalog(附录 C 的 10 条,写在代码里,理由同权限码)
两端接口:PC /api/v1/audit/*(§11)、小程序 /api/v1/mp/teacher/audit/*(§10.2~10.5,只做初审)
待办的 MP 与 PC **共用一份 VO**(契约里那两份重合九成):做两份迟早分叉,
而"同一个学生在手机上是红、在电脑上是绿"最伤信任。
二、★ 三件必须守住的事
① 权限 ≠ 范围:全校几十个班主任都有 audit:first,但只有这个班的那位能审这个学生。
初审查询强制带 org 范围,且**空列表 = 看不见任何单子**(SQL 里显式 1 = 0,
不是"不过滤")—— 那是这类查询最经典也最严重的写法错误。
E2E 专门造了个"同样是班主任但没被指定"的 T2:看不到待办、打不开详情、点不动通过。
② 有红问题不许通过(40032),且校验是**现场重算**的:提交时落库的 red_count 是快照,
而 OCR/识别是异步的、名册与材料规则也可能被改。"放过一份有问题材料"不可撤销。
③ 批量通过只带绿色项,红黄一律排除(onlyClean 服务端强制 true,传 false 不报错也不生效),
且跳过的每一条都要说清原因 —— 否则用户只会反复点那个大按钮。
三、红黄计数:列表用快照,动作/详情现场重算并**回写快照**(自己会收敛,最多错一次)
⚠️ 因此 detail() 不能标 readOnly=true —— 它要写回写。标了 PG 直接拒绝 UPDATE,
而这个 bug 只在"现场值与快照不一致"时炸,也就是最需要它工作的那一次。已踩并修。
四、⚠️ 三处文档与代码不一致,需要文档侧对齐(详见交接摘要 §一·二十七)
1. 撤回的动作码:DDL 注释 3=批量通过,而接口设计 §11.4 写"action = 3(撤回)"。
1~6 沿用 DDL,**撤回另取 7** —— 时间轴最不能混淆:把撤回显示成批量通过,
会让人以为这份单子被通过了。
2. 撤回的适用状态:§11.4 只列了状态 3;额外允许**状态 2**("班主任点错了通过"正是这个状态)。
状态机自洽:撤初审 → 1,撤终审 → 2。**需客户确认**。
3. 文件 source 的语义:DDL(2 班主任代传/3 管理员代传/4 整理器导入) 与 §10.6(source=4 代传)
冲突,以 DDL 为准;代传接口(M5-06)未做,时间轴已按 source∈{2,3} 预留。
五、顺带补掉两个"没人写过"的窟窿
· 名册行的 submit_status **全仓库只有读没有写** → 名册列表状态列恒为"未提交",
而 REPLACE 护栏只靠 bind_user_id 在生效。现在学生侧与审核侧都写它。
它**不是单调的**:被驳回(4)后重交必须回到 2,否则班主任一直以为他还欠一份。
· collect_submission 少映射了 class_audit_time / admin_audit_time(DDL 里一直有)。
口径是"**本轮**审核的状态"(驳回时清空,历史在 collect_audit_record 里一条不少)。
六、审核端看材料走单独通道
MaterialFileService 抽出 urlOf/contentOf(调用方授权),学生端仍 /mp/files/*、
审核端 /audit/files/*。不要为了"让管理员也能看"在学生那个方法里加 or 条件——那一刻学生端就漏了。
七、一个把「取值口径」讲清楚了的坑
SubmissionValidator 原本走 FillService.getForm(taskId)(学生视角:先过门禁、再拿自己的草稿),
审核人必然撞 40007 且拿到的是自己那份草稿。已抽 assembleFor(task, roster, sub),两条路径共用同一段装配。
紧接着暴露第二层:**改提交单的列不一定改变"组装出来的值"** —— 学生没动过的字段值会**回退到名册**。
想造红问题要用**名册里没有的字段**(E2E 最后用"本职业工作年限填 999"→ VAL-17)。
八、E2E:scripts/e2e-m5-audit.py → 87/87
复用了既有的两个脚本工具(import e2e-m3-submit 拿学生侧造数、import e2e-m2-roster 拿服务端工具),
不复制第二份 mint/upload。覆盖:范围隔离、详情与策略、40032 现场重算、幂等回放、
终审、批量通过的三种跳过、驳回与升级、学生重交、撤回的 40033/40034/40301 与"不能撤两次"。
夹具教训:身份证材料的目录默认长边下限是 1400,小图必判「清晰度偏低」=黄色警告,
于是"绿色单"前提不成立、批量断言集体失败 —— 失败的样子像功能坏了,其实只是夹具不满足前提。
九、没做的(都在计划内):代传(M5-06)、统计(M5-09)、通知(M5-12)、批量驳回/转交(P1)。
因为没有通知渠道,驳回响应**如实**回 notifySent=false + needManualNotice=true ——
不能为了让契约好看而写 true,那会让"学生到底收到没有"变成没人能回答的问题。
十、回归:全量 **753 条断言、0 失败**(新增 87);verify-docs 19 项通过。
下一步:M5 前端(班主任卡片流只右滑通过 / 学生详情 / 驳回页 / 一键通过确认 / PC 终审)。
|
2026-09-29 22:29:00 +08:00 |
|
lovife
|
e6cd1049d4
|
chore(tenant): 租户域名改为默认关闭(客户决定先不做)—— 能力保留,关得干净
客户:先不做多租户了。
处理:fill.tenant-domain.enabled 默认 true → **false**,代码全部保留。
理由:
· 这个能力是隔离的:关掉后 TenantDomainResolver 一律返回 null,
登录走老路径、AuthInterceptor 那段校验根本不触发 —— 不影响任何既有行为
· 它已经验过(§一·二十五 16/16),删掉等于扔掉一份验过、将来要用的东西
· 要真用上它,前提是有正式域名 + 泛域名解析 + 泛域名证书;这些没定之前
开它只会让人以为"域名能区分单位了",实际并不能。等定了改一个配置 + 重启即可。
「关得干净」是本轮的验收点:E2E 改成**两种模式都能跑**,按当前模式选断言。
现在跑的是关闭态这一节(6 条):
✓ t1.localhost + 不填租户码 → 40012(域名不参与判断)
✓ 同一域名 + 填租户码 → 照样能登;主域名 + 租户码 → 照样能登
✓ 主域名 + 不填租户码 → 仍是 40012(跨单位重名的原行为)
✓ 关掉之后不引入任何新校验:带 t1 的 token 访问 t14 域名照常通过
脚本自己探测模式(免租户码登录成功=开、40012=关),所以开着时跑它会跑那 16 条,
不会因为模式不对而假绿。
将来开启:填 base → 配泛域名解析+证书 → XT_TENANT_DOMAIN_ENABLED=true → 重启 → 跑 E2E。
两条硬约束不变:微信授权回调域名只能配一个(回调统一落主域名再按 scene 跳回)、
小程序域名固定白名单(小程序端永远靠 scene)。
随之暂缓:§一·二十五 的三条待拍板(正式域名 / 手填租户码 / 小程序确认)现在都不用追了;
PRD 与 03-接口设计的补文档也等真要做时再写(现在写进去只会多一条"设计了但没启用")。
全量 666 条断言、0 失败(租户域名 16 → 6,因为跑的是关闭态那节)。
|
2026-09-29 21:42:00 +08:00 |
|
lovife
|
5b2f2c109d
|
docs(handoff): 补上 §一·二十五(租户域名)—— 上一版幂等判断被正文引用误判,整节没写进去
上一版脚本用 "§一·二十五" in text 当"已存在就跳过"的判断,而 §一·二十四 的结尾
正好有「见下一节(§一·二十五)」这句话 → 误判为已存在 → 整节没落进文档。
教训:幂等判断要认**标题**(## §一·二十五),不能认"整篇里有没有这个词";
并且写完要**自查一次**标题能否找到(这次加了)。
另:本次提交还顺带记录了一条同类教训 —— 在 PowerShell 里用 git commit -m "带中文引号的消息"
会被 shell 拆成多个 pathspec(这次就炸了)。本项目一直用消息文件,就是为此。
|
2026-09-29 21:37:54 +08:00 |
|
lovife
|
d05cec0a02
|
feat(tenant): 租户域名 —— 用子域前缀判断"这是哪个单位的站点"(t1.xxx.com / t2.xxx.com)
客户新提:不同的租户采用不同的地址前缀来获取租户ID。
一、它解决什么
在此之前"这次是哪个单位"都要人填或扫:PC 登录要手填租户码(跨单位重名时报 40012
「请填写租户码」,而用户根本不知道该填什么)、H5 与小程序靠扫码 scene。
而域名本身就是"我是谁"最自然的答案 —— 在 t1.xxx.com 上打开页面时,
系统不该再问"你是哪个单位的"。
前缀用 t{tenantId},与扫码 scene 的 t{tid} **同一套约定**(scene 早就是 t1-k17),
不引入第二套编号。
二、★ 唯一必须守住的规则:Host 定位,token 授权
未登录 → Host 决定"去哪个单位里找这个人"
已登录 → 一律以 token 里的 tenantId 为准,且 Host 不一致就拒绝
不校验后者的话:在 t1.xxx.com 上拿 t2 的 token 就能读 t2 的数据 ——
而界面一切正常(前端本来就会带 token),是个安静且极难发现的越权。
所以 AuthInterceptor 里那段判断比"登录免填租户码"重要得多,E2E 专门守着它。
三、落地
FillProperties.TenantDomain(enabled/base/scheme) 默认关,siteOf(tid) 拼站点地址
support/TenantDomainResolver 解析不出来返回 null(不是错误)
TenantMapper.selectActiveById 停用/已删的租户不解析
UserMapper.selectByTenantIdAndUsername 域名路径按 id 找用户
AuthService.locateUser 域名优先;手填租户码与域名矛盾 → 明确报错
AuthInterceptor Host 与 token 租户不一致 → 403
MpH5AuthController.redirectToApp 按 scene 的 t{tid} 跳回该单位站点
application-dev.yml enabled/base=localhost/scheme=http
null 的语义很重要:解析不出来 ≠ 出错,而是"这是统一入口/单域名部署",回退老路径。
所以单域名部署仍然完全合法(enabled=false 即可)。
四、两条来自微信的硬约束(决定架构,不是可选)
1. 公众号网页授权的「授权回调页面域名」**只能配一个** → 每租户一个子域时,
回调统一落在主域名,再按 scene 里的 t{tid} 302 回该单位站点。
正好复用既有的 state(scene 随 state 走)+ token 放 fragment 的机制。
2. 小程序的 request 合法域名是**固定白名单**,配不了 *.xxx.com →
小程序端用不了域名,继续靠 scene 里的 t{tid}。这点客户需要知道。
五、本地开发的坑(已处理)
开发期 base 是 localhost,真按它回跳会把手机上的学生从穿透域名扔到 t1.localhost ——
打不开,且是浏览器级失败,看不出是我们跳错了。所以回跳加了本机地址判断
(localhost/127.0.0.1/192.168.*/10.*/*.local),本机 base 一律回退到 h5-base-url。
六、E2E:scripts/e2e-m1-tenant-domain.py → 16/16
[1] t1.localhost + 不填租户码 → 登录成功(对照:不带域名 → 40012);
带端口/大小写/多一层子域都能解析
[2] 域名与手填租户码矛盾 → 明确报错;一致则正常
[3] ★ 拿 t1 的 token 访问 t14 域名 → 403,提示可理解
[4] 边界:x1.localhost / t999999.localhost / example.com → 都回退老路径
用 http.client 伪造 Host 头(urllib 会按 URL 自己算 Host,覆盖不掉)。
七、全量 676 条断言、0 失败(新增 16);verify-docs 19 项通过
八、⏳ 需要拍板的三件事(已写进交接摘要 §一·二十五)
1. 正式域名(base)+ 泛域名解析 + 泛域名证书 *.xxx.com(备案一个主域名即可覆盖子域)
2. 要不要保留"手填租户码"(我保留了:统一入口、老客户端、运维排查都需要)
3. 小程序端确认继续靠 scene
另:这条新需求应补进 PRD 与 03-接口设计(目前只落在代码与本摘要里)——下一轮补。
|
2026-09-29 21:37:11 +08:00 |
|
lovife
|
f964cf45fe
|
feat(scope): 报考范围聚合与一致性体检(M2-04/05)—— 把"人眼核对"换成"接口约束"
一、先纠正上一节里我自己写错的待办
§一·二十三 的下一步写着「sys_eval_plan 那几张表还没落地,需要先定计划从哪来」——
这是错的。PRD V2.0(L508)已明确:那三张计划表**作废删除**,因为机构根本不下发
计划清单,真实名单里报考三项老师已经填好(28/28)。所以范围 = 那三列的去重组合。
教训与 §七 同类:引用/怀疑已有设计之前先查文档,不凭记忆。
二、落地
后端 PlanScopeMapper / PlanScopeService / PlanScopeVO / PlanScopeSaveReq / Controller
前端 apis/xt/plan-scope.ts + 名册抽屉里的「报考范围」区块
实体补上 plan_scope_json / plan_scope_confirmed(V1 建过列,只是没映射)
三、这一步真正的价值:确认时要求**恰好覆盖全员**
rosterIds 并集 ≠ 名册全员 → 40024 并点名。漏一个人,导出包就缺人,
而打开导出包时**不会有人数出 27 个**。所以"排除某人"的正确做法是把他所在的分组
标成 EXCLUDED,而不是从 rosterIds 里删掉 —— 删掉会校验失败,提示正好告诉他
"请明确排除,不要悄悄丢掉"。
体检也必须说得出**是哪几个人**(每条 issue 带 rosterIds,服务端还把名字写进 detail)。
四、五类体检实现四类,一类有理由地缓做
✅ SCOPE_UNFILLED(只判职业:「无」是合法值)
✅ SCOPE_LEVEL_MIXED(**只把少数派算异常**,多数派是"正常那一批")
✅ SCOPE_TYPEO_MISMATCH
✅ SCOPE_OUT_OF_TASK(只在任务显式声明 jobName 时才判;**等级不比** ——
名单写「四级/中级工」而任务写「四级」,精确比必然误报)
⛔ SCOPE_FREE_TEXT:要国标职业字典,而 sys_occupation_ref 一行数据都没有(FR-312 是 P2)。
硬编一份列表会在机构用别的写法时全部误报,而误报会让人整体忽略体检 —— 比不做更糟。
⚠️ level 的口径:契约只给了值、没给语义,且 UNFILLED=1 与 FREE_TEXT=1 严重性明显不同。
所以代码里只把 level 当排序/展示用,判 passed 时一视同仁 —— ignoredIssueCodes 的存在
正说明"要不要放过"由人决定,不该按级别猜规则。
五、FR-311(追加名单告警,P0)一并做了
范围确认后导入,出现范围外的报考组合 → 导入体检里出 SCOPE_OUT_OF_TASK 警告。
范围内不告警 —— 两个方向都验了(不然就是"全报"或"全不报")。
六、E2E:scripts/e2e-m2-scope.py → 38/38
夹具刻意做出:6×车工/四级 + 1×车工/三级(少数派)+ 1×铣工(任务声明车工)+ 1×漏填。
浏览器实测:分组表、4 条体检(级别 + 说明 + 涉及谁 + 忽略勾选)、
确认按钮在未处理时禁用、勾选忽略后解禁、确认后显示「已于 … 确认」且体检变通过。
七、顺带抓到的两个(都在我的测试代码里)
1. 夹具身份证号出生日期写成 0000 → 9 行全被阻断,而导入接口仍回 code=0(只回体检报告)
→ 看着像"导进去了"。所以 E2E 除了判 code 还必须判 created==9,已补。
2. call() 回元组、upload_roster() 回字典,我在新脚本里弄混了。同类:新写的代码最容易在细节上错。
八、回归
全量 660 条断言、0 失败(新增 38);vue-tsc 0 错、eslint 0 错;verify-docs 19 项通过
九、客户端新提了一个架构要求(见 §一·二十五):租户按域名前缀区分(t1.xxx.com / t2.xxx.com)
|
2026-09-29 21:28:49 +08:00 |
|
lovife
|
e85a25a97f
|
feat(roster): 名册导入前端 —— 任务发布现在真的能在界面上走通了
第 2 步。在此之前「配置 → 任务就绪」缺了**名册**那一环:班级能配、材料能配、
表单能配,但名册只能靠 SQL 造,于是任务在界面上**根本发布不了**(40016 名册非空)。
一、新增(frontend-pc/)
src/apis/xt/roster.ts 导入(multipart)+ 分页 + 类型 + 状态字典
src/views/task/TaskRosterDrawer.vue 「名单管理」抽屉:导入 → 体检报告 → 名册列表
views/task/index.vue 加「名单管理」入口;已截止/已归档只读仍可查看
config/xt-routes.ts 顶层「名单管理」菜单说明改为指向任务行
二、★ 验证了"发布真的通"(e2e-m2-roster.py 第 8 节)
配覆盖班级 → 配必传材料 → **发布成功** → 状态变「收集中」→ stat 仍带分母。
force=true 只跳过 P1 提醒,那 5 项阻断级校验 force 也绕不过 —— 所以是真链路。
三、界面上五个刻意的设计
1. 先体检再导入(两步):实测表头在第 1 行或第 6 行,一步导入只会看到
"成功 0 条",然后以为文件坏了
2. 明说「前面那 5 行是标题与说明,已跳过,这不是导错了」—— 不解释的话
用户会去改自己那份本来正确的表格
3. 行号 + 列字母 + 原值 + 原因四样一起给,用户拿着就能回去改 Excel
4. dryRun 默认 true(服务端也默认 true):不显式要求就不写库
5. 阻断行与警告行分两张表:混在一起会让人以为整批都没进
原因后面跟**机器码**而不是中文标签(第一版给中文,和上面那句原因重复了一遍)
四、顺带修掉一处「界面在说谎」
名册导进来了,任务列表却还显示「待导入名册」—— TaskStatVO 恒为 null 是 M1 的
既有缺口(注释写着"名册为空时返回 null,由界面显示待导入名册")。现在名册有了:
· TaskService.fillStats():一次算一页(两条批量查询,不逐任务查)
· **名册为空时仍返回 null**,口径没有放宽 —— 显示 0% 看起来像"没人填",
而真相是"还没有分母"
· abnormalCount / avgAuditHours 仍为 null(要 M4 的校验表 / M5 的审核记录)
· e2e-m1-task 那条断言改成措辞更准的"名册为空时 stat 为 null"(意图不变),
并在 e2e-m2-roster 补 4 条"名册有值后 stat 该有值"
这条也是第 2 步**自己制造**的问题:能导名册之后,"待导入名册"从诚实变成说谎。
五、为什么顶层「名单管理」菜单还是"待建"
名册是**按任务**的,独立页得先选任务(多一步且容易选错);它和「配置班级」
「材料规则」「表单设计」是同一层的配置。报考范围确认(M2-04/05)会接着做进
同一个抽屉,那时也不需要新页面。所以改的是那句已过期的"对应里程碑 M2"。
六、浏览器验证(真链路,只绕开"手指点选")
文件框没法自动化,但可以把夹具放到 frontend-pc/public/ 再喂进去:
fetch 字节 → new File → DataTransfer → input.files → dispatch change
实测:3 人被正确导入、脱敏证件号、报考三项、扩展字段、状态列、搜索全对。
夹具用完即删。
七、回归
全量 622 条断言、0 失败(名册 E2E 62 → 67)
frontend-pc: vue-tsc 0 错、eslint 目标文件 0 错
verify-docs.py 19 项全通过
八、两个流程教训(已写进交接摘要)
· `Test-Path jar` 不能当作"打包成功":这次 mvn 失败而旧 jar 还在,
于是检查得到"打包 ✓",测试对着旧 jar 跑,四条新断言全红。
必须看退出码 / BUILD SUCCESS。
· `call()` 返回的是 (status, body) 元组不是字典 —— 我新写的那节忘了解包。
与第 1 步"已存在判断写反"同类:新写的代码最容易在细节上错。
九、下一步(第 3 步)
报考范围聚合与一致性体检(M2-04/05)—— 产品的核心增量。
先确认:sys_eval_plan 那几张"评价计划"表还没落地(FR-311 未做),
需要先定"计划从哪来"。
|
2026-09-29 17:17:55 +08:00 |
|
lovife
|
fdfe21345a
|
feat(roster): 名册导入(M2-01/02/03 后端)—— 表头探测 + 体检报告 + 合并/替换
计划里排第一的一步。顺序不是拍的,是依赖方向:
没有名册,门禁匹配不到任何人 → 学生进不来;而名册此前只能靠 SQL 夹具造。
(做了这一步,下一步的前端一上,任务发布就能在界面上走通。)
一、新增(module/roster/)
RosterColumnCatalog 官方 21 列(A~U) ↔ 名册字段 对照 + 别名 + 表头文本归一化
RosterImportService 表头探测 + 行解析 + 体检 + 落库(APPEND / REPLACE)
RosterService 分页(姓名模糊 / 身份证号精确 / 班级 / 状态)
RosterImportReport 体检报告(严格照 §8.1 的 JSON)
RosterVO 名册项(证件号与手机号一律脱敏)
CollectRosterMapper 批量判重 / 按 hash 定位 / 整表替换(都带显式 tenant_id)
RosterController POST /tasks/{id}/roster/import、GET /tasks/{id}/roster
权限码现成且口径天然合上契约:导入 roster:manage(仅管理员),
查看 roster:view(管理员+班主任+审核员)
二、先修了一个隐患:规范化被抄成两份就会出事
门禁是按 id_card_hash(HMAC)等值查名册的,而 HMAC 的输入是**规范化后**的字符串。
原来 normalizeName 是 MpRosterService 的私有方法,导入再抄一份的话,
两处只要有一步不同 → 同一号码算出不同 hash → **名册里明明有这个人却查不到**,
而症状会把人引向"学生是不是填错了"。已抽成 support/IdentityNormalizer
(name/idCard),门禁与导入共用;并注明:加宽松(本次补的全角转换)安全,
收紧是破坏性的(已有名册的学生会突然进不来)。
验证不是"读代码比对",而是**用真门禁去核验导入进来的名册**(E2E 第 3 节)。
三、几个刻意的决定
· dryRun 默认 true —— 不显式要求就不写库
· ignoreEmptyRow(false) + 行号与 Excel 对齐 —— EasyExcel 默认跳过空行会让行号错位,
报告说"第 12 行有问题"而用户翻到第 12 行是空的,整份报告就没人信了
· 未识别的列进 ext_json(列名做键)—— "备注""推荐人"这类列不该被丢掉
· 名册上的 phone 存脱敏值(不是密文):名册不需要回放完整号码
· REPLACE 在已有人核验/提交时直接拒绝(40029)—— 否则那个学生的材料
会挂到空名单上,他下次扫码进不来
· APPEND 命中已绑定/已提交的行 → 跳过不覆盖(ROW_LOCKED):
「报考职业」是锁定字段,盖掉等于把学生已确认的材料变成另一种认定
· 校验位不通过**阻断且不自动改** —— 与"文件名里的号码"策略相反:
名册是权威基准,改它等于我们替机构编了个身份证号
四、E2E:scripts/e2e-m2-roster.py → 58/58
夹具刻意把**表头放在第 6 行**、第一张表全空白、表头带换行、另加一列"备注"。
逐条验:4 个阻断行 + 2 个警告行的**行号与 code 都对**、dryRun 不落库、
密文/脱敏值/ext_json、**完整身份证号能精确搜**(密文搜不了 LIKE)、
响应里不得出现完整证件号、重复导入合并不新增、REPLACE 被拒后名册没被破坏、
40028/40017 两条兜底出路。
⭐ 第 3 节用**真门禁接口**核验导入的名册 —— 导入与门禁的 hash 口径一致才算过。
**本次 E2E 抓到一个真 bug**:write() 里"已存在则更新"的判断写反了
(写成"不在 existingHashes 里才去查"),于是重复导入会插重复行,
撞唯一约束 uk_task_idcard → 50000。也就是"第一次能用、第二次就炸",
而重导一次正是最自然的动作。已修 + 在代码里点名这个坑。
五、又发现一处文档与代码不一致(以代码为准)
接口设计 §3.6 把报考三项的"↔列"写成 ext_json,而建表脚本里它们是真实列。
按 §3.6 实现的话,门禁回填(读真实列)会永远给学生空值。
已在 RosterVO 类注释写明。
六、回归
全量 613 条断言、0 失败(新增 58;门禁 29 照旧通过 → 规范化抽取没改变门禁行为)
verify-docs.py 19 项全通过
七、下一步(第 2 步)
名册导入前端(frontend-pc/src/views/task/):上传 → 表头确认 → 体检报告
(错行定位、红黄区分)→ 名册列表。做完它,任务发布就能在界面上走通了。
|
2026-09-29 17:00:21 +08:00 |
|
lovife
|
66e4ccade9
|
feat(mp): 前端压缩做完 —— 上传体积降到 1/8,顺带修掉两个真 bug
一、效果(浏览器实测,真存储、真接口)
手机竖拍 3200×2400 + EXIF Orientation=6,3699KB
→ 1200×1600(竖的,转正正确)· 438KB · q88 · 73ms
服务端**原样收下**:collect_file 里 raw_size=448940、final_size=448940、
quality=0(NOT_REENCODED)—— 字节完全相同,没有二次编码。
二、🐛 bug 一:浏览器对 EXIF 的处理**不一致**,猜必错一半
写完前端旋正后实测,输出是 1600×1200(横的)—— 又躺倒了。量出来:
文件里的像素 3200×2400(横)+ Orientation=6
img.naturalWidth/Height 2400×3200 ← 浏览器**已经自己按 EXIF 转正了**
createImageBitmap('none') 2400×3200 ← 连明确要求不给转也被忽略
现代浏览器会把 EXIF 先转正再交给 drawImage,我又转一次 → 转两下 = 又躺倒;
而老实现(旧 X5 / 旧 WKWebView)给的是原始像素,不转就是躺倒。
两端行为相反,**任何写死的做法都必错一半**。
解法:不猜,量出来再决定 —— 自己从 JPEG 的 SOF 段读**文件里的原始像素**
(readJpegSize,与 EXIF 无关),再和 naturalWidth/Height 比:
· 与原始尺寸互换 → 浏览器已经转过 → 不要再转
· 两者相同 → 给的是原始像素 → 我们自己转
· 都对不上 → 按现代浏览器的默认(已转正)处理
这和第 §一·二十 的 EXIF bug 是同一件事的两面:
后端不转是躺倒,前端多转一次也是躺倒。
三、🐛 bug 二:按后缀判格式 → 前端压缩一上线,学生就传不了材料
上传被拒:「身份证」只接受 jpg/jpeg/png,你传的是 .bin。
H5 用 **blob 地址**调 uni.uploadFile,multipart 里的文件名是一串 UUID、
**没有后缀**,而后端当时按 extOf(name) 判白名单 → 判成 .bin → 拒。
这不是脚手架的问题:前端压缩产出的就是 blob 地址,一上线所有压缩过的照片
都会被拒。两处一起修:
· 后端改为按**字节幻数**判格式(sniffExt),幻数认不出再退回看后缀
(顺带变强:PNG 改名成 .jpg 也能正确识别)
· 接口新增可选 fileName 字段,客户端显式声明文件名;
original_name 只用于展示留痕、不进路径拼接,并截断防超长
四、档位 → 前端行为的映射(**不按材料码猜**,档位是管理员可改的配置)
P2 证件翻拍 → 压:长边 1600 + q88 起阶梯
P3 扫描件 → 压:长边 3508 + 降质量(别套 P2 的 1600,会看不清字)
AUTO/P5 → 只降质量不缩尺寸(未知档位擅自缩尺寸是危险的)
P1 证件照 → 不压(服务端要 295×413 裁切 + 补白底)
P4 截图 → 不压(关键是保持 PNG,canvas 只能出 JPEG 会糊掉表格线)
与服务端配合的关键:必须压成**正好落在服务端那套目标里** ——
压狠了白掉一次画质,压轻了服务端再压一次、前端白干(实测 quality=0 证明走对了)。
五、小程序端:**刻意不做 canvas 旋正**(真机未验证)
orientation === 'up' → compressImage 压;其它朝向 → 不压、直传,交服务端转正。
理由:小程序 canvas 的旋正在真机上无法验证,转错了的后果是机构收到躺倒的身份证;
宁可慢一点,也不能赌。
六、E2E
e2e-m3-file 39/39,新增第 8 节守这两个 bug:
✓ **没有后缀**的真 JPEG 被接受(按字节判格式,不看后缀)
✓ 字节是 PNG、后缀写着 .txt → 按字节认作 png 并接受
全量 469 条断言、0 失败。
七、验证脚手架已拆
pages/dev/compress-check.vue + static/__devcheck/ + 造图脚本都是一次性的,
已全部删除并重建 H5(自查过产物里不再有 compress-check / __devcheck)。
它的价值在于:文件选择框没法自动化(chooseImage 会弹系统对话框),
所以那页绕开"选文件",直接拿真构造的竖拍照片喂给**真正的压缩模块**、
走**真正的上传通道** —— 验的比"尺寸对不对"更硬:
**定位色块落在右上角**(证明转的是 90° 顺时针,不只是宽高换了位置)。
|
2026-09-29 16:25:51 +08:00 |
|
lovife
|
12067b8ae4
|
fix(file): 修掉 EXIF 朝向 bug(竖拍照片会躺倒)+ 上传改一步 + 默认不留原始件
一、客户决定:不用保持原件,只要压缩满足效果
fill.storage.keep-raw 开关,默认 false。交付物就是压缩后的规范件。
顺带把上传从两步变一步:
原来 upload(存原图)→ process(读原图→压缩→存规范件)
现在 upload(收上来→立刻压缩→只存规范件)
留原始件是两步存在的**唯一理由**;不要它之后这步只剩代价
(多一个"传了但没处理"的中间态、多一次往返、多一份存储)。
process 保留为"重新规范化",但没有原始件时**直接返回现状而不报错**。
二、🐛 抓到一个会命中绝大多数真实照片的 bug:EXIF 朝向
手机竖拍时**像素其实是横的** + 一个 Orientation=6(显示时转 90°)。
浏览器认这个标记所以看着是正的;而 ImageIO **不读 EXIF**、重编码又丢掉它 →
输出成了"像素横着、也没有标记"的图 → **机构看到的是一张转了 90° 的身份证**。
实测(修前):输入 3200×2400(横)+ O=6 → 输出 1600×1200(仍横)、EXIF=None ❌
修后:1200×1600(竖)✓ 且输出不再带 EXIF(否则会被转两次)
**为什么 E2E 一直没抓到**:测试图都是程序生成的、不带 EXIF;
而真实学生手机拍的竖拍照片几乎全带 —— 这个 bug 会命中绝大多数真实材料,
是靠"想到这一层"发现的,不是靠测试。
实现:module/file/service/ExifOrientation.java
· 自己解析 JPEG 的 APP1/EXIF(只用一个 tag,三十行,不引新依赖)
· **读不出来时一律"不转"** —— 宁可保持原样,也不凭猜测把图转错
· 八种朝向用**逐像素索引映射**,不用 AffineTransform:
索引一看就懂、容易对着规范核;AffineTransform 写错了很难发现(图还是能出,只是方向不对)
· **转正必须在任何缩放之前** —— 转置会互换宽高,尺寸计算得基于转正后的
三、为"前端压缩"铺路的关键改动
compress() 现在会判断"是不是已经达标了":
没触发缩放 && 没有 EXIF 需转正 && 体积本来就在上限内 → **原样返回,不重编码**
因为前端马上会按材料项目标先压一遍(会转正并去掉 EXIF),服务端再压就是
**JPEG 二次编码** —— 白掉一次画质。quality 用 NOT_REENCODED=0 标记"没重编码过",
排查"这张图为什么有点糊"时能一眼看出被压过几次。
这条对 P4 截图尤其合适:原样保持 PNG,表格线一点不糊。
四、顺手修的
· url/content 的 original=true:没有原始件时**优雅退回到交付件**并如实标注
(FileUrl 加 original 字段)—— 学生点"看原图"只是想看自己传的那份,
报"文件不存在"是纯粹的困惑
· sniffExt():按**字节幻数**判类型,不信文件名后缀(学生传的 .jpg 里装 PNG 是常事,
而这个结论会写进数据库、决定导出包的扩展名)
五、E2E
e2e-m3-file 37/37,新增第 7 节专守 EXIF:
输入 3200×2400(横)+ O=6 → 输出 1200x1600
✓ 输出是竖的 ✓ 长边仍受 P2 约束 ✓ 不再带 EXIF
原始件相关断言改成**按策略断言**(开/关两种都对),而不是写死"必须保留"——
那会让"改配置"看起来像"代码坏了"。
全量 467 条断言、0 失败。
⏳ 还没做:**前端压缩**("传 800KB 而不是 3MB")。本轮做的是为它铺路:
服务端不再二次编码 + EXIF 转正机制已就位(前端要用 JS 做同样的事)。
前端要做:照片类读 EXIF→canvas 旋正→长边 1600→q85;P1/P4 不压直传;
压完仍超就回退原图让服务端兜底。
⚠️ 流程坑(本轮白花三次编译):**不要把 Java 字符串字面量放进 PowerShell here-string
传给 Python** —— @"..."@ 会把 " 吃掉(return "bin"; 变成 return bin;)。
带引号的代码一律用 edit 工具改,不要过转义层。
|
2026-09-29 15:58:08 +08:00 |
|
lovife
|
fe7615d12b
|
feat(h5): 学生端材料预览 + 删掉部署里的 MinIO
一、部署配置清理
docker-compose.yml 里的 MinIO 删了(连 minio-data 卷)。它当初是
"M0 阶段不写入但先起好 —— 本地/生产走同一套 S3 接口",但代码最终走
七牛官方 SDK,那个容器**从来没被连过** —— 留着只会让人以为"本地还有个
对象存储要用"。.env.example / .env 的 MINIO_* 换成 QINIU_* + XT_STORAGE_*。
⚠️ 删的时候我自己犯了个错:用 volumes:\n 当插入锚点,结果匹配到了
**pg 服务内部的 ` volumes:`**,把注释插进 pg 定义里还把它顶到第 0 列,
**YAML 结构就这么坏了**。好在写完立刻用 yaml.safe_load 复查(services: [pg, redis])
才发现。教训:改 YAML 之后一定要解析一遍,别靠肉眼看缩进。
二、学生端材料预览(新功能)
已传材料每行加「预览」,点开全屏浮层(深底 —— 材料照片多是白底证件件,
浅底会看不清边界),点图片以外关闭。
**两种存储两种取法,不能只写一种**:
· 七牛:接口返回带签名的绝对 URL,直接给 <image src> 用
· 本地磁盘:那个接口要带 Authorization,而 <image src> 发不了请求头 ——
先用 uni.request 取字节,再转 blob URL
只写七牛那一种,本地开发时预览会一片空白,而**原因完全看不出来**。
签名链接有有效期(30 分钟),所以每次预览都现取,不缓存 URL。
顺手修两个后端问题:
· LocalStorageService.url() 返回的路径指向一个**不存在的接口**
(我建的是 /{id}/content,不是 ?path=)→ 改由 MaterialFileService 按
provider 决定:七牛给签名链接、本地给 /{id}/content(按 id 取能复用归属校验)
· /content 原来固定 application/octet-stream —— 表现是**点开身份证照片
变成下载一个文件**。现在按文件名推 Content-Type + inline
三、浏览器实测
点预览 → 浮层打开、图片真的解码(naturalWidth=900、complete=true);
直接 fetch 那个签名 URL → 200 / 17728 字节。
中间发现一个视觉问题:**浮层标题与「关闭」被 uni-app 导航栏盖住了**
(page-head 的 z-index 是 998,我给的 95)—— 看起来像"标题没渲染出来"。
改成 9999 + padding-top 让出导航高度;问题清单浮层一并提到 9998。复验通过。
四、顺带
dev-seed-warn-state.py 改为没有可用草稿时自己建一份 —— 原来写死 submission id,
被 E2E 清理后就报"填报单不存在或不属于你",而那看起来像权限问题。
验收:e2e-m3-file 32/32、m3-submit 31/31、m1-storage 16/16
|
2026-09-29 15:16:36 +08:00 |
|
lovife
|
d8463a051f
|
verify(storage): 七牛云接通并验证通过 —— 对象存储这块完成
根因就是那个"多出来的一个字符":secret-key 原来是 **41** 字符,
而七牛的 AK/SK 都是 40 字符的 URL 安全 base64。用户重新复制后(SK 40 字符)一切正常。
排查方式值得记:没有凭一次失败下结论,而是做了三次互相独立的验证 ——
① 我手写的管理 API 签名(可能是我的 bug,不能作数)
② 应用启动自检里的**七牛官方 SDK** 的 uploadToken
③ 一个**能把原因分开**的调用:BucketManager.stat(bucket, 一个一定不存在的键)
612 = 鉴权通过(只是文件不存在)/ 401 = 密钥无效 / 631 = bucket 名不对
三次一致指向"密钥无效",所以给用户的结论是确定的,而不是"可能是配置问题"。
验证证据:
启动自检通过:写入 → 存在 → 读回 → 删除 全部正常(provider=qiniu)
材料存储:七牛云(bucket=tools-matrix domain=https://file.tools.coding.design)
e2e-m1-storage.py 16/16:
· 签名链接形如 .../xiaotutongxue/std/1/17/%E5%BC%A0%E5%90%8C%E5%AD%A6-…?e=…&token=…
· **中文对象键逐段编码正确**(就是设计时专门防的那个坑)
· 签名链接下载回来是真 JPEG、非空
· **不带签名的裸地址被拒(401/403)→ 空间确实私有,签名在起作用**
· 对象键带 xiaotutongxue/ 前缀
· **原始件字节与上传的一模一样**
全量回归 370 条断言、0 失败(此时 provider=qiniu,文件真的进七牛)
顺带修掉一条"写死了存储位置"的 E2E 断言:e2e-m3-file.py 原来断言
「原始文件真的存在磁盘上」,切到七牛后整片变红,而那看起来像代码坏了。
改成通过产品接口把文件取回来比字节(GET /mp/files/{id}/url → 下载):
对两种 provider 都成立,而且比原来更强(顺带验了签名链接、私有访问、内容完整性)。
教训:**在"可替换的实现"上写断言,等于把测试绑死在一种实现上。**
状态:backend/application-local.yml 已是 provider: qiniu(密钥 gitignore、不进 jar);
生产用 XT_STORAGE_PROVIDER=qiniu + QINIU_ACCESS_KEY/QINIU_SECRET_KEY。
|
2026-09-29 14:52:51 +08:00 |
|
lovife
|
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。
|
2026-09-29 14:38:37 +08:00 |
|
lovife
|
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,不误报失败。
|
2026-09-29 14:09:13 +08:00 |
|
lovife
|
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 条断言全绿)
|
2026-09-29 13:48:11 +08:00 |
|
lovife
|
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 全绿。
|
2026-09-29 13:33:09 +08:00 |
|
lovife
|
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,保持不动。
|
2026-09-29 13:21:07 +08:00 |
|
lovife
|
6620b024cf
|
docs(handoff): 追加 §一·十三(材料上传 + 提交校验)—— 五个坑、两处契约偏差、新增 VAL-22
|
2026-09-29 12:39:50 +08:00 |
|
lovife
|
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 全绿。
|
2026-09-29 12:39:16 +08:00 |
|
lovife
|
8ef09733b1
|
feat(file): 材料上传的前端界面 + 列出/删除接口
后端:GET /mp/files/submissions/{id}(列出我的材料)+ DELETE /mp/files/{id}(软删)。
两者都把归属条件(submitter_id)写进 SQL 的 WHERE —— 与门禁、草稿同一套写法,
少一个条件就是"能看/能删别人的材料"。
前端(填报页的材料区):
· 按材料项列出:名称、必传标记、格式白名单与体积上限、来源引导语
· 已传的显示「原名 → 规范名」与「压缩前 → 压缩后」体积(学生要能看见系统做了什么)
· 传完**立刻处理**并显示结果
⚠️ 为什么上传后马上处理而不是留到提交时:学生要能看到"压到了多大、叫什么名字"
才算完成。若把处理留到提交时,他上传时会以为"传上去了",提交时才发现体积超标 ——
而那时他可能已经在回家的路上。
· 学生能自己删(传错了不该只能找班主任)。已提交的填报单不给增删,与草稿同口径。
⚠️ 本轮**未完成浏览器肉眼验证**:构建产物里确实有材料区的代码
(产物 grep 到「需要上传的材料」),但浏览器加载的是**缓存的旧 chunk**
(实测 .fill__wrap 只有 4 个子元素,没有材料卡),我用带时间戳的方式绕缓存没成功。
代码与产物是对的,需要一次硬刷新(Ctrl+Shift+R)或清缓存后再确认。
这条记下来,不要当成"已验证"。
|
2026-09-29 11:59:58 +08:00 |
|
lovife
|
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(含体积、档位、命名、原图保留、幂等、白名单、份数)。
|
2026-09-29 11:49:21 +08:00 |
|
lovife
|
465700969a
|
feat(h5): 填报页(三态 + 条件级联 + 失焦自动存草稿)
学生终于能在手机上真正填一份表了。
三态渲染(契约 §9.2):
locked=true → 只读灰底 + 「如有误请联系班主任」
prefilled=true,locked=false → 可编辑 + 橙字「老师已填,请核对」
其余 → 普通空白
实测:姓名/身份证号/联系电话/报考职业/工种/等级 只读,学历可改并标注来源。
条件级联(visibleWhen)在前端求值,实测正确:
「是否在校生=是」→ 在读学校/所学专业/入学时间 出现;
「是否补考」未填 → 补考三科不出现。
**隐藏 ≠ 清空**:学生把「是否补考」改回"否",已填的补考科目仍留在草稿里
(万一他改回来)。要不要清空由后端在提交时判断 —— 前端不该替用户做不可逆的事。
保存策略:**失焦即存单个字段**。不做"整表提交"——学生填到一半切出去接电话,
回来整表丢失是最让人恼火的失败。单字段保存的代价是一次请求,
远小于"白填一遍"。
进页面时若还没有草稿会先建一份(否则第一次失焦保存会因 submissionId 为空而静默不存)。
前端拦住只读字段的保存请求(能省一次必然失败的请求),但**服务端那道拒绝才是真的**。
顺带:核验页的「开始填写材料」接上了(带 taskId + scene —— H5 跳转不保留 URL 参数)。
验收:构建产物含 pages-fill-fill;用无头浏览器实测渲染,
三态与两处级联均正确,抬头显示「请在 2026-10-15 前提交」。
|
2026-09-29 11:43:45 +08:00 |
|
lovife
|
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。
|
2026-09-29 11:41:40 +08:00 |
|
lovife
|
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 个字段分两组返回,三态与脱敏均正确。
|
2026-09-29 11:28:53 +08:00 |
|
lovife
|
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),
是我为验证留的演示数据。
|
2026-09-29 11:24:32 +08:00 |
|
lovife
|
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 秒 —— 穿透的带宽限制(花生壳免费档),不是代码问题。
|
2026-09-29 11:11:49 +08:00 |
|
lovife
|
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)。
|
2026-09-29 11:00:18 +08:00 |
|
lovife
|
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 项断言全通过。
|
2026-09-29 10:46:21 +08:00 |
|