Compare commits

...

10 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 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 f89cd998ef test(unit): 三个纯函数的最小单测(12 条)—— 本仓库第一批单测(F-9)
后端验证一直靠 E2E(打真实接口,很硬但要起服务连库)。这批先给
「输入输出确定、没有 IO」的纯函数补最小单测,成本最低、回归收益最高;
其余继续靠 E2E,不为了覆盖率硬凑。

· IdentityNormalizerTest:姓名去所有空白但**保留间隔号**(少数民族姓名的
  「·」是真实的,顺手清掉等于改人名字)、全角转半角、证件号小写 x 收尾统一、
  null 不抛 NPE
· FileProcessServiceTest:扩展名必须自带点号(少一个字符不报错,只会产出
  「身份证jpg」这种客户双击打不开的文件 —— F-46)、空变量用「未填」占位
  (留空会出现「张--身份证.jpg」)、文件名里的 / 必须清掉(「学籍表(1/2)」
  带 / 根本写不进磁盘)、超长截到 90 且不吃掉扩展名、sanitize 收拢连续分隔符

每条断言都对应一个真实踩过的坑 —— 断言的是「有理由的规则」,不是覆盖率。

验证:mvn test → 12/12 通过(exit 0)
2026-09-30 12:38:48 +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 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
87 changed files with 5409 additions and 605 deletions
+4 -2
View File
@@ -2,8 +2,8 @@
# GitLab CI(M0-04 / M7-10)
#
# 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。
# 这个仓库没有单元测试(`src/test` 是空的),验证靠三样东西:
# ① 三个机械校验脚本(契约对账 / 实体列 / 文档一致性)
# 这个仓库的单元测试很少(`src/test` 只有 12 条纯函数),验证靠三样东西:
# ① 五个机械校验脚本(契约对账 / 实体列 / 文档一致性 / 手册文案 / **接线**)
# ② 编译与构建(后端 jar、PC、H5)
# ③ **`scripts/e2e-*.py` 打真实接口 + 查库断言**(1500+ 条)
# 所以 e2e 阶段不是"锦上添花",它是这个项目的主验证手段 ——
@@ -58,6 +58,8 @@ variables:
- python scripts/verify-docs.py
- echo "── 手册里引用的错误码与文案是否真的存在(客服照着它回答) ──"
- python scripts/check-manual.py
- echo "── 声明了但没接上的东西(权限码 / 错误码 / 通知模板 / 菜单键) ──"
- python scripts/check-wiring.py --quiet
- echo "── 全部 e2e 脚本的语法(引号写错会当场红,而不是跑到一半才红) ──"
- |
python - <<'PY'
+16 -5
View File
@@ -83,22 +83,33 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
## 三、怎么验证(本仓库最不显然的一点)
**后端没有单元测试**(`src/test` 是空的)。验证靠 `scripts/e2e-*.py`:
打真实接口 + `docker exec` 进 PG 查库断言。
**后端的单元测试很少**(`backend/*/src/test` 只有 12 条纯函数断言,`mvn test` 跑)。
主验证手段是 `scripts/e2e-*.py`:打真实接口 + `docker exec` 进 PG 查库断言。
```bash
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
```
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(`e2e-m0*` / `e2e-m1-*` / `e2e-m2-roster` / `e2e-m3-*`),
目前合计 613 条断言
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1620 条断言**),
或一条命令 `python scripts/verify-all.py --with-e2e`
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
- 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转
- 跑完 `e2e-m3-*` 会**清掉名册绑定**(`collect_roster.bind_user_id`);
想继续用浏览器验演示态,要先重新绑定(见交接摘要的"演示态"一节)
- 清理残留:`python scripts/dev-clean-e2e.py`、`python scripts/dev-clean-test-users.py`
- 清理残留:`python scripts/dev-clean-e2e.py`(**默认 dry-run**;
`--only-orphan-files --apply` 只清磁盘孤儿文件 —— 压测跑几轮就能攒下几百 MB;
`--apply` 清 e2e 租户与测试组织,**会先算「仍被存活任务引用的组织」并保留**)、
`python scripts/dev-clean-test-users.py`、`python scripts/dev-clean-qiniu.py`
(清真实桶里 `xiaotutongxue/` 前缀的测试对象,**默认 dry-run**,删前先核实库中零引用)
- 文档一致性:`python scripts/verify-docs.py`
- 接线检查:`python scripts/check-wiring.py`(**"声明了但没接上"** ——
权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、
`bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上)。
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)
- 一条命令跑完上面这些:`python scripts/verify-all.py --with-e2e`
(9 项;**跳过项会如实列出来**,不是静默跳过)
## 四、接口与数据契约(不显然、容易做错)
@@ -30,6 +30,28 @@ public record LoginUser(
return tenantId != null && tenantId == PLATFORM_TENANT_ID;
}
/**
* 这个人是不是<b>平台运营</b> —— 包括"正在代管某所学校"的那种。
*
* <p>⚠️ 与 {@link #isPlatformAdmin()} 的区别非常重要,两者回答的是不同的问题:
* <ul>
* <li>{@code isPlatformAdmin()} = "当前上下文的租户<b>就是平台租户</b>" ——
* 它是租户拦截器要不要<b>整体关掉过滤</b>的开关(跨租户看数据);</li>
* <li>{@code isPlatformStaff()} = "这个人<b>是平台运营</b>" ——
* 数据范围是全量,但**租户过滤按当前上下文**照常生效。</li>
* </ul>
*
* <p>代管某所学校时(请求头 {@code X-Tenant-Id},见 {@code ActingTenantResolver})
* tenantId 被换成那所学校,于是 {@code isPlatformAdmin()} 变成 false。
* 这时如果各处只判前者,平台就会**退化成"某个普通用户"**:例如任务列表会走
* "班主任只看自己带的班"那条分支,而平台在该校没有班级 → 空列表(看起来像"这所学校没有任务")。
*
* <p>所以凡是"按身份决定数据范围"的判断,用这一个;凡是"决定要不要关掉租户过滤"的,用前者。
*/
public boolean isPlatformStaff() {
return isPlatformAdmin() || hasRole("PLATFORM_ADMIN");
}
public boolean hasRole(String roleCode) {
return roles != null && roles.contains(roleCode);
}
@@ -761,15 +761,99 @@ public class AuditService {
}
boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED;
String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审";
String content = finalPass
? "本次认定的材料已收妥,无需再操作。"
: "班主任已通过你的材料,接下来由管理员终审。";
safeNotify(NotificationService.NotifyRequest.of(
sub.getSubmitterId(),
finalPass ? CollectMessage.BIZ_APPROVE : CollectMessage.BIZ_TASK_PUBLISH,
title, content,
// ⚠️ bizType 用 4(审核通过),**不是** 1(任务发布):
// 1 是"有新任务了",而"你的材料过审了"是审核进展。
// 以前这里借用了 1,于是消息中心按"任务发布"筛会筛出初审通过(《08》F-105)。
// 文案仍按节点区分 —— "等待终审"与"已通过"是两件不同的事,
// 用同一句话会让他以为已经结束了。
safeNotify(NotificationService.NotifyRequest.ofTemplate(
sub.getSubmitterId(), CollectMessage.BIZ_APPROVE,
"AUDIT_PASS",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"姓名", nameOfSubmission(sub)),
title,
CollectMessage.LINK_SUBMISSION, sub.getId(),
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS));
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS)
.withExtra(finalPass ? "本次认定的材料已收妥,无需再操作。"
: "班主任已通过你的材料,接下来由管理员终审。"));
}
/** 任务名(模板变量用;查不到就给个不空的兜底)。 */
private String taskNameOf(Long taskId) {
if (taskId == null) {
return "本次填报";
}
CollectTask t = taskMapper.selectById(taskId);
return t == null || t.getTaskName() == null ? "本次填报" : t.getTaskName();
}
/**
* 姓名(模板变量用)。
*
* <p>**先取名册上的名字**:那才是"他在这个任务里叫什么"(老师导入的名单),
* 而账号里的 {@code real_name} 可能还没填(学生扫码核验时只建了账号)。
* 两个都没有就不编 —— 模板里那个变量渲染成空(见 {@code NotifyRequest.vars})。
*/
private String nameOfSubmission(CollectSubmission sub) {
if (sub == null) {
return null;
}
if (sub.getRosterId() != null) {
CollectRoster r = rosterMapper.selectById(sub.getRosterId());
if (r != null && r.getRealName() != null && !r.getRealName().isBlank()) {
return r.getRealName();
}
}
return nameOf(sub.getSubmitterId());
}
/** 姓名(从账号取,兜底)。 */
private String nameOf(Long uid) {
if (uid == null) {
return null;
}
SysUser u = userMapper.selectById(uid);
return u == null ? null : u.getRealName();
}
/** 被驳回的材料名(模板的 {材料名}):多个就用顿号连起来。 */
private String materialTextOf(List<String> codes) {
if (codes == null || codes.isEmpty()) {
return "全部材料";
}
return String.join("、", codes);
}
/**
* 驳回次数达上限:**通知该校的管理员**(契约 §11.4「同时通知管理员」)。
*
* <p>《08》F-105:这里原来只有一行 {@code log.warn},而注释写着"站内信也发给管理员" ——
* 于是"需要管理员介入"这件事**没有任何人知道**(学生看到的是被驳回、
* 班主任看到的是"已达上限",而管理员什么都没看到)。日志不是通知。
*/
private void notifyEscalated(CollectSubmission sub, String reason) {
try {
var admins = userMapper.userIdsOfRole(UserContext.tenantId(),
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
if (admins.isEmpty()) {
log.warn("需要管理员介入但没有管理员账号可通知:submission={}", sub.getId());
return;
}
for (Long uid : admins) {
safeNotify(NotificationService.NotifyRequest.ofTemplate(
uid, CollectMessage.BIZ_REJECT,
"AUDIT_ESCALATE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"姓名", nameOfSubmission(sub),
"驳回原因", reason),
"有一条填报单需要你介入",
CollectMessage.LINK_SUBMISSION, sub.getId(), null));
}
} catch (RuntimeException e) {
log.warn("升级通知管理员失败(不影响驳回结果):submission={}", sub.getId(), e);
}
}
/**
@@ -946,10 +1030,18 @@ public class AuditService {
boolean pushSent = false;
if (sub.getSubmitterId() != null) {
try {
var r = notificationService.notify(NotificationService.NotifyRequest.of(
// 文案走模板(FR-1002):管理员在「通知模板」页改了「被退回」那句,
// 学生收到的就是改过的那句 —— 而不是这里硬编码的旧文案(《08》F-101)。
// 提交进度/材料名这些模板表达不了的细节,用 extra 追加。
var r = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
sub.getSubmitterId(), CollectMessage.BIZ_REJECT,
"AUDIT_REJECT",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"驳回原因", fullText,
"材料名", materialTextOf(req.getMaterialCodes()),
"姓名", nameOfSubmission(sub)),
"你的材料被退回了",
fullText,
CollectMessage.LINK_SUBMISSION, sub.getId(),
NotificationService.TPL_AUDIT_RESULT));
pushSent = r.pushSent();
@@ -959,9 +1051,10 @@ public class AuditService {
}
if (escalated) {
// 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。
// 驳回次数达上限:这条留给管理员,所以**也通知管理员**(不只是写日志)。
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
submissionId, sub.getRosterId(), rejectCount, code);
notifyEscalated(sub, fullText);
}
log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}",
submissionId, node, rejectCount, code, pushSent);
@@ -43,7 +43,7 @@ public class CleanupController {
private final CleanupImportService importService;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "创建整理任务(上传压缩包)",
description = "用**与正式收集完全相同的规则**做规范化。"
+ "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
@@ -66,7 +66,7 @@ public class CleanupController {
}
@PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
+ "方便自动化脚本与排障,不必手搓 multipart")
@@ -133,7 +133,7 @@ public class CleanupController {
}
@PostMapping("/{id}/import")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "把整理结果导入为正式任务",
description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
+ "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
@@ -143,7 +143,7 @@ public class CleanupController {
}
@PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "只解析不落库(排障用)",
description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
@@ -16,6 +16,8 @@ import cn.tianbaotong.fill.module.fill.service.SubmissionValidator;
import cn.tianbaotong.fill.module.file.storage.StorageService;
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
import cn.tianbaotong.fill.module.material.service.MaterialDefService;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import cn.tianbaotong.fill.module.notify.service.NotificationService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
@@ -114,6 +116,7 @@ public class ExportService {
private final SubmissionValidator validator;
private final MaterialDefService materialDefService;
private final StorageService storageService;
private final NotificationService notificationService;
private final FillProperties props;
/**
@@ -682,6 +685,43 @@ public class ExportService {
log.info("导出完成 exportId={} 人数={} 分卷={} 包体={}MB(原始 {}MB)",
exportId, total, volumes.size(), round1(size / 1024.0 / 1024.0),
round1(written / 1024.0 / 1024.0));
// ★ 通知发起人(《08》F-103):打包是异步的(几百份材料要十几秒到几分钟),
// 在此之前用户只能守着进度条 —— 而 `collect_message` 的 bizType 6
// (导出完成)一直没有代码写过,消息中心那个分类永远是空的。
notifyExportFinish(exportId, true, null);
}
/**
* 导出结束的通知(成功与失败都发)。
*
* <p>⚠️ **正文里不放下载链接**,只让他去「导出中心」取。
* 理由不是省事:七牛是私有空间,链接是**签名 URL**,而站内信是落库的 ——
* 把签名链接写进消息正文就等于"URL 入库",与《AGENTS.md》里
* 「URL 不能入库、不能发出去让人明天再打开」直接冲突(那个链接几十分钟后就失效)。
* 内置模板里原本写着 {@code {下载链接}},本轮一并改掉了(见《08》F-103)。
*/
private void notifyExportFinish(Long exportId, boolean ok, String failReason) {
try {
CollectExportTask t = exportTaskMapper.selectById(exportId);
if (t == null || t.getCreateBy() == null) {
return;
}
notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
t.getCreateBy(), CollectMessage.BIZ_EXPORT,
"EXPORT_DONE",
NotificationService.NotifyRequest.vars(
"导出包名", t.getExportName() == null ? "导出包" : t.getExportName()),
ok ? "导出完成" : "导出失败",
CollectMessage.LINK_NONE, null, null)
.withExtra(ok
? "共 " + nzInt(t.getFileCount()) + " 份材料,请到「导出中心」下载。"
: "原因:" + (failReason == null ? "打包失败" : failReason)
+ "。请到「导出中心」查看后重试。"));
} catch (RuntimeException e) {
// 通知失败不影响导出结果本身(与审核通知同一口径)
log.warn("导出完成的通知没发出去 exportId={}", exportId, e);
}
}
private long nzLong(Long v) {
@@ -704,6 +744,9 @@ public class ExportService {
} catch (RuntimeException inner) {
log.error("标记导出失败时又出错 exportId={}", exportId, inner);
}
// 失败也要通知:否则用户会一直等一个永远不会完成的包
notifyExportFinish(exportId, false,
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
}
/**
@@ -42,7 +42,7 @@ public class FormSchemaController {
}
@PutMapping
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.FORM_MANAGE)
@Operation(summary = "保存任务表单定义(版本化)",
description = "每次保存插入一个新版本,旧版本保留。baseVersion 与当前最新不符时返回 40025,"
+ "并在 data 里带上最新定义供前端做差异合并(响应格式:{code:40025, data:最新schema})。"
@@ -90,6 +90,42 @@ public class FormSchemaService {
// ── 保存 ──────────────────────────────────────
/**
* 用一份**已经存在**的 schemaJson 作为新版本保存(规则模板套用 / 任务克隆场景)。
*
* <p>与 {@link #save(Long, FormSchemaSaveReq)} 的区别:内容<b>不再校验与归一化</b> ——
* 它当初就是从某个任务按本服务的规则存出来的,再走一遍"解析 → 对象 → 序列化"
* 只会丢掉没建模的字段(与 {@code TaskCloneService} 复制表单是同一个理由)。
* <b>状态检查与版本管理保留</b>(与手工保存同一套,两处必须一致)。
*
* @return 新版本号
*/
@Transactional(rollbackFor = Exception.class)
public int saveRawVersion(Long taskId, String schemaJson) {
if (schemaJson == null || schemaJson.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "表单定义为空,无可保存内容");
}
CollectTask task = requireTask(taskId);
int status = task.getStatus() == null ? -1 : task.getStatus();
if (!EDITABLE_TASK_STATUS.contains(status)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"任务已" + statusText(status) + ",表单不可再改");
}
int nextVersion = latestVersion(taskId) + 1;
formSchemaMapper.update(null, Wrappers.<CollectFormSchema>lambdaUpdate()
.eq(CollectFormSchema::getTaskId, taskId)
.set(CollectFormSchema::getIsCurrent, CollectFormSchema.HISTORY));
CollectFormSchema entity = new CollectFormSchema();
entity.setTaskId(taskId);
entity.setVersion(nextVersion);
entity.setSchemaJson(schemaJson);
entity.setIsCurrent(CollectFormSchema.CURRENT);
formSchemaMapper.insert(entity);
log.info("任务 {}({})表单按已存定义保存为新版本 v{}(套用模板/克隆)",
taskId, task.getTaskCode(), nextVersion);
return nextVersion;
}
@Transactional(rollbackFor = Exception.class)
public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) {
CollectTask task = requireTask(taskId);
@@ -78,6 +78,49 @@ public interface CollectJoinRequestMapper extends BaseMapper<CollectJoinRequest>
}
}
/**
* 该通知谁去审批这条申请(《08》F-102:以前一个都不通知)。
*
* <p>三类人,去重后返回:
* <ol>
* <li>这个任务覆盖班级的**班主任**({@code collect_task_org.head_user_id});</li>
* <li>这些班级的**副班主任**({@code deputy_user_ids} 存的是
* {@code ,1,2,} 这种带逗号的串,所以要用 {@code LIKE '%,id,%'} 判);</li>
* <li>本租户的**学校管理员** —— 兜底:任务没配班主任时也得有人管这件事。</li>
* </ol>
*
* <p>⚠️ 不去查"谁有 join:audit 权限":权限是"能不能做",收件人是"该谁做"。
* 一个学校有 50 个班主任,把申请推给全部 50 个等于推给没人。
*
* <p>⚠️ {@code collect_task_org} **没有 {@code deleted} 列**(V1 建表就没给,
* 全项目只有 5 张表这样,见《08》F-14 的排查记录)—— 所以这里不能写
* {@code cto.deleted = 0},否则整条 SQL 直接报"列不存在"。
*/
@Select("""
SELECT DISTINCT uid FROM (
SELECT cto.head_user_id AS uid
FROM collect_task_org cto
WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId}
AND cto.status = 1
UNION
SELECT CAST(TRIM(pid) AS BIGINT) AS uid
FROM collect_task_org cto,
LATERAL regexp_split_to_table(COALESCE(cto.deputy_user_ids, ''), ',') AS pid
WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId}
AND cto.status = 1
AND TRIM(pid) <> ''
UNION
SELECT u.id AS uid
FROM sys_user u
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0
WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1
AND ro.role_code = 'SCHOOL_ADMIN'
) x
WHERE uid IS NOT NULL
""")
List<Long> auditUserIdsOfTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/**
* 审批通过后回写申请单。
*
@@ -8,6 +8,8 @@ import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import cn.tianbaotong.fill.module.join.mapper.CollectJoinRequestMapper;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import cn.tianbaotong.fill.module.notify.service.NotificationService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
@@ -74,6 +76,7 @@ public class JoinService {
private final TaskMapper taskMapper;
private final UserMapper userMapper;
private final SensitiveCodec codec;
private final NotificationService notificationService;
// ── 学生侧 ─────────────────────────────────────
@@ -155,6 +158,7 @@ public class JoinService {
joinMapper.insert(r);
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
notifyAuditors(task, r, name);
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
vo.setRequestId(r.getId());
@@ -393,20 +397,82 @@ public class JoinService {
}
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
// ⚠️ 通知(M5-12)还没做:只能靠老师口头/群里说一声。
// 这里如实回 false,**不为了让契约好看而写 true**(同驳回接口的口径)。
log.warn("加入申请已通过但**没有发出通知**(通知渠道未实现):request={} 需人工告知学生", id);
// ★ 通知学生(《08》F-102):以前这里只写一行 log.warn,
// 学生要自己反复进小程序看结果 —— 而这条链路的另一端(通知渠道)
// 早在 M5 就做好了,只是没接上。
boolean notifySent = false;
if (r.getApplicantUid() != null) {
try {
var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_APPROVE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(r.getTaskId()),
"姓名", name),
"加入名单申请已通过",
CollectMessage.LINK_TASK, r.getTaskId(),
NotificationService.TPL_JOIN_APPROVE)
.withExtra("你现在可以扫码核验身份,然后开始填报了。"));
notifySent = nr.pushSent();
} catch (RuntimeException e) {
log.warn("加入申请通过的通知失败(不影响审批结果):request={}", id, e);
}
}
JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
vo.setRosterId(row.getId());
vo.setRosterCreated(true);
vo.setNotifySent(false);
// ⚠️ 语义与驳回接口一致:这是"**微信推送**发出去了没有"。
// 站内信一定写得进去,所以学生一定能在消息中心看到结果。
vo.setNotifySent(notifySent);
vo.setCanFillNow(r.getApplicantUid() != null);
return vo;
}
/**
* 通知该审批这条申请的人(班主任 / 副班主任 / 学校管理员兜底)。
*
* <p>《08》F-102:提交申请之后**一个通知都没有** —— 老师只能自己想到去翻审批页。
* 而"申请加入名单"本来就是一条"学生等着、老师不知道"的链路,
* 没有通知等于这条路走不通(学生会一直等,或者去问班主任"你看到我申请了吗")。
*/
private void notifyAuditors(CollectTask task, CollectJoinRequest r, String name) {
try {
var uids = joinMapper.auditUserIdsOfTask(UserContext.tenantId(), r.getTaskId());
if (uids.isEmpty()) {
log.warn("加入申请无人可通知(该任务没配班主任、租户也没有管理员):request={}", r.getId());
return;
}
for (Long to : uids) {
notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
to, CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_SUBMIT",
NotificationService.NotifyRequest.vars(
"任务名", task.getTaskName(),
"姓名", name,
"班级", r.getOrgHint()),
"有新的加入名单申请",
// 跳任务而不是跳申请单:审批页是按任务筛的(学生端 SCENE 也只带 taskId)
CollectMessage.LINK_TASK, r.getTaskId(), null));
}
log.info("加入申请已通知 {} 位审批人:request={}", uids.size(), r.getId());
} catch (RuntimeException e) {
log.warn("加入申请的通知失败(不影响学生提交):request={}", r.getId(), e);
}
}
private String taskNameOf(Long taskId) {
CollectTask t = taskId == null ? null : taskMapper.selectById(taskId);
return t == null ? null : t.getTaskName();
}
private String nz(String s) {
return s == null ? "" : s;
}
/** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */
private Long matchOrgByName(String orgHint) {
if (orgHint == null || orgHint.isBlank()) {
@@ -449,12 +515,33 @@ public class JoinService {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason);
log.warn("加入申请已驳回但**没有发出通知**(通知渠道未实现):request={}", id);
// ★ 通知学生(《08》F-102):驳回原因学生必须看到,
// 否则他会以为"申请丢了"而反复重提(每人每任务只能提 2 次)。
boolean notifySent = false;
if (r.getApplicantUid() != null) {
try {
var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_APPROVE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(r.getTaskId()),
"姓名", r.getRealName(),
"驳回原因", reason),
"加入名单申请未通过",
CollectMessage.LINK_TASK, r.getTaskId(),
NotificationService.TPL_JOIN_APPROVE)
.withExtra("原因:" + reason));
notifySent = nr.pushSent();
} catch (RuntimeException e) {
log.warn("加入申请驳回的通知失败(不影响驳回结果):request={}", id, e);
}
}
JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
vo.setNotifySent(false);
vo.setNotifySent(notifySent);
return vo;
}
}
@@ -1,12 +1,15 @@
package cn.tianbaotong.fill.module.log.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.log.dto.LogDtos;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper;
import cn.tianbaotong.fill.security.PermissionChecker;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
@@ -25,8 +28,8 @@ import java.util.List;
/**
* 日志查询(《03-接口设计》§13.4)。
*
* <p>权限用 {@code log:viewTenant}(本租户操作日志)。
* 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。
* <p>权限是**两个码的任一个**:{@code log:viewTenant}(本租户操作日志,学校管理员/教务处)
* 或 {@code log:viewAll}(全量,平台运营)。范围由 {@link #logScopeTenantId} 决定。
* 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
*
* <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel
@@ -41,15 +44,19 @@ public class LogController {
private final OperationLogMapper operationLogMapper;
private final LoginLogMapper loginLogMapper;
private final PermissionChecker permissionChecker;
@GetMapping("/operations")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT)
@RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "操作日志",
description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
description = "Query:`tenantId` `operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
+ "**按 targetType+targetId 查**是这个接口最重要的用法 —— "
+ "「这张名册项的证件号被谁从什么改成了什么」要靠它回答,"
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)")
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)。"
+ "⚠️ 范围:只有 `log:viewAll`(平台运营)能跨租户看**全量**,"
+ "也只有它能用 `tenantId` 指定某所学校;学校管理员恒为本租户")
public ApiResult<PageResult<LogDtos.OperationLogVO>> operations(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long operatorId,
@RequestParam(required = false) String module,
@RequestParam(required = false) String action,
@@ -59,20 +66,48 @@ public class LogController {
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require();
Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from);
LocalDateTime t = parseTo(to);
List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations(
me.tenantId(), operatorId, module, action, targetType, targetId,
scope, operatorId, module, action, targetType, targetId,
f, t, ps, (pn - 1) * ps);
long total = operationLogMapper.countOperations(
me.tenantId(), operatorId, module, action, targetType, targetId, f, t);
scope, operatorId, module, action, targetType, targetId, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps));
}
/**
* 日志查询的<b>租户范围</b>:{@code null} = 全量(跨租户)。
*
* <p>这是"平台运营看不到任何日志"那个 bug 的修法(2026-10-09):
* 以前控制器恒传 {@code me.tenantId()},而平台运营的 tenantId 是 <b>0</b>,
* 日志却都挂在各租户下 → 列表永远空白、且不报错。
* 而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
*
* <p>规则三条:
* <ol>
* <li>有 {@code log:viewAll}(平台运营):<b>默认全量</b>;显式传 {@code tenantId} 时只看那一所;</li>
* <li>只有 {@code log:viewTenant}(学校管理员、教务处):<b>恒为本租户</b>;</li>
* <li>学校管理员传了**别的**租户 id → 直接 {@code 40301}。
* <b>不静默忽略</b>:静默忽略会让人以为"我按这个单位筛过了",其实没筛 ——
* 而这一屏是拿去回答合规问题的,不能有这种"看起来筛了"的错觉。</li>
* </ol>
*/
private Long logScopeTenantId(Long requested) {
LoginUser me = UserContext.require();
if (permissionChecker.has(PermissionDef.LOG_VIEW_ALL)) {
return requested;
}
if (requested != null && !requested.equals(me.tenantId())) {
throw new BizException(ErrorCode.FORBIDDEN, "只能查看本单位的日志");
}
return me.tenantId();
}
/**
* 日期入参解析。
*
@@ -108,13 +143,15 @@ public class LogController {
}
@GetMapping("/logins")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT)
@RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "登录日志",
description = "Query:`userId` `userType` `result` `from` `to` + 分页。"
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Integer userType,
@RequestParam(required = false) Boolean result,
@@ -122,7 +159,7 @@ public class LogController {
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require();
Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from);
@@ -131,8 +168,8 @@ public class LogController {
Integer ok = result == null ? null : (result ? 1 : 0);
List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins(
me.tenantId(), userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(me.tenantId(), userId, userType, ok, f, t);
scope, userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(scope, userId, userType, ok, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps));
}
}
@@ -60,7 +60,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
l.create_time AS loginTime
FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="userId != null">
AND l.user_id = #{userId}
</if>
@@ -96,7 +99,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
SELECT COUNT(*)
FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="userId != null">
AND l.user_id = #{userId}
</if>
@@ -18,6 +18,15 @@ import java.util.List;
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
* 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
* 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
*
* <p>⚠️ {@code tenantId} 是<b>可选</b>条件({@code <if test="tenantId != null">}):
* 契约 §13.4 本来就把 {@code tenantId} 定义成查询参数。不传 = <b>全量</b>,
* 只有平台运营({@code log:viewAll})能走到这一支;学校管理员由
* {@code LogController} 恒填本租户。
*
* <p>以前这里写死 {@code l.tenant_id = #{tenantId}}、控制器恒传 {@code me.tenantId()} ——
* 而平台运营的 tenantId 是 0,于是它<b>一条日志都看不到</b>(列表空白,
* 没有任何报错),而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
*/
public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
@@ -57,7 +66,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
l.create_time AS createTime
FROM sys_operation_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="operatorId != null">
AND l.user_id = #{operatorId}
</if>
@@ -100,7 +112,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
<script>
SELECT COUNT(*)
FROM sys_operation_log l
WHERE l.tenant_id = #{tenantId}
WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="operatorId != null">
AND l.user_id = #{operatorId}
</if>
@@ -79,6 +79,8 @@ public class OperationLogService {
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
public static final String ACTION_DELETE_TASK = "DELETE_TASK";
public static final String ACTION_REVOKE_AUDIT = "REVOKE_AUDIT";
public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE";
public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD";
@@ -0,0 +1,64 @@
package cn.tianbaotong.fill.module.log.service;
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
/**
* 学生侧(微信)登录也写 {@code sys_login_log}(2026-09-30 客户确认补上,见《08》F-31)。
*
* <h3>为什么此前一直只有 PC 的</h3>
*
* <p>{@code AuthService.writeLog} 只挂在账号密码登录上,而学生从小程序/H5 进来走的是
* 微信授权(无密码),那条链路没人写日志 —— 于是按 §13.4 查"学生登录记录"永远是空的,
* 而这在合规上是个缺口("谁在什么时候登录过"必须两类用户都答得出来)。
*
* <h3>login_type 的取值(与 DDL 注释对齐:1 PC 2 小程序 3 开放接口)</h3>
*
* <p>小程序与 H5 网页授权**共用 {@code 2}**:DDL 只定义到 3,
* 自造新值就是又一处"文档与库不一致"。要区分两端时看 {@code user_agent}
* —— H5 是浏览器 UA,小程序是 {@code miniProgram},字段里本来就带着。
*
* <p>⚠️ 与 PC 侧同样的纪律:**写失败绝不影响登录本身**(否则日志表一满就没人能登录),
* 也不记任何凭据(code / openid 都不落库,username 用姓名或 "wx-{uid}")。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WechatLoginLogger {
/** DDL:1 PC 2 小程序 3 开放接口。 */
public static final int LOGIN_TYPE_WECHAT = 2;
private final LoginLogMapper loginLogMapper;
/**
* 记一条成功的微信侧登录。
*
* @param displayName 姓名(没绑定名册的新用户可能是 null,此时记 "wx-{uid}")
*/
public void success(Long tenantId, Long userId, String displayName,
HttpServletRequest req) {
try {
SysLoginLog row = new SysLoginLog();
row.setTenantId(tenantId == null ? 0L : tenantId);
row.setUserId(userId);
String who = displayName == null || displayName.isBlank()
? ("wx-" + userId) : displayName;
row.setUsername(who.length() <= 64 ? who : who.substring(0, 64));
row.setLoginType(LOGIN_TYPE_WECHAT);
row.setSuccess(1);
row.setFailReason(null);
row.setIp(req == null ? null : cn.tianbaotong.fill.support.WebUtil.clientIp(req));
row.setUserAgent(req == null ? null : cn.tianbaotong.fill.support.WebUtil.userAgent(req));
row.setTraceId(MDC.get("traceId"));
loginLogMapper.insertIgnoreTenant(row);
} catch (Exception e) { // noqa: BLE001
log.warn("写微信侧登录日志失败(不影响登录): {}", e.getMessage());
}
}
}
@@ -47,7 +47,7 @@ public class MaterialDefController {
}
@PostMapping("/tasks/{id}/materials")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "新增材料项",
description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。"
+ "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传")
@@ -57,7 +57,7 @@ public class MaterialDefController {
}
@PutMapping("/materials/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)")
public ApiResult<Void> update(@PathVariable Long id,
@Valid @RequestBody MaterialDefSaveReq req) {
@@ -66,7 +66,7 @@ public class MaterialDefController {
}
@DeleteMapping("/materials/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "删除材料项",
description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收,"
+ "请设为选传或加条件显示规则隐藏")
@@ -76,7 +76,7 @@ public class MaterialDefController {
}
@PostMapping("/tasks/{id}/materials/apply-template")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "按内置模板初始化材料清单",
description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*")
public ApiResult<Map<String, Object>> applyTemplate(@PathVariable Long id,
@@ -48,6 +48,7 @@ public class MpH5AuthController {
private final MpH5AuthService authService;
private final JwtService jwtService;
private final cn.tianbaotong.fill.config.FillProperties props;
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
@PublicApi
@GetMapping("/authorize")
@@ -64,7 +65,8 @@ public class MpH5AuthController {
@Operation(summary = "微信授权回调(匿名)",
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
@RequestParam(required = false) String state) {
@RequestParam(required = false) String state,
HttpServletRequest request) {
String scene = authService.consumeScene(state);
try {
if (code == null || code.isBlank()) {
@@ -72,7 +74,7 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
}
String openId = authService.exchangeOpenId(code);
return loginAndRedirect(openId, scene);
return loginAndRedirect(openId, scene, request);
} catch (BizException e) {
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
@@ -99,12 +101,13 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
}
SysUser user = authService.stubLogin(tenantId, openid, request);
return redirectWithToken(user, scene);
ResponseEntity<Void> r = redirectWithToken(user, scene, request);
return r;
}
// ── 内部 ──────────────────────────────────────
private ResponseEntity<Void> loginAndRedirect(String openId, String scene) {
private ResponseEntity<Void> loginAndRedirect(String openId, String scene, HttpServletRequest request) {
Long tenantId = MpSceneService.tenantIdOf(scene);
if (tenantId == null) {
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
@@ -112,10 +115,13 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
}
SysUser user = authService.resolveUser(tenantId, openId);
return redirectWithToken(user, scene);
return redirectWithToken(user, scene, request);
}
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene) {
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene, HttpServletRequest request) {
// 学生侧登录也写 sys_login_log(2026-09-30 补上,F-31):H5 授权回调与打桩登录
// 都从这里下发 token,写一次就覆盖两条路径。写失败不影响登录
wechatLoginLogger.success(user.getTenantId(), user.getId(), user.getRealName(), request);
String token = jwtService.issue(
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
JwtService.Client.MP);
@@ -41,11 +41,13 @@ public class MpLoginController {
description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。"
+ "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。"
+ "scene 可穷举,所以这里**不返回任何业务数据**,只返回"
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」")
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body) {
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」。"
+ "登录成功会写 sys_login_log(login_type=2,2026-09-30 补上,见 F-31)")
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body,
jakarta.servlet.http.HttpServletRequest request) {
String code = str(body == null ? null : body.get("code"));
String scene = str(body == null ? null : body.get("scene"));
return ApiResult.ok(loginService.login(code, scene));
return ApiResult.ok(loginService.login(code, scene, request));
}
@PostMapping("/bind-phone")
@@ -71,6 +71,7 @@ public class MpLoginService {
private final TaskMapper taskMapper;
private final JwtService jwtService;
private final SensitiveCodec codec;
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build();
@@ -79,6 +80,14 @@ public class MpLoginService {
@Transactional(rollbackFor = Exception.class)
public LoginVO login(String code, String scene) {
return login(code, scene, null);
}
/**
* @param request 用于登录日志取 IP / UA(可为 null:定时或内部调用没有请求上下文)
*/
@Transactional(rollbackFor = Exception.class)
public LoginVO login(String code, String scene, jakarta.servlet.http.HttpServletRequest request) {
if (code == null || code.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code");
}
@@ -130,6 +139,10 @@ public class MpLoginService {
}
log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}",
s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone());
// 学生侧登录也写 sys_login_log(2026-09-30 补上,F-31):
// 以前只有 PC 账号密码登录留痕,按 §13.4 查"学生登录记录"永远是空的 —— 合规缺口。
// 写失败不影响登录(logger 内部吞掉);tenantId/姓名在这里才齐,所以放 service 而不是 controller
wechatLoginLogger.success(user.getTenantId(), user.getId(), user.getRealName(), request);
return vo;
}
@@ -32,6 +32,8 @@ public interface UrgeMapper {
private String orgName;
private Long taskId;
private String taskName;
/** 截止时间:模板里的 {@code {截止日}} 与 {@code {剩余天数}} 靠它(以前没取,那两个变量永远空)。 */
private java.time.LocalDateTime endTime;
private Integer submitStatus;
public Long getRosterId() { return rosterId; }
@@ -48,6 +50,8 @@ public interface UrgeMapper {
public void setTaskId(Long v) { this.taskId = v; }
public String getTaskName() { return taskName; }
public void setTaskName(String v) { this.taskName = v; }
public java.time.LocalDateTime getEndTime() { return endTime; }
public void setEndTime(java.time.LocalDateTime v) { this.endTime = v; }
public Integer getSubmitStatus() { return submitStatus; }
public void setSubmitStatus(Integer v) { this.submitStatus = v; }
}
@@ -68,6 +72,7 @@ public interface UrgeMapper {
o.org_name AS orgName,
t.id AS taskId,
t.task_name AS taskName,
t.end_time AS endTime,
r.submit_status AS submitStatus
FROM collect_roster r
JOIN collect_task t
@@ -105,6 +110,25 @@ public interface UrgeMapper {
@Param("orgIds") List<Long> orgIds,
@Param("scopeUserId") Long scopeUserId);
/**
* 这个任务的提交进度("{已提交}/{应填}"),给模板变量 {@code {提交进度}} 用。
*
* <p>契约 §12.7 的变量白名单里 {@code {提交进度}} 的适用场景就是催报 ——
* 而在此之前它**永远渲染成空**:内置模板写着"目前提交进度 {提交进度}",
* 没有任何调用方传过这个变量,于是学生收到的是"目前提交进度 ,请…"。
* 一个白名单里有、实际没人填的变量,与"定义了没接上"是同一类问题。
*
* <p>"已提交"按 {@code submit_status >= 2}(已提交 / 初审中 / 已通过…)算,
* 与班主任看板的已交率同一口径。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FILTER (WHERE r.submit_status >= 2) || '/' || COUNT(*)
FROM collect_roster r
WHERE r.tenant_id = #{tenantId} AND r.deleted = 0 AND r.task_id = #{taskId}
""")
String progressOf(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/** 一个名册项(定向催报要先确认"这个人归我管")。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
@@ -115,6 +139,7 @@ public interface UrgeMapper {
o.org_name AS orgName,
t.id AS taskId,
t.task_name AS taskName,
t.end_time AS endTime,
r.submit_status AS submitStatus
FROM collect_roster r
JOIN collect_task t
@@ -60,7 +60,21 @@ import java.util.Map;
@RequiredArgsConstructor
public class NotificationService {
/** 模板码(契约 §13.6 的表)。 */
/**
* <b>微信订阅消息</b>的模板码(契约 §13.6 的「模板码与索取时机」表)。
*
* <p>⚠️ 这套码与 {@link NotifyTemplates} 里那套<b>站内信</b>模板码是
* <b>两个命名空间</b>,别混:
* <ul>
* <li>这一套({@code SUBMIT_SUCCESS} …)是**微信后台建好的订阅消息模板**的键,
* 用途有两个:查 {@code collect_subscribe_quota} 的额度、从
* {@code fill.mini-program.templates} 里取微信那边的 {@code templateId};</li>
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
* </ul>
* 两套里 {@code JOIN_APPROVE} 恰好**同名**,纯属巧合 ——
* 下一个改代码的人很容易当成同一个东西,所以在这里写清楚(见《08》F-106)。
*/
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
@@ -72,10 +86,21 @@ public class NotificationService {
private final NotifyRecordMapper notifyRecordMapper;
private final UserMapper userMapper;
private final WxMiniProgramService wxService;
private final NotifyTextResolver textResolver;
/**
* 发一条通知。
*
* <h3>文案从哪来:模板优先(FR-1002)</h3>
*
* <p>{@code req.subjectCode()} 非空时,标题与正文**以模板为准** ——
* 库里改过就用库里的、没改过用内置的({@link NotifyTextResolver})。
* 调用方传的 {@code title/content} 只在"模板码不认识 / 取模板失败"时兜底。
* 这样「通知模板」页改完就真的生效,包括驳回、通过、催报这些自动通知。
*
* <p>模板**被停用**时这条通知**不发**(这正是那个开关的意义),
* 并把原因放进 {@code failReason} 让调用方如实回 {@code notifySent=false}。
*
* @param req 内容与收件人(见 {@link NotifyRequest})
* @return 结果:{@code pushSent} 是"微信推送有没有发出去",
* {@code messageId} 是站内信 id(**一定存在**,除非写库本身失败)
@@ -84,13 +109,39 @@ public class NotificationService {
Long tenantId = req.tenantId() == null ? UserContext.tenantId() : req.tenantId();
Long userId = req.userId();
// ⓪ 文案:模板优先。这一步必须在写站内信之前 —— 模板停用意味着"根本不发"。
String title = req.title();
String content = req.content();
if (req.subjectCode() != null) {
NotifyTextResolver.Resolved r =
textResolver.resolve(req.subjectCode(), req.taskIdOrNull(), req.subjectVars());
if (r == null) {
log.warn("不认识的通知模板码 {},退回调用方给的文案(bizType={})",
req.subjectCode(), req.bizType());
} else if (!r.enabled()) {
// 停用是用户的显式选择:不发,也不写站内信与发送记录。
// 调用方会因此回 notifySent=false + needManualNotice=true,
// 于是老师知道"系统没发,你得自己说一声"。
return new NotifyResult(null, false,
"通知模板「" + req.subjectCode() + "」已被停用,这条通知未发出", null);
} else {
title = r.title();
content = r.content();
}
}
if (req.extraText() != null && !req.extraText().isBlank()) {
// 模板之外还要补一句(班级、老师附言、"今天是最后一天"这类上下文):
// 追加而不是替换 —— 模板是权威,但模板表达不了"这一次"的临时信息
content = (content == null ? "" : content + "\n") + req.extraText().trim();
}
// ① 先写站内信 —— 这是"用户一定能看到"的那条路,所以放在最前面。
// 顺序很关键:先试推送再写站内信的话,推送抛异常就可能把站内信也丢了。
CollectMessage msg = new CollectMessage();
msg.setUserId(userId);
msg.setBizType(req.bizType());
msg.setTitle(clip(req.title(), 128));
msg.setContent(clip(req.content(), 1024));
msg.setTitle(clip(title, 128));
msg.setContent(clip(content, 1024));
msg.setLinkType(req.linkType() == null ? CollectMessage.LINK_NONE : req.linkType());
msg.setLinkId(req.linkId());
msg.setIsRead(0);
@@ -115,7 +166,7 @@ public class NotificationService {
} else {
try {
wxService.sendSubscribeMessage(openid, wxTemplateIdOf(templateCode),
"pages/tasks/tasks", buildTemplateData(req));
"pages/tasks/tasks", buildTemplateData(req, title, content));
pushSent = true;
// 落地通道改成微信推送(站内信仍然在,学生随时能回来看)
messageMapper.update(null, Wrappers.<CollectMessage>lambdaUpdate()
@@ -137,7 +188,9 @@ public class NotificationService {
// ③ 如实记录发送通道结果(collect_notify_record 的定位就是这个)
// 注意:站内信本身"发成功"了,所以 status 记 1;推送那条另记一行。
recordNotify(tenantId, req, pushSent ? CollectMessage.CHANNEL_WECHAT_SUB
// ⚠️ 标题要记**渲染后**的那一份:用户改过模板时,
// "记录里是旧文案、消息中心是新文案"是查不出来的那种不一致。
recordNotify(tenantId, req, title, pushSent ? CollectMessage.CHANNEL_WECHAT_SUB
: CollectMessage.CHANNEL_IN_APP, pushSent, skippedNoQuota, failReason);
return new NotifyResult(msg.getId(), pushSent, failReason, msg.getChannel());
@@ -153,20 +206,78 @@ public class NotificationService {
public record NotifyResult(Long messageId, boolean pushSent, String failReason, String channel) {
}
/** 通知请求。 */
/**
* 通知请求。
*
* @param subjectCode 站内信**文案模板**码({@link NotifyTemplates},如 {@code AUDIT_REJECT})。
* 非空时标题与正文以模板为准(FR-1002)。为 {@code null} 表示"用调用方给的文案"
* @param subjectVars 模板变量(白名单见 {@link NotifyTemplates#VARIABLES})
* @param extraText 模板之外要补的一句(班级、老师附言、"今天是最后一天")。追加,不替换
* @param templateCode **微信订阅消息**的模板码(契约 §13.6,如 {@code AUDIT_RESULT})——
* 与 {@code subjectCode} 不是一回事,见 {@link #TPL_AUDIT_RESULT} 的注释
*/
public record NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode,
Map<String, String> templateParams,
String subjectCode, Map<String, String> subjectVars, String extraText) {
/** 旧签名的兼容构造:不带站内信模板时走"调用方给文案"的老路(无模板可用)。 */
public NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode,
Map<String, String> templateParams) {
this(tenantId, userId, bizType, title, content, linkType, linkId, templateCode,
templateParams, null, Map.of(), null);
}
/** 常用构造:学生端(带跳转)。{@code linkId} 在 {@code linkType=TASK} 时就是 taskId。 */
public static NotifyRequest of(Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode) {
return new NotifyRequest(null, userId, bizType, title, content, linkType, linkId,
templateCode, Map.of());
templateCode, Map.of(), null, Map.of(), null);
}
/**
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务)。
* 按模板发的常用构造:文案由模板决定,调用方只给变量。
*
* <p>{@code fallbackTitle} 只在模板取不到时兜底(正常情况下不会用到)。
*/
public static NotifyRequest ofTemplate(Long userId, int bizType, String subjectCode,
Map<String, String> vars, String fallbackTitle,
String linkType, Long linkId, String templateCode) {
return new NotifyRequest(null, userId, bizType, fallbackTitle, fallbackTitle,
linkType, linkId, templateCode, Map.of(),
subjectCode, vars == null ? Map.of() : vars, null);
}
/** 加一句模板之外的补充(不可变,返回新对象)。 */
public NotifyRequest withExtra(String extra) {
return new NotifyRequest(tenantId, userId, bizType, title, content, linkType, linkId,
templateCode, templateParams, subjectCode, subjectVars, extra);
}
/**
* 拼模板变量:`vars("任务名", name, "姓名", realName)` —— **null 值直接跳过**。
*
* <p>⚠️ 不要用 {@code Map.of(...)} 拼这个:它**不接受 null 值**(连 key 都不接受),
* 而"这个学生查不到姓名"是完全正常的情况 —— 于是它会抛 NPE,
* 而通知调用外面通常包着"通知失败不影响业务"的 try/catch →
* 表现是**一条通知都没发,日志里只有一行 warn**(这个坑在本轮真踩了一次:
* `Map.of("姓名", null)` → 驳回通知全丢,而驳回本身照样成功)。
* 模板渲染对"变量没有值"的处理本来就是渲染成空(见 {@link NotifyTemplates#render}),
* 所以跳过才是对的口径。
*/
public static Map<String, String> vars(String... kv) {
Map<String, String> m = new LinkedHashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) {
if (kv[i] != null && kv[i + 1] != null) {
m.put(kv[i], kv[i + 1]);
}
}
return m;
}
/**
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务;模板也要按任务取覆盖)。
*
* <p>从 {@code linkType} 推:跳任务是任务本身,跳填报单则要靠调用方另外传
* (这里返回 null 也行,只是那条记录没法按任务筛)。
@@ -184,14 +295,14 @@ public class NotificationService {
* 运营要看的是"多少人因为没授权而收不到推送"(该去引导授权),
* 而不是"推送失败了多少次"(那要查通道故障)。两者混在一起就分不出来。
*/
private void recordNotify(Long tenantId, NotifyRequest req, String channel,
private void recordNotify(Long tenantId, NotifyRequest req, String title, String channel,
boolean ok, boolean skippedNoQuota, String failReason) {
try {
// biz_type 是 varchar(32)(V1 的 DDL),所以要转字符串 —— 见 NotifyRecordMapper
int status = ok ? 1 : (skippedNoQuota ? 3 : 2);
int n = notifyRecordMapper.insertRecord(tenantId, req.taskIdOrNull(),
String.valueOf(req.bizType()),
req.userId(), channel, req.templateCode(), req.title(), status,
req.userId(), channel, req.templateCode(), title, status,
clip(failReason, 512));
if (n <= 0) {
log.warn("通知发送记录没写进去(返回 {} 行):user={} biz={}", n, req.userId(), req.bizType());
@@ -210,14 +321,14 @@ public class NotificationService {
* 所以只能由客户在微信后台建好模板后,用 {@code templateParams} 传进来。
* 这里做一个兜底:只有内容没有参数时,用通用字段名 —— 发不出去也不影响站内信。
*/
private Map<String, Object> buildTemplateData(NotifyRequest req) {
private Map<String, Object> buildTemplateData(NotifyRequest req, String title, String content) {
Map<String, Object> data = new LinkedHashMap<>();
if (req.templateParams() != null && !req.templateParams().isEmpty()) {
req.templateParams().forEach((k, v) -> data.put(k, Map.of("value", v)));
return data;
}
data.put("thing1", Map.of("value", clip(req.title(), 20)));
data.put("thing2", Map.of("value", clip(req.content(), 20)));
data.put("thing1", Map.of("value", clip(title, 20)));
data.put("thing2", Map.of("value", clip(content, 20)));
return data;
}
@@ -62,13 +62,20 @@ public final class NotifyTemplates {
"{任务名} 的填报已审核通过,无需再操作。", "终审通过"),
new Def("URGE", 5, "请尽快完成填报",
"{任务名} 目前提交进度 {提交进度},请在 {截止日} 前完成填报。", "催报"),
new Def("EXPORT_DONE", 6, "导出完成",
"导出包 {导出包名} 已生成,请及时下载:{下载链接}(链接有有效期,过期请重新导出)",
new Def("URGE_DRAFT", 5, "你的填报还没有提交",
"你已经开始填写《{任务名}》,但还没有提交。填完记得点「提交」。",
"催报(只建了草稿、没提交的人)"),
new Def("EXPORT_DONE", 6, "{导出包名} 已生成",
"导出包 {导出包名} 已生成,请到「导出中心」下载。"
+ "(下载链接有有效期,过期请重新导出)",
"导出完成"),
new Def("JOIN_APPROVE", 7, "加入名单申请已处理",
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员")
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员"),
new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
"驳回次数达上限时发给学校管理员(契约 §11.4)")
);
public static List<Def> all() {
@@ -87,6 +94,13 @@ public final class NotifyTemplates {
/** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */
public static final Set<Integer> BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7);
/** 模板码全集(脚本与测试用它核对"每个内置模板都有发送点")。 */
public static Set<String> allCodes() {
Set<String> out = new java.util.LinkedHashSet<>();
BUILTIN.forEach(d -> out.add(d.code()));
return out;
}
/**
* 渲染模板。
*
@@ -0,0 +1,131 @@
package cn.tianbaotong.fill.module.notify.service;
import cn.tianbaotong.fill.module.notify.entity.CollectNotifyTemplate;
import cn.tianbaotong.fill.module.notify.mapper.NotifyTemplateMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 按模板码取「真正要发出去的那段文案」。
*
* <h3>为什么需要这个类(FR-1002 的兑现点)</h3>
*
* <p>《08-完成度盘点》F-101:通知模板页能改文案,但**只有"手工发送"走模板** ——
* 驳回、审核通过、催报、临期提醒这些**自动**通知把文案硬编码在各自的 Service 里,
* 于是管理员把「驳回通知」改成自己学校的说法之后,学生收到的还是原来那句。
* 而 FR-1002 点名的 5 类通知(任务发布 / 临期提醒 / 驳回通知 / 审核通过 / 导出完成)
* **全是自动类** —— 也就是说这个 P0 需求当时只兑现了它没点名的那一半。
*
* <p>所以自动通知也必须先来这里取文案:<b>库里改过的 &gt; 内置的</b>,
* 与模板列表页({@code GET /notify/templates})看到的**完全同一份口径** ——
* 否则"界面上显示这句话、实际发出去另一句话"就是同一类问题的另一种形态。
*
* <h3>为什么不直接用 {@code NotifyTemplateService}</h3>
*
* <p>它是**反方向**的依赖:{@code NotifyTemplateService} 手工发送时要调
* {@code NotificationService},而 {@code NotificationService} 现在要在发送前取文案 ——
* 两个 Service 互相注入会形成循环依赖(Spring 允许用 {@code @Lazy} 糊过去,
* 但那是把"设计上分不开"这件事藏起来)。取文案只需要一张表,
* 所以单独放一个类,依赖是单向的:
* {@code NotificationService → NotifyTextResolver → NotifyTemplateMapper}。
*
* <h3>失败一律退回内置文案</h3>
*
* <p>查库失败(没有租户上下文、表被锁、连接抖动…)**绝不能变成"通知不发"**:
* 那会让一次本该校验通过的审核动作连带丢掉通知。所以这里吞掉异常,
* 退回内置文案 + 一条 warn。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotifyTextResolver {
private final NotifyTemplateMapper templateMapper;
/**
* 解析结果。
*
* @param enabled 模板有没有被停用(停用 = 这类通知不发,见 {@link #resolve} 的说明)
* @param customized 是不是用户改过的(用于日志与排查:"他收到的为什么是这句")
*/
public record Resolved(boolean enabled, String title, String content, boolean customized) {
}
/**
* 取文案。变量按白名单渲染(越界变量原样保留,见 {@link NotifyTemplates#render})。
*
* @param code 站内信模板码({@link NotifyTemplates} 里的那一套,如 {@code AUDIT_REJECT})
* @param taskId 任务级覆盖优先;没有就退到租户级;都没有就用内置
* @return 找不到这个模板码时返回 {@code null}(调用方回退到自己拼的文案)
*/
public Resolved resolve(String code, Long taskId, Map<String, String> vars) {
NotifyTemplates.Def def = NotifyTemplates.byCode(code);
CollectNotifyTemplate over = findOverride(code, taskId);
if (over == null) {
if (def == null) {
return null;
}
return new Resolved(true,
NotifyTemplates.render(def.title(), vars),
NotifyTemplates.render(def.content(), vars), false);
}
// 库里这一行只在"它自己那条"上覆盖内置:标题/内容为空时逐项退回内置,
// 而不是发出一封没有标题的消息(那在消息中心里就是一个空白条目)
String title = blank(over.getTitle())
? (def == null ? "" : def.title()) : over.getTitle();
String content = blank(over.getContent())
? (def == null ? "" : def.content()) : over.getContent();
boolean enabled = Integer.valueOf(CollectNotifyTemplate.ENABLED_YES).equals(over.getEnabled());
if (!enabled) {
log.info("通知模板 {} 已被停用,这条自动通知不发(task={})", code, taskId);
}
return new Resolved(enabled,
NotifyTemplates.render(title, vars),
NotifyTemplates.render(content, vars), true);
}
/**
* 取该模板码的覆盖行:**任务级优先于租户级**。
*
* <p>租户拦截器会补 {@code tenant_id},所以这里不用自己带租户条件 ——
* 但这也意味着**必须在租户上下文里调用**(自动通知都在:要么是租户内的用户动作,
* 要么是定时任务里 {@code TenantScope.callAs} 摆好的)。查不到/查失败都退回内置。
*/
private CollectNotifyTemplate findOverride(String code, Long taskId) {
try {
var q = Wrappers.<CollectNotifyTemplate>lambdaQuery()
.eq(CollectNotifyTemplate::getTemplateCode, code)
.eq(CollectNotifyTemplate::getChannel, CollectNotifyTemplate.CHANNEL_IN_APP);
if (taskId != null) {
q.and(w -> w.isNull(CollectNotifyTemplate::getTaskId)
.or().eq(CollectNotifyTemplate::getTaskId, taskId));
} else {
q.isNull(CollectNotifyTemplate::getTaskId);
}
List<CollectNotifyTemplate> rows = templateMapper.selectList(q);
CollectNotifyTemplate tenantLevel = null;
for (CollectNotifyTemplate row : rows) {
if (row.getTaskId() != null && row.getTaskId().equals(taskId)) {
return row;
}
tenantLevel = row;
}
return tenantLevel;
} catch (RuntimeException e) {
// 取模板失败不该让通知消失(退回内置文案)
log.warn("取通知模板失败,退回内置文案:code={} task={} 原因={}", code, taskId, e.toString());
return null;
}
}
private boolean blank(String s) {
return s == null || s.isBlank();
}
}
@@ -44,6 +44,17 @@ public class UrgeService {
/** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */
public static final String TPL_URGE = "NOTIFY_UNSUBMITTED";
/**
* 站内信模板码({@link NotifyTemplates} 那套)。
*
* <p>⚠️ 与上面的 {@link #TPL_URGE} **不是一回事**:那个是**微信订阅消息**的模板码
* (决定从哪取 {@code templateId}、扣哪一份额度),这里两个才是"学生实际读到的那句话"。
* 草稿与"一次都没填"是两条不同的话,所以是两个模板码 ——
* 合成一个就得在模板里写条件,而变量渲染做不到条件。
*/
public static final String SUBJECT_URGE = "URGE";
public static final String SUBJECT_URGE_DRAFT = "URGE_DRAFT";
/**
* 催报用的业务类型码 = {@code 5}。
*
@@ -60,9 +71,39 @@ public class UrgeService {
/** 默认去重窗口(小时)。 */
private static final int DEFAULT_EXCLUDE_HOURS = 24;
/**
* 催报分钟级限流(契约 §3:「催报 10 次/分钟/任务」,2026-09-30 补上,见《08》F-29)。
*
* <p>与 24 小时去重是**两件事**:去重防"同一个学生被重复催",限流防"老师连点把系统打爆"。
* 没有它时连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询。
*/
private static final int URGES_PER_MINUTE_PER_TASK = 10;
private final UrgeMapper urgeMapper;
private final AuditScopeMapper auditScopeMapper;
private final NotificationService notificationService;
private final org.springframework.data.redis.core.StringRedisTemplate redis;
/** 计一次催报动作,超限抛 {@code 42901}。键按任务(没有任务时按班级组合,见调用处)。 */
private void rateLimit(String scopeKey) {
String key = "xt:urge:" + scopeKey;
try {
Long n = redis.opsForValue().increment(key);
if (n != null && n == 1L) {
redis.expire(key, Duration.ofSeconds(60));
}
if (n != null && n > URGES_PER_MINUTE_PER_TASK) {
throw new BizException(ErrorCode.RATE_LIMITED,
"催报操作过于频繁(每分钟最多 " + URGES_PER_MINUTE_PER_TASK
+ " 次/任务),请稍后再试。同一学生 24 小时内不会被重复催,不必连点");
}
} catch (BizException e) {
throw e;
} catch (Exception e) { // noqa: BLE001
// Redis 不可用不该让催报瘫痪 —— 限流是保护,不是业务前提
log.warn("催报限流计数失败(放行本次):{}", e.getMessage());
}
}
// ── 定向催报(§8.6 的 `/roster/{rid}/notify`)──
@@ -87,6 +128,7 @@ public class UrgeService {
// 还没核对身份 → 没有账号可发。如实说明,别报"发送失败"
return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)")));
}
rateLimit("task:" + t.getTaskId());
String extra = req == null ? null : req.getExtraText();
NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false);
@@ -125,6 +167,10 @@ public class UrgeService {
return result(List.of());
}
// 限流放在"确实有活可干"之后:空结果就连点也不计数 ——
// 惩罚"点了但没人可催"只会让人困惑(界面上明明什么都没发生)
rateLimit(taskId != null ? "task:" + taskId : "orgs:" + orgIds);
int hours = req == null || req.getExcludeRecentHours() == null
? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours());
LocalDateTime now = LocalDateTime.now();
@@ -181,7 +227,12 @@ public class UrgeService {
LocalDateTime now = LocalDateTime.now();
Set<Long> sentUsers = new LinkedHashSet<>();
int sent = 0;
String extra = daysLeft <= 1
// 0 天 = 截止当日(FR-1003 要求"7/3/1 天 + 截止当日")。
// 三档文案必须分开:对"还有 1 天"和"就是今天"说同一句"今天是最后一天",
// 学生就无法判断到底还剩几个小时 —— 而那正是他此刻唯一需要知道的事。
String extra = daysLeft <= 0
? "今天是截止日,请在今天 24 点前提交,过后就不能再交了。"
: daysLeft == 1
? "今天是最后一天了,请务必今天提交。"
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
for (UrgeMapper.UrgeTarget t : targets) {
@@ -259,25 +310,40 @@ public class UrgeService {
/** 发一条催报。返回"跳过了没有 + 为什么"。 */
private NotifyOutcome send(UrgeMapper.UrgeTarget t, String templateCode, String extra,
boolean draft) {
String title = draft ? "你的填报还没有提交" : "请尽快完成填报";
StringBuilder content = new StringBuilder(draft
? "你已经开始填写《" + nz(t.getTaskName()) + "》,但还没有提交。填完记得点「提交」。"
: "你还没有完成《" + nz(t.getTaskName()) + "》的填报,请尽快完成。");
if (t.getOrgName() != null) {
content.append("(").append(t.getOrgName()).append(")");
// 文案走模板(FR-1002 的同一口径):管理员在「通知模板」页改了「催报」那句,
// 学生收到的就是改过的那句。草稿与"一次都没填"是两条不同的话,
// 所以是两个模板码 —— 合成一个就得在模板里加条件,那是模板做不到的事。
java.util.Map<String, String> vars = new java.util.LinkedHashMap<>();
vars.put("任务名", nz(t.getTaskName()));
vars.put("姓名", t.getRealName());
vars.put("班级", t.getOrgName());
if (t.getTaskId() != null) {
vars.put("提交进度", progressOf(t.getTaskId()));
if (t.getEndTime() != null) {
vars.put("截止日", t.getEndTime().toLocalDate()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy年M月d日")));
long days = java.time.temporal.ChronoUnit.DAYS
.between(java.time.LocalDate.now(), t.getEndTime().toLocalDate());
vars.put("剩余天数", String.valueOf(Math.max(0, days)));
}
if (extra != null && !extra.isBlank()) {
// 老师自己写的话原样附在后面 —— 它往往才是学生真正会看的那句
content.append("\n").append(extra.trim());
}
try {
var r = notificationService.notify(new NotificationService.NotifyRequest(
null, t.getUserId(), BIZ_URGE, title, content.toString(),
var r = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
t.getUserId(), BIZ_URGE,
draft ? SUBJECT_URGE_DRAFT : SUBJECT_URGE,
vars,
draft ? "你的填报还没有提交" : "请尽快完成填报",
CollectMessage.LINK_TASK, t.getTaskId(),
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode,
java.util.Map.of()));
// 站内信一定发出去了 → 算 sent。微信推送没出去只影响"他多久看到"
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode)
// 班级与老师附言是模板表达不了的"这一次"的信息,追加在后面
.withExtra(orgPart(t) + (extra == null || extra.isBlank() ? "" : extra.trim())));
// 站内信一定发出去了 → 算 sent;模板被停用则 messageId 为 null(什么都没发),
// 这时要如实归到"跳过"并给出理由,否则老师会以为催过了(《08》F-101 的连带)。
if (r.messageId() == null) {
return new NotifyOutcome(true, r.failReason());
}
// 微信推送没出去只影响"他多久看到"
return new NotifyOutcome(false, r.pushSent() ? null : r.failReason());
} catch (RuntimeException e) {
log.warn("催报失败:roster={} user={}", t.getRosterId(), t.getUserId(), e);
@@ -285,6 +351,22 @@ public class UrgeService {
}
}
/** 这个任务的提交进度(`{提交进度}` 变量:已提交/应填)。查不出来就留空,不编一个数。 */
private String progressOf(Long taskId) {
try {
Long tenantId = UserContext.tenantId();
return urgeMapper.progressOf(tenantId, taskId);
} catch (RuntimeException e) {
log.debug("取提交进度失败(模板变量留空):task={} 原因={}", taskId, e.toString());
return null;
}
}
/** 班级那一行补充说明(学生手里的任务可能不止一个班,说清是哪个班的事)。 */
private String orgPart(UrgeMapper.UrgeTarget t) {
return t.getOrgName() == null ? "" : "(" + t.getOrgName() + ")";
}
/** 范围判断:这个人所在的班归不归我。 */
private boolean inMyScope(Long tenantId, Long uid, UrgeMapper.UrgeTarget t) {
List<Long> mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid);
@@ -11,7 +11,21 @@ import java.util.List;
/** 推送记录(契约 §14.4)。 */
public interface OpenPushLogMapper extends BaseMapper<CollectOpenPushLog> {
/** 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。 */
/**
* 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。
*
* <p>⚠️ {@code @InterceptorIgnore(tenantLine = "true")} <b>不能省</b>:
* 定时任务**没有租户上下文**(它是"没有请求也会发生的事"),而租户拦截器的姿态是
* <b>默认拒绝</b> —— 少了这个注解,这条查询每次都抛 {@code 40101},
* 被 {@code OpenPushService.retryDue} 的 {@code catch (RuntimeException)} 吞成一行 warn。
* 表现是:**"安排了下次重试"却从来没有重试过**,而日志里只有每 10 秒一次的噪音。
*
* <p>这与 {@link #findBySubmissionIgnoreTenant} 是同一个规律:
* <b>"先找归属、再摆上下文"里,找归属那一步必须能无租户执行</b>。
* 跨租户的候选扫描集中在这一个方法里,逐条处理时再由
* {@code TenantScope.runAs} 摆好目标租户的上下文 —— 保护没有被放弃。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_open_push_log
WHERE deleted = 0 AND status = 2
@@ -212,11 +212,18 @@ public class OpenPushService {
// 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处)
CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId());
if (app == null || !app.enabled()) {
// 接入方没了/停了:不再重试,标成最终失败并说明原因
// 接入方没了/停了:不再重试,标成最终失败并说明原因。
//
// ⚠️ 这一步**也必须摆租户上下文**:`updateById` 会走租户拦截器,
// 而在"接入方已不存在"的情况下我们不能从 app 拿租户 —— 所以用
// **推送记录自己的 tenant_id**。少了它,这条 update 会抛 40101,
// 被下面的 catch 吞成一行 warn,于是这条记录**每 10 秒重试一次、永远停不下来**。
TenantScope.runAs(row.getTenantId(), null, Set.of(), () -> {
row.setStatus(CollectOpenPushLog.STATUS_FAILED);
row.setLastError("接入方不存在或已停用,停止重试");
row.setNextRetryAt(null);
pushLogMapper.updateById(row);
});
continue;
}
TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row));
@@ -42,7 +42,17 @@ public class OrgService {
* 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。
*/
public List<OrgTreeVO> tree(boolean withCount, Long rootId) {
// ★ 显式带上租户条件,不依赖拦截器。
//
// 拦截器对**平台运营**(tenantId=0)整个关掉了过滤(它是唯一跨租户的身份),
// 于是这里会返回"所有租户的组织混在一棵树里" —— 而只有一所学校有数据时,
// 它看起来和学校管理员看到的完全一样,**看不出错**。
// 组织树本来就是租户内的东西:平台要看某所学校的组织,得先在页面上选定学校
// (请求头 X-Tenant-Id → 见 ActingTenantResolver),那时 tenantId 就是那所学校。
// 没选学校时这里返回空树,而不是"把所有人的组织端出来"。
long tenantId = UserContext.tenantId();
List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
.eq(SysOrg::getTenantId, tenantId)
.orderByAsc(SysOrg::getSortNo)
.orderByAsc(SysOrg::getId));
@@ -62,11 +62,24 @@ public interface ScheduleMapper {
List<Map<String, Object>> tasksToClose();
/**
* 截止日正好在某一天的任务(给"提前 7/3/1 天催报"用)。
* 截止日正好在某一天的任务(给"提前 7/3/1 天 + 截止当日催报"用)。
*
* <p>为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务,
* 用 `<=` 会让"还剩 2 天"的任务在 7 天、3 天、1 天各被催一次(因为 2 < 3 也成立)
* —— 学生一天收到三条,然后关掉通知。
*
* <p>⚠️⚠️ **必须比 `end_time::date`,不能直接比 `end_time = #{date}::date`**:
* `collect_task.end_time` 存的是**当天 23:59:59**(见 TaskService 的写入),
* 而 `'2026-10-08'::date` 会被 PG 当成 `2026-10-08 00:00:00` ——
* 两者**永远不相等**,于是这个查询**一条都查不出来**:
* 自动催报(FR-1003)在真实数据上从来没发出过任何一条通知,
* 而日志里只有"没有任务"的沉默(`if (!tasks.isEmpty())` 才打日志)。
*
* <p>这个 bug 之所以活到今天,是因为**测试夹具用 SQL 摆截止日**
* (`update collect_task set end_time = CURRENT_DATE + 3` 存进去的是 00:00:00 的日期型),
* 那种形状恰好能匹配 —— 夹具与真实数据的形状不一致,把 bug 盖住了
* (与《08》F-99「假 JPEG」是同一族:**夹具不像真东西,测出来的绿灯就不算数**)。
* 现在 `e2e-m5-notify-wiring.py` 走**真实接口**建任务(end_time 自然是 23:59:59)来守它。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
@@ -74,7 +87,8 @@ public interface ScheduleMapper {
FROM collect_task
WHERE deleted = 0
AND status = 1
AND end_time = #{date}::date
AND end_time IS NOT NULL
AND end_time::date = #{date}::date
ORDER BY id
""")
List<Map<String, Object>> tasksEndingOn(@Param("date") String date);
@@ -78,8 +78,14 @@ public class ScheduleJobs {
/** 审核超时提醒一轮最多处理多少条(防止某天积压上万条时把接口打爆)。 */
private static final int TIMEOUT_BATCH = 500;
/** 提前几天催报(PRD 附录 E 的「7/3/1 天规则」)。 */
private static final int[] URGE_DAYS_BEFORE = {7, 3, 1};
/**
* 提前几天催报(PRD 附录 E 的「7/3/1 天规则」**+ 截止当日**)。
*
* <p>⚠️ {@code 0} 那一档是 FR-1003 原文里的「+ 截止当日」,本轮补上(《08》F-104):
* 原先只有 7/3/1,于是**截止那天一声不响** —— 而那天是唯一"再不做就来不及"的日子。
* 缺的是最该有的那一档。
*/
private static final int[] URGE_DAYS_BEFORE = {7, 3, 1, 0};
/** 租户到期前提醒的天数(PRD 附录 E 的「30/7/1 天」)。 */
private static final int[] EXPIRE_REMIND_DAYS = {30, 7, 1};
@@ -39,7 +39,7 @@ public class PlanScopeController {
}
@PutMapping("/{id}/plan-scope")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.PLAN_IMPORT)
@Operation(summary = "确认 / 修正报考范围",
description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— "
+ "漏一个人,导出包就会缺人,而人工核对发现不了")
@@ -107,7 +107,10 @@ public class TaskController {
@DeleteMapping("/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "删除任务", description = "只有草稿可以删除;已发布的请用截止或归档")
@Operation(summary = "删除任务",
description = "草稿直接删;**已归档**的任务可删(2026-09-30 客户确认,§17.2 D-9):"
+ "级联软删名册/填报单/材料/审核记录并尽力删除文件,全程留痕。"
+ "收集中/已截止的先「归档」再删 —— 归档是最后的后悔药")
public ApiResult<Void> delete(@PathVariable Long id) {
taskService.delete(id);
return ApiResult.ok();
@@ -164,7 +167,7 @@ public class TaskController {
// ══════════════════ 克隆 ══════════════════
@PostMapping("/{id}/clone")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.TASK_CLONE)
@Operation(summary = "克隆任务",
description = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级),"
+ "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。"
@@ -0,0 +1,95 @@
package cn.tianbaotong.fill.module.task.mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 删除**已归档任务**时的级联清理({@code TaskService.delete},2026-09-30 客户确认,
* 见《03-接口设计》§17.2 D-9 与《08》F-14 —— 此前"已发布的任务删不掉",
* 演示/测试任务只能改名挪开,库会一直堆)。
*
* <h3>为什么集中在这么一个文件里</h3>
*
* <p>级联删除是"一次动作动十四张表"的地方,散在各 Mapper 里会让人
* 只看到其中一截而以为删干净了。集中放置 + 每个方法一行,
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
*
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
* 全部业务表,2026-09-30 核对为 14 张):任务本身不在此列(由 {@code TaskService} 删);
* {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。
*
* <p><b>为什么子表物理删、任务行却软删</b>:F-14 的动机是"库会一直堆"(演示/测试任务
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
* ({@code maxSeqByCodePrefix} 靠"含软删行的最大序号"保证编码不复用 ——
* 编码同时是文件目录名)。删除动作的<b>留痕在 {@code sys_operation_log}</b>
* ({@code DELETE_TASK},记了谁删的、删了多少行多少文件)。
*
* <p>⚠️ 全部 SQL 走 MyBatis-Plus 租户拦截器(自动补 {@code tenant_id},这些表都有该列),
* 删除动作在管理员的租户上下文里执行。
*/
public interface TaskCascadeMapper {
// ── 文件路径(先取再删行:物理删除要用,删完行就查不到了)──────
/** 材料文件路径(std 与 raw 用 | 分隔,可能为空串)。 */
@Select("""
SELECT coalesce(std_path, '') || '|' || coalesce(raw_path, '')
FROM collect_file WHERE task_id = #{taskId}
""")
List<String> filePaths(@Param("taskId") Long taskId);
/** 导出产物路径。 */
@Select("""
SELECT file_path FROM collect_export_file
WHERE task_id = #{taskId} AND file_path IS NOT NULL
""")
List<String> exportPaths(@Param("taskId") Long taskId);
// ── 物理删(这 5 张表没有 deleted 列)────────────────
@Delete("DELETE FROM collect_validation_issue WHERE task_id = #{taskId}")
int deleteValidationIssues(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_audit_record WHERE task_id = #{taskId}")
int deleteAuditRecords(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_notify_record WHERE task_id = #{taskId}")
int deleteNotifyRecords(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_open_push_log WHERE task_id = #{taskId}")
int deleteOpenPushLogs(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_file WHERE task_id = #{taskId}")
int deleteFiles(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_export_file WHERE task_id = #{taskId}")
int deleteExportFiles(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_export_task WHERE task_id = #{taskId}")
int deleteExportTasks(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
int deleteSubmissions(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_join_request WHERE task_id = #{taskId}")
int deleteJoinRequests(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
int deleteRoster(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_form_schema WHERE task_id = #{taskId}")
int deleteFormSchemas(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_material_def WHERE task_id = #{taskId}")
int deleteMaterialDefs(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_notify_template WHERE task_id = #{taskId}")
int deleteNotifyTemplates(@Param("taskId") Long taskId);
@Delete("DELETE FROM collect_task_org WHERE task_id = #{taskId}")
int deleteTaskOrgs(@Param("taskId") Long taskId);
}
@@ -58,6 +58,7 @@ public class RuleTemplateService {
private final TaskMapper taskMapper;
private final MaterialDefMapper materialDefMapper;
private final MaterialDefService materialDefService;
private final cn.tianbaotong.fill.module.form.service.FormSchemaService formSchemaService;
/** 可应用的三个部分(契约 §7.13 的 `parts`)。 */
public static final Set<String> ALL_PARTS = new LinkedHashSet<>(
@@ -126,8 +127,12 @@ public class RuleTemplateService {
// 空 = 用系统默认表单,**不覆盖**目标任务的既有表单
r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)");
} else {
// 2026-09-30 起接上真正的应用路径(此前只能"跳过并说明",见 F-57/F-64)。
// 走 FormSchemaService 的版本化保存:它保留"已发布任务不可改表单"的检查
// 与版本管理 —— 这里**不再自己写表**,两处逻辑必须只有一份。
assertNotPublished(target, "表单定义");
r.getSkipped().add("FORM(一期还没有「保存表单定义」的服务端入口,见 F-57)");
formSchemaService.saveRawVersion(target.getId(), tpl.getFormSchema());
r.getApplied().add("FORM");
}
}
if (want.contains("MATERIALS")) {
@@ -258,6 +258,16 @@ public class TaskCloneService {
row.setCertType(t.getCertType());
row.setMaterials(arr.toString());
row.setNamingTemplate(t.getNamingTemplate());
// 表单定义:来源任务存过版本化表单就一并带上(2026-09-30 与「FORM 套用」一起补上 ——
// 此前存模板不存表单、套模板又用不上表单,两头各缺一半,见 F-57/F-64)。
// 没存过 = 用系统默认 → 存 null(套用时按「用默认,不覆盖」跳过)
var currentForm = formSchemaMapper.selectOne(Wrappers
.<cn.tianbaotong.fill.module.form.entity.CollectFormSchema>lambdaQuery()
.eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getTaskId, taskId)
.eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getIsCurrent,
cn.tianbaotong.fill.module.form.entity.CollectFormSchema.CURRENT)
.last("limit 1"));
row.setFormSchema(currentForm == null ? null : currentForm.getSchemaJson());
row.setIsPublic(pub ? CollectRuleTemplate.PUBLIC_YES : CollectRuleTemplate.PUBLIC_NO);
row.setUseCount(0);
ruleTemplateMapper.insert(row);
@@ -73,6 +73,8 @@ public class TaskService {
private final TaskReadinessMapper readinessMapper;
private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService;
private final cn.tianbaotong.fill.module.log.service.OperationLogService operationLog;
private final cn.tianbaotong.fill.module.task.mapper.TaskCascadeMapper cascadeMapper;
private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
// ── 列表 ──────────────────────────────────────
@@ -213,10 +215,18 @@ public class TaskService {
return o instanceof Number n ? n.longValue() : null;
}
/** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */
/**
* 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。
*
* <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
* tenantId 被换成了那所学校,{@code isPlatformAdmin()} 就是 false 了 ——
* 那时会掉进下面"只看自己带的班"的分支,而平台在该校没有班级,
* 于是工作台的任务下拉变成**空列表**,看起来像"这所学校没有任务"。
*/
private boolean seesWholeTenant() {
LoginUser me = UserContext.require();
return me.isPlatformAdmin() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
return me.isPlatformStaff() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
}
// ── 详情 ──────────────────────────────────────
@@ -369,17 +379,104 @@ public class TaskService {
taskMapper.updateById(patch);
}
// ── 删除(仅草稿)────────────────────────────
// ── 删除(草稿直接删;已归档的级联删,2026-09-30 客户确认)──────
/**
* 删除任务(《03-接口设计》§17.2 D-9;原口径"已发布即不可删"会让任务列表只增不减,
* 演示/测试数据只能改名挪开 —— 见《08》F-14)。
*
* <ul>
* <li><b>草稿</b>:直接删(没有业务数据,与原来一样);</li>
* <li><b>已归档(status=3)</b>:管理员显式删除 —— <b>子表级联物理删</b>
* (名册/填报单/材料/审核记录等 14 张表,清掉的是大头:证件号密文、材料、审核记录),
* 但<b>任务行本身软删</b> —— 它必须留下来占住 {@code task_code}:
* {@code TaskMapper.maxSeqByCodePrefix} 靠"含软删行的最大序号"保证
* <b>编码用过的永不复用</b>(编码同时是文件目录名,复用会把两批任务的文件
* 混进同一个目录)。E2E 抓到过这个坑:物理删任务行后新任务拿到重号。
* 物理文件尽力删(删不掉只记日志不阻断),<b>全程留痕</b>
* (sys_operation_log 记 DELETE_TASK + 行数/文件数);</li>
* <li><b>收集中/已截止</b>:仍然拒绝 —— 归档是"最后的后悔药",
* 直接删"还活着的数据"这个口子不开。</li>
* </ul>
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
CollectTask exist = requireTask(id);
if (exist.getStatus() == null || exist.getStatus() != CollectTask.STATUS_DRAFT) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"只有草稿状态的任务可以删除;已发布的任务请用「截止」或「归档」");
}
int st = exist.getStatus() == null ? -1 : exist.getStatus();
if (st == CollectTask.STATUS_DRAFT) {
taskMapper.deleteById(id);
log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
return;
}
if (st != CollectTask.STATUS_ARCHIVED) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"只有草稿或已归档的任务可以删除;收集中/已截止的任务请先「归档」再删"
+ "(删除会连带清掉名册、填报单、材料与文件,不可恢复)");
}
// ★ 先取文件路径再删行 —— 行删了就查不到了(顺序反了 = 文件永远留在磁盘上)
List<String> filePaths = cascadeMapper.filePaths(id);
List<String> exportPaths = cascadeMapper.exportPaths(id);
int rows = cascadeMapper.deleteValidationIssues(id)
+ cascadeMapper.deleteAuditRecords(id)
+ cascadeMapper.deleteNotifyRecords(id)
+ cascadeMapper.deleteOpenPushLogs(id)
+ cascadeMapper.deleteFiles(id)
+ cascadeMapper.deleteExportFiles(id)
+ cascadeMapper.deleteExportTasks(id)
+ cascadeMapper.deleteSubmissions(id)
+ cascadeMapper.deleteJoinRequests(id)
+ cascadeMapper.deleteRoster(id)
+ cascadeMapper.deleteFormSchemas(id)
+ cascadeMapper.deleteMaterialDefs(id)
+ cascadeMapper.deleteNotifyTemplates(id)
+ cascadeMapper.deleteTaskOrgs(id);
// ★ 任务行**软删**(不是物理删):唯一约束 uk_task_tenant_code 不看 deleted,
// 软删行继续占住编码 → maxSeqByCodePrefix 的"编码不复用"不变量保持成立。
// 物理删它的话,下一个同批次任务会拿到重号(e2e-m1-task 的断言抓到过)。
taskMapper.deleteById(id);
int removed = deleteFilesQuietly(filePaths, exportPaths);
operationLog.record(
cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_DELETE_TASK,
"TASK", id,
"任务「" + exist.getTaskName() + "」(" + exist.getTaskCode() + ")已归档后删除",
"级联删除 " + rows + " 行;物理删除文件 " + removed + " 个");
log.info("删除已归档任务 {}(id={}):级联 {} 行,文件 {} 个,经办人={}",
exist.getTaskCode(), id, rows, removed, UserContext.uid());
}
/** 尽力删除物理文件(本地磁盘或对象存储)。失败只记日志 —— 行已删,别让一个文件拖垮整个删除。 */
private int deleteFilesQuietly(List<String> filePaths, List<String> exportPaths) {
int ok = 0;
for (String pair : filePaths) {
// 形如 "std/1/xx.jpg|raw/1/xx.jpg"(两段都可能是空串)
for (String p : pair.split("\\|")) {
if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
ok++;
}
}
}
for (String p : exportPaths) {
if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
ok++;
}
}
return ok;
}
private boolean deleteOneQuietly(String path) {
try {
storageService.delete(path);
return true;
} catch (Exception e) { // noqa: BLE001
log.warn("删除任务时清理文件失败(不影响删除本身):{} → {}", path, e.getMessage());
return false;
}
}
// ── 发布 ──────────────────────────────────────
@@ -36,7 +36,7 @@ public class TenantController {
private final TenantService tenantService;
@GetMapping
@RequirePerm(PermissionDef.TENANT_MANAGE)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户列表")
public ApiResult<PageResult<TenantVO>> list(
@RequestParam(required = false) String keyword,
@@ -48,7 +48,7 @@ public class TenantController {
}
@GetMapping("/{id}")
@RequirePerm(PermissionDef.TENANT_MANAGE)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户详情")
public ApiResult<TenantVO> detail(@PathVariable Long id) {
return ApiResult.ok(tenantService.detail(id));
@@ -64,7 +64,7 @@ public class TenantController {
@PutMapping("/{id}")
@RequirePerm(PermissionDef.LOG_VIEW_ALL)
@RequirePerm(PermissionDef.TENANT_MANAGE)
@Operation(summary = "修改租户 / 套餐(§6.5)",
description = "⚠️ `tenantCode` **不允许改**:它出现在域名解析与对接文档里,"
+ "改了会让已经发出去的文档与配好的域名全部失效,"
@@ -77,7 +77,7 @@ public class TenantController {
}
@GetMapping("/{id}/usage")
@RequirePerm(PermissionDef.LOG_VIEW_ALL)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户用量统计(§6.7)",
description = "`period` 支持 `2026-09`(月)与 `2026Q3`(季);不传是当月。"
+ "⚠️ `ocrCount` / `smsCount` **恒为 0 并带 notes 说明** —— "
@@ -119,4 +119,22 @@ public interface UserMapper extends BaseMapper<SysUser> {
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
""")
Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
/**
* 本租户下持某个内置角色的用户 id(按角色找,而不是按固定账号 —— 换管理员不该改代码)。
*
* <p>用途:需要"管理员介入"的自动通知的收件人(例如驳回次数达上限,契约 §11.4)。
* 与 `ScheduleMapper.usersOfRole` 的区别:那条是**跨租户**的定时任务用的(忽略租户拦截器),
* 这条在租户上下文里用、租户条件显式给,并与拦截器互为印证。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT DISTINCT u.id
FROM sys_user u
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0
WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1
AND ro.role_code = #{roleCode}
""")
List<Long> userIdsOfRole(@Param("tenantId") Long tenantId, @Param("roleCode") String roleCode);
}
@@ -164,7 +164,11 @@ public class UserService {
final String kw = (raw == null || raw.isEmpty()) ? null : raw;
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery();
q.eq(orgId != null, SysUser::getOrgId, orgId)
// ★ 显式租户条件(与 OrgService.tree 同一个理由):拦截器对平台运营
// (tenantId=0)整个关掉了过滤,不加这一条就会把**所有租户的账号**混在一起返回。
// 平台要看某所学校的账号,先在页面上选定学校(X-Tenant-Id),那时 tenantId 就是它。
q.eq(SysUser::getTenantId, UserContext.tenantId())
.eq(orgId != null, SysUser::getOrgId, orgId)
.eq(userType != null, SysUser::getUserType, userType)
.eq(status != null, SysUser::getStatus, status);
@@ -31,6 +31,7 @@ public class AuthInterceptor implements HandlerInterceptor {
private final TokenBlacklist blacklist;
private final PermissionChecker permissionChecker;
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
private final cn.tianbaotong.fill.support.ActingTenantResolver actingTenantResolver;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
@@ -52,6 +53,21 @@ public class AuthInterceptor implements HandlerInterceptor {
LoginUser user = jwtService.parse(token);
// ★ 平台运营的「当前学校」(请求头 X-Tenant-Id)。
//
// 平台是唯一跨租户的身份,租户拦截器因此对它关掉了过滤 —— 于是「组织架构」
// 「账号管理」这类租户内的页面会看到**所有租户混在一起**的数据,而且看不出错
// (只有一所学校有数据时,它和学校管理员看到的一模一样)。
// 所以要代管某所学校时必须显式指明:换掉 tenantId 之后 isPlatformAdmin() 变 false
// → 租户过滤自动生效,权限仍按 PLATFORM_ADMIN 那套判。
//
// ⚠️ 必须在下面的一致性校验**之前**:切换后要拿"生效租户"去和域名比,
// 否则在 t2 域名上代管 t1 会被放过去。
Long actingTenant = actingTenantResolver.resolve(request, user);
if (actingTenant != null) {
user = new LoginUser(user.uid(), actingTenant, user.orgIds(), user.roles(), user.userType());
}
// ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
//
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
@@ -24,6 +24,15 @@ public enum BuiltinRole {
PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL,
// ⚠️ TASK_VIEW 是**读**权限(任务列表 / 详情 / 覆盖班级 / 规则模板 / 任务统计),
// PRD §3.2 的「查看填报进度看板」对平台运营是 ✅,而工作台的数据看板
// 要拿任务列表才能选任务 —— 少了它,平台点工作台会拿到 `/tasks` 40301。
//
// 它以前被"平台=超管"这个捷径掩盖着(`PermissionChecker.has` 对平台恒真),
// 直到平台开始**代管某所学校**(`X-Tenant-Id` → 不再是 isPlatformAdmin)
// 才暴露出来:那时只认角色声明的权限,于是任务列表直接 403。
// 只补读权限:TASK_MANAGE / TASK_CLONE 依旧不给(PRD 里平台运营是「—」)。
PermissionDef.TASK_VIEW,
PermissionDef.LOG_VIEW_ALL),
Set.of("dashboard", "tenant", "org", "user", "log")),
@@ -41,8 +50,22 @@ public enum BuiltinRole {
PermissionDef.FILE_PROXY_UPLOAD,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task", "form", "material",
"roster", "join", "audit", "export", "cleanup", "log")),
Set.of("dashboard", "org", "user", "role", "task",
"join", "audit", "export", "cleanup", "log",
// ⚠️ 顶层菜单里**没有**「名单管理 / 表单设计 / 材料规则」这三条。
// 它们的功能早就做好了,但都是**按任务**的:入口在「收集任务」
// 每一行的链接里(名单管理 / 表单设计 / 材料规则)。
// 以前菜单里挂着三条"功能在别处"的占位项,点进去只有一句指路 ——
// 那既占了导航,又让人以为"这里应该有事可做"。现在直接去掉。
// ⚠️ 这与权限无关:task/form/material/roster 相关权限码照旧保留,
// 只是不再有对应的菜单键。
//
// ⚠️ 这两个菜单键以前**没有任何角色持有** —— 而 PC 端的菜单可见性
// 就是拿这里拼出来的集合过滤的(`buildXtRoutes` 按 menuKeys 过滤),
// 于是「字典管理」与「通知记录」两个页面**做得再好也点不到**:
// 功能在、接口在、路由在,只是菜单里永远不出现(《08》F-106)。
// 这类错没有任何测试会红 —— 所以 `check-wiring.py` 现在会查它。
"dict", "notify")),
/** 班主任:只看自己带的班。 */
HEAD_TEACHER("HEAD_TEACHER", "班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 20,
@@ -50,7 +73,7 @@ public enum BuiltinRole {
PermissionDef.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")),
Set.of("dashboard", "task", "audit")),
/** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */
DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
@@ -58,14 +81,14 @@ public enum BuiltinRole {
PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")),
Set.of("dashboard", "task", "audit")),
/** 审核员:专做审核,不给任何管理权限(最小权限原则)。 */
AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40,
Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_ALL,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL),
Set.of("dashboard", "task", "roster", "audit")),
Set.of("dashboard", "task", "audit")),
/** 学生:只能操作本人那一份。 */
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
@@ -0,0 +1,88 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 平台运营的「当前学校」(请求头 {@code X-Tenant-Id})。
*
* <h3>它解决什么</h3>
*
* <p>平台运营({@code tenantId = 0})是唯一跨租户的身份,而租户拦截器对它
* <b>整个关掉了租户过滤</b>(见 {@code MybatisPlusConfig.FillTenantLineHandler})。
* 于是「组织架构」「账号管理」这类<b>本来就是租户内</b>的页面,在平台账号下看到的是
* <b>所有租户混在一起</b>的数据 —— 而这<b>看不出错</b>:当只有一所学校有数据时,
* 它和学校管理员看到的一模一样。
*
* <p>所以平台要操作某所学校的组织/账号时,必须<b>显式指明是哪一所</b>:
* 请求头带上目标租户 id,本类把它解析出来,{@code AuthInterceptor} 用它把
* {@link LoginUser} 的 tenantId 换成目标租户 —— 之后租户过滤、上下文写入、
* 权限判定全部按"我就是那所学校"走,<b>不需要每个接口各加一个参数</b>。
*
* <h3>四条约束(安全相关,不要松)</h3>
*
* <ol>
* <li><b>只认平台身份</b>:非平台用户带这个头一律忽略 —— 否则改一个数字就能越租户,
* 那正是本系统最不能出的一类漏洞;</li>
* <li>目标租户必须<b>存在且启用</b>,否则 {@code 40001},且不区分"不存在"与"被停用";</li>
* <li>换租户之后 {@code isPlatformAdmin()} 变成 false → 租户过滤自动生效。
* 这是<b>关键机制</b>(不是副作用):无需在别处再加一个"是否已切换"的判断;</li>
* <li><b>不带这个头时行为完全不变</b> —— 平台照旧可以跨租户看租户列表、全量日志。</li>
* </ol>
*
* <p>与 {@link TenantDomainResolver} 的分工:那个是"这条请求落在谁的域名上"(定位,匿名可用),
* 这个是"平台此刻要代管哪所学校"(显式选择,仅平台可用)。两者都会改变租户上下文,
* 所以都在 {@code AuthInterceptor} 里、<b>与 token 的租户一致性校验一起</b>完成。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ActingTenantResolver {
/** 平台运营指定"当前学校"的请求头。 */
public static final String HEADER = "X-Tenant-Id";
private final TenantMapper tenantMapper;
/**
* 解析本次请求的目标租户。
*
* @return 目标租户 id;<b>不是平台身份 / 没带头 / 头就是平台自身</b> 时返回 {@code null}
* (表示"不切换",按原有语义走)
*/
public Long resolve(HttpServletRequest req, LoginUser user) {
if (user == null || !user.isPlatformAdmin()) {
// 非平台身份带头一律忽略:这一条是安全边界,不是"顺手支持一下"
return null;
}
String raw = req == null ? null : req.getHeader(HEADER);
if (raw == null || raw.isBlank()) {
return null;
}
long target;
try {
target = Long.parseLong(raw.trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
HEADER + " 不是合法的租户号:" + raw);
}
if (target == LoginUser.PLATFORM_TENANT_ID) {
// 指着平台自己 = 不切换(而不是报错:前端清空选择时可能发 0)
return null;
}
SysTenant t = tenantMapper.selectById(target);
if (t == null || t.getStatus() == null || t.getStatus() != 1) {
// 两种原因给同一句话:不额外透露"这个租户存在但被停用了"
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "目标单位不存在或已停用");
}
log.info("平台运营切换租户:uid={} → tenantId={}({})", user.uid(), target, t.getTenantName());
return target;
}
}
@@ -0,0 +1,88 @@
package cn.tianbaotong.fill.module.file.service;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 规范文件名引擎的最小单测(《08》F-9 / F-46)。
*
* <p>断言的是<b>有理由的规则</b>,不是覆盖率:
* <ul>
* <li>扩展名必须自带点号(F-46:少一个字符不报错,只会产出「身份证jpg」这种
* 客户双击打不开的文件);</li>
* <li>空变量用「未填」占位,而不是留空 —— 留空会出现「张同学--身份证.jpg」,
* 看起来像系统出错;</li>
* <li>文件名里的 {@code /} 必须被清掉(学生的「学籍表(1/2)」很常见,
* 带 / 的名字根本写不进磁盘);</li>
* <li>超长截断 —— Windows 路径 260 限制,目录还要留空间。</li>
* </ul>
*/
class FileProcessServiceTest {
private final FileProcessService svc = new FileProcessService();
private static Map<String, String> vars(String name, String id, String material) {
Map<String, String> m = new HashMap<>();
m.put("姓名", name);
m.put("证件号", id);
m.put("材料名", material);
return m;
}
@Test
void 默认模板_姓名证件号材料名_扩展名带点() {
String n = svc.renderName(null, vars("张三", "361029200907295110", "身份证"), ".jpg");
assertEquals("张三-361029200907295110-身份证.jpg", n);
}
@Test
void 模板带后缀占位符时不重复拼扩展名() {
String n = svc.renderName("{材料名}-{姓名}{后缀}",
vars("张三", "361029200907295110", "身份证"), ".jpg");
assertEquals("身份证-张三.jpg", n);
}
@Test
void 空变量用未填占位_不会出现双横线() {
String n = svc.renderName(null, vars("张三", "", "身份证"), ".jpg");
assertEquals("张三-未填-身份证.jpg", n);
assertFalse(n.contains("--"), "留空会出现「张--身份证.jpg」,看起来像系统出错: " + n);
}
@Test
void 文件名里的斜杠必须被清掉() {
// 「学籍表(1/2)」在真实学生的上传里很常见;带 / 的名字写不进磁盘
String n = svc.renderName(null, vars("张三", "361029200907295110", "学籍表(1/2)"), ".jpg");
assertFalse(n.contains("/"), "斜杠会导致写盘失败: " + n);
assertTrue(n.contains("学籍表(1_2)"));
}
@Test
void 超长文件名截到安全长度() {
Map<String, String> m = vars("张".repeat(120), "361029200907295110", "身份证");
String n = svc.renderName(null, m, ".jpg");
assertTrue(n.length() <= 90, "Windows 路径 260 限制,正文要给目录留空间: " + n.length());
assertTrue(n.endsWith(".jpg"), "截断不能吃掉扩展名");
}
@Test
void sanitize_空白给未命名_全非法字符收拢成单个下划线() {
// "///" → "___" → 收拢成 "_"(不是「未命名」:未命名只留给纯空白,
// 全非法字符收拢后仍是可写盘的合法名字)
assertEquals("_", FileProcessService.sanitize("///"));
assertEquals("未命名", FileProcessService.sanitize(" "));
assertEquals(null, FileProcessService.sanitize(null));
}
@Test
void sanitize_连续分隔符收拢() {
assertEquals("a_b", FileProcessService.sanitize("a___b"));
assertEquals("a-b", FileProcessService.sanitize("a---b"));
}
}
@@ -0,0 +1,51 @@
package cn.tianbaotong.fill.support;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* {@link IdentityNormalizer} 的最小单测(《08》F-9:先给纯函数补单测,其余继续靠 E2E)。
*
* <p>选这三条规则断言,是因为每一条背后都有一个<b>真实踩过的坑</b>:
* <ul>
* <li>姓名去空白、但<b>不去间隔号</b> —— 少数民族姓名里的「·」是真实的;
* 顺手"清一清"会弄坏一部分人的名字(F-20 的另一面:规范化规则不能互相借用);</li>
* <li>全角转半角 —— 手机输入法常给全角数字,而它进校验位算法就是错的;</li>
* <li>证件号小写 {@code x} 收尾要统一成大写 —— 名册比对按字符串等值。</li>
* </ul>
*/
class IdentityNormalizerTest {
@Test
void 姓名去所有空白_含全角空格() {
assertEquals("张三", IdentityNormalizer.name("张 三"));
assertEquals("张三", IdentityNormalizer.name("张 三")); // 全角空格 U+3000
assertEquals("张三", IdentityNormalizer.name(" 张\t三\n"));
}
@Test
void 姓名里的间隔号必须保留() {
// 阿依古丽·买买提:间隔号是姓名的一部分,清掉等于改了人家的名字
assertEquals("阿依古丽·买买提", IdentityNormalizer.name("阿依古丽·买买提"));
assertEquals("阿依古丽·买买提", IdentityNormalizer.name("阿依古丽 · 买买提"));
}
@Test
void 全角ASCII转半角_其余不动() {
assertEquals("123X", IdentityNormalizer.toHalfWidth("123X"));
assertEquals("中文不变", IdentityNormalizer.toHalfWidth("中文不变"));
assertEquals("", IdentityNormalizer.toHalfWidth(null));
}
@Test
void 证件号去空白_转大写_全角可入() {
assertEquals("36102920090729511X", IdentityNormalizer.idCard("3610292009 0729511x"));
assertEquals("36102920090729511X", IdentityNormalizer.idCard("36102920090729511X"));
}
@Test
void 姓名空值返回空串而不是NPE() {
assertEquals("", IdentityNormalizer.name(null));
}
}
+1
View File
@@ -68,6 +68,7 @@
| 2026-09-28 | **P5 开发任务拆解** | 完成 `05-开发任务拆解.md`(27.6KB / 468 行)。**94 个任务 / 380 人日 / 含缓冲 456**,工期 **14 周**。关键结论:**工期由关键路径(67 人日串行)决定,不能按人日总数摊人**;压缩周期只能砍范围不能加人。含 3 个客户演示点(A 第 5 周 / B 第 10 周 / C 第 14 周)与 §9 可复现核算口径 |
| 2026-09-28 | **P4 核心模块设计** | 完成 `04-核心模块设计说明.md`(54KB / 1218 行)。六个模块全部伪代码级:M1 命名(含证件号三源裁决表)、M2 压缩(五档+二分降质+二次有损防护)、M3 识别(四层递进+成本闸)、M4 门禁(**并发绑定必须 UPDATE...WHERE bind_user_id IS NULL**)、M5 校验、M6 导出(快照先固化+中文文件名编码)。**校验中发现并修正三处真问题**,见下条教训 |
| 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL |
| 2026-10-09 | **验收走查后的四项修正** | ① **去掉 3 条无用菜单**(名单管理 / 表单设计器 / 材料规则)—— 功能都在「收集任务」行内入口,菜单里那一条只是指路;后端菜单键同步删除;② **修掉班主任点工作台必弹 403**(工作台无条件调了要 `org:view` / `progress:viewAll` 的两个接口,班主任都没这两个权限)→ 改为按权限决定发不发请求;③ **新增请求头 `X-Tenant-Id`(§1.2)**:平台运营的「组织架构 / 账号管理 / 工作台」原先拿到的是**所有租户混在一起**的数据,现在必须先在页面上选定学校;④ **修掉开放接口的"推送重试"从未生效**(F-112:定时扫描缺 `@InterceptorIgnore`,异常被 warn 吞掉;E2E 补了"真的重试了"的断言)。详见 `交接摘要` §一·三十三 |
---
+19 -3
View File
@@ -217,7 +217,7 @@ B1 选 A,但希望增加审批环节
|---|---|---|---|
| D1 | OCR 引擎选型 | 待比价(腾讯云 / 百度 / 本地模型) | 需同时评估**数据出境合规** |
| D2 | 视觉大模型(VLM)选型 | 抽成**可替换接口**,不绑定供应商 | 实测触发率仅 2%~10%,成本可控 |
| ~~D3~~ | ~~门禁限流阈值~~ | ⚠️ **口径已更新,见 E2**:改为"**只对失败计数**"(按人 10 次/小时、按 IP 20 次/分钟、成功侧 200 次/分钟)—— 旧口径会把整间教室挡住 | 压测后已改(2026-09-30) |
| ~~D3~~ | ~~门禁限流阈值~~ | ⚠️ **口径已更新,见 E2**:改为"**只对失败计数**"(按人 10 次/小时、按 IP 20 次/分钟;成功侧 600 次/分钟,2026-09-30 二次压测后由 200 上调)—— 旧口径会把整间教室挡住 | 压测后已改(2026-09-30,两次) |
| D4 | 材料识别强特征词表 | 平台预置 + 租户可自维护 | **需在"社会评价"批次数据上补充标定** |
| D5 | 一寸照片形态判定阈值 | 用真实样本标定(宽高比容差、背景纯净度) | — |
| D6 | 微信三条来源通道的体积上限 | 需真机实测 | 决定超限提示与降级路径 |
@@ -267,7 +267,7 @@ B1 选 A,但希望增加审批环节
|---|---|---|---|
| 失败(按人) | **只计失败** | 10 次/小时 | 防猜 |
| 失败(按 IP) | **只计失败** | 20 次/分钟 | 防枚举 |
| 成功(按 IP) | 只计成功 | 200 次/分钟 | 防"批量取数据" |
| 成功(按 IP) | 只计成功 | **600 次/分钟**(2026-09-30 由 200 上调,见下) | 防"批量取数据" |
**为什么必须改**:一个班的学生坐在同一间教室、连同一个 WiFi,出口是同一个公网 IP。
按旧口径,60 人同时扫码时**只有 20 个能进**,其余 40 个看到「操作过于频繁」——
@@ -275,9 +275,16 @@ B1 选 A,但希望增加审批环节
关键区分:**枚举攻击产生的是失败,教室产生的是成功。**
> **2026-09-30 二次修正(备案,无需回复)**:成功侧上限原定 200 次/分钟,
> 实测**恰好卡在"一个年级"的量级** —— 250 个不同学生同时核验(全部正确)时
> **213 个成功、37 个被挡**。全校在同一个出口 IP 集中填报(开学第一课集体扫码)
> 就会撞上,而**没有人做错了什么**。已上调为 **600 次/分钟**,
> 复测 250 人 **250/250**、500 人 **500/500**;失败侧两个计数器原样未动
> (防枚举的强度没有降低)。详见《09-部署与运维》§9.3。
### E3. 二期(社会收集 / 社会评价 / 专项职业能力)的启动时点
一期范围已按约定锁死为「学校收集 + 自主认定」(其他模式一律返回 `40044`)。
一期范围已按约定锁死为「学校收集 + 自助认定」(其他模式一律返回 `40044`)。
但**二期什么时候启动**会影响两件现在就能做、也可以等的事:
| 事项 | 如果二期在 3 个月内启动 | 如果更晚 |
@@ -288,6 +295,15 @@ B1 选 A,但希望增加审批环节
回复:☐ 二期预计 ____ 启动 ☐ 暂无计划
### E4. 国标职业库数据的来源(字典页「职业(国标)」目前是空的)
`sys_occupation_ref`(国标职业库)一期为 **0 行**:契约 §13.3 把 `OCCUPATION` 字典的数据来源
指定为这张表,而它是**国标数据**(GB/T 职业分类),需要贵方提供或采购后导入 ——
不是我们能自行生成的。现状:字典管理页里「职业(国标)」显示 0 项(如实展示,不假装有数据);
`itemCount` 直接用 `COUNT(*)`,数据导入后自动就对了,**不需要改代码**。
回复:☐ 我方提供数据(预计 ____) ☐ 采购(预算 ____) ☐ 一期不上这个字典
## 附:已闭环的决策(无需再讨论)
| 决策 | 内容 | 依据 |
+1 -1
View File
@@ -19,7 +19,7 @@
| FR-600 材料与规则 | 23 | 18 | ✅ | ✅ | ⚠️ | ✅ 识别结果已并入审核详情(P3) |
| FR-800 审核工作台 | 10 | 7 | ✅ | ✅ | ✅ | ✅ 撤回接口已补(P3) |
| FR-900 汇总导出 | 10 | 8 | ✅ | ✅ | ✅ | ✅ **全覆盖** |
| FR-1000 通知催报 | 6 | 4 | ✅ | ✅ | ⚠️ | ✅ 消息中心与额度已补(P3) |
| FR-1000 通知催报 | 6 | 4 | ✅ | ✅ | ⚠️ | ✅ 消息中心与额度已补(P3);⚠️ **FR-1002 / FR-1003 在 2026-10-08 才真正兑现** —— 模板原先只对手工发送生效、自动催报因日期比较写错**一条都没发过**(见《08》F-101 / F-104) |
| FR-1100 数据看板 | 5 | 2 | ✅ | ✅ | ✅ | ✅ **全覆盖** |
| FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 |
| FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
+184 -34
View File
@@ -70,6 +70,25 @@
**Token 有效期**:PC 端 8 小时(可续期)/ 小程序端 7 天(静默续期)
#### `X-Tenant-Id`:平台运营的「当前学校」(可选,仅平台运营)
平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户过滤对它整体关闭。于是
「组织架构」「账号管理」这类**本身就是租户内**的接口,平台不指明学校时会拿到
**所有租户混在一起**的数据 —— 而这看不出错(只有一所学校有数据时,它与学校管理员看到的完全一样)。
| 项 | 约定 |
|---|---|
| Header | `X-Tenant-Id: <tenantId>` |
| 谁能用 | **只认平台运营**。其它身份带这个头一律忽略(改了也不生效,不是"报错") |
| 作用 | 本次请求的租户上下文换成该租户:`/orgs/**`、`/users/**`、`/tasks/**` 等按它过滤与写入 |
| 不带 / 传 `0` | 不切换(`0` 视为"平台自己")。平台的租户列表、全量日志等跨租户能力照旧 |
| 非法值 | 非数字、或租户不存在/已停用 → `40001` |
| 与 token 的关系 | 只对平台生效,**不能**用来访问别的租户(学校管理员带它不会越权) |
| 权限怎么判 | 切换之后**不再享受"平台超管"捷径**,一律按 `PLATFORM_ADMIN` 声明的权限码判 —— 所以那些码必须真的够用(例如「查看填报进度看板」要 `task:view` + `progress:viewAll`) |
> 例:`GET /api/v1/orgs/tree` + `X-Tenant-Id: 1` → 返回租户 1 的组织树;
> 不带该头 → 返回平台自己租户的组织(空树),**不再**把各租户的组织混在一起。
### 1.3 统一响应
```json
@@ -252,24 +271,20 @@
| `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 |
| `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 |
| `auditNode` | `1` 初审 / `2` 终审 |
| `auditAction` | `1` 通过 / `2` 驳回 / `3` **撤回** / `4` 代传 |
| `auditAction` | `1` 通过 / `2` 驳回 / `3` 批量通过 / `4` 批量驳回 / `5` 转交 / `6` 代传 / `7` **撤回** |
| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 |
| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 |
| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 |
| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` 导出完成 / `6` 催报 / `7` 加入名单审批 ⚠️ 见下方注 |
| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` **催报** / `6` **导出完成** / `7` 加入名单审批 |
| `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 |
| `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
| `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 |
> ⚠️ **`notifyBizType` 的 `5` / `6` 在本文件内部是不一致的**:本表写「`5` 导出完成 / `6` 催报」,
> 而 §13.5 消息中心的示例(`2` 临期提醒 / `3` 驳回)与落库值 `collect_notify_record.biz_type`
> 用的是「**`5` 催报 / `6` 导出完成**」。
>
> **实现以 §13.5 为准**(代码里是 `CollectMessage.BIZ_URGE = 5` / `BIZ_EXPORT = 6`,
> 催报去重按 `5` 走)。这不是文案问题:若按本表实现,催报会被写成"导出完成",
> **去重会静默失效**(老师点两下学生收到两条),而排查时从数据上完全看不出是码对不上。
>
> 需要客户确认以哪一边为准;若确认以本表为准,要一起改的是两个常量 + 已落库的历史数据。
> ✅ **`notifyBizType` 的 `5`/`6` 曾经在本文件内部对调过(已修正,2026-09-30 客户确认)**:
> 本表旧版写「`5` 导出完成 / `6` 催报」,与 §13.5 消息中心示例、落库值
> `collect_notify_record.biz_type`(`5` 催报 / `6` 导出完成)不一致 ——
> 若按旧表实现,催报会被写成"导出完成",**催报去重会静默失效**(老师点两下学生收到两条),
> 而排查时从数据上完全看不出是码对不上。现全文档统一为「`5` 催报 / `6` 导出完成」。
> 记录见《08-完成度盘点与问题清单》**F-30**。
---
@@ -472,18 +487,14 @@
| `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) |
| `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` |
| `issues` | array | — | 该文件的 `ValidationIssueVO[]` |
| `source` | int | `source` | `1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传 ⚠️ 见下方注 |
| `source` | int | `source` | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入 |
| `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** |
> ⚠️ **`collect_file.source` 有两套互相冲突的口径**:本表写的是"从哪个入口选的"
> (`1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传),而建表注释(`V1__business_tables.sql`)
> 与实现(`CollectFile.SOURCE_*`)写的是"**谁传的**":
> `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入。
>
> **实现以建表注释为准**:§10.6 要求代传必须可追溯,要回答的是"**谁**替他传的、
> **为什么**",而不是"他从哪个入口选的"—— 后者在同一个学生身上还会随操作路径变化。
> 两个口径都是 4 个值、含义完全不同,所以"照字段表写"会得到一份**看起来有数据、
> 但答案是错的**统计。记录见《08-完成度盘点与问题清单》**F-38 / F-39**。
> ✅ **`collect_file.source` 的口径曾经有两套,已确认(2026-09-30 客户确认)**:本表旧版写
> "从哪个入口选的"(`1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传),与建表注释
> (`V1__business_tables.sql`)冲突。**以建表注释为准("谁传的")**:§10.6 要求代传可追溯,
> 要回答的是"**谁**替他传的、**为什么**",而"从哪个入口选的"会随同一个学生的操作路径变化,
> 统计上没有意义。实现见 `CollectFile.SOURCE_*` 常量。记录见《08》**F-38 / F-39**。
**`ownership.verdict` 取值**(对应 PRD §6.1 归属裁决矩阵)
@@ -533,7 +544,7 @@
| `id` | int64 | — |
| `auditNode` | int | `1` 初审 / `2` 终审 |
| `auditorId` / `auditorName` | int64 / string | 审核人 |
| `action` | int | 见 §2.7 `auditAction`(含 `3` **撤回**) |
| `action` | int | 见 §2.7 `auditAction`(**撤回是 `7`**;`3` 是批量通过 —— 旧版此表把两者写混过) |
| `reasonCode` | string | 驳回原因码(见 PRD 附录 C) |
| `reasonText` | string | 手填原因 |
| `materialCodes` | string[] | 关联材料 |
@@ -1792,7 +1803,18 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**自动保存**:前端每 15 秒或失焦时调一次;服务端**不限流**(草稿合并是轻量操作)。
### 9.5 ⭐ `POST /api/v1/mp/files/upload-token` 获取直传凭证
### 9.5 ⭐ `POST /api/v1/mp/files/upload-token` 上传前置检查(预检 / 秒传 / 压缩建议)
> ✅ **定位已确认(2026-09-30 客户确认,原「OSS 直传凭证」改为此口径)**:
> 一期实现是**服务端一步上传**(`POST /api/v1/mp/files/upload`,multipart,
> 收上来即压缩 + 按模板改名,见 §17.2 D-3),**不走对象存储直传** ——
> 直传要把上传凭证交给前端、且七牛直传必须配回调才能让服务端知道"传完了"。
> 本接口因此**不是安全边界**(客户端可以跳过它直接上传,服务端上传本身做全部校验),
> 它的价值是**在上传一个字节之前**完成三件事:
> ① 预检(格式 / 体积 / 份数,传 8MB 才发现格式不对就晚了);
> ② **秒传**(同 `fileHash` 已存在 → `existed: true`,网络断开重试时不用重传);
> ③ 端侧压缩建议(能大幅降低上传流量)。
> 大文件 / 弱网场景要恢复直传时再单独立项(含回调闭环),见《08》**F-3 / F-71 / F-72**。
**幂等**:是
@@ -1805,7 +1827,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
| `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) |
| `fileSize` | int64 | ✅ | 字节 |
| `contentType` | string | ✅ | MIME |
| `source` | int | — | `1` 聊天记录 / `2` 相册 / `3` 手机文件(⚠️ 落库口径见 §3.6 注:实际存的是「谁传的」= 1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入) |
| `source` | int | — | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入(口径见 §3.6 注) |
| `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) |
**响应**
@@ -1815,10 +1837,9 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
"code": 0,
"data": {
"fileId": 987654321,
"uploadUrl": "https://oss.xxx.com/raw/1001/2026090001/202609/9f3a....jpg?Signature=...",
"objectKey": "raw/1001/2026090001/202609/9f3a....jpg",
"direct": false,
"uploadUrl": "/api/v1/mp/files/upload",
"expireSeconds": 900,
"headers": { "Content-Type": "image/jpeg" },
"existed": false,
"clientCompressHint": {
"enabled": true, "maxLongSide": 1600, "quality": 80, "targetSizeKb": 1024
@@ -1829,14 +1850,23 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
| 字段 | 说明 |
|---|---|
| `direct` | 恒为 `false`(一期不做对象存储直传)。`uploadUrl` 就是本项目自己的接收地址,学生端 `wx.uploadFile` 直接 POST 它,与 H5 路径完全一样 |
| `existed` | `true` = **秒传命中**(同 hash 已存在),前端跳过上传直接进入处理 |
| `clientCompressHint` | **端侧预压缩建议**。这是第一道防线,能大幅降低上传流量与处理时长;**服务端仍会按规则重压**,端侧压缩只是加速 |
> **端侧压缩与「一次编码到位」的关系**:实测 300dpi/q85 即可达标,**端侧压缩只是减少上传量的优化,不是质量决定环节**。服务端始终以原图(或端侧压缩后的图)为输入重新规范,保证输出一致。
### 9.6 ⭐ `POST /api/v1/mp/files/{fileId}/process` 文件规范化(核心)
### 9.6 ⭐ `POST /api/v1/mp/files/{fileId}/process` 文件规范化(有原始件时的重新处理)
**说明**:这是**命名 + 压缩 + 识别 + 校验**的总入口。
> ✅ **语义已确认(2026-09-30,原「命名+压缩+识别+校验总入口」改为此口径)**:
> 一期上传是**一步**的 —— `POST /mp/files/upload` 收上来**当场**完成压缩、按模板改名、校验、
> 记录问题(含 VAL-06 自动修复),默认**只存规范件**(`fill.storage.keep-raw=false`)。
> 所以本接口**只在「保留了原始件」时有用**:对原始件重新跑一遍规范化
> (典型场景:改了材料规则后重压)。默认配置下多数文件没有原始件,调用会明确说明这一点,
> 而不是假装处理了一遍。识别 / OCR 的四层递进是二期(M3/M4),见 §17.2 D-12。
> 下面的响应结构保留作为「规范化产物」的字段参考(上传接口的产物与之同构)。
**说明**:对**有原始件**的文件重新执行规范化(压缩 + 改名 + 校验)。
**请求**
@@ -1937,7 +1967,11 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**删除约束**:已提交并进入审核的文件删除需先**撤回**(`40030`)。
### 9.9 `POST /api/v1/mp/ocr/idcard`
### 9.9 `POST /api/v1/mp/ocr/idcard`(一期缓做)
> ⚠️ **一期未实现(等 OCR 供应商选型,《01-客户与机构确认函》D1)**。
> 这是 121 个契约接口里**唯一**没有实现的一个;表单预填与校验位校验在学生端走人工填写。
> 选型要同时评估**数据出境合规**。选型定了之后本节按原设计实现,以下为设计参考。
**说明**:身份证 OCR,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。
@@ -2287,11 +2321,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
| 条件 | 结果 |
|---|---|
| 当前状态 `3`(已通过)且未被导出 | ✅ 允许,回退到 `2` 或 `1` |
| 当前状态 `2`(待终审) | ✅ 允许,回退到 `1`(撤回的是**初审**结论 —— 初审通过后发现填错,正是最需要撤的时刻) |
| **已被导出打包** | ❌ `40033`——**已交付的数据不可追溯修改** |
| 已归档 | ❌ `40034` |
| 撤回者非原审核人且非管理员 | ❌ `40301` |
**留痕**:写入一条 `action = 3`(撤回)的 `collect_audit_record`,时间轴显示"李老师撤回了通过结论"。
**留痕**:写入一条 `action = 7`(撤回)的 `collect_audit_record`,时间轴显示"李老师撤回了通过结论"。
(旧版写 `action = 3` —— `3` 实际是**批量通过**,撤回恒为 `7`,与代码常量 `ACTION_REVOKE` 一致。)
### 11.5 `GET /api/v1/audit/submissions/{id}/timeline`
@@ -2320,15 +2356,19 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**说明**:任务级全量校验,产出问题清单。
> ✅ **改为同步(2026-09-30 客户确认)**:旧版声明 `async` 默认 `true` 并返回 `jobId`,
> 但全契约**没有"查 jobId"的接口** —— 那个 jobId 查不到任何东西。
> 实测一份填报单的校验 <100ms、一个任务的量级秒级,**同步跑完直接回结果**
> 比返回一个查不到状态的作业号有用。保留 `jobId` 仅作本次运行的标识(幂等对账用)。
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `taskId` | int64 | ✅ | — |
| `scope` | string | `ALL` | `ALL` / `ISSUES_ONLY` / `APPOINTED` |
| `submissionIds` | int64[] | — | `scope=APPOINTED` 时必填 |
| `rules` | string[] | 全部 | 只跑指定规则(如 `["VAL-02","VAL-04"]`) |
| `async` | bool | `true` | — |
**响应**:`{ "jobId": "v_3f2a", "estimatedSeconds": 30 }`
**响应**:`{ "jobId": "v_3f2a", "checkedSubmissions": 320, "issuesFound": 47, "unresolvedLeft": 31, "elapsedMs": 812 }`
### 12.2 `GET /api/v1/validation/issues`
@@ -2613,6 +2653,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
| 调用者 | 权限 | 看到什么 |
|---|---|---|
| 平台运营 | `log:viewAll` | **默认全量**(跨租户);传 `tenantId` 则只看该单位 |
| 学校管理员 / 教务处 | `log:viewTenant` | **恒为本租户**;传了别的 `tenantId` → `40301`(**不静默忽略**:静默会让人以为"筛过了") |
**响应**
```json
@@ -2648,7 +2695,9 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
#### `GET /api/v1/logs/logins`
**Query**:`userId`、`userType`、`result`、`from` / `to`、分页
**Query**:`tenantId`、`userId`、`userType`、`result`、`from` / `to`、分页
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
@@ -2883,6 +2932,7 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
| 版本 | 日期 | 变更 |
|---|---|---|
| **V2.6** | **2026-09-30** | **六处口径修正 + 新增 §17(全部经客户确认)**:① §2.7 `notifyBizType` 5/6 对调修正(5 催报 / 6 导出完成,F-30);② §2.7 `auditAction` 补全为 7 个动作、撤回=7(F-7);③ §3.6/§9.5 `source` 改为建表口径「谁传的」(F-38/39);④ §9.5 改为「上传前置检查」定位 + §9.6 `process` 语义改为「有原始件时的重新规范化」(F-3/71/72);⑤ §9.9 OCR 标注一期缓做;⑥ §11.4 撤回约束补「状态 2 可撤(撤初审)」、留痕 `action=7`;⑦ §12.1 `validation/run` 改同步并返回统计(F-51);⑧ **新增 §17 实现补充与偏差记录**(41 个 §4 未列接口按六类分组 + 12 条已确认偏离 D-1~D-12)。同步记录见《08-完成度盘点与问题清单》 |
| **V2.5** | **2026-09-30** | **两处"文档内部口径冲突"加注(不改口径,只把冲突摆到明处)**:① §2 枚举 `notifyBizType` 的 `5`/`6`(本文件写「5 导出完成 / 6 催报」)与 §13.5 消息中心示例、落库值 `collect_notify_record.biz_type`(「**5 催报 / 6 导出完成**」)不一致 —— **实现以 §13.5 为准**,需要客户确认以哪边为准(若以 §2 为准,要同时改两个常量与已落库数据);② §3.6 与 §9.5 的 `collect_file.source` 字段表写的是"入口来源"(1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传),而建表注释与实现(`CollectFile.SOURCE_*`)是"**谁传的**"(1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入)—— **实现以建表口径为准**(§10.6 要求代传可追溯,要回答的是"谁替他传的")。两处均已在原文加 ⚠️ 注。记录见《08-完成度盘点与问题清单》**F-30 / F-38 / F-39**。 |
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 |
| **V1.2** | **2026-09-28** | **错误码规范化**:`40007b` / `40007c` 带字母后缀,与 §1.3「`code` 为数字」的约定冲突(JSON 里会变成字符串,前端 `code === 0` 判断失效)。改为纯数字:**`40007b` → `40071`**(门禁限流)、**`40007c` → `40072`**(名单项已被绑定)。总码数不变,仍 **58 个**。 |
@@ -2902,3 +2952,103 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
| 错误码数量 | 58 个(新增 38 个标 ⭐)✓ |
| 文件规模 | 约 100 KB / 2570 行 |
---
## §17 实现补充与偏差记录
> **§4 的 121 个是设计口径**。本节回答两件事:① 代码里还有哪些 §4 没列的接口;
> ② 哪些地方**有意偏离**了本文档的原始写法、为什么、谁确认的。
>
> ⚠️ 17.1 的清单由工具生成:`python scripts/check-api-parity.py --extra`
> (当前 **41 个**,生成于 2026-09-30)。**手抄清单会过期,以命令输出为准**;
> 下表按"为什么不在 §4"分组,便于决定"补进 §4 还是留在本节"。
### 17.1 代码有、§4 清单没有的接口(41 个,按偏离原因分组)
**A · 材料访问通道(7 个)** —— 审核端与学生端各一条通道(审核端要看防伪纹,学生端只看自己的);上传是一步 multipart
| 接口 | 说明 |
|---|---|
| `POST /api/v1/mp/files/upload` | 实际的上传入口(一步 multipart,收上来即压缩+改名) |
| `GET /api/v1/mp/files/{id}/url` | 学生端取签名预览链接 |
| `GET /api/v1/mp/files/{id}/content` | 学生端直出内容 |
| `GET /api/v1/mp/files/submissions/{submissionId}` | 按提交单列出材料 |
| `GET /api/v1/audit/files/{id}/url` | 审核端签名链接(独立通道;支持 `?thumb=true` 取缩略图) |
| `GET /api/v1/audit/files/{id}/content` | 审核端直出内容 |
| `GET /api/v1/mp/teacher/files/{fileId}/preview` | 教师端(代传场景)预览学生材料 |
**B · 微信授权链路代替 `POST /mp/login`(4 个)**
| 接口 | 说明 |
|---|---|
| `GET /api/v1/mp/h5/authorize` | H5 微信网页授权跳转 |
| `GET /api/v1/mp/h5/callback` | 授权回调(token 放 URL fragment) |
| `GET /api/v1/mp/h5/stub-login` | **仅非生产**打桩登录(`stub-enabled` + profile 双重保护) |
| `GET /api/v1/mp/scene-context` | 匿名扫码落地页查询(scene 三段式,只返回最少信息 + 限流) |
**C · 签名 URL 下载(1 个)**
| 接口 | 说明 |
|---|---|
| `GET /api/v1/exports/{id}/file` | 导出包的流式下载。§12.6 只定义了"取下载信息";真正下文件走这条,**URL 有有效期、不能入库/外发** |
**D · 界面与接口必需、清单漏列(17 个)**
| 接口 | 说明 |
|---|---|
| `GET /api/v1/auth/captcha` | 图形验证码(连续失败 2 次后必填) |
| `GET /api/v1/auth/public-key` | 密码 RSA 加密传输用的公钥 |
| `GET /api/v1/material-catalog` | 材料目录(码/档位/格式/模板/体积范围) |
| `POST /api/v1/tasks/{id}/materials/apply-template` | 应用内置材料模板(幂等) |
| `POST /api/v1/tasks/{id}/save-as-template` | 保存为模板(§7.13 的缺口,F-62) |
| `POST /api/v1/audit/submissions/batch-approve` | 批量通过(§11.3 标题带过、清单未单列) |
| `GET /api/v1/mp/teacher/audit/reason-codes` | 教师端驳回原因字典(§11.7 有 PC 版) |
| `GET /api/v1/mp/teacher/submissions/{id}/proxy-context` | 代传前取上下文(哪个班、哪份草稿) |
| `GET /api/v1/qrcodes/{scene}` | 匿名出图 |
| `POST /api/v1/roster/{rid}/deactivate` | 名册项"标记不参与"(删除的可逆替代,§8.3/8.4) |
| `GET /api/v1/tenants/{id}` | 租户详情 |
| `PUT /api/v1/users/{id}/status` | 启用/停用(清单只有 DELETE 停用) |
| `DELETE /api/v1/dicts/items/{id}` | 删除自定义字典项(§13.3) |
| `POST /api/v1/dicts/{code}/items/{itemCode}/toggle` | 内置字典项停用/恢复(§13.3) |
| `GET /api/v1/exports/precheck` | 导出前置检查(只检查不创建;界面靠它禁用按钮) |
| `GET /api/v1/exports/preview` | 导出目录树预览(与真正打包共用同一份构建代码) |
| `GET /api/v1/cleanup/jobs/{id}/result` | 整理器报告(§13.2 界面轮询用) |
**E · 契约链条原本断在第一步(8 个,已实现)**
| 接口 | 说明 |
|---|---|
| `POST /api/v1/notify/templates/{code}/customize` | 把内置模板复制成可编辑的库行(F-52:内置模板没有 id,`PUT /{id}` 拿不出 id) |
| `DELETE /api/v1/notify/templates/{code}` | 恢复默认模板 |
| `GET /api/v1/notify/templates/variables` | 变量白名单(前端抄一份迟早不一致) |
| `POST /api/v1/open-apps` · `GET /api/v1/open-apps` | §14 的接入方管理(F-67:没有它,appSecret 只能改库产生) |
| `POST /api/v1/open-apps/{id}/reset-secret` | 重置密钥(显式动作,会让对方现有签名立即失效) |
| `POST /api/v1/open-apps/{id}/toggle` | 启停接入方 |
| `GET /api/v1/open-apps/push-logs` | 推送记录(人工重推的依据) |
**F · 运维与调试动作(4 个)** —— 定时任务在测试里"等不起",所以都有手工入口(权限走运营侧 `log:view:all`)
| 接口 | 说明 |
|---|---|
| `POST /api/v1/schedules/run` | 手工触发定时任务(`job` 取值 close/urge/timeout/raw/usage/retry/expire) |
| `POST /api/v1/exports/cleanup-run` | 手工触发导出包过期清理(可重复执行) |
| `POST /api/v1/cleanup/jobs/json` | 整理器:直接提交 JSON(不走上传) |
| `POST /api/v1/cleanup/jobs/parse-preview` | 整理器:解析预览(不落库) |
### 17.2 有意偏离契约的地方(均已确认,2026-09-30)
| # | 偏离点 | 契约原口径 | 实现口径 | 依据 |
|---|---|---|---|---|
| D-1 | `notifyBizType` 5/6 | §2.7 旧表「5 导出完成 / 6 催报」 | 「**5 催报 / 6 导出完成**」(与 §13.5、落库值一致) | F-30;按旧表实现催报去重会静默失效 |
| D-2 | `collect_file.source` | §3.6 旧表「入口来源」(聊天记录/相册/手机文件/代传) | 「**谁传的**」:1 本人 / 2 班主任代传 / 3 管理员代传 / 4 整理器(按建表注释) | F-38/F-39;§10.6 代传可追溯 |
| D-3 | 上传链路 | OSS 直传 + `upload-token` 凭证 | **服务端一步上传**;`upload-token` 定位为**预检+秒传+压缩建议**(非安全边界) | F-3/F-71/F-72;直传需配回调且把凭证交前端 |
| D-4 | `process` 语义 | 「命名+压缩+识别+校验总入口」 | 上传当场完成这些;`process` = **有原始件时的重新规范化** | F-3;默认 `keep-raw=false` |
| D-5 | `POST /validation/run` | `async=true` + jobId | **同步跑完**并返回 `checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs` | F-51;全契约没有查 jobId 的接口 |
| D-6 | 撤回动作码 | §11.4 旧文 `action=3` | **`action=7`(撤回)**;`3` 是批量通过;状态 2/3 均可撤(2 撤的是初审) | F-7;时间轴不能混淆 |
| D-7 | §14 路径前缀 | §14 详情写 `POST /open/v1/...`(无 `/api/v1`) | 统一 `/api/v1/open/v1/...`(与 §1 及其余接口一致,网关一条规则) | F-65 |
| D-8 | `biz_type` 落库类型 | §6 字段表按数字码定义 | `collect_notify_record.biz_type` 是 `varchar(32)`,存**数字码的字符串形式**("3");接口层仍是数字 | F-26;DDL 不可改 |
| D-9 | 任务删除 | 已发布任务不可删(`40001`) | **已归档(status=3)的任务可删**:管理员显式动作 + 操作留痕(`DELETE_TASK`,记行数/文件数);**子表级联物理删**(名册/材料/审核记录等 14 张表)、**任务行本身软删**(占住 `task_code` 不复用 —— 编码是文件目录名) | F-14;否则任务列表只增不减 |
| D-10 | 租户域名 | 未写 | 能力已实现(Host 定位 + token 授权),**默认关闭**;启用四步见《交接摘要》§一·二十六 | F-10;客户决定先不做 |
| D-11 | 通知降级链 | 「订阅消息→公众号模板→短信→站内信」四级 | 一期**两级**(订阅消息→站内信);公众号模板需已认证服务号、短信需采购通道(商务动作),不假装可发 | F-28 |
| D-12 | OCR(§9.9) | 已定义 | **一期缓做**(等供应商选型,见《01》D1,需评估数据出境合规) | F-3;121 个接口里唯一未实现 |
+70 -20
View File
@@ -154,7 +154,7 @@
- **影响**:占用端口 + 误导(新人会以为本地有对象存储可用)。
- **建议**:`docker rm -f xt-minio`;若还有旧数据要留,先 `docker run --rm -v` 导出再删。同时在 `AGENTS.md` 的"环境与流程坑"里保留"MinIO 已删,别去找"这句(已有,够用)。
### F-3【P1 · 文档与代码不一致】`/mp/files/upload-token` 与 `/mp/ocr/idcard`
### F-3【P1 · 文档与代码不一致】`/mp/files/upload-token` 与 `/mp/ocr/idcard` —— **已闭环**
- **现象**:契约(PRD §8 与 03 §9.5/§9.9)规定上传是「OS 直传 + `upload-token`」,OCR 走 `/mp/ocr/idcard`;实际实现是 `POST /mp/files/upload`(服务端压缩 + 按模板改名,**默认不留原始件**),OCR 完全没做。
- **影响**:前端按契约写会调不存在的接口;`upload-token` 是"防重复建文件记录"的关键设计(重复提交幂等),现在靠 `POST /mp/files/upload` 每次新建记录来承担。
@@ -163,7 +163,9 @@
2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。
### F-4【P1 · 文档与代码不一致】契约里没有的接口(首次盘点 19 个,现在 41 个)
- ✅ **已闭环(2026-09-30,客户确认后按建议改文档)**:§9.5 重写为「上传前置检查(预检/秒传/压缩建议)」、§9.6 改为「有原始件时的重新规范化」、§9.9 标注一期缓做 —— 口径见契约 §17.2 D-3/D-4/D-12。
### F-4【P1 · 文档与代码不一致】契约里没有的接口(首次盘点 19 个,现在 41 个) —— **已闭环**
- **现象**(代码有、03 文档 §4 清单没有):最典型的是 `GET /mp/scene-context`、
`GET /auth/captcha`、`GET /mp/h5/authorize|callback|stub-login`(代替 `/mp/login`)、
@@ -174,11 +176,30 @@
所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。
- **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py`(它逐节核对接口数,加节时注意别改动 §4 的声称数)。
### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析"
- ✅ **已闭环(2026-09-30)**:契约新增 **§17 实现补充与偏差记录** —— 41 个接口按六类分组(清单由 `check-api-parity.py --extra` 生成,写明「手抄件会过期,以命令输出为准」),外加 12 条已确认偏离 D-1~D-12(含租户域名一行、`biz_type` 落库字符串、§14 路径前缀)。
### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析" —— **清理工具已补齐(2026-09-30)**
- **现象**:`sys_tenant` 17 行、`sys_user` 300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。
⚠️ 现在回头看,这个数字被低估了:跑了几十轮之后是 **48 个 `e2e*` 租户 / 709 个用户 /
144 个挂在 demo 租户下的测试组织 / 82 个【演示·旧】任务**。
- **影响**:本地演示/截图时列表里全是 `e2e-*` 名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。
- **建议**:`scripts/dev-clean-e2e.py` 增加一节"按名字前缀删测试租户与账号"(默认 dry-run,`--yes` 才真删),并在 `AGENTS.md` 里把"跑完 E2E 顺手清一次"写成固定动作。
- **真正的窟窿(这一轮才看清)**:**删库不删盘**。`backend/data/uploads` 里 **281 个文件 / 276 MB**,
而库里只剩 16 行文件 —— 压测与 E2E 都只删自己的库行。
文件的归属**只有库知道**,所以这件事必须由"拿库做对照"的工具来收,不能靠 `rm -rf`。
- **已做**(`scripts/dev-clean-e2e.py`,**默认 dry-run**):
① `--only-orphan-files --apply`:删**没有任何库行指向**的磁盘文件 —— **已执行,255 个 / 275.4 MB,
uploads 从 276 MB 降到 0.3 MB**;
② `--apply`:清 e2e 租户 + 测试组织 + **它们的业务行**(业务表按 `information_schema` 动态枚举,
以后新增业务表不会漏);③ `--demo-tasks`:清【演示·旧】任务及其子表与文件。
- ⚠️ **后两项我没有执行**,原因具体:**当前演示态正挂在其中一个"E2E 24数控1班 645451"组织上**
(就是导出目录里那个班级名),删了组织等于把演示态弄坏。所以:
要清就先确认演示态迁到正式组织名下,再跑 `--apply`。
我也因此把 `--demo-tasks` 的匹配**收窄到只带「旧」的那批** ——
`【演示】审核台` 是当前在用的,原来那句 `LIKE '【演示】%'` 会把它一起删掉,
而这种错在预览里只表现为"83 个"(比 82 多一个),**极容易被放过**。
- **建议**:把 `--only-orphan-files --apply` 写进"跑完压测/E2E 的收尾动作"(见 `AGENTS.md` 第三节)。
- ✅ **已执行(2026-09-30,含「在用组织保护」)**:`--apply --demo-tasks --orphan-files` 清掉 **49 个 e2e 租户 + 147 个测试组织 + 84 个【演示·旧】任务,共 3714 行、27 个文件**;清理前先算「仍被存活任务引用的组织」并保留(**3 个**,其中就有当前演示态挂着的 E2E 24数控1班 —— 演示态完好:任务 1449 / 名册 3 人 / 组织 43 都在)。另外 **xt-minio 容器已删**(F-2)、**七牛桶已清**(F-93)。
### F-6【P2 · 文档误导】`frontend-pc/AGENTS.md` 里的 Mock 说明已过时 —— **已修(2026-09-30)**
@@ -194,11 +215,13 @@
但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。
核实方式:`frontend-pc/` 下无 `mock/`,`package.json` 与 `vite.config.ts` 里都没有 mock 关键字。
### F-7【P2 · 一致性】撤回的动作码与适用状态
### F-7【P2 · 一致性】撤回的动作码与适用状态 —— **已闭环**
- **现象**:DDL 注释 `3=批量通过` 与 03 §11.4「写入 `action = 3`(撤回)」冲突,代码取 `7`(已按"时间轴不能混淆"处理);§11.4 的状态表只列状态 3,代码额外允许状态 2。
- **建议**:客户确认后同步两处文档;若客户不同意状态 2 的撤回,删一个分支即可(代码里有注释标明)。
- ✅ **已闭环(2026-09-30,客户确认按代码口径)**:§2.7 `auditAction` 补全 7 个动作(撤回=7),§11.4 约束表补「状态 2 可撤(撤的是初审)」、留痕改 `action=7`。DDL 注释少列 7(迁移已发布不改,以 §2.7 为准)。
### F-8【P2 · 安全/上线】打桩登录与存储密钥
- **现象**:`application-dev.yml` 里 `stub-enabled: ${WX_H5_STUB:true}`(**dev 默认开着**),而代码侧默认 `false`;`keep-raw: ${XT_STORAGE_KEEP_RAW:false}` 默认不留原始件;七牛 AK/SK 只在本地 `application-local.yml`。
@@ -209,13 +232,15 @@
- **现象**:`src/test` 为空,验证全靠 E2E(很硬,但每次要起服务 + 连库)。
- **建议**:先给三个**纯函数**补最小单测(不引框架之外的东西):`IdentityNormalizer`、命名规范化引擎、压缩档位推导。理由是这三个"输入输出确定、没有 IO",单测成本最低、回归收益最高;其余继续靠 E2E。
### F-10【P3 · 一致】租户域名相关文档暂缓
### F-10【P3 · 一致】租户域名相关文档暂缓 —— **已闭环**
- **现象**:§一·二十五 的能力已实现且默认关闭,PRD 与 03 文档未写(当时有意暂缓)。
- **建议**:保持暂缓,但**在 03 文档 §17 留一行**"租户域名能力:`Host 定位 + token 授权`,默认关闭,启用条件 4 步",避免将来重开时找不到入口。
---
- ✅ **已闭环(留一行入口,按建议)**:契约 §17.2 **D-10** 写明「能力已实现(Host 定位 + token 授权),默认关闭,启用四步见《交接摘要》§一·二十六」。
## 六、施工记录(本轮自主完成的事)
> 客户要求:**"完成所有的开发计划,中途发现问题自己决定,记录出来让我知道即可。"**
@@ -296,7 +321,7 @@
| F-11 | **`SubmissionValidator` 的 severity 口径有三处互相矛盾的注释**:`Issue` 的 JavaDoc 写"1信息 2警告 3错误",而 `Severity` 常量注释写"1=红 2=黄 3=蓝",实际代码是 `INFO=1 / WARN=2 / ERROR=3`(`SubmissionService` 数红黄就用它们)。**我第一版导出前置检查按"1=红"写,会把"提示"当成阻断 → 正常数据也导不出来** | 已改:导出按 `Issue.ERROR` 判定;前端 `ISSUE_SEVERITY` 与详情页配色改为 1 蓝 / 2 黄 / 3 红;`Issue` 的 JavaDoc 补上"不要按直觉改常量"。**建议**:把 `Severity` 常量注释也一起改掉(本轮未动,属纯注释) |
| F-12 | **`collect_export_task` 表与契约字段不一致**:契约 §12.5 有 `currentStep / stepText / stats.rawSizeMb`,表里三列都没有 | 不新建迁移,改为推导(D6)。已在实体注释里写明"不要再按契约建字段" |
| F-13 | `RevokePolicy` 在"已导出"之后**不返回** `auditRecordId`(只把 `canRevoke` 置 false) | 这不是 bug,但意味着 **`40033` 在界面上不可达**(只会出现 40001「缺少要撤回的记录 id」)。已在 E2E 里按"用户真正看得见的东西"断言,并写清 40033 只在绕过前端时可达 |
| F-14 | 已发布的任务**既删不掉也改不了名**(`40001`),演示/测试数据无法清理 | 开发种子脚本改为"SQL 清空 + 改名挪开"(夹具性质,已注明)。**建议**:给管理员留一个"归档后可删"的口径,否则本地库会一直堆任务 |
| F-14 | 已发布的任务**既删不掉也改不了名**(`40001`),演示/测试数据无法清理 | 开发种子脚本改为"SQL 清空 + 改名挪开"(夹具性质,已注明)。**建议**:给管理员留一个"归档后可删"的口径,否则本地库会一直堆任务 ✅ 已闭环(客户确认「归档后可删」):`DELETE /tasks/{id}` 支持 status=3 —— **级联物理删** 14 张表(刻意不软删:本条的动机就是库会一直堆)+ 尽力删物理文件 + `sys_operation_log` 记 `DELETE_TASK`;收集中/已截止仍拒删(归档是最后的后悔药)。验证:新增 `scripts/e2e-m1-task-delete.py` → **19/19**。 |
| F-15 | 我第一次汇总时把 753 读成了 771(**我的算术错,不是脚本的问题**)。逐脚本累加可复现:原 18 个脚本合计**恰好 753**,加上新增的 `e2e-m6-export.py`(60)为 **813** | 已更正为 813,并在正文给出"753 + 60"的算式。**建议**:这类数字一律附上算式,别只写结论 —— 否则下一次还是会算错 |
| F-16 | ⚠️ **我第一版把导出目录结构做错了**:做成"班级/姓名/文件"(更符合"给人翻"的直觉),而 PRD §5.9 明确是「`{批次号}-{任务名称}` / 班级 / **材料类型** / 文件」,根目录也不是用户填的导出名 | 已改回规范,并把"根目录名、第二层、第三层、汇总表位置"四条都写成 E2E 断言(`e2e-m6-export.py`),另加"预览节点与产物逐项一致"。**教训**:目录结构是**交付物的形状**,属于需求,不能按直觉设计 —— 这类错只在客户解压时暴露 |
| F-17 | 汇总表文件名:契约 §12.4 只说"生成汇总 Excel",PRD §5.9 给的是机构叫法 `考生线下报名信息导入模板(汇总).xlsx` | 按 PRD 命名(机构按这个名字找它)。已写进 E2E |
@@ -308,13 +333,13 @@
| F-23 | ⚠️ **后端启动直接崩**:`TeacherDashboardMapper` 的 `<script>` 动态 SQL 里写了裸 `<>`(SQL 的"不等于"),而 `<script>` 块要按 **XML** 解析 —— `<` 必须转义成 `&lt;`。错误是 `SAXParseException: 元素内容必须由格式正确的字符数据或标记组成`,**指不到具体那一行 SQL**,而我第一反应是去查 SQL 逻辑(走了一段弯路)。更危险的是:`mvn package` **照样成功**,只有启动时才炸 | 改成 `&lt;&gt;`(SQL 标准的"不等于"),并顺手扫了 `module/mp/mapper` 下所有动态 SQL,确认没有别的裸 `<`。**教训**:带 `<script>` 的 Mapper **编译期不校验 XML**,改完必须**真启动一次** —— 这类错的表现是"服务起不来",不是"接口报错" |
| F-24 | ⚠️ **`collect_message` 少了两列审计字段**(`create_by` / `update_by`):实体继承 `TenantEntity`,而 `FillMetaObjectHandler` 会填这两列 → 插入报 `column "create_by" of relation "collect_message" does not exist`,**站内信一条都写不进去**。这个坑在 `collect_task_org` / `collect_export_task` 上都踩过,是第三次 | V8 **还没发布**(本轮才加、且只在开发库跑过一次),所以直接在迁移里补上两列——而不是像前两次那样"去掉实体字段"。**判断依据**:这张表以后要按 `create_by` 查"谁发的消息",少这列以后要再加就麻烦了。**教训**:新建业务表先问一句"它是 `TenantEntity` 还是普通实体",然后照着列清单核对一遍 |
| F-25 | ⚠️ **`@Update` 里的 `&lt;` 不会被还原成 `<`**:`SubscribeQuotaMapper.consume` 写的是 `AND used &lt; total`,而**没有 `<script>` 包裹的注解 SQL 不经过 XML 解析** → 字面量 `&lt;` 原样发给 PG → `syntax error at or near "&"`。表现更绕:这个异常在 `NotificationService` 里被捕获降级成"仅站内信",所以**用户侧完全正常**,只有 `collect_notify_record` 里一行都没有 | 外面套一层 `<script>`(本意不是为了动态 SQL,就是为了转义),并写进注释。**连带教训**:这类"被吞掉的异常"最难查 —— 我一开始以为是"没被调用",查了三轮日志才看到真正的堆栈。**排查手法**:先把被吞的异常用 `System.err` 打出来,别信日志配置 |
| F-26 | ⚠️ **`collect_notify_record.biz_type` 是 `varchar(32)`,不是数字**:契约 §6 的字段表把 `bizType` 定义成数字码(1~7),而 V1 的 DDL 建的是 varchar + 注释只写"业务类型"。传 Integer 进去 PG 直接拒绝(`column "biz_type" is of type character varying but expression is of type integer`)—— 同样被降级逻辑吞掉,表现还是"表里一行都没有" | 按 DDL 走:`biz_type` 存**数字码的字符串形式**(`"3"`),接口层仍然是数字(契约不变)。`collect_message.biz_type` 我用的是 `SMALLINT`(新表,我自己定的),两者"A 通过 bizType 关联"依然成立。**建议**:契约 §6 补一句"`collect_notify_record.biz_type` 落库为字符串" |
| F-26 | ⚠️ **`collect_notify_record.biz_type` 是 `varchar(32)`,不是数字**:契约 §6 的字段表把 `bizType` 定义成数字码(1~7),而 V1 的 DDL 建的是 varchar + 注释只写"业务类型"。传 Integer 进去 PG 直接拒绝(`column "biz_type" is of type character varying but expression is of type integer`)—— 同样被降级逻辑吞掉,表现还是"表里一行都没有" | 按 DDL 走:`biz_type` 存**数字码的字符串形式**(`"3"`),接口层仍然是数字(契约不变)。`collect_message.biz_type` 我用的是 `SMALLINT`(新表,我自己定的),两者"A 通过 bizType 关联"依然成立。**建议**:契约 §6 补一句"`collect_notify_record.biz_type` 落库为字符串" ✅ 已闭环:契约 §17.2 **D-8** 写明「落库为数字码的字符串形式(varchar 列),接口层仍是数字」。 |
| F-27 | `notifySent` 的语义**被收窄了**:以前它表示"有没有通知渠道"(恒 `false`),现在表示"**微信推送**有没有真的发出去"。站内信一定写得进去,所以 `needManualNotice` 仍然可能为 `true`(学生不一定会进小程序) | 已在代码里写清,并且**没有**顺手把 `needManualNotice` 改成恒 `false`。E2E 断言"降级时如实告知:notifySent=false / needManualNotice=true",防止后人把它"优化"成乐观值。**这是对前端契约的语义变更,已在本文档记录** |
| F-28 | **降级链只做了两级(订阅消息 → 站内信),中间两级没做**:契约 §13.6 写的是「订阅消息 → 公众号模板 → 短信 → 仅站内信」。后两级的缺不是代码问题 —— 公众号模板消息要**已认证服务号 + 用户已关注**(一期没有公众号),短信要买通道与签名(按条计费,是商务动作)。我选择**只实现能真正走通的两级**,而不是写两级"看起来有、其实永远失败"的通道 | 在 `NotificationService` 的类注释里写清了为什么少做,并用 `channel` 字段如实标出这条消息最终怎么到的。**理由**:永远失败的通道会往日志里刷一片失败,把真正的原因(没配通道)淹掉,而运营看到"通知失败率 90%"会去查代码,实际上该去找商务 |
| F-29 | **催报的分钟级限流还没做**:契约 §3 的表里写了「催报 10 次/分钟/任务」,本轮只做了 Service 层的**去重**(`excludeRecentHours`,默认 24 小时),没有做分钟级限流。**两者的作用不同**:去重防的是"同一个学生被重复催",限流防的是"老师连点把系统打爆"。现在连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询 | 记为 P1 剩余项(与 §3 的其它限流一起做)。**不做的理由**:现在没有真实并发压力,而限流放哪个维度(租户/用户/任务)需要与 §3 的其它限流统一,单独加一个会分叉 |
| F-28 | **降级链只做了两级(订阅消息 → 站内信),中间两级没做**:契约 §13.6 写的是「订阅消息 → 公众号模板 → 短信 → 仅站内信」。后两级的缺不是代码问题 —— 公众号模板消息要**已认证服务号 + 用户已关注**(一期没有公众号),短信要买通道与签名(按条计费,是商务动作)。我选择**只实现能真正走通的两级**,而不是写两级"看起来有、其实永远失败"的通道 | 在 `NotificationService` 的类注释里写清了为什么少做,并用 `channel` 字段如实标出这条消息最终怎么到的。**理由**:永远失败的通道会往日志里刷一片失败,把真正的原因(没配通道)淹掉,而运营看到"通知失败率 90%"会去查代码,实际上该去找商务。 ✅ **已决策(2026-09-30 客户确认:一期维持两级)**,备案进契约 §17.2 **D-11**;接公众号模板/短信是商务动作(认证服务号、短信通道采购),不是代码欠账 |
| F-29 | **催报的分钟级限流还没做**:契约 §3 的表里写了「催报 10 次/分钟/任务」,本轮只做了 Service 层的**去重**(`excludeRecentHours`,默认 24 小时),没有做分钟级限流。**两者的作用不同**:去重防的是"同一个学生被重复催",限流防的是"老师连点把系统打爆"。现在连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询 | 记为 P1 剩余项(与 §3 的其它限流一起做)。**不做的理由**:现在没有真实并发压力,而限流放哪个维度(租户/用户/任务)需要与 §3 的其它限流统一,单独加一个会分叉 ✅ 已闭环:补上**分钟级限流**(10 次/分钟/任务,超限 `42901` 且话说清等多久;Redis 不可用时放行不瘫痪);`e2e-m5-urge.py` → **42/42**(新增「第 11 次被限流」断言)。 |
| F-30 | ⚠️ **契约里 `notifyBizType` 的 5/6 是对调的**:§259 那行写「`5` 导出完成 / `6` 催报」,而 §2655 的消息中心示例是「`bizType: 2` 临期提醒、`bizType: 3` 驳回」—— 后者能与代码对上,所以不是我看错行。**后果很具体**:我第一版按契约写 `biz_type = '6'` 做催报去重,而实际写入的是消息中心的 5,于是去重**静默失效**(老师点两下学生收到两条),排查时完全看不出是"码对不上" | 统一按**消息中心的码**(`5` 催报 / `6` 导出完成,见 `CollectMessage` 的常量与 `UrgeService.BIZ_URGE` 的注释),并把不一致记在这里。**建议**:契约 §6 的 `notifyBizType` 与 §5.9/§13.5 对齐成同一套码 —— 改哪一边要客户确认 |
| F-31 | **登录日志只有 PC 账号密码登录**(`login_type` 恒为 1,库里 1101 行全是 `PASSWORD`):学生从小程序/H5 进来走的是**微信授权**(`/mp/h5/*`),那条链路**不写 `sys_login_log`**。所以按 §13.4 查"学生登录记录"会是空的 | 本轮**没有补**(补它要动 MP 授权链路,属于另一处改动),而是在接口与页面的说明里**如实写清**这是已知边界 —— 否则运维会以为日志功能坏了。记为 P1 剩余项。**风险**:合规上"谁在什么时候登录过"对学生侧是缺口 |
| F-30 | ⚠️ **契约里 `notifyBizType` 的 5/6 是对调的**:§259 那行写「`5` 导出完成 / `6` 催报」,而 §2655 的消息中心示例是「`bizType: 2` 临期提醒、`bizType: 3` 驳回」—— 后者能与代码对上,所以不是我看错行。**后果很具体**:我第一版按契约写 `biz_type = '6'` 做催报去重,而实际写入的是消息中心的 5,于是去重**静默失效**(老师点两下学生收到两条),排查时完全看不出是"码对不上" | 统一按**消息中心的码**(`5` 催报 / `6` 导出完成,见 `CollectMessage` 的常量与 `UrgeService.BIZ_URGE` 的注释),并把不一致记在这里。**建议**:契约 §6 的 `notifyBizType` 与 §5.9/§13.5 对齐成同一套码 —— 改哪一边要客户确认 ✅ 已闭环:客户确认**以 §13.5 为准**(5 催报 / 6 导出完成),§2.7 表已改,全文档统一。 |
| F-31 | **登录日志只有 PC 账号密码登录**(`login_type` 恒为 1,库里 1101 行全是 `PASSWORD`):学生从小程序/H5 进来走的是**微信授权**(`/mp/h5/*`),那条链路**不写 `sys_login_log`**。所以按 §13.4 查"学生登录记录"会是空的 | 本轮**没有补**(补它要动 MP 授权链路,属于另一处改动),而是在接口与页面的说明里**如实写清**这是已知边界 —— 否则运维会以为日志功能坏了。记为 P1 剩余项。**风险**:合规上"谁在什么时候登录过"对学生侧是缺口 ✅ 已闭环:新增 `WechatLoginLogger`,小程序登录与 H5 授权回调/打桩登录都写 `sys_login_log`(`login_type=2` 与 DDL 注释对齐;H5/小程序共用 2,靠 userAgent 区分);`e2e-m3-h5-auth.py` → **24/24**。 |
| F-32 | ⚠️ **"发布"与"恢复"都是 `→收集中`**:我第一版让 `patchStatus` 从 `(原状态, 新状态)` **反推**动作名,于是 `resume` 被记成了 `PUBLISH_TASK` —— 而合规要区分的恰恰是"这个任务是第一次发布,还是被暂停后又恢复的"(恢复意味着它曾经被暂停,那段时间学生的填报是被关掉的) | 改成**由调用方显式给动作名**(`patchStatus(id, status, ACTION_*)`),并补了契约表里没有但必须有的两个动作:`RESUME_TASK` / `CLOSE_TASK`。**教训**:凡是从结果反推原因的地方,遇到"多对一"就一定会错 —— 而这类错通常表现为"日志看起来有、但答案是错的",比缺日志更难发现 |
@@ -325,7 +350,7 @@
| F-36 | **写机器检查本身也会假阳性**:`check-entity-columns.py` 第一版直接搜 `@TableLogic` 字符串,结果把 4 个实体的**类注释**也算进去了 —— 而那四段注释恰恰在解释"为什么不继承基类"(里面当然会出现 `@TableLogic` 这个词)。表现是"4 个实体报错",而我差点据此去改那四个**本来就是对的**实体 | 改成**先去注释再找注解**。**教训**:静态检查一定要能区分"代码"与"解释代码的文字" —— 否则它会指向正好写着"这里不该这么做"的地方 |
| F-37 | **`@Scheduled` 的定时任务在测试里等不起**:导出包过期清理每小时跑一次,而验证"过期之后下载会怎样"总不能把过期时间改成 7 天后再等一小时 | 加了 `POST /exports/cleanup-run`(手工触发,权限同导出管理员)。清理本身**可重复执行**(已清理的卷会跳过),所以手工触发没有副作用。**这是本项目第一个定时任务**,所以顺手在 `FillApplication` 上写了"别顺手加更多:定时任务是「没有请求也会发生的事」,每加一个都要能回答「它失败了谁会知道」" |
| F-38 | ⚠️ **`collect_file.source` 的口径在两份文档里冲突**:PRD 的建表注释(第 1894 行)是「1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入」,而接口设计 §3.6 的字段表(第 464 行)是「1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传」。两者都是 4 个值但含义完全不同 | **以 DDL 为准**(它是"这一列表示什么"的定义;已有数据全为 1 与它一致;PC 端 `FILE_SOURCE` 常量已按它写),并在列注释里写明冲突 —— 见 F-39 的同类处理。**建议**:契约 §3.6 的字段表要改成 DDL 口径,否则下一个人会按字段表写 |
| F-38 | ⚠️ **`collect_file.source` 的口径在两份文档里冲突**:PRD 的建表注释(第 1894 行)是「1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入」,而接口设计 §3.6 的字段表(第 464 行)是「1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传」。两者都是 4 个值但含义完全不同 | **以 DDL 为准**(它是"这一列表示什么"的定义;已有数据全为 1 与它一致;PC 端 `FILE_SOURCE` 常量已按它写),并在列注释里写明冲突 —— 见 F-39 的同类处理。**建议**:契约 §3.6 的字段表要改成 DDL 口径,否则下一个人会按字段表写 ✅ 已闭环:客户确认**以建表注释为准(谁传的)**,§3.6/§9.5 字段表已改。 |
| F-39 | ⚠️ **`collect_file.source` 从来没有被代码显式写过**:库里那 10 行全是**列默认值 1**,也就是「学生自己传的」与「老师代传的」在数据上**完全区分不出来**。而契约 §10.6 明确要求代传必须留痕(`source = 4`/我按 DDL 用 2) | V12 起写入侧显式赋值:学生上传写 1、代传在插入后改成 2 并记 `proxy_by` + `proxy_reason`。**为什么还加 `proxy_by`**:`source=2` 只说"是班主任代传的",说不出**是哪个**班主任、**为什么**替他传 —— 而契约要求时间轴显示「由李老师代传」,名字只能存在这里 |
@@ -349,7 +374,7 @@
| F-49 | ⚠️ **参数校验排在幂等判断之后就等于没校验**:整理结果导入的 `importFiles=true`(一期做不到,本该 40044)在**第二次**导入时被安静接受 —— 因为请求先命中了"已经导过了,直接返回原任务"。**一次不合法的请求不该因为"恰好之前导过一次"而变成成功** | 参数校验提到幂等判断之前 |
| F-50 | ⚠️ **`collect_validation_issue` 与 `collect_rule_template` 两张表 V1 就建好了,却一直没有代码用它们**(连 `expected_val`/`actual_val`/`auto_fixed`/`resolved_by` 都有,与契约 §3.9 逐列一致)。也就是"当初就设计好了",只是 §12.1~12.3 三个接口没做 —— 而盘点工具只比"契约 vs 控制器",**看不到"表建了但没人用"** | 本轮把问题清单做完;`collect_rule_template`(§4.3 的规则模板 2 个接口)仍待做。**教训**:盘点时要连"迁移里建好的表有没有代码在用"一起算,否则会重复设计 |
| F-51 | ⚠️ **契约的 `POST /validation/run` 声明 `async` 默认 true 并返回 `jobId`,但整个契约没有"查作业状态"的接口** —— 那个 jobId 查不到任何东西 | 一期**同步跑完**(实测 1 份填报单 <100ms),`jobId` 只表示"本次运行",并**加返回** `checkedSubmissions / issuesFound / unresolvedLeft / elapsedMs`,让界面能立刻显示结果 —— 比返回一个查不到状态的作业号有用(加字段记在 F-52) |
| F-51 | ⚠️ **契约的 `POST /validation/run` 声明 `async` 默认 true 并返回 `jobId`,但整个契约没有"查作业状态"的接口** —— 那个 jobId 查不到任何东西 | 一期**同步跑完**(实测 1 份填报单 <100ms),`jobId` 只表示"本次运行",并**加返回** `checkedSubmissions / issuesFound / unresolvedLeft / elapsedMs`,让界面能立刻显示结果 —— 比返回一个查不到状态的作业号有用(加字段记在 F-52) ✅ 已闭环:客户确认**改同步**并返回统计字段(`checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs`),§12.1 已改。 |
| F-52 | ⚠️ **契约 §12.7 的模板接口链断在第一步**:`GET /notify/templates` 返回的内置模板**没有 id**(它不在库里),而 `PUT /notify/templates/{id}` 要 id —— 用户点"编辑"时前端根本拿不出一个 id。文档上看起来是通的 | 补 `POST /notify/templates/{code}/customize`(把内置文案复制成库里一行,幂等)与 `DELETE /notify/templates/{code}`(恢复默认);另补 `GET /notify/templates/variables`(变量白名单必须服务端给,前端抄一份迟早不一致)与 `POST /notify/send` 的`rosterIds` / 整任务发法 |
@@ -361,7 +386,7 @@
| F-56 | ⚠️ **软删除与业务唯一索引打架**:`@TableLogic` 让 `delete()` 变成 `UPDATE deleted=1`,而 `uk_notify_tpl` 唯一索引**不看 deleted** → "恢复默认 → 再自定义"撞唯一约束(500)。**这类冲突在"软删除 + 业务唯一键"的组合下必然出现**,只是要到第二次操作才暴露 | 模板覆盖行改为**物理删除**。**判断依据**:这张表的行是"配置"还是"业务数据" —— 前者的历史价值低、而唯一键冲突的代价高 |
| F-57 | ⚠️ **`PUT /tasks/{id}/form-schema` 是有的**(`FormSchemaService.save` 实现了),但**规则模板的 `FORM` 部分没有落地的应用路径** —— `RuleTemplateService` 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。**建议**:下一步把「应用表单定义」接上,或把模板的 FORM 部分从契约里去掉 |
| F-57 | ⚠️ **`PUT /tasks/{id}/form-schema` 是有的**(`FormSchemaService.save` 实现了),但**规则模板的 `FORM` 部分没有落地的应用路径** —— `RuleTemplateService` 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。**建议**:下一步把「应用表单定义」接上,或把模板的 FORM 部分从契约里去掉 ✅ 已闭环:`FormSchemaService.saveRawVersion`(版本化保存只有一份),`RuleTemplateService` 的 FORM 分支真正应用;`e2e-m7-taskconfig.py` → **75/75**。 |
| F-58 | ⚠️ **契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」** —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与**租户自建**" | 补 `POST /tasks/{id}/save-as-template`,并加边界:**只有平台管理员能把模板设为公共**(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) |
@@ -371,8 +396,8 @@
| F-61 | ⚠️ **初审待办会列出"我批不了"的单子** —— 待办与审批用了**两套范围判据**:待办按 `s.org_id IN orgIds`,而 `orgIds` 在 `taskId` 为空时是「我在**任意任务**里负责过的班级」;审批按「这份单子**所属任务**里这个班归不归我」。于是「我在任务 A 是班级 43 的初审人、在任务 B 不是」时,待办照样列出任务 B 的单子,点通过回 `40301`。**真实场景里"同一个班在不同任务的初审人不同"很常见**(换班主任、分批指定),所以这不是脏数据才有的问题 —— 而表现是"我权限没配好",用户只能一条条点、一条条被拒(最糟糕的那种交互) | 待办改成与审批**同一套判据**:`EXISTS (select 1 from collect_task_org cto where cto.task_id = s.task_id and cto.org_id = s.org_id and 我是它的初审人)`。改动覆盖 `pageTodos` 与 `counts`(含四个档位计数),并在 E2E 里加了**性质断言**(待办里每一条都必须通过同一套 SQL 判据),而不是"待办有几条"这种随夹具变化的断言 |
| F-62 | ⚠️ **契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」** —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与**租户自建**" | 补 `POST /tasks/{id}/save-as-template`,并加边界:**只有平台管理员能把模板设为公共**(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) |
| F-63 | ⚠️ **公共模板会被租户拦截器挡掉**:规则模板的公共模板是 `tenant_id = 0`,而拦截器会给查询补 `tenant_id = <当前租户>` → 公共模板一条都查不出来,**而表现是"列表空着但不报错"**(功能看起来没做) | `RuleTemplateMapper` 用 `@InterceptorIgnore(tenantLine="true")` + 手写 `(tenant_id = 本租户 OR (is_public = 1 AND tenant_id = 0))`,并把"关掉框架保护之后由这一行保证隔离"写进注释(与 `MpSceneMapper` 同一做法)。**这是"默认拒绝"体系里必须主动开的口子,而不是顺手关掉** |
| F-64 | ⚠️ **`PUT /tasks/{id}/form-schema` 声称实现,但规则模板的 `FORM` 部分没有落地的应用路径**:`RuleTemplateService` 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。**建议**:下一步把"应用表单定义"接上,或把模板的 FORM 部分从契约里去掉 |
| F-65 | ⚠️ **契约 §14 的路径与 §1 的路径约定冲突**:§14 的详情节写 `POST /open/v1/auth/token`(**不带** `/api/v1`),而 §1 声明全部接口在生产是 `https://api.tianbaotong.cn/api/v1/**` | 按 §1 走,实现为 `/api/v1/open/v1/...`(与其它 110 多个接口同一前缀,网关只需一条规则)。`check-api-parity.py` 的 `norm()` 会去掉 `/api/v1` 前缀,两种写法都能对上,**所以工具不会替我们发现这个冲突** —— 它只能靠人读文档发现 |
| F-64 | ⚠️ **`PUT /tasks/{id}/form-schema` 声称实现,但规则模板的 `FORM` 部分没有落地的应用路径**:`RuleTemplateService` 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。**建议**:下一步把"应用表单定义"接上,或把模板的 FORM 部分从契约里去掉 ✅ 已闭环:同 F-57;顺带补上「存为模板**也存表单**」(此前存模板不存表单、套模板用不上,两头各缺一半)。 |
| F-65 | ⚠️ **契约 §14 的路径与 §1 的路径约定冲突**:§14 的详情节写 `POST /open/v1/auth/token`(**不带** `/api/v1`),而 §1 声明全部接口在生产是 `https://api.tianbaotong.cn/api/v1/**` | 按 §1 走,实现为 `/api/v1/open/v1/...`(与其它 110 多个接口同一前缀,网关只需一条规则)。`check-api-parity.py` 的 `norm()` 会去掉 `/api/v1` 前缀,两种写法都能对上,**所以工具不会替我们发现这个冲突** —— 它只能靠人读文档发现 ✅ 已闭环:§17.2 **D-7** 写明统一 `/api/v1/open/v1/...` 前缀(网关一条规则)。 |
| F-66 | ⚠️ **契约 §14.4 把一个"我方 → 机构"的报文写成了我方的一个路径**:正文说"系统在终审通过后主动推送",请求示例也标注"我方 → 机构",但接口清单里它是 `POST /open/v1/callback/push`(一个我方路径) | 实现为**人工重推入口**(`{submissionId}`),并重置重试次数 —— 它是"人判断过该再试一次",不该被"已经失败 5 次"挡住(那正是需要人工的场景)。自动推送与自动重试分别发生在终审通过的 `afterCommit` 与定时任务里 |
@@ -383,8 +408,8 @@
| F-69 | ⚠️ **开放接口的令牌路径必然 500**:`appOfToken` 里用 `selectById` 回查接入方,而开放接口是匿名的 —— **请求进来时还没有租户上下文**,租户拦截器直接拒绝(`未登录或登录已失效`),对外是 50000「系统繁忙」。HMAC 路径不出现(它走 `findByAppKey`,已忽略租户),所以**只有令牌路径 500**,看起来像"令牌功能没做" | 凡在"还没有租户上下文"时查归属都用忽略租户的专用方法(`findByAppKey` / `findByIdIgnoreTenant` / `findBySubmissionIgnoreTenant`),拿到归属后立刻 `TenantScope.callAs` 摆上下文。**"先找归属、再摆上下文"这个模式里,找归属那一步必须能无租户执行** —— 范围就是那几个方法,逐个写清理由 |
| F-70 | ⚠️ **nonce 的占用顺序反了会变成拒绝服务入口**:我原来的顺序是"先占 nonce 再验签",注释还写了一句"反过来的话错误签名能消耗 nonce"—— **这句话恰好说反了**。先占的话,任何人发一个签名错误的请求就能把那个 nonce 消耗掉;先验签则签名错直接拒、nonce 不被碰 | 改成**先验签、后占 nonce**。E2E 有一条专门守它("错误签名不该消耗 nonce")—— 这条断言的价值在于:它测的是一个**副作用**,而不是返回值 |
| F-71 | ⚠️ **§9.5 的"直传凭证"给的是 S3 风格的签名 PUT URL,而本项目存储走七牛** —— 七牛直传是"POST 到 upload.qiniup.com + form 带 token",而且**必须配回调**(`callbackUrl`)才能让服务端知道"传完了、文件在哪"。没有回调,文件会安静地躺在存储里,而库里没有任何记录指向它 | 一期如实返回 `direct: false` + `uploadUrl` = 本项目的接收地址(学生端 `wx.uploadFile` 直接 POST 它,与 H5 路径**完全一样**)。凭证这一层仍保留它真正的价值:**预检 + 秒传 + 压缩提示** —— 这三件事都能在"不传一个字节"的情况下完成,而那正是它要解决的问题(传了 8MB 才发现格式不对/重复了) |
| F-72 | ⚠️ **`uploadToken` 签发之后没有"消费"它的地方**:我按契约实现了凭证的签发与预检,但**上传接口没有接它**(`/mp/files/upload` 仍然只认 `submissionId + materialCode`)。于是凭证目前只用于"预检与秒传判断",**不构成一道校验** —— 客户端完全可以跳过凭证直接上传 | 如实记录(不假装它是安全机制)。**建议**:要么让 `/mp/files/upload` 接受并校验凭证(一次性、15 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 |
| F-71 | ⚠️ **§9.5 的"直传凭证"给的是 S3 风格的签名 PUT URL,而本项目存储走七牛** —— 七牛直传是"POST 到 upload.qiniup.com + form 带 token",而且**必须配回调**(`callbackUrl`)才能让服务端知道"传完了、文件在哪"。没有回调,文件会安静地躺在存储里,而库里没有任何记录指向它 | 一期如实返回 `direct: false` + `uploadUrl` = 本项目的接收地址(学生端 `wx.uploadFile` 直接 POST 它,与 H5 路径**完全一样**)。凭证这一层仍保留它真正的价值:**预检 + 秒传 + 压缩提示** —— 这三件事都能在"不传一个字节"的情况下完成,而那正是它要解决的问题(传了 8MB 才发现格式不对/重复了) ✅ 已闭环:§9.5 重写为 `direct:false` + 服务端接收地址;恢复直传的前置条件(含七牛回调)写进 §17.2 D-3。 |
| F-72 | ⚠️ **`uploadToken` 签发之后没有"消费"它的地方**:我按契约实现了凭证的签发与预检,但**上传接口没有接它**(`/mp/files/upload` 仍然只认 `submissionId + materialCode`)。于是凭证目前只用于"预检与秒传判断",**不构成一道校验** —— 客户端完全可以跳过凭证直接上传 | 如实记录(不假装它是安全机制)。**建议**:要么让 `/mp/files/upload` 接受并校验凭证(一次性、15 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 ✅ 已闭环(客户确认定位为**预检/秒传/压缩建议**,非安全边界):契约 §9.5 明写「客户端可跳过;服务端上传本身做全部校验」。 |
| F-73 | ⚠️ **秒传检查排在份数检查之后** —— 而"同一个文件重传"恰恰是秒传要解决的场景(学生传完网络断了、客户端重试)。顺序反了会得到「身份证最多 2 份,你已经传了 2 份。要换请先删掉上一份」,让用户在"我明明刚传的"这件事上原地打转 | 改成**先秒传、后份数**。**顺序体现的是"哪个答案对用户更有用"**:「你已经传过了」永远比「你超了」更有用,因为前者不需要他做任何事 |
| F-74 | ⚠️ **平台运营账号(`platform`)登录会撞图形验证码**:`LoginGuard` 在连续失败后要求验证码(`xt:login:fail:{account}`),而验证码是图片、脚本给不出来。这不是缺陷(那是防爆破的设计),但它让"验收运营类接口"卡在第一步 —— 而 §6.5/§6.7 的权限正是"运营" | E2E 先清 `xt:login:fail:platform` 再登录,并把这个 key 写进注释(真实验收时运营账号被锁,运维也要知道清哪个)。⚠️ 顺带确认:**平台账号的密码是 `Platform@123`**(在 `docs/07-开发实施计划.md`),不是学校管理员的 `Admin@123` —— 我先按 `Admin@123` 试,得到"账号或密码错误",很容易误判成"平台账号没建" |
| F-75 | ⚠️ **压缩率会出现负数**:一个 3 个小文件(各 40 字节)的包,`raw=120B`、`final=802B` → 页面显示 `-568.3%`。原因是"压缩"在小包上本来就不适用 —— 里面的文件已经是压过的(jpg),或者重新打 zip 的**包头开销**比省下来的还多。问题不在"不好看",而在**客户会以为系统算错了**(而它没算错) | 服务端对 `ratio < 0` 回 `—`(与"还没算"同一个符号:**都是"这个数说明不了什么"**)。⚠️ 顺带发现 **E2E 的断言太弱**:`"%" in compressRatio` 对 `-568.3%` 也成立 —— 它确实"是个百分比",只是没有意义。已改成"要么形如 `12.3%`(非负),要么是 `—`" |
@@ -405,12 +430,33 @@
| F-90 | ⚠️ **"起来了"被误报成"起不来"**:`check-test-profile.py` 在 Tomcat 打印 `Tomcat started` 的**同一瞬间**就发健康检查,那时端口还没开始接受连接 → connection refused → 脚本报"起不来",而日志里明明是 `Tomcat started on port 18098`。**误报的方向恰好最危险**:它会让人去查一个不存在的问题 | 健康检查加 20 次重试。⚠️ 同一个脚本还有第二个缺陷:写死端口且 `kill()` 后不等退出,**第二次跑就撞端口**,报的错(`Port xxx already in use`)与"配置对不对"无关 —— **一个只能跑一次的检查等于没有检查**,改成每次 `socket` 要一个空闲端口 + `wait(timeout=15)`。并把它加进 CI(在 e2e 起服务**之前**单独跑一步:失败了就是"配置起不来",而不是"E2E 脚本有问题") |
| F-91 | ⚠️ **PRD 附录 E 里有一条定时任务,对应的机制在 V1.5 就被废除了**:附录 E 第 6 条写着「待认领名册清理|每日|超时未认领的人员移出应填名单并通知管理员」,而 PRD 正文 §"容错与纠正对照表"明确写着:「📌 **已废除**:原矩阵中"不在名册 → 转待认领队列"—— **"自助认领"机制已在 V1.5 废除**(会绕过门禁)。证件号不在名册时的正确出路是引导走「申请加入名单」通道(FR-509),由管理员逐条审批后入册」 | **不做这一项**,并把 9 项核销为 8 项。⚠️ 为什么这条值得单独记:附录 E 是一张**看起来最像清单的清单**(9 行、有频率、有说明),而它漏跟了正文的一次机制废除 —— 照着它做,会**把一个已被产品明确废除的门禁旁路重新实现出来**。**清单类附录最容易过期,因为它没有"会被读到"的必然性**(正文条款在讨论时一定会被翻) |
| F-92 | ⚠️⚠️ **门禁限流会把整间教室挡住**(压测抓到的真缺陷):IP 限流 `20 次/分钟`计的是**所有请求**(成功也算),于是 60 个**不同**学生同一出口 IP 同时核验时,**只有 20 个能进,其余 40 个收到 `40071 操作过于频繁`**。而"一个班的学生坐在同一间教室、连同一个 WiFi"正是**首次落地第一分钟就会发生的事** —— 这不是性能问题,是上线即事故 | 拆成两个计数器,因为**目的不同**:失败(按人 10/小时、按 IP 20/分钟)用于防猜与防枚举;成功(按 IP 200/分钟)只用于防"拿这个接口批量取数据"。**关键洞见:枚举攻击产生的是失败,而教室产生的是成功** —— 把成功也算进防枚举额度里,等于用防攻击的代价挡住正常用户,而挡住的量级恰好是"一个班"。⚠️ 另有一条**刻意不计**的失败:`GATE_ALREADY_BOUND`(两个人抢同一个名册项)——开学第一天必然出现几例,算进额度会让整间教室在几秒内被限流,**而没有人做错了什么** |
| F-93 | ⚠️ **压测会打到真实对象存储**:本机 `application-local.yml` 里 `provider: qiniu`,所以上传压测落的是**线上那个空间**(bucket=tools-matrix)—— 40 张图打过去,七牛回了 `status:614` | 上传压测改成默认不跑(`--with-upload` 才跑),并在脚本里打印"要跑请先 `XT_STORAGE_PROVIDER=local` 重启后端"。两件事都不对:**压测不该依赖外部服务**(网络抖动会让"性能结论"变成对七牛的评价),**也不该往真实桶里灌测试文件**(删库不删桶) |
| F-93 | ⚠️ **压测会打到真实对象存储**:本机 `application-local.yml` 里 `provider: qiniu`,所以上传压测落的是**线上那个空间**(bucket=tools-matrix)—— 40 张图打过去,七牛回了 `status:614` | 上传压测改成默认不跑(`--with-upload` 才跑),并在脚本里打印"要跑请先 `XT_STORAGE_PROVIDER=local` 重启后端"。两件事都不对:**压测不该依赖外部服务**(网络抖动会让"性能结论"变成对七牛的评价),**也不该往真实桶里灌测试文件**(删库不删桶) ✅ 已闭环:压测改为打**本地存储实例**(docs/09 §9.4 推荐跑法);真实桶里 2036 个测试对象 / 116 MB 已用 `scripts/dev-clean-qiniu.py` 清空(只动 `xiaotutongxue/` 前缀,删前核实库中零引用,删后复验 0 个)。 |
| F-94 | ⚠️ **我自己在压测里犯了三个度量错误**:① 吞吐算成了 `n / (avg × n)`,化简就是 `1/平均耗时` —— 那不是吞吐,而"一行看起来很像结论的数字"最容易被抄进汇报里;② 只报"混合 p95",看不出**哪个接口**慢(而"该优化哪个"才是压测的目的);③ **用两个并发数字推断出"每行约 2.8ms"**,而单请求复测(20/50/200 页 → 47/36/44ms)直接否掉了它 —— 并发下的差异是排队噪声,不是按行成本 | ①改成"请求数 / 墙钟耗时";②按端点分别统计;③把"单请求中位"也纳入报告,并在 §9 里写明"并发数字不能用来推断每行成本"。**教训:并发压测的数字只能回答"系统在并发下的表现",不能反推单请求的成本结构** —— 要问"哪里慢"就得用"数 SQL 条数 + 单请求计时 + EXPLAIN"这三样 |
| F-95 | ⚠️ **手册初稿里有两处"按设想的系统写",而不是"按真的系统写"**:① 我写了「同一张图重复传会被自动识别(秒传)」—— 而 `/mp/files/upload` **只计算 `file_hash`、没有去重查询**(秒传逻辑在 `/mp/files/upload-token` 里,而 F-72 已记过:上传接口并不消费它)。手册这么写会让学生以为重复传不占份数,然后撞上份数上限;② 我写了「代传**要**填原因(这是审计要求)」—— 而 `proxyReason` 是**选填**,手册说"必须填"会让老师以为界面坏了(因为不填也能提交) | 两处都改成与实现一致的说法。**并把这件事故化**:新增 `scripts/check-manual.py`,把"手册引用的错误码与提示文案是否真的存在"变成机械检查并进 CI —— 因为**手册是文档、文案在代码里**,改一次文案手册就静默失准,而没有任何东西会红(与 F-82「首页在说一件不真的事」同一类)。⚠️ 但要说清它**查不到什么**:它拦的是"手册写了代码里没有的东西",**拦不住"手册漏了新增的东西"** —— 那仍然要靠人 |
| F-96 | ⚠️ **本轮我连续犯了三个"没断言就往下走"的错**,形态不同、根因相同:① **文本补丁静默没生效**:`str.replace` 没命中时不报错、不返回失败,脚本照样打印"成功",而文件一个字没改 —— 随后的编译错("方法不存在")看起来像另一个问题;② **夹具构造失败却继续往下走**:`with_checksum` 传错位数后,失败发生在 300 行之外的名册导入报告里("当前 19 位");③ **`R.call` 的返回值没查 `code`**:建草稿失败时 `data` 是 `null`,报的是 `TypeError: 'NoneType' object is not subscriptable`,真正原因一个字都没打 | ①改成"每处替换都断言命中"(`must_replace`),并**回读校验**;②把 helper 改成**参数不合法就抛**(治本,前两次我只改调用点);③每个 `R.call` 先查 `code` 并把 `msg` 带进断言信息。**根因是同一条:把"我以为发生了"当成"确实发生了"** —— 而这三处的共同代价是**把排查引向错误的方向**(比直接报错贵得多) |
| F-97 | ⚠️ **我断言错了字段名,于是"缩略图功能"看起来没生效**:`FileUrl` 有 `url`(原图)与 `thumbUrl`(缩略图)两个字段,而我的验证脚本去检查 `d2["url"]` 里有没有 `imageView2` —— 按设计它**永远是原图链接**,所以那个断言必然为 false;更糟的是它连着把后面两条断言一起带错了(下载的是原图,于是"没变小") | 改成检查 `thumbUrl`,并**加一条反向断言**:`url` 不该带处理参数、`thumbUrl` 该带。⚠️ 这与 F-77(用错 CSS 选择器,差点修一个不存在的 bug)**是同一类**:**断言之前先确认"你在量的那个东西是对的"**。这次的隐蔽之处是字段名很像(`url` vs `thumbUrl`),而且接口确实返回 200、数据确实存在 —— 一切看起来都正常,只有断言是错的 |
| F-98 | ⚠️ **"随机端口"这个防撞设计带来一个反直觉的副作用**:`check-test-profile.py` 每次取**随机空闲端口**,于是上一次失败留下的 JVM **永远撞不到**这个端口、也就永远不会被发现 —— 它会一直占着内存与 CPU,直到某一次"怎么都起不来"(实测:连跑几轮后本机攒了 5~7 个 java,启动从 10 秒变成 180 秒超时;而**下一次跑又好了**,这正是"时好时坏"的来源,很容易被归因成"配置有问题")。同时那次失败**没法排查**:子进程输出走 `PIPE` 而没人读,事后才读又已被 kill 截断,最后只剩一句"180 秒内没探活成功" | ①开跑前**按 jar + profile 清残留**(只杀本项目的 jar,且**保留 dev 后端** —— E2E 正打着它,顺手杀掉等于把后面的检查全废掉);②子进程输出**落盘**(`backend/_test-profile.txt`,已被 `.gitignore` 的 `backend/_*.txt` 覆盖),失败时打印"停在哪一行 + 关键字命中 + 尾部 20 行",成功时打印**启动用时**(基线 ~10 秒 / 72 行 / 9.5KB,一旦变成几十秒先怀疑本机负载);③等待期间每 15 秒报一次进度,**卡住时不留沉默**。**教训:给"防撞"加一层机制时,要问一句"那它把什么东西藏起来了"** |
| F-99 | ⚠️ **补测"没测过的场景"时,压测工具自己连续撒了 8 次谎** —— 它们的共同点是"**度量/夹具出问题时,现象长得像产品缺陷**"。① **夹具前置没断言**:单独跑 `--only upload` 时 40 张图**全部**报「没有草稿」—— 因为上传需要先经门禁绑定,而以前它总是跟在门禁场景后面跑、"恰好"能跑;② **假 JPEG**:夹具图是"JPEG 头 + 随机字节",能过扩展名与大小校验,但服务端**解码即失败**,走的根本不是压缩路径 —— 换上真图后立刻量到 2850KB → 1329KB(**47%**);③ **猜列名**:`collect_export_file` 的列是 `part_no / file_name / file_path`,我按直觉写成 `part / name / path`,SQL 直接报错;④ **判据取错返回值**:`upload_file` 返回的是 `(HTTP 码, body)`,我拿 HTTP 码当业务判据,把 `code: 0` 的成功报成"串行基线失败"(与 F-84 / F-96 同一类,**第三次**);⑤ **跨租户同名账号**:`select id from sys_user where username='admin'` 命中的是**别人家**的 admin(库里有 50 多个同名),于是"初审人"被设成跨租户用户 id —— 正确做法是**从 token 的 `sub` 取 uid**;⑥ **不查接口返回码**:`PUT /tasks/{id}/orgs` 被服务端拒(`headUserId` 必须是**真持班主任角色**的账号)而我没查,"发布"失败也没查,最后学生端整齐地报 `40008 任务已截止、已归档或已暂停`,排查方向整个错了;⑦ **PowerShell `Select-Object -First N` 会提前停止读取上游**:被截断的进程**仍然活着**,看起来就是"卡住",我为此白等了十几分钟 —— 而外层工具又把输出缓冲到命令结束,于是"慢"与"被堵"根本分不开;⑧ **清理只删库行不删磁盘文件**:压测跑几轮就在 `backend/data/uploads` 攒下几百 MB | ①在上传/导出场景里**显式绑定 + 断言前置**(不成立就报"前置不成立",而不是"上传失败");②`make_img` 改用 Pillow 生成**真 JPEG**(噪声图,纯色量不出 CPU 成本);③按 DDL 写列名;④改判 `body["code"]`;⑤新增 `uid_of(token)`(解 JWT 的 `sub`);⑥新增 `call_ok()`:夹具里每个接口调用**都断言业务码为 0**;⑦长跑/压测/起服务**一律把日志落盘**,不用管道截断(与 F-98 同一个手段);⑧`cleanup()` 先取出文件路径再删行,**连磁盘文件一起删**(存储是七牛时那些路径本地不存在,是安全的 no-op)。**教训:补测的价值一半在数字,另一半在"原来夹具一直不成立"** —— 而后者只有在**断言前置**之后才会暴露 |
| F-100 | ⚠️ **门禁"成功侧上限"卡在年级量级,会挡住全校集中填报**(F-92 的第二次复发,量级更大):契约 §1.6 只规定**失败侧**(同 openid 10 次/小时、同 IP 20 次/分钟),成功侧 200 次/分钟是本项目自己加的(防"拿匹配接口批量取数据"),而当初注释里写的本意是"一个 200 人的年级同时扫码也不该被挡"。实测 **250 个不同学生同时核验(全部正确)→ 213 成功、37 被挡**,提示「当前网络访问过于集中,请稍等 1 分钟再试」。"全校在同一个出口 IP 集中扫码"(开学第一课集体填报)正是会发生的场景,而**这些人没有一个做错了什么** —— 与 F-92「教室被挡住」是同一类,只是从"一个班"放大到"一所学校" | 上调 `IP_OK_LIMIT_PER_MINUTE` **200 → 600**(依据:一所学校一次集中填报的峰值;它仍然是限速,防枚举靠的是失败侧那两个计数器,**原样未动**)。**复测**:250 人 **250/250**、500 人 **500/500**(500 并发 p50 1.4s / p95 2.5s,是排队不是限流),`e2e-m3-gate.py` 仍 **29/29**。已在 `MpRosterService` 常量注释里写明依据与"改之前先量"的命令,并同步 `docs/01` E2、`docs/09` §9.3。**教训:自己加的"防御性限额"也要过一遍真实场景的量级 —— 否则它与它要防的东西一样,都是事故** |
> **以下 F-101 ~ F-111 是"把 F-1~F-100 全部核销之后"新起的一轮**。
> 起点是一个问题:**还有没有同一族的错?** —— F-1(日志没人写)、F-31(微信登录不写日志)、
> F-50(表建好没人用)、F-52/F-67(契约定义了但链路没接上)都是**同一个形状**:
> **每一处单看都是对的**(类在、表在、常量在、注释写得很清楚),
> 所以编译、typecheck、code review **全都不会红**,只有"从入口走一遍"才暴露。
> 于是先把这一类固化成命令(`scripts/check-wiring.py`),再按它的输出逐条修。
| F-101 | ⚠️⚠️ **FR-1002「通知模板可配置」只兑现了一半,而且是不点名的那一半**:模板页能改文案、`POST /notify/send`(**手工**发送)也确实按模板渲染 —— 但驳回、审核通过、催报、临期这些**自动**通知把文案硬编码在各自的 Service 里(`AuditService` 拼 `"你的材料被退回了"`、`UrgeService` 拼 `"请尽快完成填报"`…)。而 FR-1002 点名的 5 类通知(**任务发布 / 临期提醒 / 驳回通知 / 审核通过 / 导出完成**)**全是自动类**。后果很具体:管理员把「驳回通知」改成自己学校的说法,学生收到的还是原来那句,而**界面上显示的是改过的**(模板列表页读的正是被改过的那行)—— 这种"显示一套、发出去另一套"没有任何测试会红 | 新增 `NotifyTextResolver`(单向依赖,避免 `NotificationService` ↔ `NotifyTemplateService` 循环注入):**库里改过的 > 内置**,与模板列表页**同一份口径**;`NotifyRequest` 增加 `subjectCode` / `subjectVars` / `extraText`(模板之外的"这一次"的补充:班级、老师附言、最后一天提醒);驳回/通过/催报/升级/加入申请/导出完成**全部改走模板**。**模板被停用 = 这类通知不发**(那正是开关的意义),并如实回 `notifySent=false` + `needManualNotice=true`。验证:新增 `scripts/e2e-m5-notify-wiring.py`(**43 条**,含"改掉模板 → 触发真驳回 → 断言学生收到的是改过的那句"与"停用 → 一条都不发") |
| F-102 | ⚠️ **加入名单申请一个通知都不发**:`JoinService` 里三处写着 `log.warn("…没有发出通知(通知渠道未实现)")`,而通知渠道**早在 M5 就做好了**(`NotificationService`),只是没人接上;`CollectMessage.BIZ_JOIN_AUDIT=7` 零使用、`JOIN_SUBMIT` / `JOIN_APPROVE` 两个内置模板零发送点。后果:学生提交申请后**老师不知道**(只能自己想到去翻审批页),审批通过/驳回后**学生不知道**(他会以为申请丢了而反复重提,而每人每任务只能提 2 次) | 提交后通知**该审批的人**(`collect_task_org.head_user_id` + `deputy_user_ids` + 本租户学校管理员兜底 —— 不是"所有有 `join:audit` 权限的人",一个学校 50 个班主任等于推给没人);通过/驳回后通知学生(含驳回原因)。验证:`e2e-m5-notify-wiring.py` 第 [3] 节 |
| F-103 | ⚠️ **导出完成后不通知**(`BIZ_EXPORT=6` 零使用、`EXPORT_DONE` 模板零发送点):打包是**异步**的(几百份材料十几秒到几分钟),用户只能守着进度条;失败也不通知,于是"包永远不会好"这件事没人知道 | `pack()` 成功与 `markFailed()` 各发一条给**发起人**。⚠️ **正文里不放下载链接**:七牛是私有空间、链接是**签名 URL**,而站内信是**落库**的 —— 把签名链接写进正文就等于"URL 入库",与铁律直接冲突(那个链接几十分钟后就失效)。内置模板里原本写着 `{下载链接}`,**一并改掉**。验证:`e2e-m5-notify-wiring.py` 第 [4] 节(含"正文里没有 http"断言) |
| F-104 | ⚠️⚠️ **自动催报从来没有发出过任何一条通知**(FR-1003 的 P0 功能实际是"零"):`tasksEndingOn` 写的是 `end_time = #{date}::date`,而 `collect_task.end_time` 存的是**当天 23:59:59**,`'2026-10-08'::date` 是 `00:00:00` —— **两者永不相等**。它活了这么久是因为**测试夹具用 SQL 摆截止日**(`update collect_task set end_time = CURRENT_DATE + 3` 存进去的是 00:00:00 的日期型),那种形状恰好能匹配;而日志里只有 `if (!tasks.isEmpty())` 的沉默 | SQL 改为 `end_time::date = #{date}::date`;同时补上 **FR-1003 原文里的「+ 截止当日」那一档**(`URGE_DAYS_BEFORE` 由 `{7,3,1}` 改 `{7,3,1,0}`)—— 缺的恰恰是"再不做就来不及"的那天,且 0 天与 1 天的文案必须分开("今天是截止日,24 点前提交" vs "今天是最后一天")。验证:`e2e-m5-notify-wiring.py` 第 [5] 节(**走真实接口建任务**,end_time 自然是 23:59:59) |
| F-105 | ⚠️ **驳回次数达上限时,"需要管理员介入"没有任何人知道**:契约 §11.4 写「`escalated: true`,**同时通知管理员**」,代码注释也写着"站内信也发给管理员",而实际只有一行 `log.warn` —— **日志不是通知**:学生看到被驳回、班主任看到"已达上限",管理员什么都没看到 | 新增 `AUDIT_ESCALATE` 模板 + `notifyEscalated()`:发给**本租户的学校管理员**(`UserMapper.userIdsOfRole`,按角色找而不是固定账号);`40010 REJECT_LIMIT_EXCEEDED` **不回错误码**(驳回本身成功了,这是响应字段而不是错误)—— 已在例外表里写明理由。验证:`e2e-m5-notify-wiring.py` 第 [6] 节(连驳 3 次) |
| F-106 | ⚠️ **两个做好的页面**(字典管理、通知记录)**从菜单点不到**:PC 端菜单可见性是拿 `/auth/me` 返回的 `menus[]` 过滤 `XtMenu` 的(`buildXtRoutes`),而 `BuiltinRole` 里**没有任何角色声明 `dict` / `notify`** → 页面、路由、接口都在,菜单里永远不出现。这是 F-78「待建页在说谎」的镜像:**那次是"说没做其实做了",这次是"做了但没人看得见"**。⚠️ **手册早就把它们写进去了**(`docs/10` 的"遇到这事去哪办"表里写着「加一个下拉选项 → 配置 → 字典管理」「查通知到底发出去没有 → 收集管理 → 通知记录」)—— 也就是说**用户按手册找不到那个菜单**,而客服会收到"我这里没有这一项" | 给 `SCHOOL_ADMIN` 补这两个菜单键;`check-wiring.py` 增加"菜单键 ↔ XtMenu"的双向核对(角色声明了但前端没有 / 前端有但没角色声明,两种都报)。验证:`e2e-m5-notify-wiring.py` 第 [7] 节读真实 `/auth/me` |
| F-107 | ⚠️ **10 个权限码定义了却没有任何地方校验**(`task:clone` / `form:manage` / `material:manage` / `plan:import` / `cleanup:run` / `tenant:view` / …):接口挂的是更宽的 `task:manage`,于是"克隆任务"和"改任务名"是同一个权限;`PUT /tenants/{id}` 与"租户用量"甚至挂着 `log:view:all`(复制粘贴留下的)。**权限码写宽了不会报错,只会让本该分开的能力分不开** | 6 个能明确对应的接口改挂它自己的码(克隆 → `task:clone`、表单 → `form:manage`、材料 → `material:manage`、报考范围 → `plan:import`、整理器 → `cleanup:run`、租户读 → `tenant:view`、租户改 → `tenant:manage`);其余 4 个(`roster:manageOwn` / `progress:viewOwn` / `submission:own` / `export:unmasked`)是**范围类或二期开关**,写进 `check-wiring.py` 的例外表并说明理由。验证:`e2e-m5-notify-wiring.py` 第 [8] 节(班主任克隆/整理器 → `40301`,管理员 → 成功) |
| F-108 | 📌 **两个内置模板"零发送点"是刻意的**(`TASK_PUBLISH` / `TASK_DEADLINE`,连带 `BIZ_TASK_PUBLISH=1`):"发布即通知学生"在本系统的流程里**没有收件人** —— 学生要先扫码核验才会绑定账号,而发布之前没有人会去扫(扫码落地页对草稿任务显示"本次收集尚未开始")。发出去只会是 0 条。**所以不做**,并在 `check-wiring.py` 的例外表里写明原因(连同"什么时候该重新考虑它") | 不做,登记例外。**理由要留痕**:否则下一个人看到模板里有它就以为该发,而实现出来是个永远 0 条的功能 |
| F-109 | ⚠️ **`Map.of(...)` 拼模板变量 → 一条通知都没发**:`Map.of` **不接受 null 值**,而"这个学生查不到姓名"完全正常(学生账号是扫码核验时建的,`real_name` 可能还没填)→ 抛 NPE → 被外层"通知失败不影响业务"的 try/catch 吞成**一行 warn**,于是**驳回通知全丢,而驳回本身照样成功**。这是 F-25「被吞掉的异常」的第二次:**降级逻辑越完善,这类错越安静** | 新增 `NotifyRequest.vars(...)`(null 值直接跳过 —— 模板渲染对"没有值"的处理本来就是渲染成空);姓名改为**优先取名册上的名字**(那才是"他在这个任务里叫什么")。发现方式:`e2e-m5-notify-wiring.py` 第一次跑就红了(标题对、正文里姓名是空的) |
| F-110 | ⚠️ **一条断言写成了"全库计数",于是它会在几天后突然变红**:`e2e-m7-schedule.py` 的"刚提交的不该被提醒"数的是**全库**的「审核超时」消息,而超时扫描是**跨租户**的 —— 库里只要存在别的老填报单(几天前别的脚本留下的),它就会照样提醒。本轮真的红了,而**代码一行都没改**(那些单子的 `submit_time` 终于超过 48 小时了) | 断言**限定到本次夹具的收件人与任务**(`user_id` + 正文里的任务名)。**教训:跨租户扫描类功能的断言必须"限定到自己的夹具"** —— 否则它测的是"库里干不干净",而那是另一件事(与 F-42「cleanup 静默失败污染后续脚本」同一族) |
| F-111 | ⚠️ **`ON CONFLICT (id) DO UPDATE` 不更新用户名 → 看起来像密码错**:E2E 夹具按 id upsert 测试账号,只更新了 `real_name`,于是上一轮残留的行保留着**旧用户名**,而脚本按新用户名登录 → `40012 账号或密码错误`。第一反应会去查密码哈希(而它是对的) | 夹具改为**固定用户名 + `username = EXCLUDED.username`**(幂等);已写进 `e2e-m5-notify-wiring.py` 的注释 |
---
@@ -514,6 +560,10 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核
| 2026-09-30 | **缩略图(P2-7)+ H5 独立 dev server(P2-9)**:`StorageService.thumbUrl` / `FileUrl.thumbUrl` / 审核端 `?thumb=true`;`frontend-mp` 加 `/api` 代理与 `VITE_API_BASE`;新增 `scripts/e2e-m9-thumb.py`(12 条,打到真实七牛);**同步 AGENTS.md 里"H5 没有独立 dev server"那句过期说明**;新增 F-96(**三个"没断言就往下走"**:补丁静默无效 / 夹具静默失败 /不查 code)/F-97(**断言错了字段名**,与 F-77 同类) |
| 2026-09-30 | **对外待决事项归拢 + 一键交付自检**:`docs/01` 新增 **E 组·第二轮待确认**(E1 `union_id` 是"H5 与小程序不能同时上线"的阻断项,E2 D3 限流口径更新、E3 二期时点,D3 那行划掉);§四 的每条卡点补上"**找谁 / 编号**";交接摘要新增 §一·二十八(坐标刷新 + 这 20 多轮最该记住的 7 条);新增 `scripts/verify-all.py`(一条命令跑完交付自检,**跳过项如实列出、不算失败**)。全量回归 **36 脚本 / 1548 断言 / 0 失败** |
| 2026-09-30 | **文档小账清理 + `check-test-profile` 加固**:① **附录 A 改为"可再生成"** —— `check-api-parity.py` 加 `--extra` 现算"代码有、契约 §4 没有"的接口(**41 个**;旧手抄表写 83/62/21,已差近一倍),并按"谁去改什么"分六类(只有 E 类要客户定口径);② **F-6 已修**:`frontend-pc/AGENTS.md` 的 Mock 说明改正,**并连带修 `.agents/skills/project-api/SKILL.md`**(整篇仍以 `vite-plugin-mock` 为主线,而 `mock/` 目录与插件都已删除 —— 只改指路牌不改路的尽头等于没改);③ `docs/03` 给 **§2 `notifyBizType` 的 5/6** 与 **§3.6/§9.5 的 `source`** 加 ⚠️ 注(**不改口径**,只把文档内部冲突摆明 + 指出实现以哪边为准,待客户确认);④ `check-test-profile.py` 改为**日志落盘 + 按 jar/profile 清残留 JVM** —— 随机端口让残留**永远撞不到端口**,于是静静攒着,直到某次"怎么都起不来"(新增 F-98) |
| 2026-09-30 | **补测 `docs/09` §9.5 的"没测过"项(上传并发 / 名册导入规模 / 导出大包 / 长跑稳定性)**,`scripts/load-test.py` 加 `--base` / `--fixture-only` / `--files-per-user` / `--split-mb` / `--soak` / `--rps`,上传夹具换成**真 JPEG**,并新增 `export-big` 场景(造 N 份已通过的单 → 打包 → 逐卷验证 ZIP)。结果:**上传 20 并发 40/40**(串行 321ms / 并发 p50 620ms、p95 801ms;2850KB → 1329KB = **47%**)、**5000 行名册导入 20.2s**(RSS 782MB)、**导出 150 人 194.6MB 打包 13.2s**、**分卷 4 卷每卷可单独解开**、**长跑 60 分钟**(RSS / PG 连接数趋势见 `docs/09` §9.6)。**同一轮里压测工具自己撒了 8 次谎**(前置未断言 / 假 JPEG / 猜列名 / 判据取错返回值 / 跨租户同名账号 / 不查返回码 / 管道截断 / 清理不删文件)→ 新增 **F-99**,并把结论写进 `docs/09` §9.1 / §9.4 / §9.5 / §9.6 |
| 2026-09-30 | **17 项决策全部执行(A 组改契约 / B 组补代码 / C 组备案 / D 组清环境)**:① **契约 V2.6**:§2.7 `notifyBizType` 5/6 修正(F-30)、`auditAction` 补全 7 动作(F-7)、§3.6/§9.5 `source` 改建表口径(F-38/39)、§9.5 重写为「上传前置检查」+ §9.6 `process` 改语义 + §9.9 标注缓做(F-3/71/72)、§11.4 撤回约束补状态 2、§12.1 `validation/run` 改同步(F-51)、**新增 §17 实现补充与偏差记录**(41 接口六类 + D-1~D-12,F-4/10/26/65);② **代码**:规则模板 FORM 真正可套用 + 存模板也存表单(F-57/64,`e2e-m7-taskconfig` 75/75)、已归档任务可删——级联**物理删** 14 表 + 留痕(F-14,新增 `e2e-m1-task-delete` 19/19)、催报分钟级限流 10 次/分/任务(F-29,`e2e-m5-urge` 42/42)、学生微信登录写 `sys_login_log`(F-31,`WechatLoginLogger`,`e2e-m3-h5-auth` 24/24)、三个纯函数最小单测 12 条(F-9);③ **备案**:通知降级链一期维持两级(F-28→D-11)、国标职业库待客户提供(`docs/01` 新增 **E4**,F-34);④ **环境**:`xt-minio` 已删(F-2)、本地清 49 e2e 租户/147 测试组织/84 演示旧任务(3714 行 + 27 文件,**在用组织保护**保住演示态,F-5)、七牛桶 `xiaotutongxue/` 前缀 **2036 个对象/116MB 清空**(删前核实库中零引用,F-93,新增 `dev-clean-qiniu.py`)——至此 **F-1~F-100 无未决项,剩余全部等外部输入** |
| 2026-09-30 | **长跑稳定性测完 + 门禁成功上限 200→600(F-100)+ 磁盘清理(F-5 落地)**:① 长跑 51 个样本 / 15300 请求 / **0 失败**,RSS 前 1/3 均值 603.9MB → 后 1/3 582.9MB(**-3.5% 平稳**)、PG 连接 17.0 → 6.2(**定时任务高峰后正常回落**)、p95 62ms 纹丝不动 → 写进 `docs/09` §9.5.4;② **门禁成功侧上限实测卡在年级量级**(250 人 → 37 被挡)→ 上调 600,复测 250/250、500/500,`e2e-m3-gate` 29/29(**F-100**),同步 `docs/01` E2、`docs/09` §9.3;③ `dev-clean-e2e.py` 扩展(`--demo-tasks` / `--orphan-files` / `--only-orphan-files`,业务表按 `information_schema` 动态枚举),**已清磁盘孤儿文件 255 个 / 275.4MB**(uploads 276MB → 0.3MB);e2e 租户与"E2E 24数控1班"组织**刻意不删**(当前演示态挂在其中一个班下面,已记录原因)——问题清单累计 **F-1~F-100** |
| 2026-10-08 | **新增接线检查 `scripts/check-wiring.py` + 修 F-101~F-111(11 条,全部带 E2E)**。起点:F-1/F-31/F-50/F-52 是**同一个形状**(每一处单看都对 → 编译/lint/review 全不红 → 只有从入口走一遍才暴露),所以先把它变成命令:查**权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、`bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上**,查不到的逐条由人判断"忘记接线"还是"有意留的"(有意的写进脚本上方的例外表 + 理由,**不靠下次再想一遍**)。⚠️ 检查器自己也修了三次假阳性:① 只数 `@RequirePerm` → 把服务层 `permissionChecker.require` 守着的 `AUDIT_WITHDRAW` 报成"没人用";② 按标识符数引用 → `JOIN_APPROVE` 撞上操作日志的动作码,**零发送点的模板被算成有 3 处引用**;③ **注释里的提及被当成接线**(`ExportController` 的类注释写着"权限只有两个:export:download 与 export:unmasked",而代码从没校验过它)→ 改为**先去 Java 注释再数**(F-36 的老教训:静态检查要能区分"代码"与"解释代码的文字")。**修掉的真问题**:F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半)、F-102 加入申请三处通知(代码里还写着"通知渠道未实现",而渠道早做好了)、F-103 导出完成/失败通知(正文**不放签名 URL**,模板里原本写着 `{下载链接}`)、**F-104 自动催报从来没发出过任何一条**(`end_time = date::date` 与库里的 `23:59:59` 永不相等,而测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」那一档、F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)、F-106 字典/通知记录两个菜单**没有任何角色持有**(页面做得再好也点不到)、F-107 6 个权限码挂到自己的接口(克隆/表单/材料/报考范围/整理器/租户)。**工具链**:`check-wiring.py` 进 `verify-all.py`(第 5 项机械校验)与 CI 的 check 阶段。**验证**:新增 `scripts/e2e-m5-notify-wiring.py`(**43 条断言**,全部打真实接口:改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节),并修 F-110(schedule 断言全库计数 → 限定到夹具,它在几天后会自己变红)/F-111(夹具 upsert 不更新用户名 → 看起来像密码错) |
+152 -17
View File
@@ -242,11 +242,19 @@ XT_TENANT_DOMAIN_ENABLED=true # 如果要按域名区分单位
| 阶段 | 本地怎么验 | 结果 |
|---|---|---|
| `check` | 直接跑那三个校验脚本 + 把 CI 里的 heredoc 片段原样执行 | ✅ |
| `check` | 直接跑那**五个**校验脚本(契约 / 实体列 / 文档 / 手册 / **接线**)+ 把 CI 里的 heredoc 片段原样执行 | ✅ |
| `build` | `mvn package`、`pnpm typecheck`、`pnpm build` | ✅ |
| `e2e` | 起 `test` profile 确认能起来(`check-test-profile.py`);全套 E2E 在 `dev` 上跑 | ✅ |
| `生产环境自检` | **真的起三次 jar**(三种误配形态) | ✅ 9/9 |
> **接线检查(`scripts/check-wiring.py`)为什么值得单独放一个阶段**:它查的是
> "**声明了但没接上**"(权限码没人校验、错误码没人引用、通知模板没有发送点、
> 菜单键前端没有对应项)—— 这一族错**编译 / lint / review 全都不红**,
> 只有"从入口走一遍"才暴露(F-1/F-31/F-50/F-52/F-101/F-106 都是它)。
> 它自己的假阳性也要当心:本轮修了三次(只数注解漏掉服务层、按标识符数引用被同名常量骗过、
> **注释里的提及被当成接线**)—— 所以它现在**先去 Java 注释再数**,
> 并且有意不接线的逐条写在脚本上方的例外表里(带理由),**不静默跳过**。
**本地验不到的**(要在真实 runner 上跑):
`services:` 的容器别名解析、`apt-get`/`pip3` 在 runner 镜像里可用性、
artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows,
@@ -257,23 +265,31 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
## §9 压测记录(M7-09)
脚本:`python scripts/load-test.py [--rows N] [--only gate|read|upload|export] [--with-upload]`
脚本:`python scripts/load-test.py [--rows N] [--only gate|read|upload|export|export-big|soak] [--with-upload] [--base URL] [--fixture-only] [--files-per-user K] [--split-mb M] [--soak 分钟] [--rps N]`
⚠️ **本机是"负载生成器 + 后端 + PG + Redis 全在同一台机器(且 PG/Redis 在 Docker 里)"**,
所以绝对值偏悲观;**看比例与形状,不要看绝对值**。
另一条同样重要:**谈响应时间必须带规模**(同一个接口在 60 行与 2000 行上是两件事),
所以报告里每个数字都带规模。
### 9.1 结果(规模:名册 2000 人)
**本机规格(2026-09-30 补记,读数字时一起看)**:AMD RYZEN AI MAX+ 392,
12 物理核 / 24 逻辑核,内存 59.6 GB;后端 JVM、PG、Redis、压测进程、浏览器全在同一台机器上。
### 9.1 结果(规模:名册 2000 人,除注明外)
| 场景 | 结果 |
|---|---|
| **门禁核验**(60 人同一出口 IP 同时扫码) | **60/60 成功**(修复前 **20/60**,见 9.3);并发下 p50 218ms / p95 327ms |
| **门禁核验·学校规模**(250 / 500 人同一出口 IP) | **250/250、500/500 成功**(成功上限 200→600 之后;此前 250 人会挡 37 个,见 9.3)。500 并发 p50 1.4s / p95 2.5s —— 是排队,**无人被限流** |
| **读接口**(20 并发 × 8 轮 = 640 请求,四类混合) | **0 失败**;单请求中位 **36~47ms**(与页大小 20/50/200 **无关**);20 并发下 p50 156~375ms |
| **名册列表的 SQL 条数** | **恒为 3 条**(`selectPage` + `count` + 组织名映射),**与 pageSize 无关 → 没有 N+1** |
| **DB 侧耗时** | 分页查一页 **1.2ms**、计数 **0.66ms**、组织映射 **0.06ms** |
| **导出** | 前置检查正确拦住(`40035`,见 9.4);大包打包**未测**(需要真实审批过的数据) |
| **材料上传** | **默认不跑**(见 9.4) |
| **材料上传**(20 并发,2850KB 真图) | **40/40 成功**;**串行单请求 321ms**(2850KB → 1329KB,**压到 47%**);20 并发 p50 **620ms** / p95 **801ms** / max 851ms;吞吐 **6/s**(见 9.6) |
| **名册导入规模**(5000 行,PRD 说的上限) | **导入接口 20.2s**(2000 行 7.7s);其中"造 Excel"是压测脚本自己的成本 **0.7s**,要分开看;导入后后端 RSS **782 MB** |
| **导出大包**(150 人 / 194.6 MB) | **打包墙钟 13.2s**,单卷 194.7 MB;150 个材料 + 汇总表共 **151 个条目**,ZIP 可解;30 人(38.9 MB)时 **2.8s** |
| **导出分卷**(同一批 150 人,`splitSizeMb=50`) | **4 卷**(49.3 / 49.3 / 49.3 / 46.7 MB),**每一卷都能单独解开**(39/38/38/36 个条目),汇总表在第 1 卷根目录 |
| **导出前置检查** | 不通过时正确拦成 `40035` 且**创建接口也拒绝**(见 9.4) |
| **长跑稳定性**(5 请求/秒 × 60 分钟,名册 2000 行) | 见 **9.5.4** |
### 9.2 结论:后端没有 N+1,DB 不是瓶颈
@@ -287,6 +303,10 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络),
不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。**
同一条道理用在上传上:串行 321ms vs 20 并发 p50 620ms ——
差值就是排队。**压缩本身是 CPU 密集的单线程活**,所以"上传慢"的解法是加 CPU / 限并发,
不是改上传接口。
### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修)
压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确),
@@ -303,7 +323,7 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
|---|---|---|---|
| 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 |
| 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 |
| 成功(按 IP) | 只计成功 | 防"批量取数据" | 200 次/分钟 |
| 成功(按 IP) | 只计成功 | 防"批量取数据" | **600 次/分钟**(2026-09-30 由 200 上调,见下) |
关键洞见:**枚举攻击产生的是失败,而教室产生的是成功。**
把成功也算进防枚举的额度里,等于用防攻击的代价挡住正常用户 ——
@@ -313,23 +333,138 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
开学第一天几十人同时扫码必然出现几例,把它算进额度里会让整间教室在几秒内被限流,
**而没有一个人做错了什么**。
### 9.4 两条使用注意
⚠️ **成功上限经历过一次实测修正(200 → 600,2026-09-30)**:
契约 §1.6 只规定了失败侧(同 openid 10 次/小时、同 IP 20 次/分钟),成功侧上限是本项目
自己加的,而最初定的 **200 恰好卡在"一个年级"的量级** —— 实测 **250 个不同学生
同时核验(全部正确)时 213 个成功、37 个被挡**,提示「当前网络访问过于集中」。
全校在同一个出口 IP 集中填报(开学第一课集体扫码)就会撞上,而**没有人做错了什么**。
上调到 600 后复测:**250 人 250/250、500 人 500/500**,且失败侧两个计数器原样未动。
要再调这个数,先跑 `python scripts/load-test.py --only gate --rows 500` 量一遍。
- **上传压测默认关闭**(`--with-upload` 才跑):本机 `application-local.yml` 里
`provider: qiniu`,所以上传落的是**线上那个空间**(bucket=tools-matrix)。
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对:
### 9.4 压测怎么跑(两条使用注意)
- **上传压测默认关闭**(`--with-upload` 才跑),因为本机 `application-local.yml` 里
`provider: qiniu`,上传落的是**线上那个空间**(bucket=tools-matrix):
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对 ——
压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),
也不该往真实桶里灌测试文件(删库不删桶)。
**要跑就先把存储切成本地**:`XT_STORAGE_PROVIDER=local` 重启后端。
也不该往真实桶里灌测试文件(**删库不删桶**)。
**正确跑法(推荐)**:另起一个用本地存储的实例,把压测打过去,不碰线上桶:
```bash
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=dev --server.port=18080 --fill.storage.provider=local
python scripts/load-test.py --base http://localhost:18080 --only upload --conc 20
python scripts/load-test.py --base http://localhost:18080 --only export-big --rows 150 --split-mb 50
```
⚠️ 这个实例**也会跑定时任务**(`@EnableScheduling` 没有开关),
所以量完就把它停掉,别让它长期和 dev 实例并行。
- **导出创建被 `40035` 拦住不是失败**:那是铁律(`canExport=false` 时禁止导出)
在服务端的正确生效。脚本会把这种情形报成「被正确拦住」,
并**断言创建接口也必须拒绝** —— 界面禁用按钮只是第一道,服务端是最后一道。
### 9.5 还没测的
### 9.5 补测记录(2026-09-30):原来"没测过"的四项
这一节记的是**怎么量出来的**,因为数字会过期、方法不会。
#### 9.5.1 上传并发(CPU 密集的压缩)
```bash
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=dev --server.port=18080 --fill.storage.provider=local
python scripts/load-test.py --base http://localhost:18080 --only upload --conc 20
```
| 量 | 结果 |
|---|---|
| 夹具 | **真 JPEG 2000×1500 / 2850 KB**(噪声图;纯色量不出 CPU 成本) |
| **串行单请求** | **321 ms**,产物 **1329 KB → 压到 47%** |
| 20 并发(40 次上传) | **40/40 成功**;p50 **620 ms** / p95 **801 ms** / max 851 ms |
| 吞吐 | **6 次/秒**(40 次 / 6.6 秒墙钟) |
读法:**单请求 321ms vs 并发 p50 620ms,差值就是排队** —— 压缩是单线程 CPU 活,
所以"上传慢"的解法是加 CPU 或限并发,不是改接口。
⚠️ 上传场景有**前置条件**:学生必须先经过门禁绑定,否则 `POST /mp/submissions` 会被拒;
脚本现在会自己绑定并**断言草稿数**,不成立时明确报"前置不成立"而不是"上传失败"。
#### 9.5.2 名册导入规模(PRD 提到的 5000 行上限)
```bash
python scripts/load-test.py --rows 5000 --fixture-only
```
| 规模 | 造 Excel(脚本自己的成本) | **导入接口**(系统的成本) | 备注 |
|---|---|---|---|
| 150 行 | 0.3 s | **0.8 s** | |
| 2000 行 | 0.4 s | **7.7 s** | |
| **5000 行** | 0.7 s | **20.2 s** | 导入后后端 RSS **782 MB** |
⚠️ 两段必须分开报:**造 Excel 是压测脚本的成本**(openpyxl),混在一起会把脚本的耗时
算到"名册导入"头上 —— 那正是"度量工具的偏差伪装成产品缺陷"。
导入是**同步**的:5000 行会让这个请求占用 20 秒,前端要按长请求处理(别设 10 秒超时)。
#### 9.5.3 导出大包(客户最关心的那条链)
```bash
python scripts/load-test.py --base http://localhost:18080 --only export-big \
--rows 150 --files-per-user 1 # 不分卷
python scripts/load-test.py --base http://localhost:18080 --only export-big \
--rows 150 --files-per-user 1 --split-mb 50 # 强制分卷
```
夹具会自己走完整条业务链:门禁绑定 → 建草稿 → 上传 → 提交 → **初审(班主任)→ 终审(管理员)**
→ 前置检查 → 创建导出 → 轮询到完成 → **逐卷解开验证**。
| 规模 | 原件 | 打包墙钟 | 产物 | 条目数 |
|---|---|---|---|---|
| 30 人 | 38.9 MB | **2.8 s** | 单卷 38.9 MB | 31(30 材料 + 汇总表) |
| 150 人 | 194.6 MB | **13.2 s** | 单卷 194.7 MB | 151 |
| 150 人 + `splitSizeMb=50` | 194.6 MB | **11.4 s** | **4 卷**(49.3 / 49.3 / 49.3 / 46.7 MB) | 39 / 38 / 38 / 36 |
三条结论:
1. **吞吐约 15 MB/s**,且"原件 → 产物"压缩率 **0.0%** —— JPEG 本来就压不动,
打包的瓶颈是磁盘 I/O 而不是 CPU;所以大包的耗时基本可以按体积线性估。
2. **每一卷都能单独解开**(脚本对每卷跑 `zipfile.testzip()`)—— 这是分卷的承诺,
而它只能靠真解一遍来验证,"文件存在"断言不到。
3. 目录结构符合 PRD §5.9:`{批次号}-{任务名}/班级/材料类型/文件`,
汇总 Excel 在**第 1 卷的根目录**。
#### 9.5.4 长跑稳定性(60 分钟)
```bash
python scripts/load-test.py --only soak --rows 2000 --soak 60 --rps 5
```
判据是**趋势**,不是"跑完没崩":每分钟记一行 RSS(从系统取,不是应用自报的堆用量)
与 PG 连接数,最后比较**前 1/3 与后 1/3** 的均值。
**结果(2026-09-30 实测)**:
| 量 | 前 1/3 均值 | 后 1/3 均值 | 结论 |
|---|---|---|---|
| RSS | 603.9 MB | 582.9 MB | **-3.5%,平稳**(GC 还回收了一点) |
| PG 连接数 | 17.0 个 | 6.2 个 | **-63.7%,回落**(定时任务高峰把池撑到上限 20 后收回 minimumIdle≈6) |
| 延迟 p95 | 63.2 ms | 62.8 ms | **-0.7%,纹丝不动** |
样本 51 行(标称 60 分钟,实际每行约 70 秒:300 个请求 × 0.2s 均匀间隔)、
请求 **15300**、失败 **0**。RSS 峰值 622 MB 出现在**定时任务在跑的那几分钟**。
**结论:60 分钟内没有内存泄漏、没有连接泄漏、延迟没有退化。**
(更长的窗口见 9.6 —— 那是仍然没测的。)
⚠️ 读这条曲线时要先知道一件事:**每小时的第 5 分钟、以及每 10 分钟**有定时任务在跑
(`ScheduleJobs` 的 cron),那一刻 PG 连接数会**跳一下**再落回来。
实测见过 6 → 21 → 6 的形状 —— 那是任务在干活,**不是连接泄漏**。
真正的泄漏长这样:**回不去**,且后 1/3 的均值明显高于前 1/3。
### 9.6 仍然没测的
| 场景 | 为什么没测 | 怎么测 |
|---|---|---|
| 导出**大包打包**(几百人 / 1GB+) | 需要真实审批过的数据,而造那种数据要跑完整审核链路 | 用 staging 的真实批次,量 `ExportService` 的打包耗时与内存峰值 |
| 上传并发(CPU 密集的压缩) | 见 9.4(会打到外部存储) | 切本地存储后 `--with-upload` |
| 5000 人名册(PRD 提到的导入上限) | 导入本身是同步的,压测里造 5000 行要 ~20s | `--rows 5000`,重点是**导入**耗时与内存 |
| 长时间稳定性(内存泄漏、连接泄漏) | 一次性压测测不出来 | 持续 1~2 小时的低强度压测,看 RSS 与 Hikari 活跃连接数是否单调上升 |
| **真机上的上传**(微信小程序 + 手机相册) | 需要真机与小程序资质 | 见 `docs/01` **D6**:量"选一张 5MB 照片到提交成功"的端到端时间 |
| **对象存储(七牛)的上传并发** | 本地跑会把测试文件灌进真实桶(见 9.4) | 用 staging 的独立 bucket,`provider=qiniu` + `--with-upload` |
| **多副本部署下的导出打包** | 本机只有一个实例 | 两实例同时导出同一任务,确认不会互相覆盖产物(导出线程池与落盘路径都要看) |
| **5000 人**(不是 5000 行)**的全链路** | 5000 行导入已测(9.5.2),但"5000 人都提交 + 审核 + 导出"是另一件事 | 按 9.5.3 的 `export-big` 放大到 5000,注意先看导出分卷与磁盘占用 |
| **生产规模下的长跑(数小时~数天)** | 60 分钟只够发现"快速泄漏" | 在 staging 按 9.5.4 的 soak 跑 12~24 小时,看 RSS 与 PG 连接数是否仍然平稳 |
+401 -1
View File
@@ -2286,7 +2286,7 @@ M1 规则模板与任务克隆 → M2 报考范围与名册改删 → M3 提交
**§14 开放接口(认定机构对接,HMAC)** → 小程序登录与组织导入 → 工作台数据看板 →
CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。
**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 98 条编号问题(F-1~F-98)。
**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 100 条编号问题(F-1~F-100)。
### 这 20 多轮里最该记住的几条
@@ -2379,3 +2379,403 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
`docs/08` §四 剩下的**全部是外部依赖**(OCR 选型 / 小程序类目 / `union_id` / 真机 / 二期),
另有 4 项"**没测过**":导出大包打包、上传并发、5000 行名册导入、长跑稳定性
(`docs/09` §9.5 逐条写明"没测就是没测")。
---
## §一·三十 把"没测过的四项"测完 + 门禁上限二次修正(2026-09-30 上午)
> §一·二十九 里那 4 项"没测过",这一轮全部变成了"测过",数字都在 `docs/09` §9.5。
> 顺带抓到一个会挡住**一所学校**的真缺陷(F-100),并把本地磁盘的糊涂账清了(F-5 落地)。
### 一、四项补测的结果(`docs/09` §9.5 有复现命令)
| 场景 | 结果 |
|---|---|
| 上传并发(真 JPEG 2850KB / 20 并发) | **40/40 成功**;**串行单请求 321ms**(压到 **47%**);并发 p50 620ms / p95 801ms —— 差值是排队,压缩是单线程 CPU 活 |
| 名册导入 5000 行(PRD 上限) | **20.2s**("造 Excel 0.7s"是压测脚本自己的成本,已分开报);RSS 782MB |
| 导出大包 150 人 / 194.6MB | **打包墙钟 13.2s**(≈15MB/s,JPEG 压不动 → 瓶颈是磁盘 I/O);强制分卷 → **4 卷且每卷都能单独解开**,汇总表在第 1 卷根 |
| 长跑 60 分钟(5 请求/秒 / 名册 2000 行) | 51 样本 / **15300 请求 / 0 失败**;RSS **603.9 → 582.9MB(平稳)**;PG 连接 17.0 → 6.2(定时任务高峰后正常回落);p95 62ms 不变。**无泄漏迹象** |
### 二、门禁成功上限 200 → 600(F-100,F-92 的第二次复发)
- 契约 §1.6 只规定**失败侧**;成功侧 200/分钟是本项目自己加的,而它**恰好卡在"一个年级"**:
实测 **250 个不同学生同时核验 → 213 成功、37 被挡**(「当前网络访问过于集中」)。
全校在同一出口 IP 集中扫码(开学第一课集体填报)就会撞上,**而没有人做错了什么**。
- 已上调 600,复测 **250/250、500/500**,`e2e-m3-gate` 仍 **29/29**;
**失败侧两个计数器原样未动**(防枚举强度没有降低)。同步 `docs/01` E2 与 `docs/09` §9.3。
### 三、磁盘的糊涂账清了(F-5 落地)
`backend/data/uploads` 里 **281 个文件 / 276MB,而库里只剩 16 行** —— 压测与 E2E 都只删库行。
`dev-clean-e2e.py` 新增 `--orphan-files` / `--only-orphan-files` / `--demo-tasks`
(业务表按 `information_schema` **动态枚举**,以后加表不会漏),已清 **255 个 / 275.4MB**。
⚠️ e2e 租户与"E2E 24数控1班"组织**刻意没删**:当前演示态正挂在其中一个班下面,
删了会把演示态弄坏 —— 要清先确认演示态已迁到正式组织(预览:`python scripts/dev-clean-e2e.py`)。
### 四、这轮最该记住的两条
1. **补测的价值一半在数字,另一半在"原来夹具一直不成立"**(F-99,压测工具自己撒了 8 次谎:
前置未断言 / 假 JPEG / 猜列名 / 判据取错返回值 / 跨租户同名账号 / 不查返回码 /
PowerShell `-First` 截管道 / 清理不删文件)—— 其中"跨租户同名 admin"与"HTTP 码当业务码"
都是 F-84/F-96 的同类,**第三次出现**。
2. **自己加的防御性限额也要过一遍真实场景的量级**(F-100)——
否则它与它要防的东西一样,都是事故。
---
## §一·三十一 17 项决策全部执行完毕(2026-09-30 下午)
> 客户对 100 条问题逐项拍板后("都按照你的建议来"),A/B/C/D 四组 17 项一次做完。
> **至此 F-1~F-100 没有未决项**;剩下的全部等外部输入(OCR 选型 / 小程序类目 /
> `union_id` / 真机 / 二期时点 / 国标职业库数据)。
### 一、A 组:契约口径修正(V2.6,全部经客户确认)
| 处 | 改了什么 |
|---|---|
| §2.7 | `notifyBizType` 5/6 对调修正(**5 催报 / 6 导出完成**,F-30);`auditAction` 补全 7 个动作(**撤回=7**,F-7) |
| §3.6 / §9.5 | `collect_file.source` 改为建表口径「**谁传的**」(1 本人/2 班主任代传/3 管理员代传/4 整理器,F-38/39) |
| §9.5 / §9.6 / §9.9 | 上传凭证改为「**上传前置检查**(预检/秒传/压缩建议,非安全边界)」;`process` 改为「有原始件时的重新规范化」;OCR 标注一期缓做(F-3/71/72) |
| §11.4 / §12.1 | 撤回约束补「状态 2 可撤(撤的是初审)」;`validation/run` 改**同步**并返回统计(F-51) |
| **新增 §17** | **实现补充与偏差记录**:41 个 §4 未列接口按六类分组(清单由 `--extra` 生成,声明"手抄件会过期")+ **12 条已确认偏离 D-1~D-12**(含租户域名、`biz_type` 落库字符串、§14 路径、归档可删、通知两级、OCR 缓做)—— F-4/10/26/65 一次收口 |
### 二、B 组:补代码(每项都带 E2E)
| 项 | 实现 | 验证 |
|---|---|---|
| 规则模板 FORM 可套用(F-57/64) | `FormSchemaService.saveRawVersion`(版本化保存只有一份);顺带**存模板也存表单**(此前两头各缺一半) | `e2e-m7-taskconfig` **75/75** |
| 已归档任务可删(F-14) | `DELETE /tasks/{id}` 支持 status=3:**子表级联物理删**(14 张表,清掉大头)+ **任务行软删占住 `task_code`**(编码是文件目录名,复用会混两批文件 —— 第一版物理删被 E2E 抓到重号后改的)+ 尽力删文件 + 留痕 | 新增 `e2e-m1-task-delete` **21/21**(含"删除后新建不重号"回归) |
| 催报分钟级限流(F-29) | 10 次/分钟/任务,超限 `42901` 且说清等多久;Redis 不可用时放行不瘫痪 | `e2e-m5-urge` **42/42** |
| 学生微信登录留痕(F-31) | 新增 `WechatLoginLogger`:小程序登录 + H5 回调/打桩登录都写 `sys_login_log`(`login_type=2`,与 DDL 注释对齐) | `e2e-m3-h5-auth` **24/24** |
| 三个纯函数最小单测(F-9) | `IdentityNormalizerTest` + `FileProcessServiceTest`(每条断言都对应一个踩过的坑) | `mvn test` **12/12** |
### 三、C/D 组:备案与清环境
- **备案**:通知降级链一期维持两级(F-28 → §17.2 D-11);国标职业库待客户提供(`docs/01` 新增 **E4**,F-34)。
- **xt-minio 容器已删**(F-2,代码早已不用 S3)。
- **本地测试数据已清**(F-5):49 个 e2e 租户 + 147 个测试组织 + 84 个【演示·旧】任务,
共 3714 行 / 27 个文件;清理工具先算「仍被存活任务引用的组织」并保留(3 个)——
**演示态完好**(任务 1449 / 名册 3 人 / 组织 43 都在)。
- **七牛桶已清空本项目前缀**(F-93):`xiaotutongxue/` 下 **2036 个对象 / 116 MB**,
删前核实库中**零引用**、删后复验 **0 个**。新增 `scripts/dev-clean-qiniu.py`
(默认 dry-run;签名按官方文档逐字核对 —— 踩了两个 401:旧 QBox 格式、batch 的 Body 没进签名串)。
### 四、终验与提交
- `verify-all --with-e2e` → 见提交信息里的最终数字(37 个 E2E 脚本全绿)。
- E2E 总数 36 → **37**(新增 `e2e-m1-task-delete.py`),断言数相应增加。
---
## §一·三十二 接线检查:把"定义了但没接上"变成命令(2026-10-08)
> F-1~F-100 全部核销之后,这一轮只做一件事:**问"还有没有同一族的错"**。
> F-1(日志没人写)、F-31(微信登录不写日志)、F-50(表建好没人用)、
> F-52/F-67(契约定义了但链路没接上)是**同一个形状** ——
> 每一处单看都对(类在、表在、常量在、注释写得很清楚),
> 所以编译 / typecheck / lint / code review **全都不红**,只有"从入口走一遍"才暴露。
> 结论:**先把它变成命令**(`scripts/check-wiring.py`),再按输出逐条修。
> 本轮共修 **11 条(F-101~F-111)**,新增 E2E **43 条**,终验 `verify-all --with-e2e`
> → **9 项全过 / 0 跳过**(38 个 E2E 脚本 / **1620 条断言** / 0 失败)。
### 一、`check-wiring.py` 查五件事(进 `verify-all.py` 与 CI 的 check 阶段)
| # | 查什么 | 没接上的后果 |
|:--:|---|---|
| 1 | 权限码有没有地方校验(`@RequirePerm` **或**服务层 `permissionChecker`) | 权限码是死的 |
| 2 | 权限码有没有被任何内置角色分配 | **这个接口谁都调不了** |
| 3 | `ErrorCode` 的码有没有被引用 | 文档里有、代码里没有对应分支 |
| 4 | 站内信模板有没有发送点 | 模板页能改,但那条通知根本不发 |
| 5 | 菜单键 ↔ PC 端 `XtMenu` 双向核对 | 菜单永远不显示 / 前端有菜单没人看得见 |
**例外表写在脚本里**(连同理由):有意不接线的(二期功能、范围类权限、M4 等选型)
逐条登记并打印出来 —— **不靠下次再想一遍**,也不静默跳过。
⚠️ **检查器自己修了三次假阳性**(这段比结论更值得记):
① 只数 `@RequirePerm` → 把服务层守着的 `AUDIT_WITHDRAW` 报成"没人用";
② 按标识符数引用 → `JOIN_APPROVE` 撞上操作日志的动作码,**零发送点的模板被算成有引用**;
③ **注释里的提及被当成接线**(`ExportController` 类注释写着"权限只有两个:
`export:download` 与 `export:unmasked`",而代码从没校验过它)→ 改成**先去 Java 注释再数**。
> 静态检查的通用教训(F-36 的老话):**要能区分"代码"与"解释代码的文字"**,
> 而且写完要**自己验一遍会不会漏**(本轮拿一个故意写错的模板码试了一次,确认它真的会红)。
### 二、修掉的 11 条(每条都有 E2E 断言)
| # | 问题一句话 | 修法 |
|:--:|---|---|
| **F-101** | **FR-1002「通知模板可配置」只兑现了手工发送那一半** —— 驳回/通过/催报这些**自动**通知把文案硬编码在 Java 里,而 FR-1002 点名的 5 类**全是自动类** | 新增 `NotifyTextResolver`(单向依赖,避开循环注入):**库里改过的 > 内置**,与模板列表页同一份口径;`NotifyRequest` 加 `subjectCode/subjectVars/extraText`;**模板停用 = 这类通知不发** |
| **F-102** | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现",而渠道早做好了) | 提交 → 通知该审批的人(班主任/副班主任 + 学校管理员兜底);通过/驳回 → 通知学生(含原因) |
| **F-103** | 导出完成/失败不通知(异步打包,用户只能守进度条) | 通知发起人;⚠️ **正文不放签名 URL**(站内信落库,链接几十分钟就失效)—— 内置模板里原本写着 `{下载链接}`,一并改掉 |
| **F-104** | ⚠️⚠️ **自动催报从来没发出过任何一条**:`end_time = date::date` 与库里的 `23:59:59` **永不相等** | 改 `end_time::date = #{date}::date`;并补 FR-1003 原文的**「+ 截止当日」**(0 天,文案与"最后一天"分开) |
| **F-105** | 驳回达上限只写一行日志,而契约说"**同时通知管理员**" | 新增 `AUDIT_ESCALATE` 模板 + 发给本租户学校管理员(按角色找,不写死账号) |
| **F-106** | 字典管理 / 通知记录**没有任何角色持有菜单键** → 页面做得好也点不到(**手册还写着"去那里办"**) | 给 `SCHOOL_ADMIN` 补 `dict` / `notify` |
| **F-107** | 10 个权限码没挂到任何接口(克隆/表单/材料/报考范围/整理器/租户…) | 6 个改挂自己的码;4 个范围类/二期开关进例外表 |
| **F-108** | `TASK_PUBLISH` / `TASK_DEADLINE` 零发送点 | **不做**(发布时刻没有收件人:学生要先扫码才绑账号,而发布前没人会扫)→ 登记例外 + 理由 |
| **F-109** | `Map.of(...)` 不接受 null → 拼模板变量抛 NPE → 被"通知失败不影响业务"吞成一行 warn(**驳回通知全丢而驳回照样成功**) | 新增 `NotifyRequest.vars(...)`(null 跳过);姓名优先取**名册**上的名字 |
| **F-110** | schedule 的断言数的是**全库**「审核超时」消息 → 几天后因别的老数据自己变红(代码一行没改) | 断言**限定到本次夹具**(跨租户扫描类功能的断言必须这么做) |
| **F-111** | 夹具 `ON CONFLICT (id) DO UPDATE` 不更新用户名 → 登录报"账号或密码错误"(看起来像哈希不对) | 固定用户名 + `username = EXCLUDED.username` |
### 三、这一轮最值得记住的三件事
1. **F-104 是被"夹具不像真东西"盖住的**:测试用 `update collect_task set end_time = CURRENT_DATE + 3`
摆截止日(存进去是 00:00:00 的日期型),恰好能匹配那个写错的 SQL;
而真实接口存的是 `23:59:59` → **生产上一条都不会发**。
与 F-99「假 JPEG」是同一族:**夹具不像真东西,绿灯就不算数**。
现在这条断言**走真实接口建任务**(`e2e-m5-notify-wiring.py`)。
2. **"改了就生效"必须验到"发出去的那句话"上**:F-101 的关键断言不是"接口返回 0",
而是"**改掉模板 → 触发真驳回 → 查库看学生收到的那条标题**"。
3. **降级逻辑越完善,接线类的错越安静**:F-109 的 NPE 被 try/catch 吞掉、
F-104 的沉默被 `if (!tasks.isEmpty())` 挡住、F-106 的错只在"点菜单"时才出现 ——
所以"从入口走一遍"这件事只能靠 E2E,不能靠 review。
---
## §一·三十三 验收走查后的三项修正 + 平台「选择学校」(2026-10-09)
> 起点是客户自己点着看了一圈,报了三件事:**菜单里有几条没用**、
> **班主任点工作台弹 403**、**平台运营与学校管理员看到的组织架构一模一样**。
> 三件事都不是"功能没做",而是"做完了但口径不对" —— 与前几轮同一族。
### 一、去掉 3 条无用菜单(客户明确要求)
| 菜单 | 为什么去掉 |
|---|---|
| 名单管理 | 名册按任务,入口在「收集任务」每行的「名单管理」 |
| 表单设计器 | 表单按任务,入口在任务行的「表单设计」 |
| 材料规则 | 材料按任务,入口在任务行的「材料规则」 |
⚠️ **这一条推翻了 §一·二十三 里"顶层菜单保留「待建」页更诚实"的取舍** ——
当时的理由是"说'还没做'而功能在隔壁会让人得出错误结论"。客户看了之后的判断是:
一条点进去只有指路、没有任何操作的菜单,**本身就是导航噪音**。于是直接删掉。
- 后端 `BuiltinRole` 的 `menus` 同步删除这三个键(`check-wiring.py` ⑤ 双向核对,
只删一边会红)。⚠️ **权限码一个都没动**:`task:manage` / `form:manage` /
`material:manage` / `roster:*` 照旧,删的只是菜单键(可见性)。
- PC 端 `XT_MENUS` 删掉三条;**顺带删掉 `entryAt` 机制**("功能已就绪 + 跳转按钮"
那段)—— 它当初就是为这三条写的,留着就是一段永远走不到的代码。
「待建」页现在只服务「角色与授权」(二期)。
- 班主任/副班主任/审核员的菜单也一起去掉了 `roster`:他们本来也点不进去(没有 `roster` 页)。
### 二、班主任点工作台必弹 403 —— 前端调了"自己没有权限"的接口
| 接口 | 要求权限 | 班主任有吗 |
|---|---|---|
| `GET /orgs/tree`(工作台的"组织节点"卡片) | `org:view` | ❌ |
| `GET /audit/statistics`(数据看板的"待审/审核情况") | `progress:view:all` | ❌(只有 `progress:view:own`)|
工作台是**所有角色共用的落地页**,而这两个调用是无条件的 —— 于是班主任一进首页就连吃两个 403。
⚠️ **`try/catch` 拦不住它**:403 走的是 HTTP 状态码,`utils/http.ts` 的传输层分支
**先 `Message.error('拒绝访问(403)')` 再 reject**,异常被 catch 了、提示仍然弹出来。
前端唯一正确的做法是**按权限决定发不发这个请求**:
- 卡片值如实显示 `—`,并把原因写在卡片脚注("需要「组织查看」权限");
- 「审核情况」对没有 `progress:view:all` 的角色显示一句说明,而不是一张空表。
> 教训(与 §一·三十二 的接线检查同一族):**"共用页面 + 接口级权限"是一对必踩的组合**。
> 页面对所有角色都渲染,但里面的接口各要各的权限 —— 这类错只有"换个角色登进去点一遍"
> 才看得见,E2E 全绿也抓不到(E2E 一直用管理员身份跑工作台)。
### 三、平台运营的「组织架构 / 账号管理」:新增 `X-Tenant-Id`
**根因**:平台运营(`tenantId = 0`)是唯一跨租户身份,租户拦截器对它
**整体关闭过滤**(`MybatisPlusConfig.FillTenantLineHandler.ignoreTable`)。
于是 `/orgs/tree` 返回的是**所有租户混在一棵树里**,`/users` 是**所有租户的账号**。
⚠️ 这**看不出错**:当时只有 demo 一所学校有数据,平台看到的正好就是那 22 个节点 ——
和学校管理员一模一样。等第二所学校建了组织,它会看到两所学校的班级混在同一层。
**做法(客户选定"先选学校,再看该校数据")**:
```
前端:平台运营的「组织架构 / 账号管理」页顶部多一个「选择学校」下拉(GiTenantPicker)
→ 选中后所有请求带 X-Tenant-Id: <tenantId>
后端:ActingTenantResolver 解析这个头(support/,与 TenantDomainResolver 并列)
→ AuthInterceptor 把 LoginUser 的 tenantId 换成目标租户
→ isPlatformAdmin() 随之变 false → 租户过滤自动生效(这是机制,不是副作用)
```
**四条必须守住的**(都写了断言,`scripts/_verify-fixes.py`):
1. **只认平台身份**:学校管理员/班主任带这个头一律忽略 —— 否则改一个数字就能越租户;
2. 目标租户必须存在且启用,否则 `40001`(不区分"不存在"与"停用");
3. 不带这个头时**平台行为完全不变**(租户列表、全量操作日志照旧跨租户);
4. `OrgService.tree()` 与 `UserService.list()` 补了**显式** `tenant_id` 条件 ——
原来它们只靠拦截器,而拦截器对平台是不生效的;显式写死之后
"平台没选学校"得到的是**空树/自己的账号**,而不是"所有人的数据"。
⚠️ 角色可选范围也跟着走:给某所学校建账号时只能发学校那套角色
(发 `PLATFORM_ADMIN` 会被服务端的租户白名单拒掉,报 40301)。
### 四、`OpenPushService` 的"推送重试"从来没重试过(**已修,F-112**)
后端每 10 秒刷一次堆栈:`OpenPushService 扫描推送重试失败 → 未登录或登录已失效`。
- 根因:`OpenPushService.retryDue()` 的扫描查询 `pushLogMapper.dueRetries()`
**没有租户上下文**,而该方法**漏了 `@InterceptorIgnore(tenantLine="true")`** ——
同一个文件里 `findBySubmissionIgnoreTenant` 有,注释还正好写着这条规律
("先找归属、再摆上下文"里找归属那一步必须能无租户执行)。
- 后果:`docs/08` 记的**第 8 项定时任务「开放接口推送重试」从未真正重试**,
只是每 10 秒打一行 warn(异常被 `catch (RuntimeException) → log.warn` 吞掉)。
- **顺带修掉同一个方法里的第二处**:`接入方已删除/停用` 那条分支直接
`pushLogMapper.updateById(row)`,它同样没有租户上下文 —— 少了上下文这句 update 会抛 40101
被同一个 catch 吞掉,于是那条记录**每 10 秒重试一次、永远停不下来**。
改成用**推送记录自己的 `tenant_id`** 摆上下文再写。
- **E2E 补了"真的发生了"的断言**:`e2e-m8-open.py` 原来只断言"失败后**安排了**下次重试"
(`next_retry_at` 被写上),现在多一条 —— 等 16 秒(扫描间隔 10s + 首次退避 1s + 余量),
断言 `attempts` **真的增加**。**"安排了"与"发生了"是两件事**,只断言前者的话,
60/60 全绿也照样漏掉这个 bug(F-104 是同一族的第二次)。
修后 `e2e-m8-open.py` → **61/61**。
### 五、平台运营的**工作台**也要"选择学校"
客户要求"工作台的数据看板也按学校看"。改完之后连着暴露了两个**更深的问题**:
| # | 现象 | 根因 | 修法 |
|:--:|---|---|---|
| 1 | 平台选了学校后,任务下拉**空**(`/tasks` 40301) | 切到代管租户后 `isPlatformAdmin()` 变 false,权限判定**只认平台角色声明的权限** —— 而 `PLATFORM_ADMIN` 里**没有 `TASK_VIEW`**。它以前被"平台=超管"(`PermissionChecker.has` 对平台恒真)这个捷径掩盖着 | 给 `PLATFORM_ADMIN` 补 **`TASK_VIEW`**(纯读:任务列表/详情/统计)。PRD §3.2「查看填报进度看板:平台运营 ✅」正是这件事;`TASK_MANAGE` / `TASK_CLONE` 依旧不给(PRD 里平台是「—」) |
| 2 | 补了权限后 `/tasks` 返回 200,但**还是 0 条** | `TaskService.seesWholeTenant()` 写的是 `isPlatformAdmin() \|\| SCHOOL_ADMIN/AUDITOR` —— 代管时前者为 false,于是**掉进"班主任只看自己带的班"那条分支**,而平台在该校没有班级 → 空列表(看起来像"这所学校没有任务") | 新增 `LoginUser.isPlatformStaff()`(= `isPlatformAdmin() \|\| hasRole("PLATFORM_ADMIN")`),并把这条判据换成它 |
⚠️ **`isPlatformAdmin()` 与 `isPlatformStaff()` 的区别要记住**(两者回答不同的问题):
- `isPlatformAdmin()` = "当前上下文的租户**就是平台租户**" → 它是租户拦截器要不要
**整体关掉过滤**的开关(跨租户看数据);
- `isPlatformStaff()` = "这个人**是平台运营**" → 数据范围是全量,但**租户过滤照常生效**。
**凡是"按身份决定数据范围"的判断用后者**;否则平台一代管某校就会**退化成普通用户**
(空列表、少一半数据,而界面上没有任何提示)。前端也一并按学校取:工作台顶部同样放
`GiTenantPicker`(与组织架构/账号管理共用 `useActingTenantStore`),任务列表、任务统计、
审核统计都带上 `X-Tenant-Id`。
### 六、⭐ 把"权限码写错"变成命令:`check-wiring.py` 第 ⑦ 项
本轮我自己踩了一个**最该被工具抓住**的错:给工作台加"按权限决定发不发请求"时,
把 `progress:viewAll` 写成了 **`progress:view:all`**(多一个冒号)。后果:
那个判断**恒为 false** →「审核情况」整块**对所有人**都不显示 ——
而编译、typecheck、lint、后端日志、E2E **全都不红**(字符串对不上是纯运行期的事;
"权限不足就少显示一块"看起来还很像设计)。
所以给 `check-wiring.py` 加了第 ⑦ 项:**PC 端引用的权限码必须存在于 `PermissionDef`**
(认两种写法:`permissions.includes('x')` 与 `v-hasPerm="['x']"`)。
两个"检查器自己"的细节值得记:
1. **它第一版报了 5 条假阳性** —— 全都来自 `directives/hasPerm.ts` 里的
**注释与示例字符串**(模板自带的 `home:btn:add`)。这与本脚本 2026-10-08 修过的
第 ③ 条(Java 注释里的提及被当成接线)是同一个教训:
**检查器必须能区分"代码"与"解释代码的文字"**。修法:先按引号感知地剥掉 JS 注释
(`strip_js_comments`,不能见 `//` 就砍),并且 `v-hasPerm` **只在 `.vue` 里找**
(那是模板语法,`.ts` 里的同名文本必然是示例或报错文案)。
2. **加完要拿一个故意写错的码试一次**(本仓库的老规矩):把 `progress:viewAll` 改回错的
→ 检查器红、指向 `dashboard/index.vue:196`;改回来 → 0 条。**它真的会红,才算加上。**
### 七、平台运营的「操作日志」永远是空的(**已修**)
客户点出来的现象:**平台运营进「操作日志」一条都没有**(页面正常、不报错、就是空表)。
- 根因:`LogController` 恒把 `me.tenantId()` 传给查询,SQL 又写死 `WHERE l.tenant_id = #{tenantId}` ——
而平台运营的 tenantId 是 **0**,日志却都挂在各租户下 → **恒为 0 条**。
- 而契约 §13.4 **本来就把 `tenantId` 定义成查询参数**,PRD §3.2 对平台运营要求的也是
「**全量**操作日志 ✅」(学校管理员才是「🔸 本租户」)。也就是说:**参数没实现、权限码只挂了一半**。
- 修法(两个接口一起:`/logs/operations` 与 `/logs/logins`):
1. 注解改成 **`@RequirePerm({LOG_VIEW_TENANT, LOG_VIEW_ALL})`**(任一即可);
2. 新增 `LogController#logScopeTenantId(tenantId)` —— 有 `log:viewAll` → **默认全量**、
显式传 `tenantId` 时只看那一所;只有 `log:viewTenant` → **恒为本租户**,
传别人的租户 **`40301`**(⚠️ **不静默忽略**:这一屏是拿去回答合规问题的,
"看起来筛过了"比报错更糟);
3. 两条手写 SQL 的租户条件改成可选(`WHERE 1 = 1` + `<if test="tenantId != null">`)。
- 前端:平台运营多一列「**租户**」(它看的是跨租户全量,不显示单位就分不清这条属于谁;
学校管理员永远是本租户,不显示这一列),单位名取自 `/tenants`,取不到就退回 `#id`。
- 按客户要求,**同时删掉了这一屏顶部那段说明**(脱敏值/指纹、IP 可伪造、真正的证据是…)——
那段话本身没错,但它是"给审查者看的免责声明",挂在日常操作界面上属于噪音。
⚠️ 结论性的知识(IP 可伪造、登录日志一期只有 PC)**保留在 `apis/xt/log.ts` 的注释与契约里**,
不是删掉了事。
### 八、验证
| 手段 | 结果 |
|---|---|
| `scripts/_verify-fixes.py`(本轮临时脚本,**34 条断言**) | 全过。含 4 条安全边界(非平台带头不生效、非法值 40001、`0` 视为不切换、平台跨租户能力不变)、4 条**写路径**断言(平台代管新建组织落在租户 1、不带头落在租户 0、代管时建班主任账号绑的是**租户 1 的角色行**、代管时发 `PLATFORM_ADMIN` 被 40301 拒)、**8 条日志范围断言**(平台不带头=全量 1250 条、有行、每条带 tenantId、`tenantId=159`→0 条;学校管理员=本租户、指定别家 → 40301;登录日志同口径) |
| `scripts/_check-platform-tasks.py` | 平台不带头的任务列表 31 条(跨租户);带 `X-Tenant-Id: 1` → 31 条(该校的);带 `159`(没有任务的租户)→ **0 条** → 头真的在按租户收窄 |
| 浏览器(真点,非"看代码") | ① 班主任工作台:**不再弹 403**,组织节点/待审显示 `—` 并写明原因,左侧菜单只剩「收集任务 / 审核」;② 学校管理员菜单:名单管理/表单设计器/材料规则三条消失,其余不变;③ 平台「组织架构」:未选学校时提示"请先选择学校",选定 demo 后拿到该校的组织树;切到「账号管理」**选择被保留**(共用 store);④ 平台**工作台**选定学校后:组织节点、任务下拉、待审 **3**、审核情况表出数(改前是空/`—`);⑤ 学校管理员工作台复验:照旧显示(说明权限码那个错**对所有人**的影响都已修回);⑥ 平台「操作日志」:**出数**、多一列「租户」(显示"示范职业学校")、顶部那段说明**已消失**;学校管理员同一页:只有本租户、**没有**「租户」列、说明同样已消失 |
| `pnpm typecheck` | 通过 |
| `mvn -DskipTests package` | 通过(jar 已重启) |
| E2E 回归 | `e2e-m0` 36/36(含 A8/A9/A10 菜单断言)、`e2e-m1-orgs` 48/48、`e2e-m0-user` 55/55、`e2e-m5-notify-wiring` 43/43(dict/notify 菜单仍在)、**`e2e-m5-logs` 44/44**(日志接口本尊)、`e2e-m1-tenant-domain` 6/6(就在我改动的 `AuthInterceptor` 旁边)、`e2e-m8-open` **61/61**(含新增的"真的重试了"断言) |
| `check-wiring.py` / `verify-docs.py` | 未接线声明 **0 条**(第 ⑦ 项:PC 端 7 个权限码全部命中 `PermissionDef`);文档一致性 22 项全过 |
> ⚠️ 三条**自己踩出来的**教训(都属于"看起来正常,其实不生效"):
> ① 把"平台看到 22 个节点""租户 5 个"**写死**在验证脚本里 —— 跑完 E2E 之后它自己变红了
> (E2E 会往 demo 租户里加组织、加租户),而**代码一行没改**。改成从库里算期望值(F-110 同族)。
> ② 「选择了学校」必须放在 store 而不是各页面 `ref`:否则切页面要重选,
> 更糟的是**两个页面各看一所学校**,看起来像数据不一致。
> ③ **平台的"超管"捷径会掩盖权限缺口**:只要平台还在自己的租户上下文里,
> `PermissionChecker` 对它恒真,缺哪个权限码都看不出来;一旦开始代管某所学校,
> 缺口立刻变成 403 或空列表。**要验平台,就得在"代管态"下验一遍。**
> ④ 同一族的第四例:**"按租户写死"的接口,对平台就是"永远查不到"**(本轮的操作日志)。
> 平台运营的 tenantId 是 0,任何 `WHERE tenant_id = 当前租户` 都会静默返回空。
> 已在 `OrgService.tree` / `UserService.list` / `TaskService.seesWholeTenant` / `LogController` 四处统一处理;
> **下次新增"租户内"的查询接口时,先问一句:平台运营调它会返回什么?**
---
## §一·三十四 PC 端四项走查修复(2026-10-09 晚)
> 客户在验收里又点了 4 件事,三件已改;**第 3 件(配色)改了之后客户要求整体还原** ——
> 第四节专门记了这条"不要再动",免得下一个会话好心重做一遍。
### 一、平台运维「操作日志」:去掉用户 ID + 分页器根本看不见
| 现象 | 根因 | 修法 |
|---|---|---|
| 「操作人」列是 `张校长 / ID 2` 两行 | 数字 ID 对看日志的人没有意义(要 ID 的时候查库更快) | 只留账号/姓名(`operatorName`);接口照旧返回 `operatorId`,只是不显示 |
| **分页器"没有正确展示"** | 它**在 DOM 里一直是好的**(`共 1245 条 … 20 条/页`),但被裁在可视区之外:`GiPageLayout` 的内容区是 `overflow: hidden`(页面本身不滚),而「一页 20 条 + 工具栏 + 提示条」比屏幕高 → 卡片连着分页器一起被切掉,怎么滚都看不到 | 新增全局类 **`.g-list-card`**(`styles/global.scss`):卡片撑满内容区 → 表体自己滚 → 分页器钉在卡片底部;表格改 `:scroll="{ x, y: '100%' }"` |
| 「N 条/页」改了以后数字对不上 | Arco 表格在 `pagination` 非 false 时**自己也会再切一次数据**(内部 `flattenData` 会 `slice`),只改本地每页条数、不重新取数,就会得到"分页器说 50 条/页、表里还是 20 条" | 补 `@page-size-change` → 回到第 1 页重新取数 |
⚠️ `y: '100%'` 不是可选项:**字符串**形式的 `y` 才会让 Arco 把表头与表体**拆开**
(内部 `splitTable`,表头钉住、表体自己滚)。不写它时 `.arco-table-content` 的
`overflow-y` 是 `hidden`(那是给横向滚动条留的),多出来的行会被**直接裁掉且无法滚动**。
### 二、收集任务:状态切换改成单独一行的 tab
原来「全部 / 草稿 / …」是和「新建任务 / 批次号 / 搜索」挤在同一行的一排按钮 ——
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
现在 `<a-tabs hide-content>` 单独占一行,数据源仍是 `statusFilter`
(tab 的 key ↔ status 的映射只在 `STATUS_TABS` 一处定义,别再写第二份)。
⚠️ **加了这一行之后,任务页也踩了同一个分页器坑**(卡片变高 → 分页器被顶出可视区):
同一处 `.g-list-card` 一并修掉。**改这类"往卡片里加东西"的活,先看分页器还在不在视野里。**
### 三、通知铃铛:按客户要求先去掉
删掉顶栏铃铛与 `HeaderRightBar/Notice.vue`(那是 Gi 模板的假数据:写死 9 条、点了没反应,
和站内信没有任何关系)。将来要做,数据源是「通知记录」(`/collect/notify`,接口与页面都已就绪),
**不要恢复成写死的假列表**。
### 四、配色:改过一版腾讯云深蓝外壳,**客户要求整体还原**(不要再改)
本轮先按"左侧菜单栏 + 顶栏用腾讯云控制台深蓝、不要纯白"改了一版
(新增 `--xt-shell-*` 变量、`.g-shell`、菜单恒定 `theme="dark"`、并删掉设置抽屉里的「深色菜单」开关),
**客户看到后明确要求还原配色**,已按文件 `git checkout` 回原样:外壳仍是纯白,
`isMenuDark` / `.app-menu-dark` / 「深色菜单」开关**都保持原样**。
> **不要再"顺手"改配色。** 要改先问。
### 五、这一轮踩到的两个坑(都值得记)
1. **HMR 下 `Drawer.open({ content: () => h(SettingDrawerPanel) })` 不会热更新**:
该组件只出现在闭包里、不在任何模板里 —— 改了它,页面看起来毫无变化,
很容易把"没生效"误判成"改错了/改不动"。
**验证前先真重载**(`about:blank` → 目标 URL;同 URL 再 open 不一定会重新拉模块)。
2. **演示账号不是 `frontend-pc/README.md` 里那个**:那里的 `admin / 123456` 是 Gi 模板的旧值,
本项目是 **`admin / Admin@123` + 租户码 `demo`**
(`python scripts/dev-login.py` 用的就是它)。账号在多个单位下重名时,
登录页才会出现「租户码」输入框(服务端 `40012`)。
### 六、未做(同一族的问题,留给下一轮)
「通知记录」与「账号管理」两页的分页器**会被同样地裁掉**
(实测分页器顶边 1612 / 886,视口高 900)。修法是给这两页的卡片加同一个 `.g-list-card`
并把表格改成 `y: '100%'`,本轮没动(客户只报了操作日志那一处)。
+32
View File
@@ -0,0 +1,32 @@
import type { AxiosRequestConfig } from 'axios'
/**
* 平台运营的「当前学校」——请求头 `X-Tenant-Id`。
*
* ## 为什么需要它
*
* 平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户拦截器因此对它
* **整个关掉了租户过滤**(`MybatisPlusConfig`)。于是「组织架构」「账号管理」
* 这类**本来就是租户内**的页面,在平台账号下拿到的是**所有租户混在一起**的数据 ——
* 而且看不出错:只有一所学校有数据时,它和学校管理员看到的完全一样。
*
* 所以平台要代管某所学校时,必须**显式指明是哪一所**:带上这个头,服务端会把
* 本次请求的租户上下文换成目标租户(`ActingTenantResolver` + `AuthInterceptor`),
* 之后租户过滤、写入归属、权限判定都按"我就是那所学校"走。
*
* ## 三条使用约定
*
* 1. **只有平台运营的页面才传它**(页面用 `userStore.roles.includes('PLATFORM_ADMIN')` 判断)。
* 非平台身份即使传了,服务端也一律忽略 —— 那是安全边界,不是"顺手支持一下"。
* 2. **不传 / 传空 = 不发这个头**:平台照旧可以跨租户看租户列表、全量日志。
* 3. 学校管理员的页面**不要**传(他们的租户由 token 决定,传了也没用)。
*/
export const ACTING_TENANT_HEADER = 'X-Tenant-Id'
/** 组装"代管租户"请求配置;没有租户时返回空对象(不发头) */
export function actingTenantConfig(tenantId?: number | null): AxiosRequestConfig {
if (!tenantId) {
return {}
}
return { headers: { [ACTING_TENANT_HEADER]: String(tenantId) } }
}
+3 -2
View File
@@ -24,6 +24,7 @@
* 数字只用于选色。这样即便枚举有调整,界面也不会说错话。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */
export type BizError = Error & { bizCode?: number, bizData?: unknown }
@@ -422,6 +423,6 @@ export function getAuditStatistics(params: {
orgId?: number
from?: string
to?: string
}) {
return http.get<AuditStatisticsVO>('/audit/statistics', params)
}, tenantId?: number | null) {
return http.get<AuditStatisticsVO>('/audit/statistics', params, actingTenantConfig(tenantId))
}
+14 -9
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 组织树节点(§6.1) */
export interface OrgTreeVO {
@@ -34,24 +35,28 @@ export interface OrgUpsertReq {
/**
* 组织树。
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
* @param tenantId **仅平台运营**:代管哪个学校的组织(见 `acting-tenant.ts`)。
* 不传 = 不代管 —— 此时服务端只会返回平台自己租户的组织(空树),
* **不会**再把所有租户的组织混在一起端出来。
*/
export function getOrgTree(params: { withCount?: boolean, rootId?: number } = {}) {
return http.get<OrgTreeVO[]>('/orgs/tree', params)
export function getOrgTree(params: { withCount?: boolean, rootId?: number, tenantId?: number | null } = {}) {
const { tenantId, ...query } = params
return http.get<OrgTreeVO[]>('/orgs/tree', query, actingTenantConfig(tenantId))
}
/** 新增,返回新节点 id */
export function addOrg(data: OrgUpsertReq) {
return http.post<number>('/orgs', data)
/** 新增,返回新节点 id(`tenantId` 仅平台运营:新建到哪个学校下) */
export function addOrg(data: OrgUpsertReq, tenantId?: number | null) {
return http.post<number>('/orgs', data, actingTenantConfig(tenantId))
}
/** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */
export function updateOrg(id: number, data: OrgUpsertReq) {
return http.put<void>(`/orgs/${id}`, data)
export function updateOrg(id: number, data: OrgUpsertReq, tenantId?: number | null) {
return http.put<void>(`/orgs/${id}`, data, actingTenantConfig(tenantId))
}
/** 删除;有下级组织或账号时返回 40015 并给出占用清单 */
export function deleteOrg(id: number) {
return http.delete<void>(`/orgs/${id}`)
export function deleteOrg(id: number, tenantId?: number | null) {
return http.delete<void>(`/orgs/${id}`, actingTenantConfig(tenantId))
}
/** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */
+11 -5
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
import { getUserList } from './user'
/** 审核流配置(§3.2) */
@@ -117,9 +118,14 @@ export interface TaskListQuery {
pageSize?: number
}
/** 任务分页列表。班主任只会拿到自己带的班级的任务 */
export function getTaskList(params: TaskListQuery) {
return http.get<{ list: TaskVO[], total: number }>('/tasks', params)
/**
* 任务分页列表。班主任只会拿到自己带的班级的任务
*
* @param tenantId **仅平台运营**:看哪所学校的任务(见 `./acting-tenant`)。
* 不传 = 平台自己租户(空列表)—— 不再把各租户的任务混在一起。
*/
export function getTaskList(params: TaskListQuery, tenantId?: number | null) {
return http.get<{ list: TaskVO[], total: number }>('/tasks', params, actingTenantConfig(tenantId))
}
export function getTaskDetail(id: number) {
@@ -343,6 +349,6 @@ export interface TaskStatisticsVO {
* ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**:
* 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。
*/
export function getTaskStatistics(taskId: number) {
return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`)
export function getTaskStatistics(taskId: number, tenantId?: number | null) {
return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`, undefined, actingTenantConfig(tenantId))
}
+28 -13
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 账号列表项(§5.6) */
export interface UserVO {
@@ -75,43 +76,57 @@ export interface UserListQuery {
pageSize?: number
}
// ── 账号接口 ────────────────────────────────────────
//
// ⚠️ 每个接口的最后都有一个可选 `tenantId`:**仅平台运营**用来指明"代管哪所学校"
// (见 `acting-tenant.ts`)。学校管理员的页面一律不传 —— 他们的租户由 token 决定。
// 不传时服务端只返回平台自己租户的账号,不会再把所有租户的账号混在一起。
/** 账号分页列表 */
export function getUserList(params: UserListQuery) {
return http.get<{ list: UserVO[], total: number }>('/users', params)
export function getUserList(params: UserListQuery, tenantId?: number | null) {
return http.get<{ list: UserVO[], total: number }>('/users', params, actingTenantConfig(tenantId))
}
/** 新增账号,返回初始密码(仅此一次) */
export function addUser(data: UserUpsertReq) {
return http.post<UserCreateResult>('/users', data)
export function addUser(data: UserUpsertReq, tenantId?: number | null) {
return http.post<UserCreateResult>('/users', data, actingTenantConfig(tenantId))
}
/** 修改账号;登录名与密码不在此接口修改 */
export function updateUser(id: number, data: UserUpsertReq) {
return http.put<void>(`/users/${id}`, data)
export function updateUser(id: number, data: UserUpsertReq, tenantId?: number | null) {
return http.put<void>(`/users/${id}`, data, actingTenantConfig(tenantId))
}
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
export function deleteUser(id: number) {
return http.delete<void>(`/users/${id}`)
export function deleteUser(id: number, tenantId?: number | null) {
return http.delete<void>(`/users/${id}`, actingTenantConfig(tenantId))
}
/** 启用 / 停用(只改 status,数据保留可再启用) */
export function setUserStatus(id: number, status: number) {
export function setUserStatus(id: number, status: number, tenantId?: number | null) {
// status 后端设计为查询参数(PUT /users/{id}/status?status=0),
// 所以走 config.params 而不是 body
return http.put<void>(`/users/${id}/status`, undefined, { params: { status } })
return http.put<void>(`/users/${id}/status`, undefined, {
...actingTenantConfig(tenantId),
params: { status }
})
}
/** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
export function resetUserPassword(id: number, data: { mode?: string, newPassword?: string } = {}) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data)
export function resetUserPassword(
id: number,
data: { mode?: string, newPassword?: string } = {},
tenantId?: number | null
) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data, actingTenantConfig(tenantId))
}
/** 批量导入账号(Excel ≤10MB),返回体检报告 */
export function importUsers(file: File, defaultRoleCode?: string) {
export function importUsers(file: File, defaultRoleCode?: string, tenantId?: number | null) {
const fd = new FormData()
fd.append('file', file)
return http.post<UserImportResult>('/users/import', fd, {
...actingTenantConfig(tenantId),
params: defaultRoleCode ? { defaultRoleCode } : undefined
})
}
+2
View File
@@ -91,6 +91,7 @@ declare module 'vue' {
GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@@ -179,6 +180,7 @@ declare global {
const GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
const GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
const GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
const GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
@@ -0,0 +1,74 @@
<template>
<a-select
v-model="model"
class="gi-tenant-picker"
:placeholder="props.placeholder"
:loading="loading"
:disabled="props.disabled"
:style="{ width: props.width }"
allow-search
allow-clear
>
<a-option v-for="t in options" :key="t.id" :value="t.id" :disabled="t.status === 0">
{{ t.tenantName }}({{ t.tenantCode }}){{ t.status === 0 ? ' · 已停用' : '' }}
</a-option>
</a-select>
</template>
<script setup lang="ts">
import type { TenantVO } from '@/apis/xt/tenant'
import { getTenantList } from '@/apis/xt/tenant'
/**
* 「选择学校」——平台运营的当前租户。
*
* 为什么要有这个东西:平台运营是跨租户身份,服务端对它的租户过滤是**关掉的**,
* 所以它必须**先指明看哪所学校**,否则「组织架构」「账号管理」会拿到所有租户混在一起的数据。
* 选中的值由页面带进请求头 `X-Tenant-Id`(见 `@/apis/xt/acting-tenant`)。
*
* ⚠️ 只有平台运营的页面才渲染它 —— 学校管理员的租户由 token 决定,给他一个
* "选择学校"的下拉框是纯粹的困惑(而且服务端会忽略他传的值)。
*
* ⚠️ 租户列表接口只对平台运营开放(学校管理员调它得 40301),所以本组件
* **不要**在非平台页面里渲染。
*/
defineOptions({ name: 'GiTenantPicker' })
const model = defineModel<number | undefined>({ default: undefined })
const props = withDefaults(defineProps<{
placeholder?: string
disabled?: boolean
width?: string
}>(), {
placeholder: '选择学校',
disabled: false,
width: '100%'
})
const loading = ref(false)
const options = ref<TenantVO[]>([])
async function load() {
loading.value = true
try {
// 一期租户规模是"几十所"量级(接口上限 200),一次取完比做分页下拉简单得多。
// 已停用的也列出来但不可选 —— 服务端会拒("目标单位不存在或已停用"),
// 与其让人选完才知道,不如在选择时就说明。
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
options.value = res.data?.list ?? []
} finally {
loading.value = false
}
}
onMounted(load)
defineExpose({ reload: load })
</script>
<style lang="scss" scoped>
.gi-tenant-picker {
min-width: 180px;
}
</style>
+1 -71
View File
@@ -30,20 +30,6 @@ export interface XtMenu {
ready: boolean
/** 待建说明 */
note?: string
/**
* 这个功能**其实已经有了**,只是入口不在左侧菜单里(例如"名单管理"是按任务的,
* 入口在「收集任务」每一行的链接里)。
*
* ⚠️ 有它的时候,「待建」页就不再显示"还没做",而是给出真实入口的指路 + 跳转按钮。
* 没有它 = 确实还没做。两者的区别很重要:说"还没做"而功能其实在隔壁,
* 会让人**得出错误结论**(比找不到更糟)。
*/
entryAt?: {
/** 跳到哪个路由 */
path: string
/** 到了之后该点哪里(要具体到"点哪一行里的哪个链接") */
hint: string
}
}
/** 分组顺序(决定左侧菜单顺序) */
@@ -67,22 +53,6 @@ export const XT_MENUS: XtMenu[] = [
sort: 11,
ready: true
},
{
key: 'roster',
title: '名单管理',
icon: 'icon-park-outline:user-business',
path: '/collect/roster',
component: 'roster/index',
group: 'collect',
sort: 12,
ready: false,
note: '名册是按任务的:入口在「收集任务」每一行里点「名单管理」。'
+ '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。',
entryAt: {
path: '/collect/task',
hint: '在「收集任务」里找到那个任务,点它这一行的「名单管理」'
}
},
{
key: 'join',
title: '加入申请',
@@ -128,36 +98,6 @@ export const XT_MENUS: XtMenu[] = [
},
// ── 配置 ─────────────────────────────────────────
{
key: 'form',
title: '表单设计器',
icon: 'icon-park-outline:form-one',
path: '/setting/form',
component: 'form/index',
group: 'setting',
sort: 21,
ready: false,
note: '内置字段锁定、扩展字段自由 —— 按任务设计,入口在「收集任务」每一行的「表单设计」。',
entryAt: {
path: '/collect/task',
hint: '在「收集任务」里找到那个任务,点它这一行的「表单设计」'
}
},
{
key: 'material',
title: '材料规则',
icon: 'icon-park-outline:file-editing',
path: '/setting/material',
component: 'material/index',
group: 'setting',
sort: 22,
ready: false,
note: '命名模板、别名、格式白名单、体积上限、压缩档位 —— 按任务配置,入口在「收集任务」每一行的「材料规则」。',
entryAt: {
path: '/collect/task',
hint: '在「收集任务」里找到那个任务,点它这一行的「材料规则」'
}
},
{
key: 'cleanup',
title: '历史整理器',
@@ -338,17 +278,7 @@ function readyItem(menu: XtMenu): UserRouteItem {
} as UserRouteItem
}
/** 按菜单键取定义(给"待建"页显示说明用) */
export function findMenu(key: string): XtMenu | undefined {
return XT_MENUS.find((m) => m.key === key)
}
/**
* 按路由路径取菜单定义(「待建」页要显示"去哪个页面")。
*
* ⚠️ 按钮上的目标页名**必须从这里取**,不要在页面里写死 ——
* 菜单哪天改名,写死的那个名字就会指向一个不存在的页面。
*/
/** 按路由路径取菜单定义(「待建」页要显示说明用) */
export function findMenuByPath(path: string): XtMenu | undefined {
return XT_MENUS.find((m) => m.path === path)
}
@@ -1,85 +0,0 @@
<!--
@file Message 组件
@description 消息通知组件,包含通知、关注和待办三个标签页
-->
<template>
<div class="message">
<!-- 消息标签页 -->
<a-tabs :active-key="activeTab" style="background-color: transparent;" @change="handleTabChange">
<a-tab-pane v-for="tab in tabs" :key="tab.key">
<template #title>{{ tab.title }}({{ tab.count }})</template>
</a-tab-pane>
</a-tabs>
<!-- 消息列表 -->
<section class="message-list">
<a-comment v-for="item in messageList" :key="item.id" :author="item.name" :content="item.content"
:datetime="item.datetime">
<template #avatar>
<a-avatar>
<img :src="item.avatar" :alt="item.name">
</a-avatar>
</template>
</a-comment>
</section>
</div>
</template>
<script setup lang="ts">
import type { TabsInstance } from '@arco-design/web-vue'
/** 组件名称 */
defineOptions({ name: 'Message' })
/** 消息类型定义 */
interface MessageItem {
id?: string | number
name: string
datetime: string
content: string
avatar: string
actionText?: string
}
/** 标签页配置 */
const tabs = [
{ key: '1', title: '通知', count: 1 },
{ key: '2', title: '关注', count: 1 },
{ key: '3', title: '待办', count: 2 }
]
/** 组件状态 */
const activeTab = ref('1')
/** 消息列表数据 */
const messageList = ref<MessageItem[]>([
{
id: 1,
name: 'Socrates',
datetime: '1小时之前',
content: 'Comment body content.',
avatar: 'https://img1.baidu.com/it/u=1817951587,3188870642&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
},
{
id: 2,
name: '木木糖醇',
datetime: '2小时之前',
content: '关注了你',
avatar: 'https://s1.ax1x.com/2022/06/14/XhteeO.jpg'
},
{
id: 3,
name: '徐欣',
datetime: '2个半小时之前',
content: '收藏了你的文章',
avatar: 'https://s1.ax1x.com/2022/06/14/XhtSwF.jpg'
}
])
/** 处理标签页切换 */
const handleTabChange: TabsInstance['onChange'] = (key) => {
activeTab.value = key as string
}
</script>
<style lang="scss" scoped></style>
@@ -14,19 +14,12 @@
</a-button>
</a-tooltip>
<!-- 消息通知下拉框 -->
<a-popover position="bottom" trigger="click">
<a-badge :count="9" dot>
<a-button size="mini" class="g-hover-btn">
<template #icon>
<icon-notification :size="18" />
</template>
</a-button>
</a-badge>
<template #content>
<Notice></Notice>
</template>
</a-popover>
<!--
消息通知(铃铛)**暂时去掉**(2026-10-09 客户要求):
原来的铃铛是一个写死 9 条假数据的下拉框,站内信还没接到这个入口上。
真要做的时候,数据源是「通知记录」(`/collect/notify`,接口已实现),
不要恢复成写死的假列表。
-->
<!-- 全屏切换按钮 -->
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
@@ -87,7 +80,6 @@ import { useFullscreen } from '@vueuse/core'
import { useBreakpoint, useTheme } from '@/hooks'
import { useAppStore, useUserStore } from '@/stores'
import ChangePasswordModal from './ChangePasswordModal.vue'
import Notice from './Notice.vue'
import SettingDrawerPanel from './SettingDrawerPanel.vue'
/** 组件名称 */
+1
View File
@@ -1,6 +1,7 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export * from './useActingTenantStore'
export * from './useAppStore'
export * from './useDictStore'
export * from './useMenuBadgeStore'
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* 平台运营的「当前学校」。
*
* ## 为什么要有这个状态
*
* 平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户过滤对它整体关闭。
* 所以「组织架构」「账号管理」这类**本身就是租户内**的页面,平台必须先指明看哪所学校 ——
* 否则拿到的是所有租户混在一起的数据,而且看不出错。
*
* 这个值是**跟着请求走的**(`X-Tenant-Id` 请求头,见 `@/apis/xt/acting-tenant`),
* 不是登录态的一部分:token 里的 tenantId 始终是 0。
*
* ## 为什么放在 store 而不是各页面自己 `ref`
*
* 两个页面都要用同一个选择。各写一份的话,用户在「组织架构」选了学校、
* 切到「账号管理」又得重选一次 —— 而更糟的是**看起来像是两所学校的数据不一致**。
*
* ⚠️ `persist: false`:刷新页面就忘掉。这是刻意的 ——
* 一个跨越登录会话的"当前学校"会在下次登录时静默生效,
* 而那时候用户心里想的可能是另一所学校。
*/
const storeSetup = () => {
/** 当前代管的租户 id;`undefined` = 未选择(此时不应请求租户内数据) */
const tenantId = ref<number | undefined>(undefined)
const setTenant = (id?: number) => {
tenantId.value = id
}
const clear = () => {
tenantId.value = undefined
}
return { tenantId, setTenant, clear }
}
export const useActingTenantStore = defineStore('actingTenant', storeSetup, { persist: false })
+4
View File
@@ -10,6 +10,7 @@ import { buildXtRoutes } from '@/config/xt-routes'
import { resetRouter } from '@/router'
import { clearToken, getToken, setToken } from '@/utils/auth'
import { isHttp } from '@/utils/validate'
import { useActingTenantStore } from './useActingTenantStore'
import { useRouteStore } from './useRouteStore'
/** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */
@@ -101,6 +102,9 @@ const storeSetup = () => {
resetRouter()
profile.value = null
menuKeys.value = []
// 平台运营的"当前学校"属于**上一次身份**的上下文,退出时一起清掉:
// 留着它,下一个登录的人(可能是学校管理员)会继承一个自己没选过的租户
useActingTenantStore().clear()
}
return true
}
+47
View File
@@ -190,6 +190,53 @@
margin-bottom: 12px;
}
/**
* 列表页卡片(卡片里装着「工具栏 + 表格 + 分页器」)。
*
* 为什么必须有它:`GiPageLayout` 的内容区是 `overflow: hidden`(页面本身不滚),
* 而一页 10~20 条比屏幕高 —— 不加这层高度链路,**分页器会被裁到可视区之外**:
* DOM 里明明有(`共 1245 条 … 20 条/页`),人却看不见,看着就像"分页器没做/坏了"。
* 操作日志页(20 条/页)与收集任务页(加了状态 tab 之后)都踩过这一脚。
*
* 用法:`<a-card class="g-list-card">`,表格必须给 `:scroll="{ x: 宽度, y: '100%' }"`——
* `y` 是字符串时 Arco 会把表头与表体**拆开**(内部 `splitTable`),表体自己滚、表头钉住;
* 不写 y 的话 `.arco-table-content` 的 `overflow-y` 是 hidden(那是横向滚动条专用的),
* 多出来的行会被直接裁掉且无法滚动。
*/
.g-list-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
.arco-card-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
// ⚠️ `.arco-spin` 必须在链路里:Arco 用 Spin 包住「表体 + 分页器」
.arco-table,
.arco-spin {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.arco-table-container {
flex: 1;
min-height: 0;
}
// 分页器不参与伸缩,永远留在卡片底部
.arco-table-pagination {
flex: none;
}
}
// 以下这些类名要放到下面,涉及到css权重优先级问题
.g-mt {
margin-top: var(--margin);
+16 -65
View File
@@ -8,43 +8,17 @@
</a-space>
</template>
<template #extra>
<a-tag :color="menu?.entryAt ? 'green' : 'orange'">
{{ menu?.entryAt ? '功能已就绪' : milestone }}
</a-tag>
<a-tag color="orange">{{ milestone }}</a-tag>
</template>
<!--
⚠️ 两种说法必须分开,不能都说「还没做」。
名单管理 / 材料规则 / 表单设计 这三个菜单项对应的功能**早就做好了**,
只是它们**按任务**(入口在「收集任务」每一行的链接里)。
以前这里一律说"这个页面还没做" + 说"已实现的页面只有工作台、组织架构、租户管理"——
而那句话早就过期了,且**没有任何机制会让它不过期**。
说"还没做"而功能其实在隔壁,比找不到更糟:**它让人得出错误结论。**
-->
<a-alert v-if="menu?.entryAt" type="success" :show-icon="false">
<template #title>
<strong>这个功能已经有了,只是入口不在这里。</strong>
{{ menu?.note }}
</template>
</a-alert>
<a-alert v-else type="info" :show-icon="false">
<a-alert type="info" :show-icon="false">
<template #title>
<strong>这个页面还没做。</strong>
{{ menu?.note ?? '该功能尚未实现。' }}
</template>
</a-alert>
<div v-if="menu?.entryAt" class="coming-soon__go">
<a-button type="primary" @click="goEntry">
<template #icon><IconArrowRight /></template>
去「{{ entryTitle }}」
</a-button>
<div class="coming-soon__hint">{{ menu?.entryAt?.hint }}</div>
</div>
<p v-else class="coming-soon__why">
<p class="coming-soon__why">
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
说明你的角色确实有这个权限 —— 只是界面还没排到。
</p>
@@ -53,7 +27,7 @@
</template>
<script setup lang="ts">
import { IconArrowRight, IconClockCircle } from '@arco-design/web-vue/es/icon'
import { IconClockCircle } from '@arco-design/web-vue/es/icon'
import { findMenuByPath } from '@/config/xt-routes'
/**
@@ -64,40 +38,27 @@ import { findMenuByPath } from '@/config/xt-routes'
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
* 3. 一个页面服务所有占位路由,成本极低。
*
* ⚠️ 但这个页面**过去在说谎**:它对"功能已经有了、只是入口在别处"的菜单项
* 也说"还没做",还列了一句早已过期的"已实现的页面"清单。
* 现在按 `entryAt` 分两种说法,并**删掉了那句清单** ——
* 任何"现状清单"写死在代码里都会静默过期,而没人会记得回来改它。
*/
defineOptions({ name: 'ComingSoon' })
const route = useRoute()
const router = useRouter()
/**
* ⚠️ 这一页**只服务"确实还没做"的菜单**(现在只剩「角色与授权」)。
* 曾经它还要区分另一种情况 —— "功能已经有了、只是入口在别处"(名单管理 / 表单设计 /
* 材料规则,都在「收集任务」行内的链接里)。那三条菜单已按客户要求**直接去掉**:
* 导航里挂着一条点进去只有指路的条目,既占位置又让人以为这里应该有事可做。
* 所以 `entryAt` 那套分支("功能已就绪"标签 + 跳转按钮)也一并删了 ——
* 留着就是一段永远不会被走到的代码。
*
* ⚠️ 按**路径**找,不要按 `route.meta.menu`。
*
* 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里
* **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )——
* 于是这里永远拿到 `undefined`,页面永远显示兜底文案
* 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。
* 而 note 恰恰是这一页最有用的东西(它写着"真实入口在哪")。
* 而 note 恰恰是这一页最有用的东西。
*/
defineOptions({ name: 'ComingSoon' })
const route = useRoute()
const menu = computed(() => findMenuByPath(route.path))
/** 跳过去之后那个页面的标题(从路由表里取,免得两处各写一遍)。 */
const entryTitle = computed(() => {
const path = menu.value?.entryAt?.path
return path ? (findMenuByPath(path)?.title ?? '目标页面') : '目标页面'
})
function goEntry() {
const path = menu.value?.entryAt?.path
if (path) {
router.push(path)
}
}
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
const MILESTONES: Record<string, string> = {
role: '二期'
@@ -117,14 +78,4 @@ const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中
color: var(--color-text-1);
}
}
.coming-soon__go {
margin-top: 20px;
}
.coming-soon__hint {
margin-top: 8px;
font-size: 13px;
color: var(--color-text-3);
}
</style>
+116 -21
View File
@@ -57,20 +57,33 @@
<template #title>数据看板</template>
<template #extra>
<a-space>
<!--
平台运营必须先选学校:任务、统计、审核数都是**租户内**的数据,
而服务端对平台的租户过滤是关掉的 —— 不选就会把各学校的任务混在一起。
-->
<GiTenantPicker
v-if="isPlatform"
v-model="actingStore.tenantId"
width="200px"
placeholder="选择学校(平台运营)"
/>
<a-select
v-model="boardTaskId"
placeholder="选择一个任务"
allow-search
style="width: 260px"
:loading="taskLoading"
:disabled="needPickTenant"
:options="taskOptions"
@change="loadBoard"
/>
<a-button :loading="boardLoading" @click="loadBoard">刷新</a-button>
<a-button :loading="boardLoading" :disabled="needPickTenant" @click="loadBoard">刷新</a-button>
</a-space>
</template>
<a-empty v-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
<a-empty v-if="needPickTenant" description="平台运营看的是某一所学校的进度:请先选择学校" />
<a-empty v-else-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
<a-spin v-else :loading="boardLoading" style="display: block">
<!-- 进度四格:应填 / 已提交 / 已通过 / 待审 -->
@@ -116,6 +129,7 @@
</span>
</div>
<a-table
v-if="canViewAllProgress"
:data="auditRows"
:pagination="false"
size="small"
@@ -127,6 +141,11 @@
<a-table-column title="说明" data-index="foot" ellipsis tooltip />
</template>
</a-table>
<!--
班主任(只有 progress:viewOwn)在这里看到的是**空表**,而空表不解释任何事。
如实说明"这里为什么不给你看",比留一张空表更清楚。
-->
<a-empty v-else description="审核统计按全租户口径,需要「全租户进度」权限" />
</a-col>
</a-row>
@@ -151,7 +170,7 @@ import * as orgApi from '@/apis/xt/org'
import * as taskApi from '@/apis/xt/task'
import * as tenantApi from '@/apis/xt/tenant'
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
import { useUserStore } from '@/stores'
import { useActingTenantStore, useUserStore } from '@/stores'
/**
* 工作台。
@@ -177,6 +196,47 @@ const roleLabel = computed(() =>
// 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false)
/**
* ⚠️ 两个"能不能调"的判断,缺一个就会在页面上弹 403。
*
* 工作台是所有角色共用的落地页,但它里面有两个接口是**按角色开放的**:
* · `GET /orgs/tree` 要 `org:view` —— 班主任/副班主任/审核员都没有;
* · `GET /audit/statistics` 要 `progress:viewAll` —— 班主任只有 `progress:viewOwn`。
*
* 以前这两个是无条件调的,于是**班主任一进工作台就弹「拒绝访问(403)」**(实测)。
* 而 403 来自 HTTP 状态码,`try/catch` 拦不住它的 toast —— 前端唯一正确的做法是
* **不去调它**,并把那一格如实显示成"—"(本页的取舍就是"只显示真实能查到的数字")。
*/
const canViewOrg = computed(() => userStore.permissions.includes('org:view'))
/**
* ⚠️ 权限码是 **`progress:viewAll`**(驼峰),不是 `progress:view:all` ——
* 权威来源是后端 `security/PermissionDef`(`PROGRESS_VIEW_ALL("progress:viewAll", ...)`)。
* 我第一版就写成了带冒号的,结果这个判断**恒为 false**:「审核情况」对**所有人**都不显示,
* 而 typecheck / lint / 后端全都不报错(字符串对不上是纯运行期的事)。
* 现在 `scripts/check-wiring.py` 会做这层对齐检查。
*/
const canViewAllProgress = computed(() => userStore.permissions.includes('progress:viewAll'))
/**
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
*
* ⚠️ 工作台也必须有这个选择:这里的**任务、统计、审核数**全是租户内的数据,
* 而服务端对平台的租户过滤是关掉的 —— 不给选择就会把各学校的任务混在一个下拉里,
* 选中之后看到的进度是"另一所学校的",而界面上没有任何地方会提示这件事。
*
* 选择与「组织架构 / 账号管理」共用同一个 store(见 `useActingTenantStore`)。
*/
const actingStore = useActingTenantStore()
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
/**
* 「组织节点」这一格能不能显示真实数字。
*
* 平台运营要先选定学校(选定后就是那所学校的节点数);没选时显示"—",
* 而不是让 `/orgs/tree` 返回一个"0 个"(那会让人以为全平台没有组织)。
*/
const orgCardShowable = computed(() => canViewOrg.value && !needPickTenant.value)
/** 已实现且当前角色可访问的页面 */
const shortcuts = computed(() =>
XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key))
@@ -186,9 +246,11 @@ const stats = computed(() => [
{
label: '组织节点',
icon: IconFolder,
value: loading.value ? '—' : (orgCount.value ?? '—'),
unit: '个',
foot: '学校 / 院系 / 班级',
value: orgCardShowable.value && !loading.value ? (orgCount.value ?? '—') : '—',
unit: orgCardShowable.value ? '个' : '',
foot: isPlatform.value
? (needPickTenant.value ? '选定学校后展示该校的组织' : '所选学校的节点数')
: (canViewOrg.value ? '学校 / 院系 / 班级' : '需要「组织查看」权限'),
tone: ''
},
{
@@ -250,10 +312,10 @@ const boardCards = computed(() => {
},
{
label: '待审',
value: audit.value?.pendingCount ?? '—',
foot: audit.value?.timeoutCount
? `其中超时 ${audit.value.timeoutCount} 件`
: '没有超时的',
value: canViewAllProgress.value ? (audit.value?.pendingCount ?? '—') : '—',
foot: canViewAllProgress.value
? (audit.value?.timeoutCount ? `其中超时 ${audit.value.timeoutCount} 件` : '没有超时的')
: '需要「全租户进度」权限',
tone: audit.value?.timeoutCount ? 'is-warn' : ''
}
]
@@ -294,10 +356,33 @@ function fmtHours(h: number | null | undefined): string {
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时`
}
/** 组织节点数(平台运营按所选学校;其它角色就是自己租户的) */
async function loadOrgCount() {
// 组织树要 `org:view`(班主任/副班主任/审核员都没有);平台没选学校时也不该问
if (!orgCardShowable.value) {
orgCount.value = null
return
}
try {
const res = await orgApi.getOrgTree({ tenantId: actingStore.tenantId })
orgCount.value = countNodes(res.data ?? [])
} catch {
orgCount.value = null
}
}
async function loadTasks() {
// 平台没选学校时不发请求:拿到的是空列表,界面上会显示成"这所学校没有任务"
if (needPickTenant.value) {
taskOptions.value = []
boardTaskId.value = undefined
stat.value = null
audit.value = null
return
}
taskLoading.value = true
try {
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 })
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 }, actingStore.tenantId)
const list = res.data?.list ?? []
taskOptions.value = list.map((x) => ({
label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`,
@@ -308,6 +393,10 @@ async function loadTasks() {
if (collecting) {
boardTaskId.value = collecting.id
loadBoard()
} else {
boardTaskId.value = undefined
stat.value = null
audit.value = null
}
} finally {
taskLoading.value = false
@@ -318,13 +407,17 @@ async function loadBoard() {
if (!boardTaskId.value) return
boardLoading.value = true
try {
// 两个接口互不依赖,并行取;任一个失败不影响另一个显示
// ⚠️ 审核统计要 `progress:viewAll`,班主任没有 —— 不能"调了再 catch":
// 403 是 HTTP 状态码,`http.ts` 的传输层分支会**先弹一个「拒绝访问(403)」**,
// catch 只能拦住异常,拦不住那个提示。所以按权限**决定发不发这个请求**。
const [s, a] = await Promise.allSettled([
taskApi.getTaskStatistics(boardTaskId.value),
auditApi.getAuditStatistics({ taskId: boardTaskId.value })
taskApi.getTaskStatistics(boardTaskId.value, actingStore.tenantId),
canViewAllProgress.value
? auditApi.getAuditStatistics({ taskId: boardTaskId.value }, actingStore.tenantId)
: Promise.resolve(null)
])
stat.value = s.status === 'fulfilled' ? s.value.data : null
audit.value = a.status === 'fulfilled' ? a.value.data : null
audit.value = a.status === 'fulfilled' ? (a.value?.data ?? null) : null
} finally {
boardLoading.value = false
}
@@ -334,13 +427,15 @@ function countNodes(nodes: OrgTreeVO[]): number {
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
}
// 换学校 = 换一整套数字(组织节点、任务下拉、看板)。不清空的话,
// 上一所学校的任务还留在下拉里,选中它看到的是**另一所学校**的进度
watch(() => actingStore.tenantId, () => {
loadOrgCount()
loadTasks()
})
onMounted(async () => {
try {
const res = await orgApi.getOrgTree()
orgCount.value = countNodes(res.data ?? [])
} catch {
orgCount.value = null
}
await loadOrgCount()
if (isPlatform.value) {
try {
const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
+69 -16
View File
@@ -1,6 +1,6 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<a-card :bordered="false" class="g-list-card">
<template #title>操作日志</template>
<template #extra>
<span class="log__hint">
@@ -8,14 +8,6 @@
</span>
</template>
<a-alert type="normal" class="log__tip">
<template #title>
证件号在这里**只有脱敏值与指纹**(日志泄露不该比业务库泄露更严重)。
IP 取代理链第一跳、**可以被伪造** —— 用于排查,不作合规证据;
真正的证据是「哪个账号在什么时候做了什么」。
</template>
</a-alert>
<div class="g-row-tool">
<a-space wrap>
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
@@ -58,9 +50,27 @@
</a-space>
</div>
<!--
`y: '100%'` 不是装饰:它让 Arco 把表头与表体**拆开**
(内部 `splitTable`),表体自己滚、表头钉住、分页器留在卡片底部。
没有它的话,`.arco-table-content` 的 `overflow-y` 是 hidden(横向滚动条专用),
多出来的行会被**直接裁掉**,而且没有任何滚动条能救回来。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1200 }" @page-change="onPageChange">
:scroll="{ x: isPlatform ? 1340 : 1200, y: '100%' }" @page-change="onPageChange"
@page-size-change="onPageSizeChange">
<template #columns>
<!--
「租户」列**只给平台运营看**:它看的是全量日志(跨租户),
不显示单位就分不清"这条是哪个学校做的";学校管理员永远是本租户,列没有意义。
-->
<a-table-column v-if="isPlatform" title="租户" :width="140">
<template #cell="{ record }">
<span class="log__muted">
{{ tenantNames[record.tenantId] ?? (record.tenantId != null ? `#${record.tenantId}` : '—') }}
</span>
</template>
</a-table-column>
<a-table-column title="时间" :width="150">
<template #cell="{ record }">
<span class="log__muted">{{ fmt(record.createTime) }}</span>
@@ -68,8 +78,12 @@
</a-table-column>
<a-table-column title="操作人" :width="120">
<template #cell="{ record }">
<div>{{ record.operatorName || '—' }}</div>
<div class="log__muted">ID {{ record.operatorId ?? '—' }}</div>
<!--
⚠️ 只显示**账号/姓名**,不显示用户 ID(2026-10-09 客户要求)。
数字 ID 对看日志的人没有意义 —— 他要知道的是"哪个账号做的",
ID 只在排查时要,而那种时候查库比看这一列快。
-->
<span>{{ record.operatorName || '—' }}</span>
</template>
</a-table-column>
<a-table-column title="动作" :width="150">
@@ -119,6 +133,8 @@ import {
OPERATION_ACTIONS,
SENSITIVE_ACTIONS
} from '@/apis/xt/log'
import { getTenantList } from '@/apis/xt/tenant'
import { useUserStore } from '@/stores'
/**
* 操作日志(《03-接口设计》§13.4)。
@@ -134,9 +150,22 @@ import {
*
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
* 而合规核查往往是几十条连着看的。
*
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
*
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定(`LogController.logScopeTenantId`)。
* ⚠️ 平台运营看到的每条日志属于哪个单位,靠这一列「租户」——它**只对平台显示**。
* 以前平台的 tenantId 是 0 而日志挂在各租户下,于是这一屏对平台**永远是空的**,
* 且不报任何错(PRD 要求的却是「全量操作日志 ✅」)。
*/
defineOptions({ name: 'OperationLogs' })
const userStore = useUserStore()
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const tenantNames = ref<Record<number, string>>({})
const loading = ref(false)
const list = ref<OperationLog[]>([])
const total = ref(0)
@@ -220,20 +249,44 @@ function onPageChange(page: number) {
load()
}
/**
* 改每页条数。
*
* ⚠️ **必须处理这个事件**,不能只处理 `page-change`:
* Arco 的表格在 `pagination` 非 false 时会**自己再切一次数据**
* (`flattenData`:`data.length > pageSize` 就 slice)。
* 只在本地改每页条数、不重新取数的话,会出现
* 「分页器显示 50 条/页、表里还是上一次那 20 条」这种自相矛盾的画面 ——
* 看起来就是"分页器坏了"。所以这里一律回到第 1 页重新取。
*/
function onPageSizeChange(size: number) {
query.pageSize = size
query.pageNum = 1
load()
}
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
onMounted(async () => {
if (!isPlatform.value) return
try {
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
tenantNames.value = Object.fromEntries((res.data?.list ?? []).map((t) => [t.id, t.tenantName]))
} catch {
tenantNames.value = {}
}
})
load()
</script>
<style lang="scss" scoped>
/* 卡片/表格的高度链路(分页器为什么必须钉在底部)见 `styles/global.scss` 的 `.g-list-card` */
.log {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__tip {
margin-bottom: 12px;
}
&__num {
width: 110px;
}
+9 -2
View File
@@ -52,9 +52,16 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
* 1. 组织编码租户内唯一
* 2. 组织类型必须比上级更低一级
* 3. 修改时不提供改上级(避免误操作把整棵子树搬走)
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:它决定这个组织建到哪所学校下
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。学校管理员传 null,行为与以前一致。
*/
defineOptions({ name: 'OrgFormModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{
(e: 'save-success'): void
}>()
@@ -132,7 +139,7 @@ async function confirm() {
orgType: form.orgType,
orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0
})
}, props.tenantId)
Message.success('已保存')
} else {
await orgApi.addOrg({
@@ -141,7 +148,7 @@ async function confirm() {
orgType: form.orgType,
orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0
})
}, props.tenantId)
Message.success('已新增')
}
visible.value = false
+67 -7
View File
@@ -13,11 +13,34 @@
<span class="org__hint">{{ totalNodes }} 个节点</span>
</template>
<!--
平台运营必须先选学校:组织树本来就是租户内的东西,而平台的租户过滤是关掉的。
不选就请求,拿到的是"所有租户的组织混在一棵树里"—— 只有一所学校有数据时,
它和学校管理员看到的完全一样,看不出错。
-->
<GiTenantPicker
v-if="isPlatform"
v-model="actingStore.tenantId"
class="org__tenant"
placeholder="选择学校(平台运营)"
/>
<a-input-search v-model="keyword" placeholder="搜索名称或编码" allow-clear class="org__search" />
<a-spin v-if="loading" class="org__loading" />
<!--
⚠️ Arco 的 `a-empty` 里一旦给了默认插槽,`description` 就**不再渲染**
(它的实现是 `<slot>{{ description }}</slot>`)—— 所以文案要写在插槽里,
否则页面上只剩半句话。
-->
<a-empty v-if="needPickTenant">
<div class="org__empty-title">请先选择学校</div>
<span class="org__hint">组织架构按学校隔离,选定学校后即可查看与维护</span>
</a-empty>
<a-empty v-else-if="!tree.length" description="还没有组织">
<a-spin v-else-if="loading" class="org__loading" />
<a-empty v-else-if="!tree.length">
<div class="org__empty-title">还没有组织</div>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd(null)">
新增顶级组织
</a-button>
@@ -35,7 +58,7 @@
</a-tree>
<template #footer>
<a-button v-if="canManage" long size="small" @click="onAdd(null)">
<a-button v-if="canManage && !needPickTenant" long size="small" @click="onAdd(null)">
<template #icon><IconPlus /></template>
新增顶级组织
</a-button>
@@ -111,7 +134,7 @@
</a-card>
</template>
<OrgFormModal ref="formModalRef" @save-success="load" />
<OrgFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
</GiPageLayout>
</template>
@@ -121,7 +144,7 @@ import { Message } from '@arco-design/web-vue'
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
import { useUserStore } from '@/stores'
import { useActingTenantStore, useUserStore } from '@/stores'
import OrgFormModal from './OrgFormModal.vue'
/**
@@ -135,6 +158,18 @@ defineOptions({ name: 'Org' })
const userStore = useUserStore()
/**
* 平台运营的「当前学校」。
*
* 服务端对平台关掉了租户过滤,所以平台**必须先指明看哪所学校**才能拿到租户内的数据
* (见 `@/apis/xt/acting-tenant`)。选择存在 `useActingTenantStore` 里,
* 与「账号管理」共用 —— 切页面不用重选,也不会出现两个页面各看一所学校。
* 学校管理员的 `isPlatform` 为 false,这个值恒为 undefined —— 不传头,行为与以前一致。
*/
const actingStore = useActingTenantStore()
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
const loading = ref(false)
const tree = ref<OrgTreeVO[]>([])
const selected = ref<OrgTreeVO | null>(null)
@@ -174,9 +209,16 @@ function findById(nodes: OrgTreeVO[], id: number): OrgTreeVO | null {
}
async function load() {
// 平台没选学校时不发请求(服务端会返回空树,徒增一次往返与一次困惑)
if (needPickTenant.value) {
tree.value = []
selected.value = null
selectedKeys.value = []
return
}
loading.value = true
try {
const res = await orgApi.getOrgTree({ withCount: true })
const res = await orgApi.getOrgTree({ withCount: true, tenantId: actingStore.tenantId })
tree.value = res.data ?? []
// 刷新后保持选中
if (selected.value) {
@@ -187,6 +229,15 @@ async function load() {
}
}
// 换学校 = 换一整棵树:不清空的话,上一所学校的节点会留在页面上,
// 而"点进去看到的是别人学校的组织"这种事没有任何报错会提醒你
watch(() => actingStore.tenantId, () => {
selected.value = null
selectedKeys.value = []
keyword.value = ''
load()
})
/**
* 树节点选中。
*
@@ -214,7 +265,7 @@ function onEdit(row: OrgTreeVO) {
}
async function onDelete(row: OrgTreeVO) {
await orgApi.deleteOrg(row.id)
await orgApi.deleteOrg(row.id, actingStore.tenantId)
Message.success('已删除')
if (selected.value?.id === row.id) {
selected.value = null
@@ -236,6 +287,10 @@ onMounted(load)
margin-bottom: 10px;
}
&__tenant {
margin-bottom: 10px;
}
&__loading {
display: block;
padding: 40px 0;
@@ -247,6 +302,11 @@ onMounted(load)
color: var(--color-text-3);
}
&__empty-title {
margin-bottom: 4px;
color: var(--color-text-1);
}
&__node {
display: inline-flex;
gap: 6px;
+69 -12
View File
@@ -1,11 +1,20 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<a-card :bordered="false" class="g-list-card">
<template #title>收集任务</template>
<template #extra>
<span class="task__hint">共 {{ total }} 个任务</span>
</template>
<!--
状态切换:**单独一行的 tab**(2026-10-09 客户要求),不再和
「新建任务 / 批次号 / 搜索」挤在同一行做成一个个按钮 ——
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
-->
<a-tabs class="task__tabs" :active-key="activeStatusKey" hide-content @change="onStatusChange">
<a-tab-pane v-for="t in STATUS_TABS" :key="t.key" :title="t.label" />
</a-tabs>
<div class="g-row-tool">
<a-space wrap>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd">
@@ -15,10 +24,6 @@
</a-space>
<a-space wrap>
<a-button v-for="c in statusChips" :key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
{{ c.label }}
</a-button>
<a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch"
@press-enter="search" />
<a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear
@@ -38,8 +43,13 @@
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
-->
<!--
`y: '100%'`:让 Arco 拆开表头与表体(表体自己滚、表头钉住),
配合卡片上的 `.g-list-card` 把分页器留在卡片底部。原因见 `styles/global.scss`。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 820 }" @page-change="onPageChange">
:scroll="{ x: 820, y: '100%' }" @page-change="onPageChange"
@page-size-change="onPageSizeChange">
<template #columns>
<a-table-column title="任务" :width="200" ellipsis tooltip>
<template #cell="{ record }">
@@ -181,6 +191,13 @@ import TaskRosterDrawer from './TaskRosterDrawer.vue'
*/
defineOptions({ name: 'Task' })
/** 状态 tab 的一项:tab 的 key 是字符串,发给接口的是 status */
interface StatusTab {
key: string
label: string
value: number | undefined
}
const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('task:manage'))
@@ -214,14 +231,26 @@ const BLOCKING_HINTS: Record<number, string> = {
40020: '命名规则有问题,请点「编辑」检查文件命名模板'
}
const statusChips = [
{ label: '全部', value: undefined },
{ label: '草稿', value: TASK_STATUS_DRAFT },
{ label: '收集中', value: TASK_STATUS_COLLECTING },
{ label: '已暂停', value: TASK_STATUS_PAUSED },
{ label: '已截止', value: TASK_STATUS_CLOSED }
/**
* 状态 tab。
*
* `key` 必须是字符串(Arco 的 tab key 不支持 undefined),
* `value` 才是发给接口的 `status` —— 「全部」= 不带 status 参数,
* 这个映射只在这一处定义,别在别处再写一份 `?? undefined`。
*/
const STATUS_TABS: StatusTab[] = [
{ key: 'all', label: '全部', value: undefined },
{ key: String(TASK_STATUS_DRAFT), label: '草稿', value: TASK_STATUS_DRAFT },
{ key: String(TASK_STATUS_COLLECTING), label: '收集中', value: TASK_STATUS_COLLECTING },
{ key: String(TASK_STATUS_PAUSED), label: '已暂停', value: TASK_STATUS_PAUSED },
{ key: String(TASK_STATUS_CLOSED), label: '已截止', value: TASK_STATUS_CLOSED }
]
/** 当前选中的 tab(由 statusFilter 反推,保持单一数据源) */
const activeStatusKey = computed(() =>
STATUS_TABS.find((t) => t.value === statusFilter.value)?.key ?? 'all'
)
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
@@ -261,6 +290,23 @@ function setStatus(v: number | undefined) {
search()
}
/** 状态 tab 切换 */
function onStatusChange(key: string | number) {
setStatus(STATUS_TABS.find((t) => t.key === String(key))?.value)
}
/**
* 改每页条数。
*
* ⚠️ 与「操作日志」同一处坑:Arco 表格在 `pagination` 非 false 时**自己也会切数据**,
* 不重新取数就会出现「分页器说 50 条/页、表里还是 10 条」的自相矛盾画面。
*/
function onPageSizeChange(size: number) {
query.pageSize = size
query.pageNum = 1
load()
}
function onPageChange(page: number) {
query.pageNum = page
load()
@@ -407,6 +453,17 @@ onMounted(load)
color: var(--color-text-3);
}
/* 状态 tab 自己占一行(下面的工具栏是另一行) */
&__tabs {
:deep(.arco-tabs-nav) {
margin-bottom: 12px;
}
:deep(.arco-tabs-nav::before) {
background-color: var(--color-border-2);
}
}
&__batch {
width: 120px;
}
+27 -4
View File
@@ -150,9 +150,17 @@ import { useUserStore } from '@/stores'
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"这个账号建到哪所学校下"
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定角色的可选范围:
* 给某所学校建账号时只能发**学校那套角色**,发 PLATFORM_ADMIN 会被服务端拒(40301)。
*/
defineOptions({ name: 'UserFormModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{
(e: 'save-success'): void
}>()
@@ -187,8 +195,17 @@ const getInitForm = () => ({
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
const form = reactive(getInitForm())
/**
* 可选角色。
*
* ⚠️ 判据不只是"我是不是平台租户",还要看**有没有代管学校**:
* · 平台运营 + 没选学校 → 建的是平台自己的账号 → 只能发 PLATFORM_ADMIN;
* · 平台运营 + 选了学校 → 建的是**那所学校**的账号 → 只能发学校那套角色
* (发 PLATFORM_ADMIN 会被服务端按租户白名单拒掉,报 40301);
* · 学校管理员 → 学校那套。
*/
const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
isPlatformTenant.value && !props.tenantId ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
)
/**
@@ -228,10 +245,16 @@ const rules: FormInstance['rules'] = {
async function loadOrgTree() {
if (orgTree.value.length) return
const res = await getOrgTree()
const res = await getOrgTree({ tenantId: props.tenantId })
orgTree.value = res.data ?? []
}
// 换了学校就把缓存的组织树丢掉:留着上一所学校的树,用户会在"组织"下拉里
// 看到别人学校的班级,而选中它提交会得到一个指向不存在组织的 orgId
watch(() => props.tenantId, () => {
orgTree.value = []
})
/** 新增 */
async function add() {
detailId.value = null
@@ -306,7 +329,7 @@ async function confirm() {
roleCodes: form.roleCodes,
userType,
status: form.status
})
}, props.tenantId)
Message.success('已保存')
visible.value = false
emit('save-success')
@@ -320,7 +343,7 @@ async function confirm() {
orgId: form.orgId ?? null,
roleCodes: form.roleCodes,
userType
})
}, props.tenantId)
const data = res.data
if (!data) {
Message.error('创建失败,请重试')
+12 -2
View File
@@ -174,9 +174,16 @@ import { useUserStore } from '@/stores'
* ② 哪几行没进去、为什么
* ③ 新账号的初始密码(只此一次)
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"导进哪所学校"
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定默认角色的可选范围。
*/
defineOptions({ name: 'UserImportModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{
(e: 'save-success'): void
}>()
@@ -191,8 +198,11 @@ const report = ref<UserImportResult | null>(null)
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
/** 与 UserFormModal 同一口径:代管某所学校时只能发学校那套角色 */
const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
isPlatformTenant.value && !props.tenantId
? PLATFORM_ROLE_OPTIONS
: SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
)
/** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
@@ -228,7 +238,7 @@ async function submit() {
if (!file.value) return
submitting.value = true
try {
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode)
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode, props.tenantId)
report.value = res.data ?? null
emit('save-success')
} finally {
+48 -9
View File
@@ -10,7 +10,17 @@
</a-space>
</template>
<a-spin v-if="orgLoading" class="user__loading" />
<!-- 平台运营必须先选学校:账号与组织都是租户内的数据(见 @/apis/xt/acting-tenant) -->
<GiTenantPicker
v-if="isPlatformTenant"
v-model="actingStore.tenantId"
class="user__tenant"
placeholder="选择学校(平台运营)"
/>
<a-empty v-if="needPickTenant" description="请先选择学校" />
<a-spin v-else-if="orgLoading" class="user__loading" />
<template v-else>
<a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'"
@@ -151,8 +161,8 @@
</a-table>
</a-card>
<UserFormModal ref="formModalRef" @save-success="load" />
<UserImportModal ref="importModalRef" @save-success="load" />
<UserFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
<UserImportModal ref="importModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
<!-- 重置密码结果:与创建一样,只显示这一次 -->
<a-modal v-model:visible="resetVisible" title="密码已重置" :width="460" :mask-closable="false"
@@ -192,7 +202,7 @@ import { IconCopy, IconFolder, IconPlus, IconUpload } from '@arco-design/web-vue
import { getOrgTree } from '@/apis/xt/org'
import * as userApi from '@/apis/xt/user'
import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } from '@/apis/xt/user'
import { useUserStore } from '@/stores'
import { useActingTenantStore, useUserStore } from '@/stores'
import UserFormModal from './UserFormModal.vue'
import UserImportModal from './UserImportModal.vue'
@@ -215,6 +225,17 @@ const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('user:manage'))
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
/**
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
*
* 服务端对平台是关掉租户过滤的,不指明学校就会把**所有租户的账号**混在一起返回 ——
* 而那种错看起来很正常(列表有数据、分页也对)。学校管理员不带这个头,行为不变。
*
* 选择与「组织架构」共用同一个 store:切页面不用重选,两个页面也不会各看一所学校。
*/
const actingStore = useActingTenantStore()
const needPickTenant = computed(() => isPlatformTenant.value && !actingStore.tenantId)
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
const loading = ref(false)
@@ -255,9 +276,13 @@ const pagination = computed(() => ({
}))
async function loadOrgTree() {
if (needPickTenant.value) {
orgTree.value = []
return
}
orgLoading.value = true
try {
const res = await getOrgTree()
const res = await getOrgTree({ tenantId: actingStore.tenantId })
orgTree.value = res.data ?? []
} finally {
orgLoading.value = false
@@ -265,6 +290,11 @@ async function loadOrgTree() {
}
async function load() {
if (needPickTenant.value) {
list.value = []
total.value = 0
return
}
loading.value = true
try {
const res = await userApi.getUserList({
@@ -274,7 +304,7 @@ async function load() {
status: statusFilter.value,
pageNum: query.pageNum,
pageSize: query.pageSize
})
}, actingStore.tenantId)
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
@@ -282,6 +312,15 @@ async function load() {
}
}
// 换学校 = 换一整套数据(组织树、列表、已选的班级都要重来)。
// 留着上一所学校的 orgId 去查新学校,会得到"一个空的列表" —— 看起来像这所学校没有账号。
watch(() => actingStore.tenantId, () => {
orgId.value = undefined
query.pageNum = 1
loadOrgTree()
load()
})
function search() {
query.pageNum = 1
load()
@@ -325,7 +364,7 @@ async function onToggleStatus(row: UserVO, status: number) {
content: '停用后该账号将无法登录,但数据保留,可以随时再启用。',
hideCancel: false,
onBeforeOk: async () => {
await userApi.setUserStatus(row.id, status)
await userApi.setUserStatus(row.id, status, actingStore.tenantId)
Message.success(`已${action}`)
await load()
resolve()
@@ -335,7 +374,7 @@ async function onToggleStatus(row: UserVO, status: number) {
})
})
}
await userApi.setUserStatus(row.id, status)
await userApi.setUserStatus(row.id, status, actingStore.tenantId)
Message.success(`已${action}`)
await load()
}
@@ -346,7 +385,7 @@ async function onResetPassword(row: UserVO) {
content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。',
hideCancel: false,
onBeforeOk: async () => {
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' })
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' }, actingStore.tenantId)
resetResult.value = res.data ?? null
resetVisible.value = true
return true
+546
View File
@@ -0,0 +1,546 @@
# -*- coding: utf-8 -*-
"""接线检查:找「定义好了、代码里也写了,但**从来没有被接上**」的东西。
## 为什么需要它(这一族错在本仓库反复出现)
盘点这个项目时抓到的 P0/P1 问题里,有一大类长着同一个样子:
| 例子 | 现象 |
|---|---|
| F-1 | `OperationLogMapper` 存在,但**全仓库只有它自己 import** → 敏感操作一个字都没留痕 |
| F-31 | `LoginLogMapper` 在写,但**微信登录那条链路不写** → 合规上查不到学生登录 |
| F-50 | `collect_validation_issue` 表 V1 就建好了,**一直没有代码用它** |
| F-24/35 | 实体要的审计列,建表时漏了 → 插入报错(四次) |
| F-52/67 | 契约里定义了接口,但没有"把链路接上"的那一步,功能等于装饰 |
它们的共同点:**每一处单看都是对的**(类在、表在、常量在、注释写得很清楚),
所以 code review、typecheck、编译、单元测试**全都不会红** —— 只有"从入口走一遍"
才会暴露。而人不会每次都走一遍全链路。
所以把它固化成命令:**列出"声明了但没有任何地方引用"的那些声明**,
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
而不是靠下次再想一遍)。
## 查七件事
| # | 查什么 | 没接上的后果 |
|:--:|---|---|
| 1 | `PermissionDef` 的权限码没有任何 `@RequirePerm` 用它 | 权限码是死的(或接口忘了标权限) |
| 2 | `PermissionDef` 的权限码没有被任何内置角色分配 | **这个接口谁都调不了**(只有平台租户能绕过) |
| 3 | `ErrorCode` 的码零引用 | 文档里有、代码里没有对应分支 |
| 4 | `NotifyTemplates` 的站内信模板零发送点 | 模板页能改,但那条通知根本不发 / 不走模板 |
| 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 |
| 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
### 第 7 项是 2026-10-09 补的(自己踩了一次)
在工作台按权限决定"发不发请求"时,我把 `progress:viewAll` 写成了 `progress:view:all`
(多一个冒号)。后果是那个判断**恒为 false**:「审核情况」整块对**所有人**都不显示 ——
而编译、typecheck、lint、后端日志、甚至 E2E **全都不红**。
这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**;
而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。
## 它**不查**什么(免得被当成"全绿就没事")
- 不查"逻辑对不对":它只回答"有没有被引用过",一个被引用了但用错地方的常量
它照样算通过;
- 不查前端有没有把权限码用上(`v-permission` 之类)—— 那要跨语言解析模板;
- 不查服务层"备好了没人调"的公开方法(如本轮发现的
`NotifyTemplateService.templateOf`)—— 这类误报太多,靠人去读;
所以第 4 项查的是"**模板码有没有发送点**",那是它能可靠判定的那一层。
跑法:`python scripts/check-wiring.py`(`--quiet` 只打摘要,给 `verify-all.py` 用)
退出码:0 = 没有未接线的声明;1 = 有(每条都打出来)。
"""
import argparse
import glob
import io
import os
import re
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(ROOT)
PERM_DEF = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionDef.java'
BUILTIN_ROLE = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java'
ERROR_CODE = 'backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java'
NOTIFY_TPL = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java'
MESSAGE_ENTITY = ('backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/'
'entity/CollectMessage.java')
XT_ROUTES = 'frontend-pc/src/config/xt-routes.ts'
# ── 已知例外:**有意不接线的**,写在这里(连同理由),不要靠下次再想一遍 ──
# 权限码:没有任何地方校验它(既没有 @RequirePerm,也没有服务层的 permissionChecker)
PERM_NO_CHECK = {
'ROLE_MANAGE': '「角色与授权」页是二期功能(权限码先定下来,见 08 文档 6n):'
'一期不开放自定义角色,所以没有任何接口需要它',
'ROSTER_MANAGE_OWN': '「本班名册」不是独立接口,而是 roster 接口里的**数据范围**分支'
'(服务层按角色判 orgIds),接口级权限用 ROSTER_MANAGE / ROSTER_VIEW',
'PROGRESS_VIEW_OWN': '同上:进度看板的"本班/本人"是同一接口的范围分支(见 ProgressService)',
'SUBMISSION_OWN': '学生端接口靠**登录态 + 只能操作本人那一份**保证,'
'不走权限码(学生是 MP 端唯一角色,加一道权限码只会多一个出错点)',
'EXPORT_UNMASKED': '脱敏导出开关 `desensitize` 是**显式保留到二期**的(名册里本来就是'
'脱敏值,见 ExportDtos 的注释),所以这个码暂时没有对应接口',
}
# 权限码:被校验了,但没有被任何内置角色分配(= 谁都调不了)
PERM_NO_ROLE = {}
# PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
FE_PERM_EXCEPTION = {}
ERROR_CODE_UNUSED = {
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
'SUSPECTED_WRONG_OWNER': '同上(M4,串档检测)',
'FILE_OWNERSHIP_DOUBT': '同上(M4,VAL-21 归属校验)',
'OCR_FAILED': '同上(M4,OCR 服务异常)',
'IMAGE_TOO_LARGE_CIRCUIT_BREAK': '同上(M4,图片熔断)',
'VLM_FAILED': '同上(M4,VLM 识别)',
'FILE_PROCESS_FAILED': '内部错误:统一由 GlobalExceptionHandler 兜成 50000 返回,'
'细分码留在文档里给排查用(客户端拿到的仍是"系统繁忙")',
'NOTIFY_FAILED': '同上(通知失败不影响主流程,只记日志)',
'STORAGE_FAILED': '同上(存储失败由 FileProcessService 转成 50001 口径的业务提示)',
'SIGNED_URL_EXPIRED': '签名链接过期由**存储层**返回(七牛 401 → StorageService 转成'
'可读提示),没有单独抛这个码;契约 §12.6 的语义已由提示覆盖',
'IDEMPOTENT_REPLAY': '契约 §9.4 的幂等键回放:一期用"重复提交返回既有结果"实现,'
'没有单独回这个码(要回它得先有幂等键,属二期)',
'REJECT_LIMIT_EXCEEDED': '驳回达上限**不是错误**:驳回本身成功了,'
'只是把 escalated 标出来(契约 §11.4「超限升级管理员」),'
'所以走的是响应字段而不是错误码 —— 见 docs/08 F-105',
}
NOTIFY_TPL_UNUSED = {
'TASK_DEADLINE': '临期提醒的自动发送走「自动催报」(BIZ 5 + URGE / URGE_DRAFT 模板),'
'所以这个码只在模板页里可编辑;要单独发"临期提醒"得先有"未绑定学生的账号",'
'见 docs/08 F-108 的记录',
'TASK_PUBLISH': '「发布即通知学生」在本系统的流程里**没有收件人**:'
'学生要先扫码核验才会绑定账号,而发布之前没有人会去扫 —— '
'所以这条通知发出去只会是 0 条(见 docs/08 F-108)',
}
BIZ_UNUSED = {
'BIZ_TASK_PUBLISH': '同上:bizType 1(任务发布)与 TASK_PUBLISH 模板同因,'
'在"发布时刻没有可发的收件人"这件事解决之前不发',
}
def read(path):
return io.open(path, encoding='utf-8').read()
def strip_java_comments(src):
"""把注释替换成空白(**保留换行**,行号才不会错位)。
⚠️ 必须先去注释再数引用:本仓库的注释极其啰嗦,而且**专门在解释"为什么不这么做"** ——
例如 `ExportController` 的类注释里写着"权限只有两个:export:download 与 export:unmasked",
而代码里从来没校验过 `EXPORT_UNMASKED`。不剥注释的话,这个检查会被自己的文档满足
(假阴性),而那正是《08》F-36 踩过的坑:**静态检查要能区分"代码"与"解释代码的文字"**。
"""
out = []
i, n = 0, len(src)
state = None # None / 'line' / 'block' / 'str' / 'char'
while i < n:
c = src[i]
nxt = src[i + 1] if i + 1 < n else ''
if state is None:
if c == '/' and nxt == '/':
state = 'line'
out.append(' ')
i += 2
continue
if c == '/' and nxt == '*':
state = 'block'
out.append(' ')
i += 2
continue
if c == '"':
state = 'str'
elif c == "'":
state = 'char'
out.append(c)
i += 1
continue
if state == 'line':
if c == '\n':
state = None
out.append('\n')
else:
out.append(' ')
i += 1
continue
if state == 'block':
if c == '*' and nxt == '/':
state = None
out.append(' ')
i += 2
continue
out.append('\n' if c == '\n' else ' ')
i += 1
continue
# 字符串 / 字符字面量里:原样保留(模板码就是字符串),只处理转义与结束符
if c == '\\':
out.append(src[i:i + 2])
i += 2
continue
if (state == 'str' and c == '"') or (state == 'char' and c == "'"):
state = None
out.append(c)
i += 1
return ''.join(out)
def java_sources():
out = {}
for p in glob.glob('backend/**/*.java', recursive=True):
try:
out[p] = strip_java_comments(read(p))
except Exception:
pass
return out
def refs(sources, name, skip_files):
"""名字作为**整个标识符**(不是子串)在其它文件里出现过几次。"""
pat = re.compile(r'\b' + re.escape(name) + r'\b')
hits = []
for p, src in sources.items():
if os.path.abspath(p) in skip_files:
continue
for i, line in enumerate(src.split('\n'), 1):
if pat.search(line):
hits.append((p, i, line.strip()))
return hits
def set_of_blocks(src):
"""把每个 `Set.of( ... )` 的参数原文取出来(括号配对,能吃下注释与换行)。
⚠️ 第一版用正则 `Set\\.of\\(\\s*((?:"[^"]*"\\s*,?\\s*)+)\\)` 匹配 ——
往菜单集合里加一行 `//` 注释之后它就**静默不匹配了**,于是 8 个菜单键
全被报成"没有任何角色声明"(假阳性)。静态检查里凡是"匹配不到就当没有"的写法,
都要先问一句"匹配不到时会发生什么"。
"""
out = []
for m in re.finditer(r'Set\.of\(', src):
i = m.end()
depth = 1
while i < len(src) and depth > 0:
if src[i] == '(':
depth += 1
elif src[i] == ')':
depth -= 1
i += 1
out.append(src[m.end():i - 1])
return out
def strip_line_comments(block):
return '\n'.join(re.sub(r'//.*$', '', ln) for ln in block.split('\n'))
def strip_js_comments(src):
"""去掉 JS/TS 的注释,**但要认引号**(不能见到 `//` 就砍)。
⚠️ 为什么需要它:第 ⑦ 项第一版把 `directives/hasPerm.ts` 里的
**注释与示例字符串**(`<button v-hasPerm="['home:btn:add']">`、报错文案里的示例)
当成了真实用法,一口气报了 5 条假阳性 —— 模板作者的示例码当然不在 PermissionDef 里。
这与本脚本 2026-10-08 修过的第 ③ 条是同一个教训(当时是 Java 注释里的提及
被当成接线):**检查器必须能区分"代码"与"解释代码的文字"**,
否则它要么天天误报、要么被人加一堆例外表绕过去。
"""
out = []
i, n = 0, len(src)
quote = None # 当前是否在字符串里(' 或 ")
while i < n:
c = src[i]
if quote:
out.append(c)
if c == '\\' and i + 1 < n: # 转义:连同下一个字符一起吞掉
out.append(src[i + 1])
i += 2
continue
if c == quote:
quote = None
i += 1
continue
if c in '\'"`':
quote = c
out.append(c)
i += 1
continue
if c == '/' and i + 1 < n and src[i + 1] == '/':
while i < n and src[i] != '\n':
i += 1
continue
if c == '/' and i + 1 < n and src[i + 1] == '*':
i += 2
while i + 1 < n and not (src[i] == '*' and src[i + 1] == '/'):
i += 1
i += 2
out.append(' ')
continue
out.append(c)
i += 1
return ''.join(out)
def template_send_points(sources, code, tpl_file):
"""站内信模板的"发送点" = 代码里有人把这个码当成模板码用。
⚠️ 判据不能用"标识符出现次数":`JOIN_APPROVE` 同时是
`OperationLogService.ACTION_JOIN_APPROVE`(操作日志动作码)与
`NotificationService.TPL_JOIN_APPROVE`(微信订阅消息模板码)的值 ——
按标识符数会得出"3 处引用",而它当时**一个发送点都没有**。
所以认两件事:
① 字面量 `"CODE"` 出现在非"常量声明"的行上;
② 以 `"CODE"` 为值的常量被**在通知调用的上下文里**引用
(±6 行窗口里出现 `ofTemplate` / `NotifyRequest` / `notify(` / `subjectCode`)——
因为发送点常常写成 `SUBJECT_URGE` 这种具名常量,字面量根本不出现。
②是**启发式**:它可能被别的写法骗过(窗口里恰好有这些词)。它是烟雾报警器,不是证明。
"""
hits = []
aliases = {}
for p, s in sources.items():
for line in s.split('\n'):
m = re.search(r'static final String\s+(\w+)\s*=\s*"' + re.escape(code) + r'"', line)
if m:
aliases[m.group(1)] = p
lit = re.compile(r'"' + re.escape(code) + r'"')
for p, s in sources.items():
if os.path.abspath(p) == os.path.abspath(tpl_file):
continue
for i, line in enumerate(s.split('\n'), 1):
if lit.search(line) and not re.search(r'static final String\s+\w+\s*=', line):
hits.append((p, i, line.strip()))
ctx = re.compile(r'ofTemplate|NotifyRequest|notify\(|subjectCode')
for name in aliases:
pat = re.compile(r'\b' + name + r'\b')
for p, s in sources.items():
lines = s.split('\n')
for i, line in enumerate(lines, 1):
if not pat.search(line) or re.search(r'static final String\s+' + name + r'\b', line):
continue
window = '\n'.join(lines[max(0, i - 7):i + 5])
if ctx.search(window):
hits.append((p, i, line.strip()))
return hits
def menu_exception(key):
"""菜单键的已知例外(不是 PC 端菜单,或由基础路由提供)。"""
if key == 'dashboard':
return 'PC 工作台是**基础路由**(不在 XtMenu 里,所有登录用户都能看到)'
if key.startswith('mp-'):
return '这是**小程序**的 tabBar 键(学生端菜单),PC 端当然没有'
return None
def main():
ap = argparse.ArgumentParser(description='接线检查')
ap.add_argument('--quiet', action='store_true')
args = ap.parse_args()
def say(*a):
if not args.quiet:
print(*a)
src = java_sources()
problems = [] # (类别, 名字, 说明)
notes = [] # 已知例外,照样打出来(不能静默)
say('=' * 88)
say(' 接线检查 「声明了、但没有任何地方引用它」')
say('=' * 88)
# ── ① 权限码:有没有地方校验它 ────────────────────────────
perm_src = read(PERM_DEF)
perms = re.findall(r'^\s{4}([A-Z][A-Z0-9_]*)\(', perm_src, re.M)
# @RequirePerm 的用法(可能跨行、可能 `{A, B}` 多条)
require_perm = set()
for s in src.values():
for block in re.findall(r'@RequirePerm\s*\(([^)]*)\)', s, re.S):
require_perm.update(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', block))
role_perm = set(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', read(BUILTIN_ROLE)))
# 服务层校验(permissionChecker.has / require):**同样是校验点** ——
# ⚠️ 第一版只数 @RequirePerm,于是把 AUDIT_WITHDRAW 报成"没有接口用它",
# 而它明明在 AuditService 里用 permissionChecker.require 守了两处(F-36 的教训:
# 静态检查必须先确认"它数的那件事"就是"要判定的那件事")。
service_perm = set()
skip_perm_files = {os.path.abspath(PERM_DEF), os.path.abspath(BUILTIN_ROLE)}
for p, s in src.items():
if os.path.abspath(p) in skip_perm_files:
continue
service_perm.update(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', s))
say('\n【①】权限码 %d 个(PermissionDef)' % len(perms))
for name in perms:
checked = name in service_perm or name in require_perm
in_role = name in role_perm
if checked and in_role:
where = '接口注解' if name in require_perm else '服务层'
say(' ✓ %-22s %s' % (name, where))
continue
# 有例外登记的照实打出来(**不能打 ✓**:那会让"未接线"看起来像已接线)
why = []
if not checked:
why.append(PERM_NO_CHECK.get(name) or '没有任何地方校验它(@RequirePerm 与服务层都没有)')
if not in_role:
why.append(PERM_NO_ROLE.get(name) or '没有被任何内置角色分配(这个接口谁都调不了)')
if (not checked and name in PERM_NO_CHECK) or (not in_role and name in PERM_NO_ROLE):
notes.append('权限码 %s —— 已知例外:%s' % (name, ';'.join(why)))
say(' — %-22s 已知例外:%s' % (name, ';'.join(why)[:60]))
else:
problems.append(('权限码', name, ';'.join(why)))
say(' ✗ %-22s %s' % (name, ';'.join(why)))
# ── ② 错误码:有没有被引用 ────────────────────────────────
ec_src = read(ERROR_CODE)
codes = re.findall(r'^\s{4}([A-Z][A-Z0-9_]*)\((\d+),', ec_src, re.M)
skip = {os.path.abspath(ERROR_CODE)}
say('\n【②】错误码 %d 个(ErrorCode)' % len(codes))
unused_codes = []
for name, num in codes:
if name == 'SUCCESS':
continue
if not refs(src, 'ErrorCode.' + name, skip):
unused_codes.append((name, num))
for name, num in unused_codes:
if name in ERROR_CODE_UNUSED:
notes.append('错误码 %s(%s) 零引用 —— 已知例外:%s' % (name, num, ERROR_CODE_UNUSED[name]))
else:
problems.append(('错误码', '%s(%s)' % (name, num), '全仓库零引用(除定义处)'))
say(' 零引用的码:%s' % ('、'.join('%s(%s)' % (n, c) for n, c in unused_codes) or '无'))
# ── ③ 站内信模板:有没有发送点 ────────────────────────────
tpl_src = read(NOTIFY_TPL)
tpls = re.findall(r'new Def\("([A-Z_]+)",\s*(\d+)', tpl_src)
say('\n【③】站内信模板 %d 个(NotifyTemplates)' % len(tpls))
for code, biz in tpls:
hits = template_send_points(src, code, NOTIFY_TPL)
if hits:
say(' ✓ %-14s biz=%s %d 处引用(%s)'
% (code, biz, len(hits), os.path.basename(hits[0][0])))
elif code in NOTIFY_TPL_UNUSED:
notes.append('站内信模板 %s 零发送点 —— 已知例外:%s' % (code, NOTIFY_TPL_UNUSED[code]))
say(' — %-14s biz=%s 零发送点(已知例外)' % (code, biz))
else:
problems.append(('站内信模板', code, '没有任何发送点(模板页能改,但这条通知不发)'))
say(' ✗ %-14s biz=%s **零发送点**' % (code, biz))
# 撞名:同一个字面量被两个命名空间当成"自己的码"
say('\n【③b】模板码撞名(不同命名空间、同一个字符串)')
for code, biz in tpls:
owners = []
for p, s in src.items():
for m in re.finditer(r'static final String\s+([A-Z_]+)\s*=\s*"%s"' % re.escape(code), s):
owners.append(m.group(1))
if len(owners) > 1:
say(' ! %-14s 同时是 %s 的值 —— 不是 bug,但下一个改代码的人会当成同一个东西'
% (code, '、'.join(sorted(set(owners)))))
# ── ④ 消息中心 bizType:有没有被用过 ──────────────────────
msg_src = read(MESSAGE_ENTITY)
bizs = re.findall(r'public static final int (BIZ_[A-Z_]+) = (\d+);', msg_src)
skip_msg = {os.path.abspath(MESSAGE_ENTITY)}
say('\n【④】消息中心 bizType %d 个(CollectMessage)' % len(bizs))
for name, num in bizs:
hits = refs(src, name, skip_msg)
if hits:
say(' ✓ %-18s = %s %d 处引用' % (name, num, len(hits)))
elif name in BIZ_UNUSED:
notes.append('bizType %s(%s) 零使用 —— 已知例外:%s' % (name, num, BIZ_UNUSED[name]))
else:
problems.append(('bizType', '%s(%s)' % (name, num),
'零使用(消息中心这个分类永远是空的)'))
say(' ✗ %-18s = %s **零使用**' % (name, num))
# ── ⑤ 内置角色菜单键 ↔ PC 端菜单 ──────────────────────────
role_src = read(BUILTIN_ROLE)
menu_keys = set()
for block in set_of_blocks(role_src):
cleaned = strip_line_comments(block)
# 权限集合里是 `PermissionDef.X`(没有引号)→ 只有"全是字符串字面量"的才是菜单
if re.search(r'PermissionDef\.', cleaned):
continue
menu_keys.update(re.findall(r'"([^"]+)"', cleaned))
pc_keys = set(re.findall(r"key:\s*'([a-zA-Z-]+)'", read(XT_ROUTES)))
say('\n【⑤】菜单键:内置角色声明 %d 个 / PC 端 XtMenu %d 个'
% (len(menu_keys), len(pc_keys)))
for k in sorted(menu_keys - pc_keys):
ex = menu_exception(k)
if ex:
notes.append('菜单键 %s —— 已知例外:%s' % (k, ex))
say(' — %-14s %s' % (k, ex))
else:
problems.append(('菜单键', k, '内置角色声明了它,但 PC 端 XtMenu 里没有这个 key'))
say(' ✗ %-14s 角色声明了,PC 端没有这个 key' % k)
for k in sorted(pc_keys - menu_keys):
problems.append(('菜单键', k, 'PC 端有菜单,但没有任何内置角色声明它(谁都看不到)'))
say(' ✗ %-14s PC 端有,但没有任何角色能看到' % k)
for k in sorted(menu_keys & pc_keys):
say(' ✓ %s' % k)
# ── ⑦ PC 端引用的权限码 ↔ PermissionDef ─────────────────────
#
# 两个写法都认:`userStore.permissions.includes('org:view')` 与
# `v-hasPerm="['audit:final']"`(后者是模板里写的指令参数)。
#
# ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。
# 2026-10-09 加它的时候正是拿一个写错的码试过的(那时是真的写错了)。
perm_codes = set(re.findall(r'\(\s*"([^"]+)"\s*,\s*"', strip_java_comments(read(PERM_DEF))))
fe_codes = {} # code -> 出现位置(文件:行)
for p in sorted(glob.glob('frontend-pc/src/**/*.vue', recursive=True)
+ glob.glob('frontend-pc/src/**/*.ts', recursive=True)):
try:
raw = read(p)
except Exception:
continue
# 先去掉注释,再找用法 —— 否则注释/示例文案里的码会被当成真用法(假阳性)
src = strip_js_comments(raw)
is_vue = p.endswith('.vue')
for i, line in enumerate(src.split('\n'), 1):
for m in re.finditer(r"permissions\.includes\(\s*'([^']+)'", line):
fe_codes.setdefault(m.group(1), []).append('%s:%d' % (p, i))
# `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找
if is_vue:
for m in re.finditer(r"v-hasPerm\s*=\s*\"\[([^\]]*)\]\"", line):
for c in re.findall(r"'([^']+)'", m.group(1)):
fe_codes.setdefault(c, []).append('%s:%d' % (p, i))
say('\n【⑦】PC 端引用的权限码 %d 个 / PermissionDef 声明 %d 个'
% (len(fe_codes), len(perm_codes)))
for code in sorted(fe_codes):
where = fe_codes[code][0]
if code in perm_codes:
say(' ✓ %-22s %s' % (code, where))
elif code in FE_PERM_EXCEPTION:
notes.append('PC 端权限码 %s —— 已知例外:%s' % (code, FE_PERM_EXCEPTION[code]))
say(' — %-22s %s' % (code, FE_PERM_EXCEPTION[code]))
else:
problems.append(('前端权限码', code,
'PermissionDef 里没有这个码(判断会恒为 false:%s)' % where))
say(' ✗ %-22s PermissionDef 里没有它 → 这个判断永远是 false %s' % (code, where))
# ── 汇总 ────────────────────────────────────────────────
print('\n' + '=' * 88)
if notes:
print(' 已知例外 %d 条(有意不接线,写在本脚本上方的表里):' % len(notes))
for n in notes:
print(' — %s' % n)
print(' 未接线的声明 %d 条:' % len(problems))
for kind, name, why in problems:
print(' ✗ [%s] %s —— %s' % (kind, name, why))
if not problems:
print(' (无)')
print('=' * 88)
sys.stdout.flush()
return 1 if problems else 0
if __name__ == '__main__':
raise SystemExit(main())
+191 -11
View File
@@ -1,31 +1,47 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
清理 e2e-m0.py 产生的测试数据。
开发库与本地磁盘的**测试残留**清理。
E2E 每跑一次会:
1. 新建一个租户(编码 e2e######)并给它建角色与账号;
2. **在 demo 租户下**建三个组织(学校/院系/班级,编码带时间戳)。
第 2 点容易被忽略——这些组织挂在正常租户下,不清掉会让组织树里
堆满 "E2E 示范学校 586923" 这种名字。
第 2 点容易被忽略——这些组织挂在正常租户下,不清掉会让组织树里堆满
"E2E 示范学校 586923" 这种名字。
跑几十轮之后真正的问题不是"列表里有测试名字",而是两笔越滚越大的账:
· **租户垃圾**:`sys_tenant` 里 48 个 `e2e*`(每个还带着用户、角色、组织);
· **磁盘垃圾**:`backend/data/uploads` 里几百 MB 的文件 ——
**删库不删盘**。压测与 E2E 会删掉自己的库行,但文件留在磁盘上,
而"哪些文件还有主"只有库知道(`collect_file.std_path/raw_path`、
`collect_export_file.file_path`)。实测:库里 16 行文件、磁盘上 281 个文件。
用法
----
python scripts/dev-clean-e2e.py # 只看会删什么(默认 dry-run)
python scripts/dev-clean-e2e.py --apply # 真删
python scripts/dev-clean-e2e.py --apply # 真删:e2e 租户 + 测试组织 + 它们的业务行
python scripts/dev-clean-e2e.py --apply --demo-tasks # 连【演示·旧】任务一起删
python scripts/dev-clean-e2e.py --apply --orphan-files # 连"没有库行指向"的磁盘文件一起删
⚠️ 只在开发库上跑,不要对生产执行。
⚠️ `--orphan-files` 删的是**磁盘文件**,不可恢复;它靠"库里没有任何一行指向它"来判定,
所以只要库是完整的,它就不会删到在用的文件。默认 dry-run,先看一眼再 `--apply`。
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UPLOADS = os.path.join(ROOT, "backend", "data", "uploads")
# 子表在前、主表在后(无外键约束,顺序只为可读性与排查方便)
TENANT_CHILD_TABLES = [
("角色授权", "sys_role_permission"),
@@ -45,6 +61,13 @@ DEMO_ORG_FILTER = (
"OR org_name LIKE 'E2E %' OR org_name LIKE '重复编码%' OR org_name LIKE '层级颠倒%'"
)
# 演示残留:给客户演示时反复摆过的任务,为了不污染演示态,已发布的那批被**改名挪开**
# 而不是删掉(F-14:已发布的任务删不掉)。它们属于"当时的演示数据",不是需求数据。
#
# ⚠️ 只匹配带「旧」的那一批:`【演示】审核台` 是**当前**演示态在用的任务,
# 删了它 = 把演示态弄坏(而且它的班级就是下面那批 "E2E 24数控1班",同理不能删)。
DEMO_TASK_FILTER = "task_name LIKE '【演示·旧】%'"
class Db:
def __init__(self, container: str, user: str, name: str):
@@ -62,25 +85,114 @@ class Db:
return [x for x in r.stdout.split("\n") if x.strip()]
def tables_with(db: Db, column: str) -> list[str]:
"""
所有带这一列的 `collect_*` 表。
⚠️ **动态枚举,不写死清单**:写死的话,以后新增一张业务表就会漏清,
而"漏清"的表现是下一次跑 E2E 时某个统计数字莫名偏大 —— 排查方向完全错。
"""
sql = ("SELECT c.table_name FROM information_schema.columns c "
"JOIN information_schema.tables t "
" ON t.table_name = c.table_name AND t.table_schema = c.table_schema "
"WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE' "
f" AND c.table_name LIKE 'collect\\_%' AND c.column_name = '{column}' "
"ORDER BY c.table_name")
return [r.split("|")[0] for r in db.rows(sql)]
def db_file_paths(db: Db) -> set[str]:
"""库里所有被引用的文件路径(相对 uploads 目录)。"""
known: set[str] = set()
for table, cols in (("collect_file", ("raw_path", "std_path")),
("collect_export_file", ("file_path",))):
for col in cols:
for r in db.rows(f"SELECT {col} FROM {table} WHERE {col} IS NOT NULL"):
p = r.split("|")[0].strip().replace("\\", "/")
if not p:
continue
known.add(p)
if os.path.isabs(p):
known.add(os.path.relpath(p, UPLOADS).replace("\\", "/"))
return known
def orgs_referenced_by_live_tasks(db: Db) -> set[int]:
"""
仍被**存活任务**引用的组织 id —— 这些组织绝不能删。
⚠️ 为什么必须有它:当前演示态就挂在其中一个 "E2E 24数控1班" 上
(历史原因:演示种子当时用了 E2E 建的班)。删了组织 = 演示态的审核范围、
导出目录全部失去归属,而**界面上一句话都不会提示**。
引用判断用任务表的 deleted(collect_task_org 没有 deleted 列,不能用它判活)。
"""
keep: set[int] = set()
for r in db.rows(
"SELECT DISTINCT cto.org_id FROM collect_task_org cto "
"JOIN collect_task t ON t.id = cto.task_id AND t.deleted = 0 "
"WHERE cto.org_id IS NOT NULL"):
try:
keep.add(int(r.split("|")[0]))
except ValueError:
pass
return keep
def scan_orphans(db: Db) -> tuple[list[str], int]:
"""磁盘上有、而库里没有任何一行指向的文件。"""
known = db_file_paths(db)
orphans, size = [], 0
for dirpath, _dirs, files in os.walk(UPLOADS):
for fn in files:
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, UPLOADS).replace("\\", "/")
if rel not in known:
orphans.append(rel)
try:
size += os.path.getsize(full)
except OSError:
pass
return sorted(orphans), size
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="真正执行删除(默认只预览)")
ap.add_argument("--demo-tasks", action="store_true",
help="连【演示·旧】任务(及其名册/材料/填报单/文件)一起删")
ap.add_argument("--orphan-files", action="store_true",
help="连磁盘上「没有库行指向」的文件一起删(不可恢复)")
ap.add_argument("--only-orphan-files", action="store_true",
help="**只**清磁盘孤儿文件,不碰租户/组织/任务(最安全的一档)")
ap.add_argument("--container", default="xt-pg")
ap.add_argument("--user", default="fill")
ap.add_argument("--db", default="filldb")
args = ap.parse_args()
if args.only_orphan_files:
args.orphan_files = True
db = Db(args.container, args.user, args.db)
tenants = db.rows(
tenants = [] if args.only_orphan_files else db.rows(
f"SELECT id || '|' || tenant_code || '|' || tenant_name "
f"FROM sys_tenant WHERE {TENANT_FILTER} ORDER BY id")
demo_orgs = db.rows(
# 在用组织保护:先算好(任何删除发生之前),删除阶段只动"没被引用的"
keep_orgs = orgs_referenced_by_live_tasks(db) if not args.only_orphan_files else set()
demo_orgs = [] if args.only_orphan_files else [
r for r in db.rows(
f"SELECT id || '|' || org_name FROM sys_org "
f"WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id")
if int(r.split("|")[0]) not in keep_orgs
]
kept_note = ("(另有 %d 个测试组织被存活任务引用,**保留**:其中就有当前演示态挂着的班)"
% len(keep_orgs)) if keep_orgs else ""
demo_tasks = (db.rows(f"SELECT id || '|' || task_name FROM collect_task "
f"WHERE deleted = 0 AND {DEMO_TASK_FILTER} ORDER BY id")
if (args.demo_tasks and not args.only_orphan_files) else [])
orphans, orphan_size = scan_orphans(db) if args.orphan_files else ([], 0)
if not tenants and not demo_orgs:
print("没有 e2e 测试数据,无需清理")
if not any((tenants, demo_orgs, demo_tasks, orphans)):
print("没有需要清理的测试数据")
return 0
if tenants:
@@ -92,13 +204,32 @@ def main() -> int:
print(f" ... 另有 {len(tenants) - 15} 个")
if demo_orgs:
print(f"\n② 其它租户下的 e2e 测试组织 {len(demo_orgs)} 个:")
print(f"\n② 其它租户下的 e2e 测试组织 {len(demo_orgs)} 个{kept_note}:")
for r in demo_orgs[:15]:
p = r.split("|")
print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else ""))
if len(demo_orgs) > 15:
print(f" ... 另有 {len(demo_orgs) - 15} 个")
if demo_tasks:
print(f"\n③ 演示残留任务 {len(demo_tasks)} 个(--demo-tasks):")
for r in demo_tasks[:10]:
p = r.split("|")
print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else ""))
if len(demo_tasks) > 10:
print(f" ... 另有 {len(demo_tasks) - 10} 个")
if args.orphan_files:
print(f"\n④ 磁盘上没有库行指向的文件 {len(orphans)} 个 / {orphan_size / 1048576.0:.1f} MB"
f"(--orphan-files)")
by_dir: dict[str, int] = {}
for rel in orphans:
by_dir[rel.split("/")[0]] = by_dir.get(rel.split("/")[0], 0) + 1
for k, v in sorted(by_dir.items(), key=lambda x: -x[1]):
print(" %-10s %d 个" % (k, v))
for rel in orphans[:5]:
print(" 例:%s" % rel)
if not args.apply:
print("\n这是预览。加 --apply 才会真正删除。")
return 0
@@ -106,20 +237,69 @@ def main() -> int:
print("\n开始清理:")
total = 0
if not args.only_orphan_files:
for label, table in TENANT_CHILD_TABLES:
n = len(db.rows(f"DELETE FROM {table} WHERE {SUB_FILTER} RETURNING id"))
total += n
print(" %-10s %-22s 删除 %d 行" % (label, table, n))
# e2e 租户的**业务行**:不删的话,租户没了、任务还在,
# 于是"每个请求多一次租户解析"的旧账会一直留着(F-5)
for table in tables_with(db, "tenant_id"):
n = len(db.rows(f"DELETE FROM {table} WHERE tenant_id IN "
f"(SELECT id FROM sys_tenant WHERE {TENANT_FILTER}) RETURNING id"))
if n:
total += n
print(" %-10s %-22s 删除 %d 行" % ("业务行", table, n))
n = len(db.rows(f"DELETE FROM sys_tenant WHERE {TENANT_FILTER} RETURNING id"))
total += n
print(" %-10s %-22s 删除 %d 行" % ("租户", "sys_tenant", n))
n = len(db.rows(f"DELETE FROM sys_org WHERE {DEMO_ORG_FILTER} RETURNING id"))
# 测试组织:按 id 删(keep_orgs 已在预览阶段剔除;这里再校验一次,
# 因为上面可能刚删过任务,引用集合应该只减不增 —— 双保险,宁可少删)
keep_orgs = orgs_referenced_by_live_tasks(db) if not args.only_orphan_files else set()
org_ids = [int(r.split("|")[0]) for r in db.rows(
f"SELECT id FROM sys_org WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id")
if int(r.split("|")[0]) not in keep_orgs]
if org_ids:
# 一次 IN 里塞太多 id 会撑爆语句,分批(144 个一批也行,但分批无成本)
for i in range(0, len(org_ids), 200):
batch = org_ids[i:i + 200]
n = len(db.rows("DELETE FROM sys_org WHERE id IN (%s) RETURNING id"
% ",".join(map(str, batch))))
total += n
print(" %-10s %-22s 删除 %d 行" % ("测试组织", "sys_org", n))
else:
print(" %-10s %-22s 没有可删的(都在用或已清)" % ("测试组织", "sys_org"))
print(f"\n完成,共删除 {total} 行。")
if args.demo_tasks:
# 先按任务 id 删子表(同样动态枚举"带 task_id 的表")
for table in tables_with(db, "task_id"):
n = len(db.rows(f"DELETE FROM {table} WHERE task_id IN "
f"(SELECT id FROM collect_task WHERE {DEMO_TASK_FILTER}) RETURNING id"))
if n:
total += n
print(" %-10s %-22s 删除 %d 行" % ("演示子表", table, n))
n = len(db.rows(f"DELETE FROM collect_task WHERE {DEMO_TASK_FILTER} RETURNING id"))
total += n
print(" %-10s %-22s 删除 %d 行" % ("演示任务", "collect_task", n))
removed = 0
if args.orphan_files:
# ⚠️ 删文件前**重算一次**:上面刚删过库行,此时"还有主"的文件集合变小了,
# 但那些刚被删掉主的文件属于"任务数据",本该跟着一起清 —— 重算正好覆盖它们
orphans, orphan_size = scan_orphans(db)
for rel in orphans:
try:
os.remove(os.path.join(UPLOADS, rel))
removed += 1
except OSError:
pass
print(" %-10s %-22s 删除 %d 个文件 / %.1f MB"
% ("磁盘文件", "backend/data/uploads", removed, orphan_size / 1048576.0))
print(f"\n完成,共删除 {total} 行" + (f"、{removed} 个文件。" if args.orphan_files else "。"))
return 0
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
清理七牛**真实桶**里本项目灌进去的测试对象(《08》F-93:删库不删桶)。
## 背景
本机 `application-local.yml` 里 `provider: qiniu`(bucket=tools-matrix,私有),
历史上联调/压测把测试文件传进去过 —— 库行早删了,**对象还躺在桶里**。
## 安全边界(刻意做窄)
- 只碰 `prefix`(xiaotutongxue/)下的对象 —— 那是本项目的名字空间,
同桶其它项目的文件**一个都不动**;
- **默认 dry-run**:只列清单与体积;`--apply` 才真删;
- 删除走七牛 batch API(一批最多 200 个),逐批打印结果;
- 列表失败 / 数量异常(>5000)直接停,不猜。
## 用法
python scripts/dev-clean-qiniu.py # 只看(数量 / 总体积 / 样例)
python scripts/dev-clean-qiniu.py --apply # 真删
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import json
import os
import sys
import urllib.parse
import urllib.request
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOCAL_YML = os.path.join(ROOT, "backend", "application-local.yml")
HOST = "rs.qiniuapi.com" # 单资源 / batch 操作
HOST_RSF = "rsf.qiniuapi.com" # ⚠️ 列举(/list)在 rsf,不在 rs —— 打错 host 会得到
# 400 "Bad method: list"(我踩过,别按直觉写)
def read_local_cfg() -> tuple[str, str, str, str]:
"""从 application-local.yml 读 AK/SK/bucket/prefix(那本来就是唯一放密钥的地方)。"""
import re
text = open(LOCAL_YML, encoding="utf-8").read()
def grab(key: str) -> str:
# 值可能带单引号(密钥)也可能不带(bucket/prefix)—— 两种都要认
m = re.search(rf"^\s*{key}:\s*['\"]?([^'\"\s]+)['\"]?\s*$", text, re.M)
return m.group(1) if m else ""
ak = grab("access-key")
sk = grab("secret-key")
bucket = grab("bucket")
prefix = grab("prefix") or "xiaotutongxue"
if not (ak and sk and bucket):
print("✗ 在 backend/application-local.yml 里读不到七牛 AK/SK/bucket")
sys.exit(2)
return ak, sk, bucket, prefix
def qbox_sign(sk: str, host: str, method: str, path_query: str,
content_type: str | None = None, body: bytes | None = None) -> str:
"""
七牛管理凭证签名(官方算法,2026-09-30 按文档逐字核对):
signingStr = Method + " " + Path[?query] + "\\n"
+ "Host: " + Host + "\\n"
[+ "Content-Type: " + ct + "\\n"] ← 设了才加
+ "\\n" ← ★ 结尾是**两个**换行
[+ Body] ← ★ 有 Body 且 ct 非 octet-stream 时必须加
Authorization: Qiniu <AK>:<urlsafe_base64(hmac_sha1(SK, signingStr))>
⚠️ 两个都踩过(都报 401 bad token,但原因不同):
① 旧 QBox 格式(一个换行 + "QBox " 前缀);
② /batch 的 Body 没进签名 —— 列举(无 Body)能过、批删全 401,
**同一个签名函数在两种请求上一好一坏**,线索就在 Body 上。
以 developer.qiniu.com/kodo/1201/access-token 的示例为准。
"""
data = f"{method} {path_query}\nHost: {host}\n"
if content_type:
data += f"Content-Type: {content_type}\n"
data += "\n"
if body and content_type and content_type != "application/octet-stream":
data += body.decode()
mac = hmac.new(sk.encode(), data.encode(), hashlib.sha1).digest()
return base64.urlsafe_b64encode(mac).decode()
def call_qiniu(ak: str, sk: str, method: str, path_query: str,
content_type: str | None = None, body: bytes | None = None,
host: str = HOST) -> tuple[int, bytes]:
url = f"http://{host}{path_query}"
req = urllib.request.Request(url, data=body, method=method, headers={
"Authorization": f"Qiniu {ak}:{qbox_sign(sk, host, method, path_query, content_type, body)}",
**({"Content-Type": content_type} if content_type else {}),
})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def list_objects(ak: str, sk: str, bucket: str, prefix: str) -> list[dict]:
out, marker = [], ""
while True:
q = f"bucket={urllib.parse.quote(bucket)}&prefix={urllib.parse.quote(prefix)}&limit=500"
if marker:
q += f"&marker={urllib.parse.quote(marker)}"
code, raw = call_qiniu(ak, sk, "GET", f"/list?{q}", host=HOST_RSF)
if code != 200:
print(f"✗ 列举失败:HTTP {code} {raw[:200]}")
sys.exit(2)
data = json.loads(raw)
out += data.get("items") or []
marker = data.get("marker") or ""
if not marker:
return out
def main() -> int:
ap = argparse.ArgumentParser(description="清理七牛桶里本项目的测试对象(默认 dry-run)")
ap.add_argument("--apply", action="store_true", help="真删(默认只列清单)")
args = ap.parse_args()
ak, sk, bucket, prefix = read_local_cfg()
print(f"bucket={bucket} prefix={prefix}/")
objs = list_objects(ak, sk, bucket, prefix)
total = sum(o.get("fsize", 0) for o in objs)
print(f"共 {len(objs)} 个对象 / {total / 1048576.0:.1f} MB")
for o in objs[:10]:
print(f" {o.get('fsize', 0):>10} B {o.get('key')}")
if len(objs) > 10:
print(f" ... 另有 {len(objs) - 10} 个")
if not objs:
print("桶是干净的,无需清理")
return 0
if len(objs) > 5000:
print("✗ 数量异常(>5000),先人工确认再删 —— 不猜")
return 2
if not args.apply:
print("\n这是预览。加 --apply 才会真正删除(只删该前缀,不可恢复)。")
return 0
# batch delete:一批最多 200 个 op
done = 0
for i in range(0, len(objs), 200):
batch = objs[i:i + 200]
ops = "&".join(
"op=/delete/" + base64.urlsafe_b64encode(
f"{bucket}:{o['key']}".encode()).decode()
for o in batch)
code, raw = call_qiniu(ak, sk, "POST", "/batch",
content_type="application/x-www-form-urlencoded",
body=ops.encode())
ok_n = code // 100 == 2
done += len(batch) if ok_n else 0
print(f" 批 {i // 200 + 1}:HTTP {code}({len(batch)} 个){'' if ok_n else raw[:160]}")
print(f"\n完成:请求删除 {len(objs)} 个,确认 {done} 个。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+256
View File
@@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
"""
已归档任务可删(§17.2 D-9 / F-14,2026-09-30 客户确认)的 E2E。
## 背景
原口径"已发布的任务删不掉也改不了名(40001)"—— 于是演示/测试任务只能改名挪开,
任务列表只增不减。现在:**草稿直接删;已归档的显式删(级联软删 + 尽力删文件 + 留痕);
收集中/已截止仍然拒绝**(归档是"最后的后悔药")。
## 本脚本验什么
1. 收集中 → 删被拒,提示要说清"先归档"(不是一句笼统的 40001);
2. 已截止 → 仍然拒(活数据不提供删除口子);
3. 已归档 → 删成功:**子表级联物理删净**(名册/材料/覆盖班级/审核记录——大头清掉),
**任务行本身软删**(占住 `task_code` 不复用:编码是文件目录名,重号会混两批文件),
且 `sys_operation_log` 有一条 `DELETE_TASK`;
4. **删除后新建任务不重号**(编码不复用 —— 第一版做成物理删时,就是这条被
`e2e-m1-task` 的"编码递增"断言抓到的);
5. 删完再删 → 明确报错(不存在),不是 500;
6. 草稿 → 直接删(回归原行为)。
跑法:python scripts/e2e-m1-task-delete.py(后端 8080 + xt-pg 在跑)
"""
import argparse
import importlib.util
import json
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = "http://localhost:8080"
SUFFIX = os.urandom(3).hex()
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-roster-del-%s.xlsx" % SUFFIX)
PASSED = []
FAILED = []
CREATED = []
def _load(name, fn):
spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
def ok(name):
PASSED.append(name)
print(" [OK] " + name)
def fail(name, detail):
FAILED.append((name, detail))
print(" [FAIL] " + name + "\n " + detail)
def check(name, cond, detail=""):
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return bool(cond)
def sql(stmt):
_, out = R.sql(stmt)
return out
def body_of(method, path, token=None, payload=None):
_, b = R.call(method, BASE + path, token, payload)
return b
def build_roster(rows):
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "删除班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, idc) in enumerate(rows):
vals = [i + 1, name, idc, "中专", "1380013770%d" % i, "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=i + 2, column=c, value=v)
wb.save(TMP_ROSTER)
def main():
argparse.ArgumentParser(description="已归档任务删除 E2E").parse_args()
print("=" * 74)
print(" 已归档任务删除(§17.2 D-9 / F-14)E2E")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(" x 管理员登录失败:" + str(adm))
return 2
at = adm["data"]["token"]
# ── 0. 夹具:一个已发布的任务(材料 + 班级 + 名册 2 人)────────
print("\n[0] 摆夹具")
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E删除 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2099-12-31"
})
# ⚠️ 每一步都查 code:夹具不成立时越早报清楚,越好排查(F-96/F-99 的教训)
if b.get("code") != 0:
print(" x 建任务失败:" + json.dumps(b, ensure_ascii=False)[:200])
return 2
tid = b["data"]["id"]
CREATED.append(tid)
_, b = R.call("GET", BASE + "/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
# 班级(orgType=3)在树的**下层**,只扫顶层会一个都找不到
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
org = classes[0]
body_of("PUT", "/api/v1/tasks/%d/orgs" % tid, at,
{"orgs": [{"orgId": org["id"], "expectedCount": 2}]})
body_of("POST", "/api/v1/tasks/%d/materials/apply-template" % tid, at,
{"templateCode": "TPL_SCHOOL_ROLL"})
build_roster([("删除甲", R.with_checksum("36102920080909031")),
("删除乙", R.with_checksum("36102920080909032"))])
body = R.upload_roster(BASE, at, tid, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("夹具:名册 2 人导入", body.get("code") == 0
and (body.get("data") or {}).get("created") == 2,
json.dumps(body, ensure_ascii=False)[:200])
body_of("POST", "/api/v1/tasks/%d/publish" % tid, at, {"force": True})
st = sql("select status from collect_task where id=%d;" % tid).strip()
check("夹具:任务已发布(status=1)", st == "1", "status=%s" % st)
# ── 1. 收集中:删除被拒,且提示要说清怎么办 ─────────────────
print("\n[1] 收集中 → 拒删")
d1 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
check("收集中删除被拒", d1.get("code") != 0, json.dumps(d1, ensure_ascii=False)[:200])
check("⭐ 拒绝的话说清「先归档」(不是一句笼统的失败)",
"归档" in (d1.get("msg") or ""), "msg=%s" % d1.get("msg"))
check("任务还在(拒绝不是误删)",
sql("select count(*) from collect_task where id=%d and deleted=0;" % tid).strip() == "1")
# ── 2. 截止(status=2):仍然拒 ────────────────────────────
print("\n[2] 已截止 → 仍拒(活数据不给删除口子)")
body_of("POST", "/api/v1/tasks/%d/close" % tid, at, {})
st2 = sql("select status from collect_task where id=%d;" % tid).strip()
check("截止成功(status=2)", st2 == "2", "status=%s" % st2)
d2 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
check("已截止仍然拒删", d2.get("code") != 0, json.dumps(d2, ensure_ascii=False)[:200])
# ── 3. 归档(status=3)→ 删除成功且级联 ────────────────────
print("\n[3] 已归档 → 删除成功(级联软删 + 留痕)")
body_of("POST", "/api/v1/tasks/%d/archive" % tid, at, {})
st3 = sql("select status from collect_task where id=%d;" % tid).strip()
check("归档成功(status=3)", st3 == "3", "status=%s" % st3)
d3 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
check("删除成功", d3.get("code") == 0, json.dumps(d3, ensure_ascii=False)[:200])
# ★ 任务行**软删**而不是物理删:它要继续占住 task_code ——
# maxSeqByCodePrefix 靠"含软删行的最大序号"保证编码不复用(编码是文件目录名)。
# (第一版做成了物理删,e2e-m1-task 的"编码递增"断言当场抓到重号 —— 这就是那条回归)
check("⭐ 任务行软删(deleted=1,占住编码不复用;子表才是物理删)",
sql("select deleted from collect_task where id=%d;" % tid).strip() == "1")
check("⭐ 名册级联删净(含证件号密文的学生数据不留残留)",
sql("select count(*) from collect_roster where task_id=%d;" % tid).strip() == "0",
"残留=%s" % sql("select count(*) from collect_roster where task_id=%d;" % tid).strip())
check("⭐ 材料清单级联删净",
sql("select count(*) from collect_material_def where task_id=%d;" % tid).strip() == "0")
check("⭐ 覆盖班级级联删净",
sql("select count(*) from collect_task_org where task_id=%d;" % tid).strip() == "0")
check("⭐ 审核记录也一并删净(这些表没有 deleted 列,物理删;留痕在操作日志)",
sql("select count(*) from collect_audit_record where task_id=%d;" % tid).strip() == "0")
log_row = sql("select count(*) from sys_operation_log where action='DELETE_TASK' "
"and target_id=%d;" % tid).strip()
check("⭐ 留痕:sys_operation_log 有 DELETE_TASK(谁删的、删了多少,要答得出来)",
log_row >= "1", "行数=%s" % log_row)
# ── 3b. 编码不复用(删除腾出来的号不能发给下一个任务)────────
old_code = sql("select task_code from collect_task where id=%d;" % tid).strip()
_, nb = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E删除后新建 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
new_code = ((nb.get("data") or {}).get("taskCode") or "")
check("⭐ 删除后新建任务不重号(编码不复用 —— 它是文件目录名,重号会混两批文件)",
nb.get("code") == 0 and new_code != "" and new_code != old_code,
f"删的={old_code} 新的={new_code}")
if (nb.get("data") or {}).get("id"):
CREATED.append(nb["data"]["id"])
# ── 4. 删完再删 → 明确报错,不是 500 ──────────────────────
print("\n[4] 删完再删")
d4 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
check("再删 → 明确报错(不存在/已删)", d4.get("code") != 0,
json.dumps(d4, ensure_ascii=False)[:200])
check("报的不是系统错误(50000)", d4.get("code") != 50000,
"code=%s" % d4.get("code"))
# ── 5. 草稿:直接删(回归原行为)─────────────────────────
print("\n[5] 草稿 → 直接删")
_, b5 = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E删除草稿 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
did = (b5.get("data") or {}).get("id")
check("草稿建好", did is not None, json.dumps(b5, ensure_ascii=False)[:160])
d5 = body_of("DELETE", "/api/v1/tasks/%d" % did, at)
check("草稿直接删成功", d5.get("code") == 0, json.dumps(d5, ensure_ascii=False)[:160])
cleanup()
print("\n" + "=" * 74)
print(" 通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED),
len(PASSED) + len(FAILED)))
print("=" * 74)
return 1 if FAILED else 0
def cleanup():
# 正常路径已经把任务删干净了;这里兜底失败/中断的残留
for tid in CREATED:
for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record",
"collect_file", "collect_submission", "collect_roster",
"collect_task_org", "collect_material_def"):
R.sql("delete from %s where task_id=%d;" % (tbl, tid))
R.sql("delete from collect_task where id=%d;" % tid)
if CREATED:
R.sql("delete from sys_operation_log where action='DELETE_TASK' "
"and target_id in (%s);" % ",".join(str(t) for t in CREATED))
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务与级联行 / 操作日志 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())
+4 -1
View File
@@ -436,7 +436,10 @@ def main() -> int:
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token)
check("已归档不可删除", body.get("code") != 0, f"code={body.get('code')}")
# 2026-09-30 客户确认(§17.2 D-9):已归档的任务**可以**删除(子表级联物理删、
# 任务行软删占住编码)。旧断言"已归档不可删除"随旧口径一起作废。
check("已归档可以删除(2026-09-30 起,级联清理 + 留痕)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 8. 草稿路径与删除 ───────────────────────────────
print("\n[8] 草稿路径与删除")
+11 -1
View File
@@ -192,6 +192,15 @@ def main() -> int:
f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'")
check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}")
# ── 4b. 登录留痕(2026-09-30 补上,F-31)────────────────
# 此前只有 PC 账号密码登录写 sys_login_log,学生从微信进来完全无痕 ——
# 按 §13.4 查"学生登录记录"永远是空的,合规上是个缺口
okk5, lls = sql("SELECT count(*) FROM sys_login_log "
f"WHERE user_id = {uid1.strip()} AND login_type = 2 AND success = 1")
check("⭐ 微信登录也写 sys_login_log(login_type=2)",
okk5 and int(lls.strip()) >= 2,
f"行数={lls.strip()}(本脚本已做两次 stub-login,应各留一条)")
# ── 5. 没有 scene 时不猜租户 ─────────────────────────
print("\n[5] 没带 scene 时")
code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}")
@@ -219,8 +228,9 @@ def main() -> int:
print("\n[7] 清理测试数据")
for uid in CREATED_USER_IDS:
sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}")
sql(f"DELETE FROM sys_login_log WHERE user_id = {uid}")
sql(f"DELETE FROM sys_user WHERE id = {uid}")
ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号")
ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号(含其登录日志)")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
+527
View File
@@ -0,0 +1,527 @@
# -*- coding: utf-8 -*-
"""通知接线 E2E(《08-完成度盘点》F-101 ~ F-107)。
这个脚本验的不是"某个接口返回 200",而是**"配置改了之后,系统真的按新配置做事"** ——
也就是本轮修掉的那一类"看起来有、其实没用":
| 编号 | 原来是什么样 | 这个脚本怎么证明它现在真的生效 |
|---|---|---|
| F-101 | 「通知模板」页能改文案,但**只有手工发送走模板**;驳回/通过/催报这些自动通知把文案硬编码在 Java 里 | 改掉「驳回通知」→ 触发一次真驳回 → 断言学生收到的是**改过的那句** |
| F-101b | 模板停用开关对自动通知没有任何作用 | 停用模板 → 触发 → 断言**一条都没发**,且接口如实回 `notifySent=false` |
| F-102 | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现") | 学生申请 → 断言班主任收到;审批通过/驳回 → 断言学生收到 |
| F-103 | 导出完成后不通知(异步打包,用户只能守着进度条) | 建导出 → 等完成 → 断言发起人收到,且**正文里没有签名 URL** |
| F-104 | 自动催报只有 7/3/1 天,**缺"截止当日"**(FR-1003 原文有) | 截止日=今天的任务 → 手工触发催报 → 断言未提交的人收到、已提交的人没收到 |
| F-105 | 驳回达上限只写一行日志,而契约说"同时通知管理员" | 连驳 3 次 → 断言管理员收到升级通知 |
| F-106 | 「字典管理」「通知记录」两个菜单**没有任何角色持有** → 页面点不到 | 管理员登录 → 断言 `/auth/me` 的 menus 里有这两个键 |
| F-107 | 6 个权限码定义了却没挂到任何接口(如 `task:clone`) | 断言班主任做克隆动作被 `40301` 拦住 |
跑法:python scripts/e2e-m5-notify-wiring.py
前置:后端 8080 在跑、xt-pg 在跑(与其它 e2e 相同)。
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
import time
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_TASK_IDS: list[int] = []
CREATED_TPL_IDS: list[int] = [] # 本脚本自定义过的通知模板行(清理时**只删自己建的**)
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-wiring-roster.xlsx")
T1 = 993801 # 班主任(初审人)
S1, S2, S3 = 993811, 993812, 993813 # 三个学生(各自不同用途)
JOINER = 993814 # 走「申请加入名单」的学生(不在名册里)
def _load(module_name: str, filename: str):
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
S = _load("e2e_m3_submit", "e2e-m3-submit.py")
A = _load("e2e_m5_audit", "e2e-m5-audit.py")
BASE = "http://localhost:8080"
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> str:
_, out = R.sql(stmt)
return out
def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict:
_, b = R.call(method, f"{BASE}{path}", token, payload)
return b
def mp(uid: int) -> str:
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def latest_msg(uid: int, biz: int) -> tuple[str, str]:
"""某人某类的最新一条站内信(标题, 正文)。没有就回空串。"""
out = sql(f"select title || '\t' || content from collect_message "
f"where user_id={uid} and biz_type={biz} and deleted=0 order by id desc limit 1;")
parts = out.strip().split("\t")
return (parts[0], parts[1]) if len(parts) == 2 else ("", "")
def msg_count(uid: int, biz: int) -> int:
v = sql(f"select count(*) from collect_message where user_id={uid} and biz_type={biz} "
f"and deleted=0;").strip()
return int(v or 0)
def find_template(token: str, code: str, task_id: int | None = None) -> dict:
q = f"/api/v1/notify/templates?taskId={task_id}" if task_id else "/api/v1/notify/templates"
for t in (body_of("GET", q, token).get("data") or []):
if t.get("templateCode") == code:
return t
return {}
def customize_template(token: str, code: str) -> dict:
"""把内置模板复制成库里一行(契约漏了这一步,见 F-52),返回可编辑的那一行。"""
body_of("POST", f"/api/v1/notify/templates/{code}/customize", token)
return find_template(token, code)
def ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str:
# ⚠️ `ON CONFLICT ... DO UPDATE` 必须**把 username 也更新**:
# 只更新 real_name 时,上一轮留下的行会保持旧用户名,
# 而脚本按新用户名登录 → "账号或密码错误"(看起来像密码哈希不对,其实是用户名没换)。
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status)
VALUES ({uid}, 1, '{username}', '{name}',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org_id}, 3, 1)
ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username,
real_name = EXCLUDED.real_name,
org_id = EXCLUDED.org_id,
deleted = 0, status = 1
""")
role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' "
"and deleted=0 limit 1;").strip()
sql(f"delete from sys_user_role where user_id={uid};")
sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);")
_, b = R.login(BASE, username, "Admin@123", "demo")
if b.get("code") != 0:
raise SystemExit(f" 班主任 {username} 登录失败:{b}")
return b["data"]["token"]
def build_roster(rows: list[tuple[str, str]]) -> None:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "通知班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, idc) in enumerate(rows):
vals = [i + 1, name, idc, "中专", f"138001377{i}", "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=i + 2, column=c, value=v)
wb.save(TMP_ROSTER)
def main() -> int:
ap = argparse.ArgumentParser(description="通知接线 E2E")
ap.parse_args()
print("=" * 78)
print(" 通知接线 E2E(F-101 ~ F-107:模板真的生效 / 该发的通知真的发了)")
print("=" * 78)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(f" ✗ 管理员登录失败:{adm}")
return 2
at = adm["data"]["token"]
# ⚠️ 管理员的 uid 要**从登录响应里取**,不能写死 1 —— demo 租户里 id=1 是 platform、
# 学校管理员 admin 是 id=2。写死 1 会让"通知发到 uid=2"看起来像"没发"
# (第一版就是这么错的:功能是好的,断言查错了人)。
admin_uid = adm["data"]["user"]["id"]
# ── 0. 夹具:任务(**截止日 = 今天**,用于验 FR-1003 的「截止当日」)、一个班、一个班主任 ──
print("\n[0] 摆夹具(任务截止日故意设成今天)")
suffix = os.urandom(3).hex()
today = time.strftime("%Y-%m-%d")
_, b = R.call("POST", f"{BASE}/api/v1/tasks", at, {
"taskName": f"E2E通知接线 {suffix}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": today
})
if b.get("code") != 0:
print(f" ✗ 建任务失败:{b}")
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
check("任务创建成功,且截止日 = 今天", True)
_, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
org = classes[0]
t1 = ensure_teacher(T1, "wireT1", "接线班主任", org["id"])
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [
{"orgId": org["id"], "expectedCount": 3, "headUserId": T1}
]})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
rows = [("接线甲", R.with_checksum("36102920080101041")),
("接线乙", R.with_checksum("36102920080202042")),
("接线丙", R.with_checksum("36102920080303043"))]
build_roster(rows)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("名册导入成功(3 人)", body.get("code") == 0,
json.dumps(body, ensure_ascii=False)[:200])
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
check("任务已发布(收集中)", sql(
f"select status from collect_task where id={task_id};").strip() == "1")
# 甲、乙、丙都绑定身份(后面各自有不同的用途)
for uid, (name, idc) in zip((S1, S2, S3), rows):
tok = mp(uid)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": name, "idCardNo": idc})
if b.get("code") != 0:
print(f" ✗ {name} 门禁绑定失败:{b}")
return 2
check("三个学生都绑定了身份", True)
# ⚠️ 加入名单申请要求申请人有 **openid**(契约:同 openid 每任务 ≤2 次,
# 没有 openid 就没法按人限次 → 服务端明确拒绝,见 JoinService.submit)。
# 门禁绑定建出来的账号没有 openid,所以这里显式补一个 —— 这是夹具,不是产品行为。
# ⚠️ 这两位**不在名册里**,也就不会被门禁绑定建号 → 必须先自己建出来再补 openid。
for uid, uname, rname in ((JOINER, "wireJoin1", "接线申请者"),
(993815, "wireJoin2", "接线被拒者")):
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id,
user_type, status, open_id)
VALUES ({uid}, 1, '{uname}', '{rname}',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org["id"]}, 4, 1,
'e2e_wire_{uid}')
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, deleted = 0, status = 1
""")
# ══════════════ F-101:改模板 → 自动驳回真的用新文案 ══════════════
print("\n[1] F-101 模板可配置:自动通知是否真的走模板")
tpl = customize_template(at, "AUDIT_REJECT")
check("「驳回通知」可以自定义(内置 → 库里一行)",
bool(tpl.get("id")) and tpl.get("customized") is True,
json.dumps(tpl, ensure_ascii=False)[:200])
if tpl.get("id"):
CREATED_TPL_IDS.append(tpl["id"])
new_title = f"【E2E改过】材料要重传 {suffix}"
new_content = "【E2E改过】{姓名} 的《{任务名}》被退回:{驳回原因}。涉及:{材料名}"
body_of("PUT", f"/api/v1/notify/templates/{tpl.get('id')}", at,
{"title": new_title, "content": new_content})
after = find_template(at, "AUDIT_REJECT")
check("改完读回来就是新文案", after.get("title") == new_title, f"{after.get('title')}")
# 甲:提交 → 驳回
tok1 = mp(S1)
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok1, {"taskId": task_id})
sid1 = (b.get("data") or {}).get("id")
res = A.prepare_student(tok1, sid1, task_id, {}, False)
check("甲已提交", (res.get("data") or {}).get("submitted") is True,
json.dumps(res, ensure_ascii=False)[:200])
rej = body_of("POST", f"/api/v1/audit/submissions/{sid1}/reject", t1,
{"reasonCode": "R001", "reasonText": "E2E 驳回原因", "materialCodes": ["IDCARD"]})
check("班主任驳回成功", rej.get("code") == 0, json.dumps(rej, ensure_ascii=False)[:200])
title1, content1 = latest_msg(S1, 3)
check("⭐ 学生收到的**是改过的那句标题**(模板真的生效)",
title1 == new_title, f"实际={title1!r}")
check("⭐ 正文也来自模板,且 {姓名}/{任务名}/{驳回原因} 都被替换了",
content1.startswith("【E2E改过】") and "{驳回原因}" not in content1
and "E2E 驳回原因" in content1 and "接线甲" in content1,
f"实际={content1!r}")
rec = sql(f"select content from collect_notify_record where receiver_id={S1} "
f"and biz_type='3' order by id desc limit 1;").strip()
check("通知记录里记的也是渲染后的文案(两个口径不分叉)",
rec.startswith("【E2E改过】") or rec == "", f"实际={rec!r}")
# ── F-101b:模板停用 → 这条自动通知不发 ──
print("\n[2] F-101b 模板停用:自动通知应当不发")
body_of("PUT", f"/api/v1/notify/templates/{tpl.get('id')}", at, {"enabled": False})
tok2 = mp(S2)
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok2, {"taskId": task_id})
sid2 = (b.get("data") or {}).get("id")
A.prepare_student(tok2, sid2, task_id, {}, False)
before = msg_count(S2, 3)
rej2 = body_of("POST", f"/api/v1/audit/submissions/{sid2}/reject", t1,
{"reasonCode": "R001", "reasonText": "E2E 停用测试"})
d2 = rej2.get("data") or {}
check("驳回本身仍然成功(停用模板不该让业务动作失败)", rej2.get("code") == 0,
json.dumps(rej2, ensure_ascii=False)[:200])
check("⭐ 学生**没有**收到站内信(停用 = 不发)",
msg_count(S2, 3) == before, f"驳回前={before} 驳回后={msg_count(S2, 3)}")
check("⭐ 接口如实回 notifySent=false + needManualNotice=true(不假装发了)",
d2.get("notifySent") is False and d2.get("needManualNotice") is True,
json.dumps(d2, ensure_ascii=False)[:200])
# 恢复默认(把库里那行删掉 = 内置文案回来)
body_of("DELETE", f"/api/v1/notify/templates/AUDIT_REJECT", at)
back = find_template(at, "AUDIT_REJECT")
check("恢复默认后读回内置文案", back.get("customized") is False, f"{back.get('customized')}")
# ══════════════ F-102:加入名单申请的通知 ══════════════
print("\n[3] F-102 加入名单申请:提交要通知老师、审批要通知学生")
joiner = mp(JOINER)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner, {
"taskId": task_id, "realName": "接线申请者", "idCardNo": R.with_checksum("36102920080404044"),
"phone": "13800137888", "orgHint": org.get("orgName"), "reason": "E2E 申请"
})
check("学生提交加入申请成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
req_id = (b.get("data") or {}).get("requestId")
t_title, t_content = latest_msg(T1, 7)
check("⭐ 班主任收到了申请通知(以前一个都不发)", bool(t_title), f"title={t_title!r}")
check("通知里说清是谁、哪个班、哪个任务",
"接线申请者" in t_content and (org.get("orgName") or "") in t_content
and "E2E通知接线" in t_content, f"实际={t_content!r}")
a_title, _ = latest_msg(admin_uid, 7) # 学校管理员(SCHOOL_ADMIN,兜底收件人)
check("学校管理员也收到了(任务没配班主任时的兜底)", bool(a_title), f"title={a_title!r}")
before_join = msg_count(JOINER, 7)
ap_body = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at,
{"orgId": org["id"]})
check("审批通过成功", ap_body.get("code") == 0, json.dumps(ap_body, ensure_ascii=False)[:200])
check("⭐ 学生收到了审批结果(站内信 bizType=7)",
msg_count(JOINER, 7) > before_join,
f"审批前={before_join} 审批后={msg_count(JOINER, 7)}")
j_title, j_content = latest_msg(JOINER, 7)
check("结果通知里带任务名,并告诉他可以开始填报了",
"E2E通知接线" in j_content and "填报" in j_content, f"实际={j_content!r}")
# 驳回那条路(换个学生,避免"每任务 2 次"的上限)
JO = 993815
joiner2 = mp(JO)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner2, {
"taskId": task_id, "realName": "接线被拒者", "idCardNo": R.with_checksum("36102920080505045"),
"phone": "13800137999", "orgHint": org.get("orgName"), "reason": "E2E 申请2"
})
req2 = (b.get("data") or {}).get("requestId")
body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at,
{"reasonCode": "NOT_STUDENT", "reason": "E2E 不是本校学生"})
_, rej_content = latest_msg(JO, 7)
check("⭐ 驳回也通知学生,且把原因说清(否则他会反复重提)",
"E2E 不是本校学生" in rej_content, f"实际={rej_content!r}")
# ══════════════ F-103:导出完成的通知 ══════════════
print("\n[4] F-103 导出完成:通知发起人(且正文里不放签名 URL)")
# 先把甲、乙审到通过,导出才有东西可打。
# ⚠️ 两位刚被驳回过(上面两节),必须先**重新提交**才能再进审核队列 ——
# 直接调 approve 会得到"当前状态不对"。重交不用重传材料(材料还在)。
for tok, sid in ((tok1, sid1), (tok2, sid2)):
S.sub(tok, sid, True)
for sid in (sid1, sid2):
body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", t1,
{"actionSource": "BUTTON"})
body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at,
{"actionSource": "BUTTON", "finalOpinion": "E2E 终审"})
passed = sql(f"select count(*) from collect_submission where task_id={task_id} "
f"and status=3 and deleted=0;").strip()
check("两份填报单已终审通过(导出有料可打)", passed == "2", f"status=3 的份数={passed}")
before_exp = msg_count(admin_uid, 6)
created = body_of("POST", "/api/v1/exports", at, {
"taskId": task_id, "exportName": f"E2E通知-{suffix}",
"scope": {"onlyApproved": True}, "format": "ZIP", "generateSummaryExcel": True
})
export_id = (created.get("data") or {}).get("exportId")
check("导出任务已创建", created.get("code") == 0 and isinstance(export_id, int),
json.dumps(created, ensure_ascii=False)[:200])
detail = {}
for _ in range(40):
detail = body_of("GET", f"/api/v1/exports/{export_id}", at).get("data") or {}
if detail.get("status") in (2, 3):
break
time.sleep(1)
check("导出完成", detail.get("status") == 2,
f"status={detail.get('status')} err={detail.get('errorMsg')}")
e_title, e_content = latest_msg(admin_uid, 6)
check("⭐ 发起人收到了「导出完成」(以前只有进度条可守)",
msg_count(admin_uid, 6) > before_exp,
f"导出前={before_exp} 导出后={msg_count(admin_uid, 6)}")
check("通知里带导出包名", f"E2E通知-{suffix}" in (e_title + e_content),
f"title={e_title!r} content={e_content!r}")
check("⭐ 正文里**没有** URL(签名链接不入库、不外发)",
"http" not in e_content and "://" not in e_content, f"实际={e_content!r}")
# ══════════════ F-104:自动催报的「截止当日」 ══════════════
print("\n[5] F-104 自动催报:截止当日那一档(FR-1003)")
# ⚠️ /schedules/run 是**运维动作**(权限走运营侧的 log:view:all),
# 学校管理员调用会被 40301 拦住 —— 所以这里必须用平台账号(与 e2e-m7-schedule 一致)
_, ops = R.login(BASE, "platform", "Platform@123", None)
if ops.get("code") != 0:
fail("平台账号登录", json.dumps(ops, ensure_ascii=False)[:200])
return 2
ops_token = ops["data"]["token"]
# 丙还没提交 → 该被催;甲、乙已提交 → 不该被催
sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}) and biz_type=5;")
run = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"})
check("手工触发自动催报成功", run.get("code") == 0,
json.dumps(run, ensure_ascii=False)[:200])
c_title, c_content = latest_msg(S3, 5)
check("⭐ 截止当日(0 天)也会催 —— 这一档以前是缺的",
msg_count(S3, 5) == 1, f"丙的催报条数={msg_count(S3, 5)}")
check("文案说的是「今天是截止日」而不是「最后一天」(两种急迫程度不同)",
"今天是截止日" in c_content, f"实际={c_content!r}")
check("⭐ 已提交的甲没有被催(催了会让他以为没交成功)",
msg_count(S1, 5) == 0, f"甲的催报条数={msg_count(S1, 5)}")
check("催报正文里带任务名(学生一眼知道是哪件事)",
"E2E通知接线" in c_content, f"实际={c_content!r}")
check("模板变量 {提交进度} 真的被填了(白名单里有、以前永远空)",
"/" in c_content.split("提交进度")[-1][:12] if "提交进度" in c_content else True,
f"实际={c_content!r}")
# ══════════════ F-105:驳回达上限 → 通知管理员 ══════════════
print("\n[6] F-105 驳回达上限:升级通知管理员(契约 §11.4)")
max_reject = 3 # 任务的 audit_config.maxRejectCount 默认 3(见 AuditService.DEFAULT_MAX_REJECT_COUNT)
before_esc = msg_count(admin_uid, 3)
# 丙:提交 → 驳回,重复到上限
tok3 = mp(S3)
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok3, {"taskId": task_id})
sid3 = (b.get("data") or {}).get("id")
A.prepare_student(tok3, sid3, task_id, {}, False)
for i in range(max_reject):
r = body_of("POST", f"/api/v1/audit/submissions/{sid3}/reject", t1,
{"reasonCode": "R001", "reasonText": f"E2E 第 {i + 1} 次驳回"})
if r.get("code") != 0:
fail(f"第 {i + 1} 次驳回", json.dumps(r, ensure_ascii=False)[:200])
break
if i < max_reject - 1:
# 驳回后学生重新提交,才能再驳一次。
# ⚠️ 只调 submit,**不要**再走 prepare_student —— 它会重新上传一遍材料,
# 而"同一材料最多 2 份"的上限会把重交顶掉,于是下一次驳回报"当前状态不对"。
S.sub(tok3, sid3, True)
esc_title, esc_content = latest_msg(admin_uid, 3)
check(f"⭐ 连驳 {max_reject} 次后,管理员收到升级通知(以前只有一行日志)",
msg_count(admin_uid, 3) > before_esc,
f"升级前={before_esc} 升级后={msg_count(admin_uid, 3)}")
check("升级通知里带学生姓名与最后一次驳回原因",
"接线丙" in esc_content and "驳回" in esc_content, f"实际={esc_content!r}")
# ══════════════ F-106:菜单键 ══════════════
print("\n[7] F-106 菜单可见性:字典管理与通知记录")
menus = set((adm.get("data") or {}).get("menus") or [])
check("⭐ 管理员菜单里有「字典管理」(dict)", "dict" in menus, f"menus={sorted(menus)}")
check("⭐ 管理员菜单里有「通知记录」(notify)", "notify" in menus, f"menus={sorted(menus)}")
check("菜单键与 PC 端 XtMenu 对得上(dict/notify 都在路由表里)",
all(k in open(os.path.join(ROOT, "frontend-pc/src/config/xt-routes.ts"),
encoding="utf-8").read() for k in ("'dict'", "'notify'")))
# ══════════════ F-107:权限码真的挂在接口上 ══════════════
print("\n[8] F-107 权限码挂到接口:克隆与整理器")
cl = body_of("POST", f"/api/v1/tasks/{task_id}/clone", t1, {"taskName": "不该建出来"})
check("⭐ 班主任(无 task:clone)克隆任务被 40301 拦住",
cl.get("code") == 40301, f"code={cl.get('code')} msg={cl.get('msg')}")
cl2 = body_of("POST", f"/api/v1/tasks/{task_id}/clone", at, {"taskName": f"接线副本{suffix}"})
check("管理员(有 task:clone)克隆成功", cl2.get("code") == 0,
json.dumps(cl2, ensure_ascii=False)[:200])
if cl2.get("code") == 0:
CREATED_TASK_IDS.append((cl2.get("data") or {}).get("id"))
cu = body_of("POST", "/api/v1/cleanup/jobs/json", t1, {"jobName": "不该建出来"})
check("⭐ 班主任(无 cleanup:run)调整理器被 40301 拦住",
cu.get("code") == 40301, f"code={cu.get('code')} msg={cu.get('msg')}")
cleanup()
print("\n" + "=" * 78)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 78)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
sys.stdout.flush()
return 1 if FAILED else 0
def cleanup() -> None:
for tid in [t for t in CREATED_TASK_IDS if t]:
R.sql(f"delete from collect_message where user_id in "
f"(select bind_user_id from collect_roster where task_id={tid} "
f"and bind_user_id is not null);")
R.sql(f"delete from collect_notify_record where receiver_id in "
f"(select bind_user_id from collect_roster where task_id={tid} "
f"and bind_user_id is not null);")
R.sql(f"delete from collect_export_file where export_id in "
f"(select id from collect_export_task where task_id={tid});")
R.sql(f"delete from collect_export_task where task_id={tid};")
R.sql(f"delete from collect_audit_record where submission_id in "
f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_file where submission_id in "
f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_submission where task_id={tid};")
R.sql(f"delete from collect_join_request where task_id={tid};")
R.sql(f"delete from collect_roster where task_id={tid};")
R.sql(f"delete from collect_task_org where task_id={tid};")
R.sql(f"delete from collect_material_def where task_id={tid};")
R.sql(f"delete from collect_form_schema where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};")
# 模板的覆盖行:**只删本脚本建的那几行**(删掉 = 恢复内置)。
# ⚠️ 不能写成 `delete from collect_notify_template where tenant_id=1` ——
# 那会把演示态里别人改过的模板一起删掉(而"演示态不能被脚本弄坏"是这个仓库的硬约束)。
for tid in CREATED_TPL_IDS:
R.sql(f"delete from collect_notify_template where id={tid};")
# 本脚本造的学生/申请人/班主任
R.sql(f"delete from collect_message where user_id in "
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
R.sql(f"delete from collect_notify_record where receiver_id in "
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
R.sql(f"delete from sys_user_role where user_id in "
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
R.sql(f"delete from sys_user where id in ({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 导出 / 申请 / 消息 / 通知记录 / 模板覆盖行 / 测试账号")
if __name__ == "__main__":
raise SystemExit(main())
+25
View File
@@ -344,6 +344,31 @@ def main() -> int:
# ── 9. 边界与权限 ──
print("\n[9] 权限与边界")
# ── 9a. 分钟级限流(§3「催报 10 次/分钟/任务」,2026-09-30 补上,F-29)──
# 先清计数再连打 11 次:断言必须**确定**第 11 次是 42901,
# 而"前面已经打了几次"取决于本脚本之前的用例 —— 靠清零把变量拿掉
print(" [9a] 分钟级限流(10 次/分钟/任务)")
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:urge:task:%d > NUL 2>&1' % task_id)
got_limit = None
for i in range(11):
rl = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0})
if rl.get("code") == 42901:
got_limit = i + 1
break
check("⭐ 连点到第 11 次被限流(42901,而不是白跑 11 次全表查询)",
got_limit == 11, "第 %s 次触发(应为 11,前 10 次都要正常)" % got_limit)
check("限流的话说清等多久",
"频繁" in (body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id,
"excludeRecentHours": 0}).get("msg") or ""),
"(再打一次仍应是 42901)")
# 清掉计数,别影响后续/重复运行
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:urge:task:%d > NUL 2>&1' % task_id)
stok = mp(S1)
b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id})
check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403),
+21 -10
View File
@@ -292,11 +292,21 @@ def main():
# ── 4. 审核超时提醒 ──
print("\n[4] 审核超时提醒(时效与 §11.6 同口径)")
# 乙的填报单刚提交 → 还没超时,先跑一次证明不发
#
# ⚠️ 断言必须**限定到本次夹具的收件人**,不能数全库的"审核超时"消息:
# 超时扫描是跨租户的,库里只要存在**别的**老填报单(几天前别的脚本留下的、
# 或者上一轮跑到一半留下的),它就会照样提醒 —— 于是这条断言会在
# **几天之后突然变红**(那些单子的 submit_time 终于超过 48 小时了),
# 而代码一行都没改。第一版就是这么写的,它在本轮真的红了。
# 判据改成"这次涉及的两个人有没有收到",就与库里的历史数据无关了。
def timeout_msgs_to(uid: int) -> str:
return sql("select count(*) from collect_message where user_id=%d and biz_type=2 "
"and title like '%%审核超时%%';" % uid)
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)",
sql("select count(*) from collect_message where biz_type=2 "
"and title like '%%审核超时%%';") == "0",
sql("select count(*) from collect_message where biz_type=2;"))
timeout_msgs_to(STU2) == "0",
"乙收到的审核超时提醒 = " + timeout_msgs_to(STU2))
# 把提交时间改到 5 天前(> 48 小时)→ 应该提醒
st = one("select status from collect_submission where task_id=%d and submitter_id=%d;"
% (urge_task, STU2))
@@ -304,20 +314,21 @@ def main():
"submission.status = " + st)
R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' "
"where task_id=%d and submitter_id=%d;" % (urge_task, STU2))
R.sql("delete from collect_message where biz_type=2 and title like '%%审核超时%%';")
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
n = sql("select count(*) from collect_message where biz_type=2 "
"and title like '%%审核超时%%';")
check("⭐ 超时 5 天的被提醒了", n != "0", "提醒条数 = " + n)
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';")
check("⭐ 超时 5 天的被提醒了", n != "0", "本次夹具的提醒条数 = " + n)
check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)",
"48" in sql("select content from collect_message where biz_type=2 "
"and title like '%%审核超时%%' order by id desc limit 1;"),
sql("select content from collect_message where biz_type=2 order by id desc limit 1;"))
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%' "
"order by id desc limit 1;"),
sql("select content from collect_message where biz_type=2 "
"and content like '%%E2E 自动催报%%' order by id desc limit 1;"))
check("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)",
sql("select count(distinct user_id) from collect_message where biz_type=2 "
"and title like '%%审核超时%%';") != "0",
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';") != "0",
sql("select user_id, count(*) from collect_message where biz_type=2 "
"group by user_id;").replace("\n", " | "))
"and content like '%%E2E 自动催报%%' group by user_id;").replace("\n", " | "))
# ── 5. 原始件清理 ──
print("\n[5] 原始件清理(raw_expire_time 到期)")
+43 -2
View File
@@ -262,12 +262,52 @@ def main():
check("⭐ 模板的 useCount 被 +1(用户能看出哪个模板常用)",
use_after == use_before + 1, "前 %d → 后 %d" % (use_before, use_after))
# 只应用 FORM:应该 skipped 且说明原因
# 只应用 FORM(公共模板没带表单定义):应该明确 skipped 且说明原因
ap2 = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["FORM"]})
check("⭐ 只应用 FORM 时明确 skipped + 原因(公共模板的 form_schema 为空)",
len((ap2.get("data") or {}).get("skipped") or []) >= 1,
json.dumps(ap2.get("data"), ensure_ascii=False)[:300])
# ── FORM 的完整链路(2026-09-30 接上,F-64:此前只能"跳过并说明",模板的 FORM 部分是装饰)──
print(" FORM 完整链路:存表单 → 存模板(带表单)→ 套用到另一个草稿")
fs = body_of("GET", "/api/v1/tasks/%d/form-schema" % tgt, at).get("data") or {}
save_fs = body_of("PUT", "/api/v1/tasks/%d/form-schema" % tgt, at, {
"baseVersion": fs.get("version"),
"fields": fs.get("fields") or [],
# ⚠️ 扩展字段的 key 必须以 extra_ 开头(服务端规则,写错会 40001)
"extras": [{"key": "extra_e2e", "label": "E2E扩展字段", "type": "text"}],
})
check("⭐ 给草稿任务存一份表单(v1,带一个扩展字段)",
save_fs.get("code") == 0 and (save_fs.get("data") or {}).get("version") == 1,
json.dumps(save_fs, ensure_ascii=False)[:200])
tpl2 = body_of("POST", "/api/v1/tasks/%d/save-as-template" % tgt, at,
{"templateName": "E2E表单模板" + suffix})
check("存为模板成功", tpl2.get("code") == 0, json.dumps(tpl2, ensure_ascii=False)[:160])
tpl2_id = ((tpl2.get("data") or {}).get("templateId"))
has_fs = sql("select (form_schema is not null)::text from collect_rule_template where id=%s;"
% tpl2_id).strip()
check("⭐ 模板把表单定义也存进去了(此前存模板不存表单,F-64 的前一半)",
has_fs == "true", "form_schema is not null = %s" % has_fs)
_, b3 = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": "E2E套表单 " + suffix, "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
tgt2 = b3["data"]["id"]
CREATED_TASK_IDS.append(tgt2)
apf = body_of("POST", "/api/v1/rule-templates/%s/apply" % tpl2_id, at,
{"targetTaskId": tgt2, "parts": ["FORM"]})
check("⭐ FORM 部分真的应用了(不再「跳过并说明」)",
"FORM" in ((apf.get("data") or {}).get("applied") or []),
json.dumps(apf.get("data"), ensure_ascii=False)[:200])
rows2 = sql("select count(*) from collect_form_schema where task_id=%d;" % tgt2).strip()
check("⭐ 目标任务有了版本化的表单行(套用走 FormSchemaService 的版本化保存)",
rows2 >= "1", "collect_form_schema 行数=%s" % rows2)
# schema_json 是 jsonb:LIKE 之前要 ::text
got_extra = sql("select (schema_json::text like '%%extra_e2e%%')::text "
"from collect_form_schema where task_id=%d "
"order by version desc limit 1;" % tgt2).strip()
check("套过去的就是源任务那份定义(含 extra_e2e 扩展字段)", got_extra == "true", got_extra)
bad_parts = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["NOPE"]})
check("parts 写错 → 明确报错并列出合法值",
@@ -568,9 +608,10 @@ def cleanup():
R.sql("delete from collect_roster where task_id=%d;" % tid)
R.sql("delete from collect_task_org where task_id=%d;" % tid)
R.sql("delete from collect_material_def where task_id=%d;" % tid)
R.sql("delete from collect_form_schema where task_id=%d;" % tid)
R.sql("delete from collect_task where id=%d;" % tid)
R.sql("delete from collect_rule_template where template_name like 'E2E自建模板%' "
"or template_name like 'E2E公共尝试%';")
"or template_name like 'E2E公共尝试%' or template_name like 'E2E表单模板%';")
R.sql("update collect_rule_template set use_count=0 where id in (1,2);")
R.sql("delete from collect_message where user_id in (995111, 995112);")
R.sql("delete from sys_user_role where user_id = 995101;")
+20
View File
@@ -472,6 +472,26 @@ def main():
rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={})
check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0,
json.dumps(rp2[1], ensure_ascii=False)[:200])
# ⭐ 定时扫描**真的会重试**(F-112,2026-10-09 修)
#
# 这一条与上面那条"安排了下次重试"是**两件事**:上面断言的是 next_retry_at 被写上,
# 而这里断言的是到点之后**真的有人来重试**。
#
# 修之前:`OpenPushLogMapper.dueRetries()` 少了 @InterceptorIgnore,定时任务没有租户上下文
# → 每次扫描抛 40101 → 被 retryDue 的 catch 吞成一行 warn。于是"安排了"永远不等于"发生了",
# 而 E2E 只断言了前者,所以 60/60 全绿也照样漏掉了这个 bug。
#
# 等待时间 = 定时任务间隔(fixedDelay 10s)+ 首次退避(1s)+ 余量
attempts_before = int(sql("select attempts from collect_open_push_log "
"where submission_id=%d order by id desc limit 1;" % sid) or 0)
time.sleep(16)
attempts_after = int(sql("select attempts from collect_open_push_log "
"where submission_id=%d order by id desc limit 1;" % sid) or 0)
check("⭐ 定时扫描真的重试了:attempts 增加(不是只安排了下次重试)",
attempts_after > attempts_before,
"attempts %d → %d(16 秒内至少应被扫描到一次)" % (attempts_before, attempts_after))
check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)",
"身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
"where submission_id=%d limit 1;" % sid) or "")
+522 -22
View File
@@ -44,7 +44,13 @@ import uuid
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 默认打 dev 后端;`--base` 可改(例如为了量上传而在 18080 另起一个
# `--fill.storage.provider=local` 的实例,避免把测试文件灌进真实对象存储)
BASE = "http://localhost:8080"
# 演示班主任(scripts/dev-seed-audit.py 摆出来的)—— 一期初审恒为开启,
# 所以"审批闭环"这类场景必须有它
TEACHER_USER = "demot1"
TEACHER_PWD = "Demo1234"
SUFFIX = uuid.uuid4().hex[:6]
TMP_ROSTER = os.path.join(ROOT, "scripts", f"_lt-roster-{SUFFIX}.xlsx")
TMP_IMG = os.path.join(ROOT, "scripts", f"_lt-img-{SUFFIX}.jpg")
@@ -141,7 +147,28 @@ def timed(fn):
# ══════════════ 夹具 ══════════════
def build_fixture(admin):
def uid_of(token):
"""
从自己的 token 里取 uid(`sub`)。
⚠️ **不要按 username 查库**:库里同一个 username 在每个租户都有一行
(`admin` 有 50 多个,`select id from sys_user where username='admin'` 会随机命中
别人家的账号)。我第一版就是这么写的,于是把"初审人"设成了一个跨租户的用户 id。
"""
p = token.split(".")[1]
p += "=" * (-len(p) % 4)
return int(json.loads(base64.urlsafe_b64decode(p))["sub"])
def call_ok(method, path, token, body=None, what="", timeout=60):
"""打一个接口并**断言业务码为 0**(不查返回码的调用是 F-96 里最贵的那一个)。"""
c, b = call(method, path, token, body, timeout=timeout)
if not (isinstance(b, dict) and b.get("code") == 0):
raise SystemExit("夹具失败:%s → %s" % (what or path, json.dumps(b, ensure_ascii=False)[:200]))
return b.get("data")
def build_fixture(admin, teacher):
"""造一个任务 + N 个名册人(每人一个已知证件号)+ 对应 uid 的 MP token。"""
_, orgs = call("GET", "/api/v1/orgs/tree", admin)
classes = []
@@ -161,17 +188,32 @@ def build_fixture(admin):
"taskName": f"压测任务 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1,
"startTime": "2026-01-01", "endTime": "2099-12-31",
# ⚠️ **只走终审**(needClassAudit=false)是刻意的:初审的判据是"这份单在你负责的
# 班级里"(AuditService.auditDenyReason),而夹具的名册导进的是组织树里第一个班,
# admin 并不是那个班的班主任 —— 于是逐份通过会整齐地报 40301「不在你负责的班级里」。
# 要按真实两段审核走,就得再造一个"带班级范围的班主任"账号;
# 而导出这条路与"谁审的"无关,所以这里选便宜且不引入额外夹具的那条。
"auditConfig": {"needClassAudit": False, "needAdminAudit": True},
})
tid = b["data"]["id"]
TASK_IDS.append(tid)
call("POST", f"/api/v1/tasks/{tid}/materials", admin, {
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 2048, "maxCount": 3,
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 4096, "maxCount": 3,
"minSizeKb": 1, "minLongSide": 100,
})
call("PUT", f"/api/v1/tasks/{tid}/orgs", admin,
{"orgs": [{"orgId": org["id"], "expectedCount": GATE_USERS}]})
# ⚠️ **必须指定 headUserId**:初审的判据是"这个班归不归我审"
# (`AuditScopeMapper.countMyOrg` 三级回退:head_user_id → deputy → 班级负责人),
# 而夹具的名册导进的是组织树里第一个班 —— 不指定的话,逐份通过会整齐地报
# 40301「这份填报单不在你负责的班级里」。
# 也**不能**靠"关掉初审"绕过:一期 `withAuditDefaults` 把 needClassAudit 强制为 true
# (契约 §3.2「社会收集恒为 false,一期不启用社会收集」),传 false 会被扳回来。
# 指定的人还得**真有班主任角色**,否则这次 PUT 会被服务端拒(我第一版就是被它拒的,
# 而当时没查返回码,表现成很远的地方"任务已截止"—— 40008)。
call_ok("PUT", f"/api/v1/tasks/{tid}/orgs", admin,
{"orgs": [{"orgId": org["id"], "expectedCount": GATE_USERS,
"headUserId": uid_of(teacher)}]}, "设置覆盖班级与初审人")
# N 个证件号(前 17 位递增,校验位由脚本补)
rows = []
@@ -189,9 +231,16 @@ def build_fixture(admin):
seq = "%03d" % (i % 1000)
idc = R.with_checksum(area + "20080101" + seq)
rows.append((f"压测{i:04d}", idc))
t_excel = time.perf_counter()
build_roster(rows)
excel_s = time.perf_counter() - t_excel
t_imp = time.perf_counter()
body = R.upload_roster(BASE, admin, tid, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
imp_s = time.perf_counter() - t_imp
# ⚠️ 两段分开报:**造 Excel 是压测脚本自己的成本,不是系统的成本** ——
# 混在一起会把 openpyxl 的耗时算到"名册导入"头上(度量工具的偏差伪装成产品缺陷)
print(" 夹具计时:造 Excel %.1fs 导入接口 %.1fs(%d 行)" % (excel_s, imp_s, len(rows)))
# ⚠️ **判据是 data.created,不是 code**:导入了 0 行也是 code=0
# (体检报告在 data 里)。判错的话压测会"没有样本",
# 而那个现象看起来像压测脚本坏了 —— 与 F-84 是同一个坑
@@ -212,7 +261,9 @@ def build_fixture(admin):
if len(roster_rows) != GATE_USERS:
raise SystemExit("夹具名册行数不对:期望 %d,实际 %d"
% (GATE_USERS, len(roster_rows)))
call("POST", f"/api/v1/tasks/{tid}/publish", admin, {"force": True})
# ⚠️ 发布**必须查返回码**:它失败时任务还是草稿,而后续所有学生接口都报
# `40008 任务已截止、已归档或已暂停` —— 排查会一路查错方向(我踩过)
call_ok("POST", f"/api/v1/tasks/{tid}/publish", admin, {"force": True}, "发布任务")
cfg = R.read_dev_config()
tokens = []
for i, r in enumerate(roster_rows):
@@ -245,15 +296,36 @@ def build_roster(rows):
wb.save(TMP_ROSTER)
def make_img(size_kb=400):
"""造一张尽量接近真实照片的图(纯色会被压得很小,失真)。"""
def make_img(size_kb=400, px=(2000, 1500)):
"""
造一张**真实**的 JPEG(Pillow 噪声图)。
⚠️ 原来是"JPEG 头 + 随机字节"的假图。它能过扩展名与大小校验,
但**服务端做的根本不是同一件事**:真图要解码 → 按 EXIF 转正 → 重编码,
假图在解码那一步就失败了(然后走的是"压缩失败"的分支)。
用压测去回答"压缩是 CPU 密集",却喂它一个不需要压缩的文件 ——
量到的不是压缩成本,而是错误处理的成本。**这类"夹具不对"的红/绿都不算数。**
噪声图是有意选的:纯色图会被压到极小,量不出 CPU 成本
(与 e2e-m9-thumb.py 里那条"纯色会被压得很小、失真"同一个理由)。
"""
try:
from PIL import Image
import random
random.seed(7)
img = Image.new("RGB", px)
img.putdata([(random.randrange(256), random.randrange(256), random.randrange(256))
for _ in range(px[0] * px[1])])
img.save(TMP_IMG, "JPEG", quality=92)
return os.path.getsize(TMP_IMG)
except ImportError:
import random
random.seed(7)
# 最小合法 JPEG 头 + 随机填充(不是真 JPEG,但上传接口只看扩展名与大小)
with open(TMP_IMG, "wb") as f:
f.write(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
f.write(bytes(random.getrandbits(8) for _ in range(size_kb * 1024)))
f.write(b"\xff\xd9")
return os.path.getsize(TMP_IMG)
def reset_gate():
@@ -335,16 +407,57 @@ def scenario_read(tid, admin, conc, rounds):
return stats
def scenario_upload(tid, tokens, conc):
def scenario_upload(tid, tokens, conc, px=(2000, 1500)):
"""上传:并发上传图片(压缩是 CPU 密集)。"""
st = Stat("材料上传")
make_img()
size = make_img(px=px)
print(" 夹具:真实 JPEG %d×%d / %.0f KB" % (px[0], px[1], size / 1024.0))
# ⚠️ 前置条件:**必须先把人绑到名册项上**,否则 `POST /mp/submissions` 会被拒
# ("没有草稿"),而 40 次上传会整齐地失败 —— 那不叫"上传慢",叫"夹具没成立"。
# 以前单独跑 `--only upload` 就是这个结果:全量压测里它跟在门禁后面,
# "恰好"能跑,于是这个前置依赖一直没被发现(F-96 的同一类:没断言前置就往下走)。
# 绑定逻辑与导出大包共用 `bind_users` —— 写两份必然分叉,而分叉的表现就是
# "另一个场景的前置悄悄失效"。
bound = bind_users(tid, tokens, conc)
print(" 前置:门禁绑定 %d/%d 人(上传必须先有绑定)" % (bound, len(tokens)))
if bound < conc:
raise SystemExit("上传压测的前置条件不成立:只有 %d 人绑定成功,需要 %d 人"
% (bound, conc))
sub_ids = {}
# 每个用户先建草稿
for t in tokens[:conc]:
for t in tokens[:conc + 1]: # 多要一份:串行基线占用第 0 个
c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid})
if c == 200 and isinstance(b, dict) and b.get("code") == 0:
sub_ids[t["uid"]] = b["data"]["id"]
else:
print(" ⚠️ 建草稿失败:%s" % json.dumps(b, ensure_ascii=False)[:160])
# ⚠️ 同样要断言:建不出草稿时"40 个请求全失败"看起来像上传功能坏了
if len(sub_ids) < conc + 1:
raise SystemExit("上传压测的前置条件不成立:只建出 %d 份草稿,需要 %d 份"
% (len(sub_ids), conc + 1))
# ── 串行基线:单请求到底多贵 ──
# ⚠️ 必须有这一段。并发下的 p50/p95 里混着排队,**不能反推单请求成本**(F-94);
# 而"值不值得优化"只能由"单请求成本 vs 排队占比"回答。
warm = tokens[0]
w_ms, w_http, w_body = timed(lambda: upload_file(
"/api/v1/mp/files/upload", warm["token"], sub_ids[warm["uid"]], TMP_IMG))
# ⚠️ 判据是 body 里的业务码,**不是** upload_file 返回的第一个值 ——
# 那是 HTTP 状态码(200),拿它当"成功"判据会把成功报成失败(我自己刚踩过)
if isinstance(w_body, dict) and w_body.get("code") == 0:
# 尺寸以库里那行为准:接口回的是原始大小,规范件大小只有落库才知道
sizes = one("select raw_size || '/' || coalesce(final_size, 0) from collect_file "
"where submission_id=%d order by id desc limit 1;" % sub_ids[warm["uid"]])
try:
raw_kb, fin_kb = [int(x) / 1024.0 for x in sizes.split("/")]
ratio = (fin_kb / raw_kb * 100.0) if raw_kb else 0.0
print(" 串行基线:单请求 %.0fms 压缩 %.0fKB → %.0fKB(%.0f%%,服务端真的在压)"
% (w_ms, raw_kb, fin_kb, ratio))
except Exception: # noqa: BLE001
print(" 串行基线:单请求 %.0fms(尺寸没取到:%s)" % (w_ms, sizes))
else:
print(" 串行基线失败:HTTP %s %s" % (w_http, json.dumps(w_body, ensure_ascii=False)[:160]))
def one_req(t):
sid = sub_ids.get(t["uid"])
@@ -357,7 +470,9 @@ def scenario_upload(tid, tokens, conc):
return ms, int(biz if biz is not None else code), body
jobs = []
for t in tokens[:conc]:
# 并发用 tokens[1:conc+1]:第 0 个已经被串行基线用掉了,混进来会让"并发里的某个用户
# 比别人多传一次",而样本数对不上时最难判断的是"到底谁慢"
for t in tokens[1:conc + 1]:
for _ in range(UPLOAD_PER_USER):
jobs.append(t)
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
@@ -400,6 +515,324 @@ def upload_file(path, token, submission_id, file_path, timeout=180):
return -1, {"msg": str(e)}
# ══════════════ 长时间稳定性 ══════════════
def base_port():
from urllib.parse import urlparse
return urlparse(BASE).port or 80
def app_pid(port):
"""监听该端口的进程 pid(没有就返回 None,不猜)。"""
try:
r = subprocess.run(["powershell", "-NoProfile", "-Command",
"(Get-NetTCPConnection -State Listen -LocalPort %d "
"-ErrorAction SilentlyContinue).OwningProcess" % port],
capture_output=True, text=True, timeout=30)
for tok in (r.stdout or "").split():
if tok.strip().isdigit():
return int(tok.strip())
except Exception: # noqa: BLE001
pass
return None
def rss_mb(pid):
"""
进程的物理内存(MB)。
⚠️ 从**系统**取,不从应用的指标取:应用自己报的堆用量看不到堆外内存、
元空间与线程栈 —— 而"内存慢慢涨"恰恰经常长在这些地方。
"""
if not pid:
return None
try:
r = subprocess.run(["powershell", "-NoProfile", "-Command",
"(Get-Process -Id %d -ErrorAction SilentlyContinue).WorkingSet64" % pid],
capture_output=True, text=True, timeout=30)
v = (r.stdout or "").strip()
return int(v) / 1048576.0 if v.isdigit() else None
except Exception: # noqa: BLE001
return None
def db_conns():
"""PG 上到 filldb 的连接数 —— Hikari 连接泄漏会直接体现为它单调上升。"""
v = one("select count(*) from pg_stat_activity where datname='filldb';")
return int(v) if v.isdigit() else None
def scenario_soak(tid, admin, minutes, rps):
"""
长时间稳定性:低强度持续打,每分钟采样一行。
⚠️ 要回答的是"**有没有单调上升**",所以判据是**趋势**,不是"跑完没崩"。
泄漏的特征是"慢慢地越来越高",一次性压测永远测不出来 ——
所以这里每分钟记 RSS 与 PG 连接数,最后比较**前 1/3 与后 1/3** 的均值。
延迟趋势一起看:泄漏的另一个表现是"越跑越慢"。
"""
paths = [
("GET", "/api/v1/tasks?pageNum=1&pageSize=20", "任务列表"),
("GET", f"/api/v1/tasks/{tid}/roster?pageNum=1&pageSize=50", "名册列表"),
("GET", f"/api/v1/tasks/{tid}/statistics", "任务统计"),
("GET", "/api/v1/audit/todos?pageNum=1&pageSize=20", "审核待办"),
]
pid = app_pid(base_port())
print(" 目标后端 pid=%s(RSS 从系统取,PG 连接数从 pg_stat_activity 取)" % pid)
print(" 强度 %.1f 请求/秒 时长 %d 分钟,每分钟一行样本" % (rps, minutes))
rows = []
n = 0
t_end = time.time() + minutes * 60
while time.time() < t_end:
lat, fail = [], 0
for _ in range(max(1, int(rps * 60))):
m, p, _label = paths[n % len(paths)]
n += 1
ms, code, body = timed(lambda: call(m, p, admin))
biz = int((body or {}).get("code", code)) if isinstance(body, dict) else code
lat.append(ms)
if biz != 0:
fail += 1
# 均匀铺开:不要"一分钟里的前 10 秒打完全部请求",那测的是尖峰不是持续压力
time.sleep(max(0.0, 1.0 / rps))
s = sorted(lat)
row = {"min": len(rows) + 1, "n": len(lat), "fail": fail,
"p50": s[len(s) // 2], "p95": s[min(len(s) - 1, int(len(s) * 0.95))],
"rss": rss_mb(pid), "conns": db_conns()}
rows.append(row)
print(" %3d 分 请求 %-4d 失败 %-3d p50 %6.1fms p95 %7.1fms RSS %7s MB PG 连接 %4s"
% (row["min"], row["n"], row["fail"], row["p50"], row["p95"],
("%.0f" % row["rss"]) if row["rss"] else "—",
row["conns"] if row["conns"] is not None else "—"))
def avg(rs, key):
vals = [r[key] for r in rs if r.get(key) is not None]
return sum(vals) / len(vals) if vals else None
third = max(1, len(rows) // 3)
head, tail = rows[:third], rows[-third:]
print("\n 样本 %d 分钟 请求 %d 失败 %d(%s)"
% (len(rows), sum(r["n"] for r in rows), sum(r["fail"] for r in rows),
"强度 %.1f/s、规模 %d 行" % (rps, GATE_USERS)))
for key, unit, label, tol in (("rss", "MB", "RSS", 10.0), ("conns", "个", "PG 连接数", 20.0)):
a, b = avg(head, key), avg(tail, key)
if a is None or b is None:
print(" %-10s 样本不足,没测出来" % label)
continue
pct = ((b - a) / a * 100.0) if a else 0.0
verdict = "平稳" if abs(pct) < tol else ("**上升** ⚠️ 疑似泄漏" if b > a else "下降")
print(" %-10s 前 1/3 均值 %8.1f %s → 后 1/3 均值 %8.1f %s(%+.1f%%) %s"
% (label, a, unit, b, unit, pct, verdict))
a95, b95 = avg(head, "p95"), avg(tail, "p95")
if a95 and b95:
print(" %-10s 前 1/3 均值 %8.1f ms → 后 1/3 均值 %8.1f ms(%+.1f%%)"
% ("延迟 p95", a95, b95, (b95 - a95) / a95 * 100.0))
return rows
def bind_users(tid, tokens, workers=10):
"""
把 N 个人绑到名册项上(上传 / 提交 / 审核的**共同前置**)。
⚠️ 抽出来是有原因的:只在 `scenario_upload` 里写一份的话,新场景(导出大包)
就会忘 —— 而忘了的表现是"请求整齐地全失败",看起来像功能坏了(F-96 的同一类)。
"""
ok = 0
fail_sample = None
def one(t):
nonlocal fail_sample
c, b = call("POST", "/api/v1/mp/roster/match", t["token"],
{"taskId": tid, "realName": t["name"], "idCardNo": t["idCardNo"]})
code = b.get("code") if isinstance(b, dict) else -1
if code not in (0, 40072) and fail_sample is None: # 0 绑定成功 / 40072 已绑定
fail_sample = json.dumps(b, ensure_ascii=False)[:160]
return code
with cf.ThreadPoolExecutor(max_workers=workers) as ex:
for code in ex.map(one, tokens):
if code in (0, 40072):
ok += 1
if fail_sample is not None:
print(" ⚠️ 绑定失败样本:%s" % fail_sample)
return ok
def scenario_export_big(tid, tokens, admin, teacher, files_per_user=1, split_mb=0, conc=10):
"""
导出**大包**:造 N 份已通过的填报单,量打包墙钟、体积、分卷与产物的完整性。
这是"交付物本身"的那条链,也是唯一一条**没法用接口断言替代**的验证:
打包耗时、产物体积、分卷是否每卷都能单独解开,只有真跑一遍才知道。
"""
import zipfile
n = len(tokens)
print(" 目标:%d 人 × %d 个材料%s"
% (n, files_per_user, ",分卷 %dMB" % split_mb if split_mb else ",不分卷"))
bound = bind_users(tid, tokens, conc)
print(" ① 门禁绑定 %d/%d 人" % (bound, n))
if bound < n:
print(" ⚠️ 有 %d 人没绑上 —— 后面的上传会失败,先看失败码再判断是不是导出的问题"
% (n - bound))
sid = {}
draft_fail = None
def draft(t):
nonlocal draft_fail
c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid})
if not (isinstance(b, dict) and b.get("code") == 0):
# ⚠️ 失败**必须打出来**:上一次它只有一句"草稿 0/150",
# 排查时完全看不出是绑定没生效、还是任务状态不对(F-96 的同一类)
if draft_fail is None:
draft_fail = json.dumps(b, ensure_ascii=False)[:200]
return t["uid"], None
return t["uid"], (b.get("data") or {}).get("id")
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
for uid, s in ex.map(draft, tokens):
if s:
sid[uid] = s
print(" ② 草稿 %d/%d 份%s"
% (len(sid), n, "" if draft_fail is None else " 首个失败:%s" % draft_fail))
if len(sid) < n:
raise SystemExit("草稿没建齐(%d/%d),导出大包的夹具不成立" % (len(sid), n))
img_kb = make_img() / 1024.0
jobs = [(t, k) for t in tokens if t["uid"] in sid for k in range(files_per_user)]
def up(job):
t, _k = job
_c, b = upload_file("/api/v1/mp/files/upload", t["token"], sid[t["uid"]], TMP_IMG)
return (b or {}).get("code") if isinstance(b, dict) else -1
t_up = time.perf_counter()
up_fail = 0
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
for code in ex.map(up, jobs):
if code != 0:
up_fail += 1
print(" ③ 上传 %d 张(失败 %d;%.0fKB/张;%.1fs)"
% (len(jobs), up_fail, img_kb, time.perf_counter() - t_up))
if up_fail:
raise SystemExit("上传有 %d 张失败,夹具不成立(先看失败码,别当成导出的问题)" % up_fail)
def submit(t):
c, b = call("POST", "/api/v1/mp/submissions/%d/submit" % sid[t["uid"]], t["token"],
{"confirmWarnings": True})
# ⚠️ 判据是 data.submitted(F-84):HTTP 200 + code 0 不等于"提交成功"
return ((b or {}).get("data") or {}).get("submitted") if isinstance(b, dict) else False
ok_sub = 0
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
for v in ex.map(submit, [t for t in tokens if t["uid"] in sid]):
if v is True:
ok_sub += 1
print(" ④ 提交 %d/%d 份(判据 data.submitted)" % (ok_sub, len(sid)))
if ok_sub != len(sid):
raise SystemExit("有 %d 份没提交成功,夹具不成立" % (len(sid) - ok_sub))
ids = [sid[t["uid"]] for t in tokens if t["uid"] in sid]
def approve(sid_, token, body_, label):
c, b = call("POST", "/api/v1/audit/submissions/%d/approve" % sid_, token, body_, timeout=120)
return (b or {}).get("code") if isinstance(b, dict) else -1, b
# ⚠️ 一期**恒有两段审核**(needClassAudit 被强制 true,见 build_fixture 的说明),
# 所以"逐份通过"要走两次:班主任初审 → 管理员终审。
# 只做一段的表现是全部停在 40301「不在你负责的班级里」或"没有待你处理的审核动作"。
for stage, (tok, body_, label) in enumerate((
(teacher, {"actionSource": "BUTTON", "remark": "压测初审"}, "初审"),
(admin, {"actionSource": "BUTTON", "finalOpinion": "压测终审"}, "终审"))):
ok_n, first_fail = 0, None
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
for code, b in ex.map(lambda s: approve(s, tok, body_, label), ids):
if code == 0:
ok_n += 1
elif first_fail is None:
first_fail = b
print(" ⑤-%d %s通过:%d/%d 份%s"
% (stage + 1, label, ok_n, len(ids),
"" if first_fail is None else " 首个失败:%s"
% json.dumps(first_fail, ensure_ascii=False)[:160]))
if first_fail is not None:
raise SystemExit("%s这一关没过,夹具不成立" % label)
_c, b = call("GET", "/api/v1/exports/precheck?taskId=%d&onlyApproved=true" % tid, admin)
pre = (b or {}).get("data") or {}
print(" ⑥ 前置检查:canExport=%s 候选 %s 人 / %s 个文件 / 原件 %.1f MB"
% (pre.get("canExport"), pre.get("candidateCount"), pre.get("fileCount"),
(pre.get("rawSizeBytes") or 0) / 1048576.0))
if pre.get("canExport") is not True:
raise SystemExit("前置检查不通过,大包测不了:%s"
% json.dumps(pre.get("blocking"), ensure_ascii=False)[:200])
body = {"taskId": tid, "exportName": "压测大包 %s" % SUFFIX, "scope": {"onlyApproved": True}}
if split_mb:
body["splitSizeMb"] = split_mb
t0 = time.perf_counter()
_c, b = call("POST", "/api/v1/exports", admin, body, timeout=600)
eid = ((b or {}).get("data") or {}).get("exportId")
if (b or {}).get("code") != 0 or not eid:
raise SystemExit("创建导出失败:%s" % json.dumps(b, ensure_ascii=False)[:200])
print(" ⑦ 导出 id=%s,创建耗时 %.1fs → 轮询到完成(打包是异步的,量墙钟)"
% (eid, time.perf_counter() - t0))
detail, seen = None, None
while time.perf_counter() - t0 < 1800:
_c, b = call("GET", "/api/v1/exports/%d" % eid, admin)
d = (b or {}).get("data") or {}
if d.get("status") in (2, 3, 4):
detail = d
break
if d.get("progress") != seen:
seen = d.get("progress")
print(" …%s%% %s" % (seen, d.get("stepText") or ""))
time.sleep(2)
wall = time.perf_counter() - t0
if not detail or detail.get("status") != 2:
print(" ✗ 导出没跑完:status=%s errorMsg=%s"
% (detail and detail.get("status"), detail and detail.get("errorMsg")))
return None
s = detail.get("stats") or {}
print(" ⑧ **打包墙钟 %.1fs** 文件 %s 个 原件 %.1f MB → 产物 %.1f MB(%s)"
% (wall, s.get("fileCount"), s.get("rawSizeMb") or 0, s.get("finalSizeMb") or 0,
s.get("compressRatio")))
# ⚠️ 列名以 DDL 为准(V11):`part_no` / `file_name` / `file_size` / `file_path` ——
# 按直觉写成 part/name/size/path 会直接 SQL 报错(我第一版就是这么写的)
vols = sql("select part_no, file_name, coalesce(file_size,0), file_path "
"from collect_export_file where export_id=%d and deleted=0 order by part_no;" % eid)
rows = [x for x in vols.split("\n") if x.strip()]
print(" ⑨ 分卷 %d 个:" % len(rows))
for line in rows:
part, name, size, path = (line.split("|") + ["", "", "", ""])[:4]
print(" %s 卷 %s %.1f MB" % (name, part, int(size or 0) / 1048576.0))
# ⚠️ 分卷的承诺是"**每一卷都是完整可解的 ZIP**"(不是 .z01 那种必须凑齐才能解)。
# 这条只能真去解一遍 —— 断言"文件存在"是断言不到它的。
base = os.path.join(ROOT, "backend", "data", "uploads")
for line in rows:
part, name, size, path = (line.split("|") + ["", "", "", ""])[:4]
full = path if os.path.isabs(path) else os.path.join(base, path)
if not os.path.exists(full):
# 存储是七牛时产物也可能不在本地(D5 说导出包落本地盘,所以这条算异常)
print(" ? %s 不在本地磁盘(路径 %s)" % (name, full))
continue
try:
with zipfile.ZipFile(full) as z:
names = z.namelist()
bad = z.testzip()
print(" ✓ %s 可解:%d 个条目,首条 %s%s"
% (name, len(names), names[0] if names else "(空)",
"" if bad is None else " ✗ CRC 错误:%s" % bad))
except Exception as e: # noqa: BLE001
print(" ✗ %s **解不开**:%s" % (name, e))
return detail
def scenario_export(tid, admin):
"""导出:前置检查(只读)+ 创建(同步打包)。"""
st_pre = Stat("导出前置检查")
@@ -442,37 +875,80 @@ def scenario_export(tid, admin):
# ══════════════ 清理 ══════════════
def cleanup():
# ⚠️ 删库行之前先把**磁盘上的文件路径**取出来:压测会传几百张图、打出几百 MB 的包,
# 只删库行的话这些文件会一直躺在 backend/data/uploads 里
# ("删库不删桶"的本地版本 —— 时间一长本地磁盘就是一本糊涂账)
disk = []
for tid in TASK_IDS:
for stmt in ("select coalesce(std_path, raw_path) from collect_file where task_id=%s;",
"select file_path from collect_export_file where task_id=%s;"):
for line in sql(stmt % tid).split("\n"):
p = line.strip()
if p and p != "null":
disk.append(p)
for tid in TASK_IDS:
for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record",
"collect_file", "collect_submission", "collect_roster",
"collect_export_file", "collect_file", "collect_submission", "collect_roster",
"collect_task_org", "collect_material_def"):
sql("delete from %s where task_id=%s;" % (tbl, tid))
sql("delete from collect_task where id=%s;" % tid)
for uid in UIDS:
sql("delete from collect_message where user_id=%d;" % uid)
base = os.path.join(ROOT, "backend", "data", "uploads")
removed = 0
for p in disk:
full = p if os.path.isabs(p) else os.path.join(base, p)
# ⚠️ 只删**确实存在**的本地文件:存储是七牛时这些路径是 bucket key,
# 本地根本不存在 —— 那时候要清的是桶,不该在本地乱删(也删不到)
if os.path.isfile(full):
try:
os.remove(full)
removed += 1
except OSError:
pass
for p in (TMP_ROSTER, TMP_IMG):
if os.path.exists(p):
os.remove(p)
print(" 已清理:压测任务 / 名册 / 材料 / 消息 / 临时文件")
print(" 已清理:压测任务 / 名册 / 材料 / 消息 / 临时文件(本地文件 %d 个)" % removed)
def main():
# ⚠️ `global` 必须出现在**同一函数里第一次使用该名字之前** ——
# 放晚了会报 "name 'X' is used prior to global declaration"
global GATE_USERS
global BASE
ap = argparse.ArgumentParser(description="压测(门禁/上传/读/导出)")
ap.add_argument("--only", default="", help="只跑一个场景:gate / read / upload / export")
ap = argparse.ArgumentParser(description="压测(门禁/上传/读/导出/长跑)")
ap.add_argument("--only", default="",
help="只跑一个场景:gate / read / upload / export / export-big / soak")
ap.add_argument("--conc", type=int, default=0, help="覆盖并发数")
ap.add_argument("--clean", action="store_true", help="只清理上次残留")
ap.add_argument("--rows", type=int, default=0,
help="名册人数(默认 %d)。⚠️ 谈响应时间必须带上规模" % GATE_USERS)
ap.add_argument("--base", default="",
help="后端地址(默认 %s)。要量上传就另起一个 local 存储的实例" % BASE)
ap.add_argument("--with-upload", action="store_true",
help="跑上传压测(⚠️ 会往真实对象存储写文件,默认关闭)")
ap.add_argument("--img-px", default="2000x1500",
help="上传夹具的图片尺寸(真实 JPEG,压缩成本与它成正比)")
ap.add_argument("--fixture-only", action="store_true",
help="只造夹具并计时(量名册导入耗时用,不做任何压测)")
ap.add_argument("--soak", type=int, default=0,
help="长时间稳定性:持续 N 分钟,每分钟采一行 RSS/PG 连接数/延迟")
ap.add_argument("--rps", type=float, default=5.0, help="长跑强度(请求/秒,默认 5)")
ap.add_argument("--files-per-user", type=int, default=1,
help="导出大包场景:每人几个材料(默认 1)")
ap.add_argument("--split-mb", type=int, default=0,
help="导出大包场景:按 N MB 分卷(0 = 不分卷,用服务端默认)")
args = ap.parse_args()
if args.base:
BASE = args.base.rstrip("/")
if args.rows > 0:
GATE_USERS = args.rows
px = tuple(int(x) for x in args.img_px.lower().split("x"))
if args.clean:
# 残留任务按名字前缀找
@@ -497,12 +973,29 @@ def main():
print("管理员登录失败:" + str(adm))
return 2
admin = adm["data"]["token"]
# 班主任账号:压测的导出大包场景要真的走一遍"初审 → 终审",
# 而这个账号由 scripts/dev-seed-audit.py 摆出来(演示数据的一部分)
_, tea = R.login(BASE, TEACHER_USER, TEACHER_PWD, "demo")
if tea.get("code") != 0:
print("演示班主任 %s 登录失败(先跑 scripts/dev-seed-audit.py):%s" % (TEACHER_USER, tea))
return 2
teacher = tea["data"]["token"]
print("\n── 造夹具 ──")
t0 = time.perf_counter()
tid, tokens = build_fixture(admin)
print(" 任务 %d,名册 %d 人,夹具耗时 %.1fs"
tid, tokens = build_fixture(admin, teacher)
print(" 任务 %d,名册 %d 人,夹具耗时 %.1fs(含名册导入本身)"
% (tid, len(tokens), time.perf_counter() - t0))
if args.fixture_only:
# 量"导入 N 行"这件事:接口耗时在上面那条"夹具计时"里,这里补内存与库内行数
print("\n --fixture-only:夹具已造好,直接清理")
pid = app_pid(base_port())
print(" 后端 RSS:%.0f MB(pid=%s)" % (rss_mb(pid) or -1, pid))
print(" 库里名册行数:%s(应为 %d)"
% (one("select count(*) from collect_roster where task_id=%d and deleted=0;" % tid),
GATE_USERS))
cleanup()
return 0
stats = {}
conc = args.conc
@@ -516,15 +1009,22 @@ def main():
stats["read"] = scenario_read(tid, admin, conc or READ_CONC, READ_ROUNDS)
if args.only == "upload" or (args.with_upload and args.only == ""):
print("\n── ③ 材料上传(%d 人同时传,压缩是 CPU 密集)──" % UPLOAD_USERS)
stats["upload"] = scenario_upload(tid, tokens, conc or UPLOAD_USERS)
stats["upload"] = scenario_upload(tid, tokens, conc or UPLOAD_USERS, px)
if args.only == "" and not args.with_upload:
print("\n── ③ 材料上传(**默认跳过**)──")
print(" ⚠️ 当前 dev 的存储是七牛**真实空间**(见 backend/application-local.yml),")
print(" 压测上传会打到外部服务并把测试文件灌进真实桶。要跑就加 --with-upload,")
print(" 且最好先把存储切成本地:XT_STORAGE_PROVIDER=local 重启后端。")
print(" 压测上传会打到外部服务并把测试文件灌进真实桶。两种跑法:")
print(" ① `--base http://localhost:18080` 打另起的 local 实例(推荐,见 docs/09 §9.5);")
print(" ② `XT_STORAGE_PROVIDER=local` 重启当前后端后再加 --with-upload。")
if args.only in ("", "export"):
print("\n── ④ 导出(前置检查 + 创建)──")
scenario_export(tid, admin)
if args.only == "export-big":
print("\n── ④' 导出大包(造 N 份已通过的单 → 打包 → 验产物)──")
scenario_export_big(tid, tokens, admin, teacher, args.files_per_user, args.split_mb)
if args.only == "soak" or args.soak > 0:
print("\n── ⑤ 长时间稳定性(%d 分钟 × %.1f 请求/秒)──" % (args.soak, args.rps))
scenario_soak(tid, admin, args.soak, args.rps)
finally:
print("\n── 清理 ──")
cleanup()
+4 -1
View File
@@ -11,7 +11,8 @@
| 实体 ↔ 建表 | `check-entity-columns.py` | 无 |
| 文档自身一致 | `verify-docs.py` | 无 |
| 手册 ↔ 代码文案 | `check-manual.py` | 无 |
| 接口行为(1548 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
| 接线(定义 ↔ 使用) | `check-wiring.py` | 无 |
| 接口行为(1577+ 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** |
| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis |
@@ -87,6 +88,8 @@ def main():
r"(\d+) 个实体全部与迁移一致((\d+) 张表)"),
("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"),
("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"),
("接线(定义↔使用)", "python scripts/check-wiring.py --quiet",
r"未接线的声明 (\d+) 条"),
]
for name, cmd, pat in checks:
code, out = run(cmd, timeout=300)
+12 -2
View File
@@ -50,6 +50,16 @@ def read(*parts: str) -> str:
# ── 1. 表格列数 ────────────────────────────────────────
def table_pipes(line: str) -> int:
"""数表格竖线,**跳过转义过的 `\\|`**。
GFM 里 `\\|` 表示"内容里的竖线",不是列分隔符(本仓库用手写表格,
正文里写 `a \\|\\| b` 是很自然的事)。第一版直接 `line.count("|")`,
于是把这种写法报成"列数不一致" —— **检查器自己要先认得它检查的语法**。
"""
return len(re.findall(r"(?<!\\)\|", line))
def check_tables(name: str, text: str) -> None:
lines = text.split("\n")
bad_rows = 0
@@ -57,10 +67,10 @@ def check_tables(name: str, text: str) -> None:
while i < len(lines):
if lines[i].strip().startswith("|") and i + 1 < len(lines) \
and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
n = lines[i].count("|")
n = table_pipes(lines[i])
j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"):
if lines[j].count("|") != n:
if table_pipes(lines[j]) != n:
bad_rows += 1
j += 1
i = j