Commit Graph

40 Commits

Author SHA1 Message Date
lovife 9f6d26c650 fix(e2e,pc): 不再产生跨租户同名的登录账号
账号唯一性是 (tenant_id, username),跨租户重名本身是合法行为(登录靠租户码区分);但默认值与 e2e 残留会让它变成默认状态:不带租户码登录会撞上 40012(该账号存在于多个单位),平台侧看账号也分不清哪个 admin 属于哪所学校。三处一起收口 ——

1) scripts/e2e-m0.py 跑完不再留租户:以前每跑一次就留下一个 e2e###### 租户 + 里面叫 admin 的管理员 + demo 下 3 个测试组织,库里那两个孤儿租户就是这么来的。现在按本次跑出的 id 自清(崩溃路径也兜住),不越界成 LIKE 'e2e%' 全清。

2) scripts/e2e-m1-tenant-domain.py 自己造、自己清「另一个租户里也有 admin」的夹具:它原本靠第 1 条的残留凑出 40012 这个前提,残留一被清掉就会静默反转成 8 条 FAIL。顺带修掉同族的第二处隐式依赖 —— 它假定 t14.localhost 是个真实租户,而那个租户早就不存在了。

3) PC 端「开通租户」的登录账号默认值不再写死 admin,改为跟随租户编码生成 admin_<编码>;运营一旦手改就不再被编码覆盖。

验证:全量 python scripts/verify-all.py --with-e2e 通过(37 脚本 / 1597 断言 / 0 失败),跑完库里仍只有 1 个租户、0 处跨租户重名;PC 端 pnpm typecheck 0 错、pnpm lint 干净、check-wiring 未接线 0 条,浏览器实演默认值跟随与手改不回退。
2026-10-13 10:21:40 +08:00
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00
lovife 5b877451eb refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。

- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
  Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
  内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
  ① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
  ② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
  ③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
  (高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
  ⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
  .section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
  index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
2026-10-10 16:48:46 +08:00
lovife 0bbac7d228 feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)
客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤,
  于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所
  学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。
  现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户:
  只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。
- OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对
  平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() ——
  isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通
  用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补
  TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。
- 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。
  按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校;
  学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。
- 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn
  吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。
  E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。

二、PC 端走查体验
- 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口,
  菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。
- 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。
  403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。
- 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在
  卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据
  铃铛;配色按客户要求还原。

顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于
PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false,
而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义
竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。

验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过;
e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、
e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。
详见 docs/交接摘要.md §一·三十三 与 §一·三十四。
2026-10-09 17:29:20 +08:00
lovife db8aecc30a chore(e2e,docs): 新脚本的清理只删自己建的模板行(不碰演示态)+ docs/09 §8 补接线检查 2026-10-08 14:47:11 +08:00
lovife 9e78e1d239 feat(notify): 通知接线检查 + 修 F-101~F-111(模板对自动通知生效 / 催报真的会发 / 该发的通知都发了)
起点:F-1/F-31/F-50/F-52 是同一个形状 —— 每处单看都对,编译/lint/review 全不红,只有从入口走一遍才暴露。先把这一类固化成命令,再按输出逐条修。

新增 scripts/check-wiring.py:权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、bizType 有没有被用过、菜单键 ↔ PC 端 XtMenu 双向核对。有意不接线的写进脚本上方例外表(带理由)。进 verify-all(第 5 项)与 CI 的 check 阶段。检查器自身修了三次假阳性:只数 @RequirePerm 漏掉服务层校验、按标识符数引用被同名常量骗过、注释里的提及被当成接线(改为先去 Java 注释再数)。

F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半):新增 NotifyTextResolver(单向依赖避开循环注入),库里改过的 > 内置;模板停用 = 这类通知不发且如实回 notifySent=false。F-102 加入申请三处通知(提交→审批人、通过/驳回→学生)。F-103 导出完成/失败通知发起人,正文不放签名 URL(模板里原本写着 {下载链接},一并改掉)。F-104 自动催报从来没发出过任何一条(end_time = date::date 与库里的 23:59:59 永不相等,测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」。F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)。F-106 字典/通知记录两个菜单没有任何角色持有(手册还写着去那里办)。F-107 6 个权限码挂到自己的接口。F-109 Map.of 不接受 null → 通知被吞成一行 warn。F-110/F-111 测试自身的两个坑。

验证:新增 e2e-m5-notify-wiring.py 43 条(改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节);verify-all --with-e2e → 9 项全过 0 跳过,38 个脚本 / 1620 条断言 / 0 失败。
2026-10-08 14:46:11 +08:00
lovife 4a5790d900 chore(clean): 测试数据三本账清完(库 / 磁盘 / 七牛桶)+ 100 条问题全部核销
一、dev-clean-e2e.py:加「在用组织保护」后执行
  · 先算「仍被存活任务引用的组织」并保留 —— 当前演示态就挂在其中一个
    「E2E 24数控1班」上,删了它演示态就坏(保留 3 个,演示态完好:
    任务 1449 / 名册 3 人 / 组织 43 都在)
  · 已执行 --apply --demo-tasks --orphan-files:
    49 个 e2e 租户 + 147 个测试组织 + 84 个【演示·旧】任务,
    共 3714 行 / 27 个文件(业务表按 information_schema 动态枚举,不写死清单)

二、dev-clean-qiniu.py(新增):清真实桶里本项目前缀的测试对象
  · 只动 xiaotutongxue/ 前缀;默认 dry-run;删前核实库中零引用、
    数量异常(>5000)直接停
  · 已执行:2036 个对象 / 116 MB 全部删除,删后复验 0 个(F-93「删库不删桶」)
  · 签名按官方文档逐字核对 —— 踩了两个 401 才对:旧 QBox 格式(一个换行 +
    "QBox " 前缀)不对,是「两个换行 + Qiniu 前缀」;batch 的 Body 必须进
    签名串(列举无 Body 能过、批删全 401 —— 同一签名函数一好一坏,线索在 Body)
  · 列举在 rsf.qiniuapi.com,不在 rs(打错 host 报「Bad method: list」)

三、xt-minio 容器已删(F-2)—— 代码早已不用 S3,deploy 里也没有它

四、文档核销与记录
  · docs/08:F-3/4/7/10(小节)与 F-14/26/29/30/31/38/51/57/64/65/71/72/93
    (表格行)全部标「已闭环」;F-5 补执行记录;F-28 备案维持两级(→D-11);
    变更记录补两行 —— **F-1~F-100 至此无未决项**,剩余全部等外部输入
  · docs/01:新增 E4「国标职业库数据来源待客户确认」(F-34,字典页如实显示 0 项)
  · 交接摘要:新增 §一·三十一(17 项决策执行记录)
  · AGENTS.md:清理一节写清三个工具与「在用组织保护」

验证:python scripts/verify-all.py --with-e2e →
      通过 8 / 失败 0 / 跳过 0(37 个 E2E 脚本 / 1577 条断言 / 0 失败,291s;
      prodguard 9/9;test profile 能起)
2026-09-30 12:38:55 +08:00
lovife 57bf39d5a1 feat(notify,log): 催报分钟级限流 + 学生微信登录写登录日志(两处合规补漏)
一、催报分钟级限流(F-29,契约 §3「10 次/分钟/任务」此前未实现)
  · UrgeService 加 Redis 计数:定向与一键催报共用,超限 42901
    且话说清等多久、24h 去重还在(去重防「学生被重复催」,限流防「老师连点
    把系统打爆」—— 两件事);限流放在「确实有活可干」之后(空结果连点不计数)
  · Redis 不可用时放行不瘫痪(限流是保护,不是业务前提)

二、学生微信登录也写 sys_login_log(F-31 —— 此前只有 PC 账号密码登录留痕,
   按 §13.4 查「学生登录记录」永远为空,合规缺口)
  · 新增 WechatLoginLogger:小程序登录(MpLoginService)与 H5 授权回调/
    打桩登录(MpH5AuthController)三条路径都写
  · login_type=2 与 DDL 注释对齐(1 PC 2 小程序 3 开放接口);H5 与小程序
    共用 2(DDL 只定义到 3,不造新值),要区分看 userAgent —— 字段里本来就带着
  · 写失败不影响登录(日志表满了不能没人能登录);不记 code/openid

验证:e2e-m5-urge → 42/42(新增「连点第 11 次被限流」断言,先清计数保证确定性)
      e2e-m3-h5-auth → 24/24(新增「两次 stub-login 各留一条 login_type=2」断言,
      清理时连登录日志一起清)
2026-09-30 12:38:48 +08:00
lovife 8f80744d63 feat(task): 规则模板 FORM 真正可套用 + 已归档任务可删(客户确认的两项)
一、规则模板的 FORM 部分(F-57/F-64)—— 此前只能「跳过并说明」,等于装饰
  · FormSchemaService.saveRawVersion:用已存在的 schemaJson 存为新版本。
    与手工保存的区别:内容不再校验/归一化(它当初就是按本服务规则存出来的,
    再走一遍解析→序列化只会丢未建模字段);状态检查与版本管理保留 —— 版本化
    逻辑只有一份
  · RuleTemplateService 的 FORM 分支接上它(不再 skipped)
  · 顺带补上另一半:saveAsTemplate 此前不存表单(存模板不存、套模板用不上,
    两头各缺一半)—— 现在把源任务的当前表单一并带走;没存过 = null =
    套用时按「用系统默认,不覆盖」跳过

二、已归档任务可删(F-14 / §17.2 D-9)—— 此前「已发布即删不掉」,
   演示/测试任务只能改名挪开,库只增不减
  · DELETE /tasks/{id} 支持 status=3:收集中/已截止仍拒(归档是最后的后悔药),
    拒绝的话说清「先归档」
  · 子表级联物理删 14 张表(TaskCascadeMapper 集中放置,表清单来自
    information_schema 的事实核对)—— 大头是名册(证件号密文)、材料、审核记录
  · ★ 任务行本身**软删**:它必须占住 task_code —— maxSeqByCodePrefix 靠
    「含软删行的最大序号」保证编码永不复用(编码同时是文件目录名,重号会把
    两批任务的文件混进同一目录)。第一版做成了物理删,e2e-m1-task 的
    「编码递增」断言当场抓到重号 —— 这条回归现在被 e2e-m1-task-delete
    的「删除后新建不重号」断言守住
  · 物理文件尽力删(先取路径再删行,顺序反了文件就永远留在磁盘上;
    删不掉只记日志不阻断);全程留痕(sys_operation_log 记 DELETE_TASK +
    行数/文件数)

验证:e2e-m7-taskconfig → 75/75(新增 FORM 完整链路 7 条断言)
      e2e-m1-task-delete(新增)→ 20/20(拒删/删净/占号软删/留痕/再删报错/草稿直删)
      e2e-m1-task → 71/71(旧口径断言更新为新口径)
2026-09-30 12:38:47 +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 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 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 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 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 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 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 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 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 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
lovife 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 传递正确。
2026-09-29 10:29:14 +08:00
lovife 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 项断言全通过。
2026-09-29 10:08:02 +08:00
lovife 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。
2026-09-29 09:47:09 +08:00
lovife 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。
2026-09-29 09:42:08 +08:00
lovife 7523ebe789 feat(task): M1-02 覆盖班级与班主任配置
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。

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

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

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

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

验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
2026-09-29 09:32:48 +08:00
lovife 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。
2026-09-28 23:48:21 +08:00
lovife 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 残留的测试账号
2026-09-28 22:25:01 +08:00
lovife 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 班级),供界面演示
2026-09-28 20:49:55 +08:00
lovife 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 完成情况
2026-09-28 17:28:21 +08:00