Commit Graph

17 Commits

Author SHA1 Message Date
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 168c76ed0e feat(pc): 工作台换成真实数据看板 + 「待建」页不再说谎
工作台底部原来是一张写着「M0 后端已完成,界面建设中」的进度卡,而项目已是 120/121 个
接口可用——首页上说一件不真的事比空着更糟。改成真实看板:选任务 → 进度四格 +
分班进度 + 审核情况 + 驳回原因分布;提交率与通过率分两列;平均审核时长按量级换单位。

名单管理/材料规则/表单设计三个菜单项对应的功能早就做好了(入口在「收集任务」每行的
链接里),却一直显示「这个页面还没做」。给 XtMenu 加 entryAt:有入口就指路 + 给跳转,
没有才说没做(角色与授权确实没做,属二期)。并删掉那句会静默过期的「已实现页面」清单。

验证:浏览器实测四个菜单与看板出数;pnpm typecheck 通过、eslint 0 错。
2026-09-30 09:02:56 +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 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 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 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 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 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