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) # GitLab CI(M0-04 / M7-10)
# #
# 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。 # 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。
# 这个仓库没有单元测试(`src/test` 是空的),验证靠三样东西: # 这个仓库的单元测试很少(`src/test` 只有 12 条纯函数),验证靠三样东西:
# ① 三个机械校验脚本(契约对账 / 实体列 / 文档一致性) # ① 五个机械校验脚本(契约对账 / 实体列 / 文档一致性 / 手册文案 / **接线**)
# ② 编译与构建(后端 jar、PC、H5) # ② 编译与构建(后端 jar、PC、H5)
# ③ **`scripts/e2e-*.py` 打真实接口 + 查库断言**(1500+ 条) # ③ **`scripts/e2e-*.py` 打真实接口 + 查库断言**(1500+ 条)
# 所以 e2e 阶段不是"锦上添花",它是这个项目的主验证手段 —— # 所以 e2e 阶段不是"锦上添花",它是这个项目的主验证手段 ——
@@ -58,6 +58,8 @@ variables:
- python scripts/verify-docs.py - python scripts/verify-docs.py
- echo "── 手册里引用的错误码与文案是否真的存在(客服照着它回答) ──" - echo "── 手册里引用的错误码与文案是否真的存在(客服照着它回答) ──"
- python scripts/check-manual.py - python scripts/check-manual.py
- echo "── 声明了但没接上的东西(权限码 / 错误码 / 通知模板 / 菜单键) ──"
- python scripts/check-wiring.py --quiet
- echo "── 全部 e2e 脚本的语法(引号写错会当场红,而不是跑到一半才红) ──" - echo "── 全部 e2e 脚本的语法(引号写错会当场红,而不是跑到一半才红) ──"
- | - |
python - <<'PY' python - <<'PY'
+16 -5
View File
@@ -83,22 +83,33 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
## 三、怎么验证(本仓库最不显然的一点) ## 三、怎么验证(本仓库最不显然的一点)
**后端没有单元测试**(`src/test` 是空的)。验证靠 `scripts/e2e-*.py`: **后端的单元测试很少**(`backend/*/src/test` 只有 12 条纯函数断言,`mvn test` 跑)。
打真实接口 + `docker exec` 进 PG 查库断言。 主验证手段是 `scripts/e2e-*.py`:打真实接口 + `docker exec` 进 PG 查库断言。
```bash ```bash
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑 python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
``` ```
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(`e2e-m0*` / `e2e-m1-*` / `e2e-m2-roster` / `e2e-m3-*`), - 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1620 条断言**),
目前合计 613 条断言 或一条命令 `python scripts/verify-all.py --with-e2e`
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` + - 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录 `application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
- 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转 - 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转
- 跑完 `e2e-m3-*` 会**清掉名册绑定**(`collect_roster.bind_user_id`); - 跑完 `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/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; 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) { public boolean hasRole(String roleCode) {
return roles != null && roles.contains(roleCode); return roles != null && roles.contains(roleCode);
} }
@@ -761,15 +761,99 @@ public class AuditService {
} }
boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED; boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED;
String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审"; String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审";
String content = finalPass // ⚠️ bizType 用 4(审核通过),**不是** 1(任务发布):
? "本次认定的材料已收妥,无需再操作。" // 1 是"有新任务了",而"你的材料过审了"是审核进展。
: "班主任已通过你的材料,接下来由管理员终审。"; // 以前这里借用了 1,于是消息中心按"任务发布"筛会筛出初审通过(《08》F-105)。
safeNotify(NotificationService.NotifyRequest.of( // 文案仍按节点区分 —— "等待终审"与"已通过"是两件不同的事,
sub.getSubmitterId(), // 用同一句话会让他以为已经结束了。
finalPass ? CollectMessage.BIZ_APPROVE : CollectMessage.BIZ_TASK_PUBLISH, safeNotify(NotificationService.NotifyRequest.ofTemplate(
title, content, sub.getSubmitterId(), CollectMessage.BIZ_APPROVE,
"AUDIT_PASS",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"姓名", nameOfSubmission(sub)),
title,
CollectMessage.LINK_SUBMISSION, sub.getId(), 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; boolean pushSent = false;
if (sub.getSubmitterId() != null) { if (sub.getSubmitterId() != null) {
try { 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, 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(), CollectMessage.LINK_SUBMISSION, sub.getId(),
NotificationService.TPL_AUDIT_RESULT)); NotificationService.TPL_AUDIT_RESULT));
pushSent = r.pushSent(); pushSent = r.pushSent();
@@ -959,9 +1051,10 @@ public class AuditService {
} }
if (escalated) { if (escalated) {
// 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。 // 驳回次数达上限:这条留给管理员,所以**也通知管理员**(不只是写日志)。
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}", log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
submissionId, sub.getRosterId(), rejectCount, code); submissionId, sub.getRosterId(), rejectCount, code);
notifyEscalated(sub, fullText);
} }
log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}", log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}",
submissionId, node, rejectCount, code, pushSent); submissionId, node, rejectCount, code, pushSent);
@@ -43,7 +43,7 @@ public class CleanupController {
private final CleanupImportService importService; private final CleanupImportService importService;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "创建整理任务(上传压缩包)", @Operation(summary = "创建整理任务(上传压缩包)",
description = "用**与正式收集完全相同的规则**做规范化。" description = "用**与正式收集完全相同的规则**做规范化。"
+ "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;" + "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
@@ -66,7 +66,7 @@ public class CleanupController {
} }
@PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE) @PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)", @Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— " description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
+ "方便自动化脚本与排障,不必手搓 multipart") + "方便自动化脚本与排障,不必手搓 multipart")
@@ -133,7 +133,7 @@ public class CleanupController {
} }
@PostMapping("/{id}/import") @PostMapping("/{id}/import")
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "把整理结果导入为正式任务", @Operation(summary = "把整理结果导入为正式任务",
description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— " description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
+ "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)") + "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
@@ -143,7 +143,7 @@ public class CleanupController {
} }
@PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "只解析不落库(排障用)", @Operation(summary = "只解析不落库(排障用)",
description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。" 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.file.storage.StorageService;
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO; import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
import cn.tianbaotong.fill.module.material.service.MaterialDefService; 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.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask; import cn.tianbaotong.fill.module.task.entity.CollectTask;
@@ -114,6 +116,7 @@ public class ExportService {
private final SubmissionValidator validator; private final SubmissionValidator validator;
private final MaterialDefService materialDefService; private final MaterialDefService materialDefService;
private final StorageService storageService; private final StorageService storageService;
private final NotificationService notificationService;
private final FillProperties props; private final FillProperties props;
/** /**
@@ -682,6 +685,43 @@ public class ExportService {
log.info("导出完成 exportId={} 人数={} 分卷={} 包体={}MB(原始 {}MB)", log.info("导出完成 exportId={} 人数={} 分卷={} 包体={}MB(原始 {}MB)",
exportId, total, volumes.size(), round1(size / 1024.0 / 1024.0), exportId, total, volumes.size(), round1(size / 1024.0 / 1024.0),
round1(written / 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) { private long nzLong(Long v) {
@@ -704,6 +744,9 @@ public class ExportService {
} catch (RuntimeException inner) { } catch (RuntimeException inner) {
log.error("标记导出失败时又出错 exportId={}", exportId, 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 @PutMapping
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.FORM_MANAGE)
@Operation(summary = "保存任务表单定义(版本化)", @Operation(summary = "保存任务表单定义(版本化)",
description = "每次保存插入一个新版本,旧版本保留。baseVersion 与当前最新不符时返回 40025," description = "每次保存插入一个新版本,旧版本保留。baseVersion 与当前最新不符时返回 40025,"
+ "并在 data 里带上最新定义供前端做差异合并(响应格式:{code:40025, data:最新schema})。" + "并在 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) @Transactional(rollbackFor = Exception.class)
public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) { public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) {
CollectTask task = requireTask(taskId); 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.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest; import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import cn.tianbaotong.fill.module.join.mapper.CollectJoinRequestMapper; 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.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask; import cn.tianbaotong.fill.module.task.entity.CollectTask;
@@ -74,6 +76,7 @@ public class JoinService {
private final TaskMapper taskMapper; private final TaskMapper taskMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final SensitiveCodec codec; private final SensitiveCodec codec;
private final NotificationService notificationService;
// ── 学生侧 ───────────────────────────────────── // ── 学生侧 ─────────────────────────────────────
@@ -155,6 +158,7 @@ public class JoinService {
joinMapper.insert(r); joinMapper.insert(r);
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name); log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
notifyAuditors(task, r, name);
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO(); JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
vo.setRequestId(r.getId()); vo.setRequestId(r.getId());
@@ -393,20 +397,82 @@ public class JoinService {
} }
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid); log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
// ⚠️ 通知(M5-12)还没做:只能靠老师口头/群里说一声。
// 这里如实回 false,**不为了让契约好看而写 true**(同驳回接口的口径)。 // ★ 通知学生(《08》F-102):以前这里只写一行 log.warn,
log.warn("加入申请已通过但**没有发出通知**(通知渠道未实现):request={} 需人工告知学生", id); // 学生要自己反复进小程序看结果 —— 而这条链路的另一端(通知渠道)
// 早在 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(); JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
vo.setRequestId(id); vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_APPROVED); vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
vo.setRosterId(row.getId()); vo.setRosterId(row.getId());
vo.setRosterCreated(true); vo.setRosterCreated(true);
vo.setNotifySent(false); // ⚠️ 语义与驳回接口一致:这是"**微信推送**发出去了没有"。
// 站内信一定写得进去,所以学生一定能在消息中心看到结果。
vo.setNotifySent(notifySent);
vo.setCanFillNow(r.getApplicantUid() != null); vo.setCanFillNow(r.getApplicantUid() != null);
return vo; 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 让管理员手选)。 */ /** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */
private Long matchOrgByName(String orgHint) { private Long matchOrgByName(String orgHint) {
if (orgHint == null || orgHint.isBlank()) { if (orgHint == null || orgHint.isBlank()) {
@@ -449,12 +515,33 @@ public class JoinService {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED); throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
} }
log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason); 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(); JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
vo.setRequestId(id); vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_REJECTED); vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
vo.setNotifySent(false); vo.setNotifySent(notifySent);
return vo; return vo;
} }
} }
@@ -1,12 +1,15 @@
package cn.tianbaotong.fill.module.log.controller; package cn.tianbaotong.fill.module.log.controller;
import cn.tianbaotong.fill.common.api.ApiResult; 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.api.PageResult;
import cn.tianbaotong.fill.common.context.LoginUser; import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext; 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.dto.LogDtos;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper; import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper; 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.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm; import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -25,8 +28,8 @@ import java.util.List;
/** /**
* 日志查询(《03-接口设计》§13.4)。 * 日志查询(《03-接口设计》§13.4)。
* *
* <p>权限用 {@code log:viewTenant}(本租户操作日志)。 * <p>权限是**两个码的任一个**:{@code log:viewTenant}(本租户操作日志,学校管理员/教务处)
* 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。 * 或 {@code log:viewAll}(全量,平台运营)。范围由 {@link #logScopeTenantId} 决定。
* 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。 * 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
* *
* <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel * <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel
@@ -41,15 +44,19 @@ public class LogController {
private final OperationLogMapper operationLogMapper; private final OperationLogMapper operationLogMapper;
private final LoginLogMapper loginLogMapper; private final LoginLogMapper loginLogMapper;
private final PermissionChecker permissionChecker;
@GetMapping("/operations") @GetMapping("/operations")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT) @RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "操作日志", @Operation(summary = "操作日志",
description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。" description = "Query:`tenantId` `operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
+ "**按 targetType+targetId 查**是这个接口最重要的用法 —— " + "**按 targetType+targetId 查**是这个接口最重要的用法 —— "
+ "「这张名册项的证件号被谁从什么改成了什么」要靠它回答," + "「这张名册项的证件号被谁从什么改成了什么」要靠它回答,"
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)") + "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)。"
+ "⚠️ 范围:只有 `log:viewAll`(平台运营)能跨租户看**全量**,"
+ "也只有它能用 `tenantId` 指定某所学校;学校管理员恒为本租户")
public ApiResult<PageResult<LogDtos.OperationLogVO>> operations( public ApiResult<PageResult<LogDtos.OperationLogVO>> operations(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long operatorId, @RequestParam(required = false) Long operatorId,
@RequestParam(required = false) String module, @RequestParam(required = false) String module,
@RequestParam(required = false) String action, @RequestParam(required = false) String action,
@@ -59,20 +66,48 @@ public class LogController {
@RequestParam(required = false) String to, @RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) { @RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require(); Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum); int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200); int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from); LocalDateTime f = parseFrom(from);
LocalDateTime t = parseTo(to); LocalDateTime t = parseTo(to);
List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations( 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); f, t, ps, (pn - 1) * ps);
long total = operationLogMapper.countOperations( 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)); 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") @GetMapping("/logins")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT) @RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "登录日志", @Operation(summary = "登录日志",
description = "Query:`userId` `userType` `result` `from` `to` + 分页。" description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— " + "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。" + "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了") + "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins( public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long userId, @RequestParam(required = false) Long userId,
@RequestParam(required = false) Integer userType, @RequestParam(required = false) Integer userType,
@RequestParam(required = false) Boolean result, @RequestParam(required = false) Boolean result,
@@ -122,7 +159,7 @@ public class LogController {
@RequestParam(required = false) String to, @RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) { @RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require(); Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum); int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200); int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from); LocalDateTime f = parseFrom(from);
@@ -131,8 +168,8 @@ public class LogController {
Integer ok = result == null ? null : (result ? 1 : 0); Integer ok = result == null ? null : (result ? 1 : 0);
List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins( List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins(
me.tenantId(), userId, userType, ok, f, t, ps, (pn - 1) * ps); scope, userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(me.tenantId(), userId, userType, ok, f, t); long total = loginLogMapper.countLogins(scope, userId, userType, ok, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps)); return ApiResult.ok(PageResult.of(list, total, pn, ps));
} }
} }
@@ -60,7 +60,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
l.create_time AS loginTime l.create_time AS loginTime
FROM sys_login_log l FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 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"> <if test="userId != null">
AND l.user_id = #{userId} AND l.user_id = #{userId}
</if> </if>
@@ -96,7 +99,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
SELECT COUNT(*) SELECT COUNT(*)
FROM sys_login_log l FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 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"> <if test="userId != null">
AND l.user_id = #{userId} AND l.user_id = #{userId}
</if> </if>
@@ -18,6 +18,15 @@ import java.util.List;
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}: * <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> { public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
@@ -57,7 +66,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
l.create_time AS createTime l.create_time AS createTime
FROM sys_operation_log l FROM sys_operation_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 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"> <if test="operatorId != null">
AND l.user_id = #{operatorId} AND l.user_id = #{operatorId}
</if> </if>
@@ -100,7 +112,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
<script> <script>
SELECT COUNT(*) SELECT COUNT(*)
FROM sys_operation_log l 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"> <if test="operatorId != null">
AND l.user_id = #{operatorId} AND l.user_id = #{operatorId}
</if> </if>
@@ -79,6 +79,8 @@ public class OperationLogService {
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */ /** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK"; public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_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_REVOKE_AUDIT = "REVOKE_AUDIT";
public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE"; public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE";
public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD"; 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") @PostMapping("/tasks/{id}/materials")
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "新增材料项", @Operation(summary = "新增材料项",
description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。" description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。"
+ "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传") + "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传")
@@ -57,7 +57,7 @@ public class MaterialDefController {
} }
@PutMapping("/materials/{id}") @PutMapping("/materials/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)") @Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)")
public ApiResult<Void> update(@PathVariable Long id, public ApiResult<Void> update(@PathVariable Long id,
@Valid @RequestBody MaterialDefSaveReq req) { @Valid @RequestBody MaterialDefSaveReq req) {
@@ -66,7 +66,7 @@ public class MaterialDefController {
} }
@DeleteMapping("/materials/{id}") @DeleteMapping("/materials/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "删除材料项", @Operation(summary = "删除材料项",
description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收," description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收,"
+ "请设为选传或加条件显示规则隐藏") + "请设为选传或加条件显示规则隐藏")
@@ -76,7 +76,7 @@ public class MaterialDefController {
} }
@PostMapping("/tasks/{id}/materials/apply-template") @PostMapping("/tasks/{id}/materials/apply-template")
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "按内置模板初始化材料清单", @Operation(summary = "按内置模板初始化材料清单",
description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*") description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*")
public ApiResult<Map<String, Object>> applyTemplate(@PathVariable Long id, public ApiResult<Map<String, Object>> applyTemplate(@PathVariable Long id,
@@ -48,6 +48,7 @@ public class MpH5AuthController {
private final MpH5AuthService authService; private final MpH5AuthService authService;
private final JwtService jwtService; private final JwtService jwtService;
private final cn.tianbaotong.fill.config.FillProperties props; private final cn.tianbaotong.fill.config.FillProperties props;
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
@PublicApi @PublicApi
@GetMapping("/authorize") @GetMapping("/authorize")
@@ -64,7 +65,8 @@ public class MpH5AuthController {
@Operation(summary = "微信授权回调(匿名)", @Operation(summary = "微信授权回调(匿名)",
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)") description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
public ResponseEntity<Void> callback(@RequestParam(required = false) String code, 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); String scene = authService.consumeScene(state);
try { try {
if (code == null || code.isBlank()) { if (code == null || code.isBlank()) {
@@ -72,7 +74,7 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "你取消了微信授权,请重新进入"); return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
} }
String openId = authService.exchangeOpenId(code); String openId = authService.exchangeOpenId(code);
return loginAndRedirect(openId, scene); return loginAndRedirect(openId, scene, request);
} catch (BizException e) { } catch (BizException e) {
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是 // ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面, // 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
@@ -99,12 +101,13 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)"); return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
} }
SysUser user = authService.stubLogin(tenantId, openid, request); 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); Long tenantId = MpSceneService.tenantIdOf(scene);
if (tenantId == null) { if (tenantId == null) {
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。 // 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
@@ -112,10 +115,13 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "请扫描班主任发的二维码进入"); return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
} }
SysUser user = authService.resolveUser(tenantId, openId); 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( String token = jwtService.issue(
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4), new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
JwtService.Client.MP); JwtService.Client.MP);
@@ -41,11 +41,13 @@ public class MpLoginController {
description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。" description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。"
+ "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。" + "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + 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 code = str(body == null ? null : body.get("code"));
String scene = str(body == null ? null : body.get("scene")); 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") @PostMapping("/bind-phone")
@@ -71,6 +71,7 @@ public class MpLoginService {
private final TaskMapper taskMapper; private final TaskMapper taskMapper;
private final JwtService jwtService; private final JwtService jwtService;
private final SensitiveCodec codec; private final SensitiveCodec codec;
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
private final HttpClient http = HttpClient.newBuilder() private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build(); .connectTimeout(Duration.ofSeconds(10)).build();
@@ -79,6 +80,14 @@ public class MpLoginService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public LoginVO login(String code, String scene) { 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()) { if (code == null || code.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code");
} }
@@ -130,6 +139,10 @@ public class MpLoginService {
} }
log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}", log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}",
s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone()); 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; return vo;
} }
@@ -32,6 +32,8 @@ public interface UrgeMapper {
private String orgName; private String orgName;
private Long taskId; private Long taskId;
private String taskName; private String taskName;
/** 截止时间:模板里的 {@code {截止日}} 与 {@code {剩余天数}} 靠它(以前没取,那两个变量永远空)。 */
private java.time.LocalDateTime endTime;
private Integer submitStatus; private Integer submitStatus;
public Long getRosterId() { return rosterId; } public Long getRosterId() { return rosterId; }
@@ -48,6 +50,8 @@ public interface UrgeMapper {
public void setTaskId(Long v) { this.taskId = v; } public void setTaskId(Long v) { this.taskId = v; }
public String getTaskName() { return taskName; } public String getTaskName() { return taskName; }
public void setTaskName(String v) { this.taskName = v; } 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 Integer getSubmitStatus() { return submitStatus; }
public void setSubmitStatus(Integer v) { this.submitStatus = v; } public void setSubmitStatus(Integer v) { this.submitStatus = v; }
} }
@@ -68,6 +72,7 @@ public interface UrgeMapper {
o.org_name AS orgName, o.org_name AS orgName,
t.id AS taskId, t.id AS taskId,
t.task_name AS taskName, t.task_name AS taskName,
t.end_time AS endTime,
r.submit_status AS submitStatus r.submit_status AS submitStatus
FROM collect_roster r FROM collect_roster r
JOIN collect_task t JOIN collect_task t
@@ -105,6 +110,25 @@ public interface UrgeMapper {
@Param("orgIds") List<Long> orgIds, @Param("orgIds") List<Long> orgIds,
@Param("scopeUserId") Long scopeUserId); @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") @InterceptorIgnore(tenantLine = "true")
@Select(""" @Select("""
@@ -115,6 +139,7 @@ public interface UrgeMapper {
o.org_name AS orgName, o.org_name AS orgName,
t.id AS taskId, t.id AS taskId,
t.task_name AS taskName, t.task_name AS taskName,
t.end_time AS endTime,
r.submit_status AS submitStatus r.submit_status AS submitStatus
FROM collect_roster r FROM collect_roster r
JOIN collect_task t JOIN collect_task t
@@ -60,7 +60,21 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class NotificationService { 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_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT"; public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND"; public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
@@ -72,10 +86,21 @@ public class NotificationService {
private final NotifyRecordMapper notifyRecordMapper; private final NotifyRecordMapper notifyRecordMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final WxMiniProgramService wxService; 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}) * @param req 内容与收件人(见 {@link NotifyRequest})
* @return 结果:{@code pushSent} 是"微信推送有没有发出去", * @return 结果:{@code pushSent} 是"微信推送有没有发出去",
* {@code messageId} 是站内信 id(**一定存在**,除非写库本身失败) * {@code messageId} 是站内信 id(**一定存在**,除非写库本身失败)
@@ -84,13 +109,39 @@ public class NotificationService {
Long tenantId = req.tenantId() == null ? UserContext.tenantId() : req.tenantId(); Long tenantId = req.tenantId() == null ? UserContext.tenantId() : req.tenantId();
Long userId = req.userId(); 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(); CollectMessage msg = new CollectMessage();
msg.setUserId(userId); msg.setUserId(userId);
msg.setBizType(req.bizType()); msg.setBizType(req.bizType());
msg.setTitle(clip(req.title(), 128)); msg.setTitle(clip(title, 128));
msg.setContent(clip(req.content(), 1024)); msg.setContent(clip(content, 1024));
msg.setLinkType(req.linkType() == null ? CollectMessage.LINK_NONE : req.linkType()); msg.setLinkType(req.linkType() == null ? CollectMessage.LINK_NONE : req.linkType());
msg.setLinkId(req.linkId()); msg.setLinkId(req.linkId());
msg.setIsRead(0); msg.setIsRead(0);
@@ -115,7 +166,7 @@ public class NotificationService {
} else { } else {
try { try {
wxService.sendSubscribeMessage(openid, wxTemplateIdOf(templateCode), wxService.sendSubscribeMessage(openid, wxTemplateIdOf(templateCode),
"pages/tasks/tasks", buildTemplateData(req)); "pages/tasks/tasks", buildTemplateData(req, title, content));
pushSent = true; pushSent = true;
// 落地通道改成微信推送(站内信仍然在,学生随时能回来看) // 落地通道改成微信推送(站内信仍然在,学生随时能回来看)
messageMapper.update(null, Wrappers.<CollectMessage>lambdaUpdate() messageMapper.update(null, Wrappers.<CollectMessage>lambdaUpdate()
@@ -137,7 +188,9 @@ public class NotificationService {
// ③ 如实记录发送通道结果(collect_notify_record 的定位就是这个) // ③ 如实记录发送通道结果(collect_notify_record 的定位就是这个)
// 注意:站内信本身"发成功"了,所以 status 记 1;推送那条另记一行。 // 注意:站内信本身"发成功"了,所以 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); : CollectMessage.CHANNEL_IN_APP, pushSent, skippedNoQuota, failReason);
return new NotifyResult(msg.getId(), pushSent, failReason, msg.getChannel()); 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) { 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, public record NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode, String linkType, Long linkId, String templateCode,
Map<String, String> templateParams) { 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。 */ /** 常用构造:学生端(带跳转)。{@code linkId} 在 {@code linkType=TASK} 时就是 taskId。 */
public static NotifyRequest of(Long userId, int bizType, String title, String content, public static NotifyRequest of(Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode) { String linkType, Long linkId, String templateCode) {
return new NotifyRequest(null, userId, bizType, title, content, linkType, linkId, 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} 推:跳任务是任务本身,跳填报单则要靠调用方另外传 * <p>从 {@code linkType} 推:跳任务是任务本身,跳填报单则要靠调用方另外传
* (这里返回 null 也行,只是那条记录没法按任务筛)。 * (这里返回 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) { boolean ok, boolean skippedNoQuota, String failReason) {
try { try {
// biz_type 是 varchar(32)(V1 的 DDL),所以要转字符串 —— 见 NotifyRecordMapper // biz_type 是 varchar(32)(V1 的 DDL),所以要转字符串 —— 见 NotifyRecordMapper
int status = ok ? 1 : (skippedNoQuota ? 3 : 2); int status = ok ? 1 : (skippedNoQuota ? 3 : 2);
int n = notifyRecordMapper.insertRecord(tenantId, req.taskIdOrNull(), int n = notifyRecordMapper.insertRecord(tenantId, req.taskIdOrNull(),
String.valueOf(req.bizType()), String.valueOf(req.bizType()),
req.userId(), channel, req.templateCode(), req.title(), status, req.userId(), channel, req.templateCode(), title, status,
clip(failReason, 512)); clip(failReason, 512));
if (n <= 0) { if (n <= 0) {
log.warn("通知发送记录没写进去(返回 {} 行):user={} biz={}", n, req.userId(), req.bizType()); log.warn("通知发送记录没写进去(返回 {} 行):user={} biz={}", n, req.userId(), req.bizType());
@@ -210,14 +321,14 @@ public class NotificationService {
* 所以只能由客户在微信后台建好模板后,用 {@code templateParams} 传进来。 * 所以只能由客户在微信后台建好模板后,用 {@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<>(); Map<String, Object> data = new LinkedHashMap<>();
if (req.templateParams() != null && !req.templateParams().isEmpty()) { if (req.templateParams() != null && !req.templateParams().isEmpty()) {
req.templateParams().forEach((k, v) -> data.put(k, Map.of("value", v))); req.templateParams().forEach((k, v) -> data.put(k, Map.of("value", v)));
return data; return data;
} }
data.put("thing1", Map.of("value", clip(req.title(), 20))); data.put("thing1", Map.of("value", clip(title, 20)));
data.put("thing2", Map.of("value", clip(req.content(), 20))); data.put("thing2", Map.of("value", clip(content, 20)));
return data; return data;
} }
@@ -62,13 +62,20 @@ public final class NotifyTemplates {
"{任务名} 的填报已审核通过,无需再操作。", "终审通过"), "{任务名} 的填报已审核通过,无需再操作。", "终审通过"),
new Def("URGE", 5, "请尽快完成填报", 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_APPROVE", 7, "加入名单申请已处理",
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"), "你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请", new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员") "{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员"),
new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
"驳回次数达上限时发给学校管理员(契约 §11.4)")
); );
public static List<Def> all() { public static List<Def> all() {
@@ -87,6 +94,13 @@ public final class NotifyTemplates {
/** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */ /** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */
public static final Set<Integer> BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7); 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`)。 */ /** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */
public static final String TPL_URGE = "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}。 * 催报用的业务类型码 = {@code 5}。
* *
@@ -60,9 +71,39 @@ public class UrgeService {
/** 默认去重窗口(小时)。 */ /** 默认去重窗口(小时)。 */
private static final int DEFAULT_EXCLUDE_HOURS = 24; 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 UrgeMapper urgeMapper;
private final AuditScopeMapper auditScopeMapper; private final AuditScopeMapper auditScopeMapper;
private final NotificationService notificationService; 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`)── // ── 定向催报(§8.6 的 `/roster/{rid}/notify`)──
@@ -87,6 +128,7 @@ public class UrgeService {
// 还没核对身份 → 没有账号可发。如实说明,别报"发送失败" // 还没核对身份 → 没有账号可发。如实说明,别报"发送失败"
return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)"))); return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)")));
} }
rateLimit("task:" + t.getTaskId());
String extra = req == null ? null : req.getExtraText(); String extra = req == null ? null : req.getExtraText();
NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false); NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false);
@@ -125,6 +167,10 @@ public class UrgeService {
return result(List.of()); return result(List.of());
} }
// 限流放在"确实有活可干"之后:空结果就连点也不计数 ——
// 惩罚"点了但没人可催"只会让人困惑(界面上明明什么都没发生)
rateLimit(taskId != null ? "task:" + taskId : "orgs:" + orgIds);
int hours = req == null || req.getExcludeRecentHours() == null int hours = req == null || req.getExcludeRecentHours() == null
? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours()); ? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -181,9 +227,14 @@ public class UrgeService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
Set<Long> sentUsers = new LinkedHashSet<>(); Set<Long> sentUsers = new LinkedHashSet<>();
int sent = 0; int sent = 0;
String extra = daysLeft <= 1 // 0 天 = 截止当日(FR-1003 要求"7/3/1 天 + 截止当日")。
? "今天是最后一天了,请务必今天提交。" // 三档文案必须分开:对"还有 1 天"和"就是今天"说同一句"今天是最后一天",
: "距截止还有 " + daysLeft + " 天,请尽早提交。"; // 学生就无法判断到底还剩几个小时 —— 而那正是他此刻唯一需要知道的事。
String extra = daysLeft <= 0
? "今天是截止日,请在今天 24 点前提交,过后就不能再交了。"
: daysLeft == 1
? "今天是最后一天了,请务必今天提交。"
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
for (UrgeMapper.UrgeTarget t : targets) { for (UrgeMapper.UrgeTarget t : targets) {
// 还没扫码核验的人**没有账号可发**,跳过(一键催报会给老师看这个理由, // 还没扫码核验的人**没有账号可发**,跳过(一键催报会给老师看这个理由,
// 自动催报没人看,所以只记 debug) // 自动催报没人看,所以只记 debug)
@@ -259,25 +310,40 @@ public class UrgeService {
/** 发一条催报。返回"跳过了没有 + 为什么"。 */ /** 发一条催报。返回"跳过了没有 + 为什么"。 */
private NotifyOutcome send(UrgeMapper.UrgeTarget t, String templateCode, String extra, private NotifyOutcome send(UrgeMapper.UrgeTarget t, String templateCode, String extra,
boolean draft) { boolean draft) {
String title = draft ? "你的填报还没有提交" : "请尽快完成填报"; // 文案走模板(FR-1002 的同一口径):管理员在「通知模板」页改了「催报」那句,
StringBuilder content = new StringBuilder(draft // 学生收到的就是改过的那句。草稿与"一次都没填"是两条不同的话,
? "你已经开始填写《" + nz(t.getTaskName()) + "》,但还没有提交。填完记得点「提交」。" // 所以是两个模板码 —— 合成一个就得在模板里加条件,那是模板做不到的事。
: "你还没有完成《" + nz(t.getTaskName()) + "》的填报,请尽快完成。"); java.util.Map<String, String> vars = new java.util.LinkedHashMap<>();
if (t.getOrgName() != null) { vars.put("任务名", nz(t.getTaskName()));
content.append("(").append(t.getOrgName()).append(")"); vars.put("姓名", t.getRealName());
} vars.put("班级", t.getOrgName());
if (extra != null && !extra.isBlank()) { if (t.getTaskId() != null) {
// 老师自己写的话原样附在后面 —— 它往往才是学生真正会看的那句 vars.put("提交进度", progressOf(t.getTaskId()));
content.append("\n").append(extra.trim()); 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)));
}
} }
try { try {
var r = notificationService.notify(new NotificationService.NotifyRequest( var r = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
null, t.getUserId(), BIZ_URGE, title, content.toString(), t.getUserId(), BIZ_URGE,
draft ? SUBJECT_URGE_DRAFT : SUBJECT_URGE,
vars,
draft ? "你的填报还没有提交" : "请尽快完成填报",
CollectMessage.LINK_TASK, t.getTaskId(), CollectMessage.LINK_TASK, t.getTaskId(),
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode, templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode)
java.util.Map.of())); // 班级与老师附言是模板表达不了的"这一次"的信息,追加在后面
// 站内信一定发出去了 → 算 sent。微信推送没出去只影响"他多久看到" .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()); return new NotifyOutcome(false, r.pushSent() ? null : r.failReason());
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("催报失败:roster={} user={}", t.getRosterId(), t.getUserId(), 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) { private boolean inMyScope(Long tenantId, Long uid, UrgeMapper.UrgeTarget t) {
List<Long> mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid); List<Long> mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid);
@@ -11,7 +11,21 @@ import java.util.List;
/** 推送记录(契约 §14.4)。 */ /** 推送记录(契约 §14.4)。 */
public interface OpenPushLogMapper extends BaseMapper<CollectOpenPushLog> { 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("""
SELECT * FROM collect_open_push_log SELECT * FROM collect_open_push_log
WHERE deleted = 0 AND status = 2 WHERE deleted = 0 AND status = 2
@@ -212,11 +212,18 @@ public class OpenPushService {
// 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处) // 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处)
CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId()); CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId());
if (app == null || !app.enabled()) { if (app == null || !app.enabled()) {
// 接入方没了/停了:不再重试,标成最终失败并说明原因 // 接入方没了/停了:不再重试,标成最终失败并说明原因。
row.setStatus(CollectOpenPushLog.STATUS_FAILED); //
row.setLastError("接入方不存在或已停用,停止重试"); // ⚠️ 这一步**也必须摆租户上下文**:`updateById` 会走租户拦截器,
row.setNextRetryAt(null); // 而在"接入方已不存在"的情况下我们不能从 app 拿租户 —— 所以用
pushLogMapper.updateById(row); // **推送记录自己的 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; continue;
} }
TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row)); 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) { 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() List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
.eq(SysOrg::getTenantId, tenantId)
.orderByAsc(SysOrg::getSortNo) .orderByAsc(SysOrg::getSortNo)
.orderByAsc(SysOrg::getId)); .orderByAsc(SysOrg::getId));
@@ -62,11 +62,24 @@ public interface ScheduleMapper {
List<Map<String, Object>> tasksToClose(); List<Map<String, Object>> tasksToClose();
/** /**
* 截止日正好在某一天的任务(给"提前 7/3/1 天催报"用)。 * 截止日正好在某一天的任务(给"提前 7/3/1 天 + 截止当日催报"用)。
* *
* <p>为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务, * <p>为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务,
* 用 `<=` 会让"还剩 2 天"的任务在 7 天、3 天、1 天各被催一次(因为 2 < 3 也成立) * 用 `<=` 会让"还剩 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") @InterceptorIgnore(tenantLine = "true")
@Select(""" @Select("""
@@ -74,7 +87,8 @@ public interface ScheduleMapper {
FROM collect_task FROM collect_task
WHERE deleted = 0 WHERE deleted = 0
AND status = 1 AND status = 1
AND end_time = #{date}::date AND end_time IS NOT NULL
AND end_time::date = #{date}::date
ORDER BY id ORDER BY id
""") """)
List<Map<String, Object>> tasksEndingOn(@Param("date") String date); List<Map<String, Object>> tasksEndingOn(@Param("date") String date);
@@ -78,8 +78,14 @@ public class ScheduleJobs {
/** 审核超时提醒一轮最多处理多少条(防止某天积压上万条时把接口打爆)。 */ /** 审核超时提醒一轮最多处理多少条(防止某天积压上万条时把接口打爆)。 */
private static final int TIMEOUT_BATCH = 500; 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 天」)。 */ /** 租户到期前提醒的天数(PRD 附录 E 的「30/7/1 天」)。 */
private static final int[] EXPIRE_REMIND_DAYS = {30, 7, 1}; private static final int[] EXPIRE_REMIND_DAYS = {30, 7, 1};
@@ -39,7 +39,7 @@ public class PlanScopeController {
} }
@PutMapping("/{id}/plan-scope") @PutMapping("/{id}/plan-scope")
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.PLAN_IMPORT)
@Operation(summary = "确认 / 修正报考范围", @Operation(summary = "确认 / 修正报考范围",
description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— " description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— "
+ "漏一个人,导出包就会缺人,而人工核对发现不了") + "漏一个人,导出包就会缺人,而人工核对发现不了")
@@ -107,7 +107,10 @@ public class TaskController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "删除任务", description = "只有草稿可以删除;已发布的请用截止或归档") @Operation(summary = "删除任务",
description = "草稿直接删;**已归档**的任务可删(2026-09-30 客户确认,§17.2 D-9):"
+ "级联软删名册/填报单/材料/审核记录并尽力删除文件,全程留痕。"
+ "收集中/已截止的先「归档」再删 —— 归档是最后的后悔药")
public ApiResult<Void> delete(@PathVariable Long id) { public ApiResult<Void> delete(@PathVariable Long id) {
taskService.delete(id); taskService.delete(id);
return ApiResult.ok(); return ApiResult.ok();
@@ -164,7 +167,7 @@ public class TaskController {
// ══════════════════ 克隆 ══════════════════ // ══════════════════ 克隆 ══════════════════
@PostMapping("/{id}/clone") @PostMapping("/{id}/clone")
@RequirePerm(PermissionDef.TASK_MANAGE) @RequirePerm(PermissionDef.TASK_CLONE)
@Operation(summary = "克隆任务", @Operation(summary = "克隆任务",
description = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级)," 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 TaskMapper taskMapper;
private final MaterialDefMapper materialDefMapper; private final MaterialDefMapper materialDefMapper;
private final MaterialDefService materialDefService; private final MaterialDefService materialDefService;
private final cn.tianbaotong.fill.module.form.service.FormSchemaService formSchemaService;
/** 可应用的三个部分(契约 §7.13 的 `parts`)。 */ /** 可应用的三个部分(契约 §7.13 的 `parts`)。 */
public static final Set<String> ALL_PARTS = new LinkedHashSet<>( public static final Set<String> ALL_PARTS = new LinkedHashSet<>(
@@ -126,8 +127,12 @@ public class RuleTemplateService {
// 空 = 用系统默认表单,**不覆盖**目标任务的既有表单 // 空 = 用系统默认表单,**不覆盖**目标任务的既有表单
r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)"); r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)");
} else { } else {
// 2026-09-30 起接上真正的应用路径(此前只能"跳过并说明",见 F-57/F-64)。
// 走 FormSchemaService 的版本化保存:它保留"已发布任务不可改表单"的检查
// 与版本管理 —— 这里**不再自己写表**,两处逻辑必须只有一份。
assertNotPublished(target, "表单定义"); assertNotPublished(target, "表单定义");
r.getSkipped().add("FORM(一期还没有「保存表单定义」的服务端入口,见 F-57)"); formSchemaService.saveRawVersion(target.getId(), tpl.getFormSchema());
r.getApplied().add("FORM");
} }
} }
if (want.contains("MATERIALS")) { if (want.contains("MATERIALS")) {
@@ -258,6 +258,16 @@ public class TaskCloneService {
row.setCertType(t.getCertType()); row.setCertType(t.getCertType());
row.setMaterials(arr.toString()); row.setMaterials(arr.toString());
row.setNamingTemplate(t.getNamingTemplate()); 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.setIsPublic(pub ? CollectRuleTemplate.PUBLIC_YES : CollectRuleTemplate.PUBLIC_NO);
row.setUseCount(0); row.setUseCount(0);
ruleTemplateMapper.insert(row); ruleTemplateMapper.insert(row);
@@ -73,6 +73,8 @@ public class TaskService {
private final TaskReadinessMapper readinessMapper; private final TaskReadinessMapper readinessMapper;
private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService; 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.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; 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() { private boolean seesWholeTenant() {
LoginUser me = UserContext.require(); 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); 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) @Transactional(rollbackFor = Exception.class)
public void delete(Long id) { public void delete(Long id) {
CollectTask exist = requireTask(id); CollectTask exist = requireTask(id);
if (exist.getStatus() == null || exist.getStatus() != CollectTask.STATUS_DRAFT) { int st = exist.getStatus() == null ? -1 : exist.getStatus();
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"只有草稿状态的任务可以删除;已发布的任务请用「截止」或「归档」"); 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); taskMapper.deleteById(id);
log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), 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; private final TenantService tenantService;
@GetMapping @GetMapping
@RequirePerm(PermissionDef.TENANT_MANAGE) @RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户列表") @Operation(summary = "租户列表")
public ApiResult<PageResult<TenantVO>> list( public ApiResult<PageResult<TenantVO>> list(
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@@ -48,7 +48,7 @@ public class TenantController {
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@RequirePerm(PermissionDef.TENANT_MANAGE) @RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户详情") @Operation(summary = "租户详情")
public ApiResult<TenantVO> detail(@PathVariable Long id) { public ApiResult<TenantVO> detail(@PathVariable Long id) {
return ApiResult.ok(tenantService.detail(id)); return ApiResult.ok(tenantService.detail(id));
@@ -64,7 +64,7 @@ public class TenantController {
@PutMapping("/{id}") @PutMapping("/{id}")
@RequirePerm(PermissionDef.LOG_VIEW_ALL) @RequirePerm(PermissionDef.TENANT_MANAGE)
@Operation(summary = "修改租户 / 套餐(§6.5)", @Operation(summary = "修改租户 / 套餐(§6.5)",
description = "⚠️ `tenantCode` **不允许改**:它出现在域名解析与对接文档里," description = "⚠️ `tenantCode` **不允许改**:它出现在域名解析与对接文档里,"
+ "改了会让已经发出去的文档与配好的域名全部失效," + "改了会让已经发出去的文档与配好的域名全部失效,"
@@ -77,7 +77,7 @@ public class TenantController {
} }
@GetMapping("/{id}/usage") @GetMapping("/{id}/usage")
@RequirePerm(PermissionDef.LOG_VIEW_ALL) @RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户用量统计(§6.7)", @Operation(summary = "租户用量统计(§6.7)",
description = "`period` 支持 `2026-09`(月)与 `2026Q3`(季);不传是当月。" description = "`period` 支持 `2026-09`(月)与 `2026Q3`(季);不传是当月。"
+ "⚠️ `ocrCount` / `smsCount` **恒为 0 并带 notes 说明** —— " + "⚠️ `ocrCount` / `smsCount` **恒为 0 并带 notes 说明** —— "
@@ -119,4 +119,22 @@ public interface UserMapper extends BaseMapper<SysUser> {
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN') AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
""") """)
Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId); 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; final String kw = (raw == null || raw.isEmpty()) ? null : raw;
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery(); 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(userType != null, SysUser::getUserType, userType)
.eq(status != null, SysUser::getStatus, status); .eq(status != null, SysUser::getStatus, status);
@@ -31,6 +31,7 @@ public class AuthInterceptor implements HandlerInterceptor {
private final TokenBlacklist blacklist; private final TokenBlacklist blacklist;
private final PermissionChecker permissionChecker; private final PermissionChecker permissionChecker;
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver; private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
private final cn.tianbaotong.fill.support.ActingTenantResolver actingTenantResolver;
@Override @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
@@ -52,6 +53,21 @@ public class AuthInterceptor implements HandlerInterceptor {
LoginUser user = jwtService.parse(token); 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 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。 // ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
// //
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。 // Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
@@ -24,6 +24,15 @@ public enum BuiltinRole {
PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW, PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW, PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL, 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), PermissionDef.LOG_VIEW_ALL),
Set.of("dashboard", "tenant", "org", "user", "log")), Set.of("dashboard", "tenant", "org", "user", "log")),
@@ -41,8 +50,22 @@ public enum BuiltinRole {
PermissionDef.FILE_PROXY_UPLOAD, PermissionDef.FILE_PROXY_UPLOAD,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN, PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
PermissionDef.LOG_VIEW_TENANT), PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task", "form", "material", Set.of("dashboard", "org", "user", "role", "task",
"roster", "join", "audit", "export", "cleanup", "log")), "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, 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.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN, PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD), 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, DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
@@ -58,14 +81,14 @@ public enum BuiltinRole {
PermissionDef.ROSTER_VIEW, PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_OWN, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD), 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, AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40,
Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW, Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_ALL,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL), PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL),
Set.of("dashboard", "task", "roster", "audit")), Set.of("dashboard", "task", "audit")),
/** 学生:只能操作本人那一份。 */ /** 学生:只能操作本人那一份。 */
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90, 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 | **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 | **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-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 引擎选型 | 待比价(腾讯云 / 百度 / 本地模型) | 需同时评估**数据出境合规** | | D1 | OCR 引擎选型 | 待比价(腾讯云 / 百度 / 本地模型) | 需同时评估**数据出境合规** |
| D2 | 视觉大模型(VLM)选型 | 抽成**可替换接口**,不绑定供应商 | 实测触发率仅 2%~10%,成本可控 | | 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 | 材料识别强特征词表 | 平台预置 + 租户可自维护 | **需在"社会评价"批次数据上补充标定** | | D4 | 材料识别强特征词表 | 平台预置 + 租户可自维护 | **需在"社会评价"批次数据上补充标定** |
| D5 | 一寸照片形态判定阈值 | 用真实样本标定(宽高比容差、背景纯净度) | — | | D5 | 一寸照片形态判定阈值 | 用真实样本标定(宽高比容差、背景纯净度) | — |
| D6 | 微信三条来源通道的体积上限 | 需真机实测 | 决定超限提示与降级路径 | | D6 | 微信三条来源通道的体积上限 | 需真机实测 | 决定超限提示与降级路径 |
@@ -267,7 +267,7 @@ B1 选 A,但希望增加审批环节
|---|---|---|---| |---|---|---|---|
| 失败(按人) | **只计失败** | 10 次/小时 | 防猜 | | 失败(按人) | **只计失败** | 10 次/小时 | 防猜 |
| 失败(按 IP) | **只计失败** | 20 次/分钟 | 防枚举 | | 失败(按 IP) | **只计失败** | 20 次/分钟 | 防枚举 |
| 成功(按 IP) | 只计成功 | 200 次/分钟 | 防"批量取数据" | | 成功(按 IP) | 只计成功 | **600 次/分钟**(2026-09-30 由 200 上调,见下) | 防"批量取数据" |
**为什么必须改**:一个班的学生坐在同一间教室、连同一个 WiFi,出口是同一个公网 IP。 **为什么必须改**:一个班的学生坐在同一间教室、连同一个 WiFi,出口是同一个公网 IP。
按旧口径,60 人同时扫码时**只有 20 个能进**,其余 40 个看到「操作过于频繁」—— 按旧口径,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. 二期(社会收集 / 社会评价 / 专项职业能力)的启动时点 ### E3. 二期(社会收集 / 社会评价 / 专项职业能力)的启动时点
一期范围已按约定锁死为「学校收集 + 自主认定」(其他模式一律返回 `40044`)。 一期范围已按约定锁死为「学校收集 + 自助认定」(其他模式一律返回 `40044`)。
但**二期什么时候启动**会影响两件现在就能做、也可以等的事: 但**二期什么时候启动**会影响两件现在就能做、也可以等的事:
| 事项 | 如果二期在 3 个月内启动 | 如果更晚 | | 事项 | 如果二期在 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-600 材料与规则 | 23 | 18 | ✅ | ✅ | ⚠️ | ✅ 识别结果已并入审核详情(P3) |
| FR-800 审核工作台 | 10 | 7 | ✅ | ✅ | ✅ | ✅ 撤回接口已补(P3) | | FR-800 审核工作台 | 10 | 7 | ✅ | ✅ | ✅ | ✅ 撤回接口已补(P3) |
| FR-900 汇总导出 | 10 | 8 | ✅ | ✅ | ✅ | ✅ **全覆盖** | | 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-1100 数据看板 | 5 | 2 | ✅ | ✅ | ✅ | ✅ **全覆盖** |
| FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 | | FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 |
| FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 | | FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
+184 -34
View File
@@ -70,6 +70,25 @@
**Token 有效期**:PC 端 8 小时(可续期)/ 小程序端 7 天(静默续期) **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 统一响应 ### 1.3 统一响应
```json ```json
@@ -252,24 +271,20 @@
| `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 | | `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 |
| `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 | | `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 |
| `auditNode` | `1` 初审 / `2` 终审 | | `auditNode` | `1` 初审 / `2` 终审 |
| `auditAction` | `1` 通过 / `2` 驳回 / `3` **撤回** / `4` 代传 | | `auditAction` | `1` 通过 / `2` 驳回 / `3` 批量通过 / `4` 批量驳回 / `5` 转交 / `6` 代传 / `7` **撤回** |
| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 | | `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 |
| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 | | `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 |
| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 | | `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` 已过期 | | `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 |
| `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 | | `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
| `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 | | `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 |
> ⚠️ **`notifyBizType` 的 `5` / `6` 在本文件内部是不一致的**:本表写「`5` 导出完成 / `6` 催报」, > ✅ **`notifyBizType` 的 `5`/`6` 曾经在本文件内部对调过(已修正,2026-09-30 客户确认)**:
> 而 §13.5 消息中心的示例(`2` 临期提醒 / `3` 驳回)与落库值 `collect_notify_record.biz_type` > 本表旧版写「`5` 导出完成 / `6` 催报」,与 §13.5 消息中心示例、落库值
> 用的是「**`5` 催报 / `6` 导出完成**」。 > `collect_notify_record.biz_type`(`5` 催报 / `6` 导出完成)不一致 ——
> > 若按旧表实现,催报会被写成"导出完成",**催报去重会静默失效**(老师点两下学生收到两条),
> **实现以 §13.5 为准**(代码里是 `CollectMessage.BIZ_URGE = 5` / `BIZ_EXPORT = 6`, > 而排查时从数据上完全看不出是码对不上。现全文档统一为「`5` 催报 / `6` 导出完成」。
> 催报去重按 `5` 走)。这不是文案问题:若按本表实现,催报会被写成"导出完成",
> **去重会静默失效**(老师点两下学生收到两条),而排查时从数据上完全看不出是码对不上。
>
> 需要客户确认以哪一边为准;若确认以本表为准,要一起改的是两个常量 + 已落库的历史数据。
> 记录见《08-完成度盘点与问题清单》**F-30**。 > 记录见《08-完成度盘点与问题清单》**F-30**。
--- ---
@@ -472,18 +487,14 @@
| `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) | | `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) |
| `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` | | `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` |
| `issues` | array | — | 该文件的 `ValidationIssueVO[]` | | `issues` | array | — | 该文件的 `ValidationIssueVO[]` |
| `source` | int | `source` | `1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传 ⚠️ 见下方注 | | `source` | int | `source` | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入 |
| `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** | | `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** |
> ⚠️ **`collect_file.source` 有两套互相冲突的口径**:本表写的是"从哪个入口选的" > ✅ **`collect_file.source` 的口径曾经有两套,已确认(2026-09-30 客户确认)**:本表旧版写
> (`1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传),而建表注释(`V1__business_tables.sql`) > "从哪个入口选的"(`1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传),与建表注释
> 与实现(`CollectFile.SOURCE_*`)写的是"**谁传的**": > (`V1__business_tables.sql`)冲突。**以建表注释为准("谁传的")**:§10.6 要求代传可追溯,
> `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入。 > 要回答的是"**谁**替他传的、**为什么**",而"从哪个入口选的"会随同一个学生的操作路径变化,
> > 统计上没有意义。实现见 `CollectFile.SOURCE_*` 常量。记录见《08》**F-38 / F-39**。
> **实现以建表注释为准**:§10.6 要求代传必须可追溯,要回答的是"**谁**替他传的、
> **为什么**",而不是"他从哪个入口选的"—— 后者在同一个学生身上还会随操作路径变化。
> 两个口径都是 4 个值、含义完全不同,所以"照字段表写"会得到一份**看起来有数据、
> 但答案是错的**统计。记录见《08-完成度盘点与问题清单》**F-38 / F-39**。
**`ownership.verdict` 取值**(对应 PRD §6.1 归属裁决矩阵) **`ownership.verdict` 取值**(对应 PRD §6.1 归属裁决矩阵)
@@ -533,7 +544,7 @@
| `id` | int64 | — | | `id` | int64 | — |
| `auditNode` | int | `1` 初审 / `2` 终审 | | `auditNode` | int | `1` 初审 / `2` 终审 |
| `auditorId` / `auditorName` | int64 / string | 审核人 | | `auditorId` / `auditorName` | int64 / string | 审核人 |
| `action` | int | 见 §2.7 `auditAction`(含 `3` **撤回**) | | `action` | int | 见 §2.7 `auditAction`(**撤回是 `7`**;`3` 是批量通过 —— 旧版此表把两者写混过) |
| `reasonCode` | string | 驳回原因码(见 PRD 附录 C) | | `reasonCode` | string | 驳回原因码(见 PRD 附录 C) |
| `reasonText` | string | 手填原因 | | `reasonText` | string | 手填原因 |
| `materialCodes` | string[] | 关联材料 | | `materialCodes` | string[] | 关联材料 |
@@ -1792,7 +1803,18 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**自动保存**:前端每 15 秒或失焦时调一次;服务端**不限流**(草稿合并是轻量操作)。 **自动保存**:前端每 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 | ✅ | 原始文件名(**可能是乱的,正常**) | | `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) |
| `fileSize` | int64 | ✅ | 字节 | | `fileSize` | int64 | ✅ | 字节 |
| `contentType` | string | ✅ | MIME | | `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(**用于秒传与去重**) | | `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) |
**响应** **响应**
@@ -1815,10 +1837,9 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
"code": 0, "code": 0,
"data": { "data": {
"fileId": 987654321, "fileId": 987654321,
"uploadUrl": "https://oss.xxx.com/raw/1001/2026090001/202609/9f3a....jpg?Signature=...", "direct": false,
"objectKey": "raw/1001/2026090001/202609/9f3a....jpg", "uploadUrl": "/api/v1/mp/files/upload",
"expireSeconds": 900, "expireSeconds": 900,
"headers": { "Content-Type": "image/jpeg" },
"existed": false, "existed": false,
"clientCompressHint": { "clientCompressHint": {
"enabled": true, "maxLongSide": 1600, "quality": 80, "targetSizeKb": 1024 "enabled": true, "maxLongSide": 1600, "quality": 80, "targetSizeKb": 1024
@@ -1829,14 +1850,23 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
| 字段 | 说明 | | 字段 | 说明 |
|---|---| |---|---|
| `direct` | 恒为 `false`(一期不做对象存储直传)。`uploadUrl` 就是本项目自己的接收地址,学生端 `wx.uploadFile` 直接 POST 它,与 H5 路径完全一样 |
| `existed` | `true` = **秒传命中**(同 hash 已存在),前端跳过上传直接进入处理 | | `existed` | `true` = **秒传命中**(同 hash 已存在),前端跳过上传直接进入处理 |
| `clientCompressHint` | **端侧预压缩建议**。这是第一道防线,能大幅降低上传流量与处理时长;**服务端仍会按规则重压**,端侧压缩只是加速 | | `clientCompressHint` | **端侧预压缩建议**。这是第一道防线,能大幅降低上传流量与处理时长;**服务端仍会按规则重压**,端侧压缩只是加速 |
> **端侧压缩与「一次编码到位」的关系**:实测 300dpi/q85 即可达标,**端侧压缩只是减少上传量的优化,不是质量决定环节**。服务端始终以原图(或端侧压缩后的图)为输入重新规范,保证输出一致。 > **端侧压缩与「一次编码到位」的关系**:实测 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`)。 **删除约束**:已提交并进入审核的文件删除需先**撤回**(`40030`)。
### 9.9 `POST /api/v1/mp/ocr/idcard` ### 9.9 `POST /api/v1/mp/ocr/idcard`(一期缓做)
> ⚠️ **一期未实现(等 OCR 供应商选型,《01-客户与机构确认函》D1)**。
> 这是 121 个契约接口里**唯一**没有实现的一个;表单预填与校验位校验在学生端走人工填写。
> 选型要同时评估**数据出境合规**。选型定了之后本节按原设计实现,以下为设计参考。
**说明**:身份证 OCR,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。 **说明**:身份证 OCR,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。
@@ -2287,11 +2321,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
| 条件 | 结果 | | 条件 | 结果 |
|---|---| |---|---|
| 当前状态 `3`(已通过)且未被导出 | ✅ 允许,回退到 `2` 或 `1` | | 当前状态 `3`(已通过)且未被导出 | ✅ 允许,回退到 `2` 或 `1` |
| 当前状态 `2`(待终审) | ✅ 允许,回退到 `1`(撤回的是**初审**结论 —— 初审通过后发现填错,正是最需要撤的时刻) |
| **已被导出打包** | ❌ `40033`——**已交付的数据不可追溯修改** | | **已被导出打包** | ❌ `40033`——**已交付的数据不可追溯修改** |
| 已归档 | ❌ `40034` | | 已归档 | ❌ `40034` |
| 撤回者非原审核人且非管理员 | ❌ `40301` | | 撤回者非原审核人且非管理员 | ❌ `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` ### 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 | ✅ | — | | `taskId` | int64 | ✅ | — |
| `scope` | string | `ALL` | `ALL` / `ISSUES_ONLY` / `APPOINTED` | | `scope` | string | `ALL` | `ALL` / `ISSUES_ONLY` / `APPOINTED` |
| `submissionIds` | int64[] | — | `scope=APPOINTED` 时必填 | | `submissionIds` | int64[] | — | `scope=APPOINTED` 时必填 |
| `rules` | string[] | 全部 | 只跑指定规则(如 `["VAL-02","VAL-04"]`) | | `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` ### 12.2 `GET /api/v1/validation/issues`
@@ -2613,6 +2653,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页 **Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
| 调用者 | 权限 | 看到什么 |
|---|---|---|
| 平台运营 | `log:viewAll` | **默认全量**(跨租户);传 `tenantId` 则只看该单位 |
| 学校管理员 / 教务处 | `log:viewTenant` | **恒为本租户**;传了别的 `tenantId` → `40301`(**不静默忽略**:静默会让人以为"筛过了") |
**响应** **响应**
```json ```json
@@ -2648,7 +2695,9 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
#### `GET /api/v1/logs/logins` #### `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 }` **响应**:`{ 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.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`)而非密文,宽度足够,无需改动。 | | **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 个**。 | | **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 个标 ⭐)✓ | | 错误码数量 | 58 个(新增 38 个标 ⭐)✓ |
| 文件规模 | 约 100 KB / 2570 行 | | 文件规模 | 约 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 已删,别去找"这句(已有,够用)。 - **建议**:`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 完全没做。 - **现象**:契约(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` 每次新建记录来承担。 - **影响**:前端按契约写会调不存在的接口;`upload-token` 是"防重复建文件记录"的关键设计(重复提交幂等),现在靠 `POST /mp/files/upload` 每次新建记录来承担。
@@ -163,7 +163,9 @@
2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子; 2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。 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`、 - **现象**(代码有、03 文档 §4 清单没有):最典型的是 `GET /mp/scene-context`、
`GET /auth/captcha`、`GET /mp/h5/authorize|callback|stub-login`(代替 `/mp/login`)、 `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 的声称数)。 - **建议**:在 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 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。 - **现象**:`sys_tenant` 17 行、`sys_user` 300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。
⚠️ 现在回头看,这个数字被低估了:跑了几十轮之后是 **48 个 `e2e*` 租户 / 709 个用户 /
144 个挂在 demo 租户下的测试组织 / 82 个【演示·旧】任务**。
- **影响**:本地演示/截图时列表里全是 `e2e-*` 名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。 - **影响**:本地演示/截图时列表里全是 `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)** ### F-6【P2 · 文档误导】`frontend-pc/AGENTS.md` 里的 Mock 说明已过时 —— **已修(2026-09-30)**
@@ -194,11 +215,13 @@
但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。 但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。
核实方式:`frontend-pc/` 下无 `mock/`,`package.json` 与 `vite.config.ts` 里都没有 mock 关键字。 核实方式:`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。 - **现象**:DDL 注释 `3=批量通过` 与 03 §11.4「写入 `action = 3`(撤回)」冲突,代码取 `7`(已按"时间轴不能混淆"处理);§11.4 的状态表只列状态 3,代码额外允许状态 2。
- **建议**:客户确认后同步两处文档;若客户不同意状态 2 的撤回,删一个分支即可(代码里有注释标明)。 - **建议**:客户确认后同步两处文档;若客户不同意状态 2 的撤回,删一个分支即可(代码里有注释标明)。
- ✅ **已闭环(2026-09-30,客户确认按代码口径)**:§2.7 `auditAction` 补全 7 个动作(撤回=7),§11.4 约束表补「状态 2 可撤(撤的是初审)」、留痕改 `action=7`。DDL 注释少列 7(迁移已发布不改,以 §2.7 为准)。
### F-8【P2 · 安全/上线】打桩登录与存储密钥 ### 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`。 - **现象**:`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(很硬,但每次要起服务 + 连库)。 - **现象**:`src/test` 为空,验证全靠 E2E(很硬,但每次要起服务 + 连库)。
- **建议**:先给三个**纯函数**补最小单测(不引框架之外的东西):`IdentityNormalizer`、命名规范化引擎、压缩档位推导。理由是这三个"输入输出确定、没有 IO",单测成本最低、回归收益最高;其余继续靠 E2E。 - **建议**:先给三个**纯函数**补最小单测(不引框架之外的东西):`IdentityNormalizer`、命名规范化引擎、压缩档位推导。理由是这三个"输入输出确定、没有 IO",单测成本最低、回归收益最高;其余继续靠 E2E。
### F-10【P3 · 一致】租户域名相关文档暂缓 ### F-10【P3 · 一致】租户域名相关文档暂缓 —— **已闭环**
- **现象**:§一·二十五 的能力已实现且默认关闭,PRD 与 03 文档未写(当时有意暂缓)。 - **现象**:§一·二十五 的能力已实现且默认关闭,PRD 与 03 文档未写(当时有意暂缓)。
- **建议**:保持暂缓,但**在 03 文档 §17 留一行**"租户域名能力:`Host 定位 + token 授权`,默认关闭,启用条件 4 步",避免将来重开时找不到入口。 - **建议**:保持暂缓,但**在 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-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-12 | **`collect_export_task` 表与契约字段不一致**:契约 §12.5 有 `currentStep / stepText / stats.rawSizeMb`,表里三列都没有 | 不新建迁移,改为推导(D6)。已在实体注释里写明"不要再按契约建字段" |
| F-13 | `RevokePolicy` 在"已导出"之后**不返回** `auditRecordId`(只把 `canRevoke` 置 false) | 这不是 bug,但意味着 **`40033` 在界面上不可达**(只会出现 40001「缺少要撤回的记录 id」)。已在 E2E 里按"用户真正看得见的东西"断言,并写清 40033 只在绕过前端时可达 | | 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-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-16 | ⚠️ **我第一版把导出目录结构做错了**:做成"班级/姓名/文件"(更符合"给人翻"的直觉),而 PRD §5.9 明确是「`{批次号}-{任务名称}` / 班级 / **材料类型** / 文件」,根目录也不是用户填的导出名 | 已改回规范,并把"根目录名、第二层、第三层、汇总表位置"四条都写成 E2E 断言(`e2e-m6-export.py`),另加"预览节点与产物逐项一致"。**教训**:目录结构是**交付物的形状**,属于需求,不能按直觉设计 —— 这类错只在客户解压时暴露 |
| F-17 | 汇总表文件名:契约 §12.4 只说"生成汇总 Excel",PRD §5.9 给的是机构叫法 `考生线下报名信息导入模板(汇总).xlsx` | 按 PRD 命名(机构按这个名字找它)。已写进 E2E | | 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-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-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-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-27 | `notifySent` 的语义**被收窄了**:以前它表示"有没有通知渠道"(恒 `false`),现在表示"**微信推送**有没有真的发出去"。站内信一定写得进去,所以 `needManualNotice` 仍然可能为 `true`(学生不一定会进小程序) | 已在代码里写清,并且**没有**顺手把 `needManualNotice` 改成恒 `false`。E2E 断言"降级时如实告知:notifySent=false / needManualNotice=true",防止后人把它"优化"成乐观值。**这是对前端契约的语义变更,已在本文档记录** |
| F-28 | **降级链只做了两级(订阅消息 → 站内信),中间两级没做**:契约 §13.6 写的是「订阅消息 → 公众号模板 → 短信 → 仅站内信」。后两级的缺不是代码问题 —— 公众号模板消息要**已认证服务号 + 用户已关注**(一期没有公众号),短信要买通道与签名(按条计费,是商务动作)。我选择**只实现能真正走通的两级**,而不是写两级"看起来有、其实永远失败"的通道 | 在 `NotificationService` 的类注释里写清了为什么少做,并用 `channel` 字段如实标出这条消息最终怎么到的。**理由**:永远失败的通道会往日志里刷一片失败,把真正的原因(没配通道)淹掉,而运营看到"通知失败率 90%"会去查代码,实际上该去找商务 | | 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 的其它限流统一,单独加一个会分叉 | | 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-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 剩余项。**风险**:合规上"谁在什么时候登录过"对学生侧是缺口 | | 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`。**教训**:凡是从结果反推原因的地方,遇到"多对一"就一定会错 —— 而这类错通常表现为"日志看起来有、但答案是错的",比缺日志更难发现 | | 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-36 | **写机器检查本身也会假阳性**:`check-entity-columns.py` 第一版直接搜 `@TableLogic` 字符串,结果把 4 个实体的**类注释**也算进去了 —— 而那四段注释恰恰在解释"为什么不继承基类"(里面当然会出现 `@TableLogic` 这个词)。表现是"4 个实体报错",而我差点据此去改那四个**本来就是对的**实体 | 改成**先去注释再找注解**。**教训**:静态检查一定要能区分"代码"与"解释代码的文字" —— 否则它会指向正好写着"这里不该这么做"的地方 |
| F-37 | **`@Scheduled` 的定时任务在测试里等不起**:导出包过期清理每小时跑一次,而验证"过期之后下载会怎样"总不能把过期时间改成 7 天后再等一小时 | 加了 `POST /exports/cleanup-run`(手工触发,权限同导出管理员)。清理本身**可重复执行**(已清理的卷会跳过),所以手工触发没有副作用。**这是本项目第一个定时任务**,所以顺手在 `FillApplication` 上写了"别顺手加更多:定时任务是「没有请求也会发生的事」,每加一个都要能回答「它失败了谁会知道」" | | 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` 只说"是班主任代传的",说不出**是哪个**班主任、**为什么**替他传 —— 而契约要求时间轴显示「由李老师代传」,名字只能存在这里 | | 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-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-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` / 整任务发法 | | 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-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`,并加边界:**只有平台管理员能把模板设为公共**(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) | | 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-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-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-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-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` 前缀,两种写法都能对上,**所以工具不会替我们发现这个冲突** —— 它只能靠人读文档发现 | | 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` 与定时任务里 | | 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-69 | ⚠️ **开放接口的令牌路径必然 500**:`appOfToken` 里用 `selectById` 回查接入方,而开放接口是匿名的 —— **请求进来时还没有租户上下文**,租户拦截器直接拒绝(`未登录或登录已失效`),对外是 50000「系统繁忙」。HMAC 路径不出现(它走 `findByAppKey`,已忽略租户),所以**只有令牌路径 500**,看起来像"令牌功能没做" | 凡在"还没有租户上下文"时查归属都用忽略租户的专用方法(`findByAppKey` / `findByIdIgnoreTenant` / `findBySubmissionIgnoreTenant`),拿到归属后立刻 `TenantScope.callAs` 摆上下文。**"先找归属、再摆上下文"这个模式里,找归属那一步必须能无租户执行** —— 范围就是那几个方法,逐个写清理由 |
| F-70 | ⚠️ **nonce 的占用顺序反了会变成拒绝服务入口**:我原来的顺序是"先占 nonce 再验签",注释还写了一句"反过来的话错误签名能消耗 nonce"—— **这句话恰好说反了**。先占的话,任何人发一个签名错误的请求就能把那个 nonce 消耗掉;先验签则签名错直接拒、nonce 不被碰 | 改成**先验签、后占 nonce**。E2E 有一条专门守它("错误签名不该消耗 nonce")—— 这条断言的价值在于:它测的是一个**副作用**,而不是返回值 | | 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-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 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 | | F-72 | ⚠️ **`uploadToken` 签发之后没有"消费"它的地方**:我按契约实现了凭证的签发与预检,但**上传接口没有接它**(`/mp/files/upload` 仍然只认 `submissionId + materialCode`)。于是凭证目前只用于"预检与秒传判断",**不构成一道校验** —— 客户端完全可以跳过凭证直接上传 | 如实记录(不假装它是安全机制)。**建议**:要么让 `/mp/files/upload` 接受并校验凭证(一次性、15 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 ✅ 已闭环(客户确认定位为**预检/秒传/压缩建议**,非安全边界):契约 §9.5 明写「客户端可跳过;服务端上传本身做全部校验」。 |
| F-73 | ⚠️ **秒传检查排在份数检查之后** —— 而"同一个文件重传"恰恰是秒传要解决的场景(学生传完网络断了、客户端重试)。顺序反了会得到「身份证最多 2 份,你已经传了 2 份。要换请先删掉上一份」,让用户在"我明明刚传的"这件事上原地打转 | 改成**先秒传、后份数**。**顺序体现的是"哪个答案对用户更有用"**:「你已经传过了」永远比「你超了」更有用,因为前者不需要他做任何事 | | 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-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%`(非负),要么是 `—`" | | 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-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-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-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-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-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-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-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-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 | **缩略图(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 | **对外待决事项归拢 + 一键交付自检**:`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 | **文档小账清理 + `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` | ✅ | | `build` | `mvn package`、`pnpm typecheck`、`pnpm build` | ✅ |
| `e2e` | 起 `test` profile 确认能起来(`check-test-profile.py`);全套 E2E 在 `dev` 上跑 | ✅ | | `e2e` | 起 `test` profile 确认能起来(`check-test-profile.py`);全套 E2E 在 `dev` 上跑 | ✅ |
| `生产环境自检` | **真的起三次 jar**(三种误配形态) | ✅ 9/9 | | `生产环境自检` | **真的起三次 jar**(三种误配形态) | ✅ 9/9 |
> **接线检查(`scripts/check-wiring.py`)为什么值得单独放一个阶段**:它查的是
> "**声明了但没接上**"(权限码没人校验、错误码没人引用、通知模板没有发送点、
> 菜单键前端没有对应项)—— 这一族错**编译 / lint / review 全都不红**,
> 只有"从入口走一遍"才暴露(F-1/F-31/F-50/F-52/F-101/F-106 都是它)。
> 它自己的假阳性也要当心:本轮修了三次(只数注解漏掉服务层、按标识符数引用被同名常量骗过、
> **注释里的提及被当成接线**)—— 所以它现在**先去 Java 注释再数**,
> 并且有意不接线的逐条写在脚本上方的例外表里(带理由),**不静默跳过**。
**本地验不到的**(要在真实 runner 上跑): **本地验不到的**(要在真实 runner 上跑):
`services:` 的容器别名解析、`apt-get`/`pip3` 在 runner 镜像里可用性、 `services:` 的容器别名解析、`apt-get`/`pip3` 在 runner 镜像里可用性、
artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows, artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows,
@@ -257,23 +265,31 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
## §9 压测记录(M7-09) ## §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 里)"**, ⚠️ **本机是"负载生成器 + 后端 + PG + Redis 全在同一台机器(且 PG/Redis 在 Docker 里)"**,
所以绝对值偏悲观;**看比例与形状,不要看绝对值**。 所以绝对值偏悲观;**看比例与形状,不要看绝对值**。
另一条同样重要:**谈响应时间必须带规模**(同一个接口在 60 行与 2000 行上是两件事), 另一条同样重要:**谈响应时间必须带规模**(同一个接口在 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 | | **门禁核验**(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 | | **读接口**(20 并发 × 8 轮 = 640 请求,四类混合) | **0 失败**;单请求中位 **36~47ms**(与页大小 20/50/200 **无关**);20 并发下 p50 156~375ms |
| **名册列表的 SQL 条数** | **恒为 3 条**(`selectPage` + `count` + 组织名映射),**与 pageSize 无关 → 没有 N+1** | | **名册列表的 SQL 条数** | **恒为 3 条**(`selectPage` + `count` + 组织名映射),**与 pageSize 无关 → 没有 N+1** |
| **DB 侧耗时** | 分页查一页 **1.2ms**、计数 **0.66ms**、组织映射 **0.06ms** | | **DB 侧耗时** | 分页查一页 **1.2ms**、计数 **0.66ms**、组织映射 **0.06ms** |
| **导出** | 前置检查正确拦住(`40035`,见 9.4);大包打包**未测**(需要真实审批过的数据) | | **材料上传**(20 并发,2850KB 真图) | **40/40 成功**;**串行单请求 321ms**(2850KB → 1329KB,**压到 47%**);20 并发 p50 **620ms** / p95 **801ms** / max 851ms;吞吐 **6/s**(见 9.6) |
| **材料上传** | **默认不跑**(见 9.4) | | **名册导入规模**(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 不是瓶颈 ### 9.2 结论:后端没有 N+1,DB 不是瓶颈
@@ -287,6 +303,10 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络), 所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络),
不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。** 不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。**
同一条道理用在上传上:串行 321ms vs 20 并发 p50 620ms ——
差值就是排队。**压缩本身是 CPU 密集的单线程活**,所以"上传慢"的解法是加 CPU / 限并发,
不是改上传接口。
### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修) ### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修)
压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确), 压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确),
@@ -303,7 +323,7 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
|---|---|---|---| |---|---|---|---|
| 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 | | 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 |
| 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 | | 失败(按 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` 里 ### 9.4 压测怎么跑(两条使用注意)
`provider: qiniu`,所以上传落的是**线上那个空间**(bucket=tools-matrix)。
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对: - **上传压测默认关闭**(`--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` 时禁止导出) - **导出创建被 `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` 的打包耗时与内存峰值 | | **真机上的上传**(微信小程序 + 手机相册) | 需要真机与小程序资质 | 见 `docs/01` **D6**:量"选一张 5MB 照片到提交成功"的端到端时间 |
| 上传并发(CPU 密集的压缩) | 见 9.4(会打到外部存储) | 切本地存储后 `--with-upload` | | **对象存储(七牛)的上传并发** | 本地跑会把测试文件灌进真实桶(见 9.4) | 用 staging 的独立 bucket,`provider=qiniu` + `--with-upload` |
| 5000 人名册(PRD 提到的导入上限) | 导入本身是同步的,压测里造 5000 行要 ~20s | `--rows 5000`,重点是**导入**耗时与内存 | | **多副本部署下的导出打包** | 本机只有一个实例 | 两实例同时导出同一任务,确认不会互相覆盖产物(导出线程池与落盘路径都要看) |
| 长时间稳定性(内存泄漏、连接泄漏) | 一次性压测测不出来 | 持续 1~2 小时的低强度压测,看 RSS 与 Hikari 活跃连接数是否单调上升 | | **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)** → 小程序登录与组织导入 → 工作台数据看板 → **§14 开放接口(认定机构对接,HMAC)** → 小程序登录与组织导入 → 工作台数据看板 →
CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。 CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。
**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 98 条编号问题(F-1~F-98)。 **每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 100 条编号问题(F-1~F-100)。
### 这 20 多轮里最该记住的几条 ### 这 20 多轮里最该记住的几条
@@ -2379,3 +2379,403 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
`docs/08` §四 剩下的**全部是外部依赖**(OCR 选型 / 小程序类目 / `union_id` / 真机 / 二期), `docs/08` §四 剩下的**全部是外部依赖**(OCR 选型 / 小程序类目 / `union_id` / 真机 / 二期),
另有 4 项"**没测过**":导出大包打包、上传并发、5000 行名册导入、长跑稳定性 另有 4 项"**没测过**":导出大包打包、上传并发、5000 行名册导入、长跑稳定性
(`docs/09` §9.5 逐条写明"没测就是没测")。 (`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 http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */ /** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */
export type BizError = Error & { bizCode?: number, bizData?: unknown } export type BizError = Error & { bizCode?: number, bizData?: unknown }
@@ -422,6 +423,6 @@ export function getAuditStatistics(params: {
orgId?: number orgId?: number
from?: string from?: string
to?: string to?: string
}) { }, tenantId?: number | null) {
return http.get<AuditStatisticsVO>('/audit/statistics', params) return http.get<AuditStatisticsVO>('/audit/statistics', params, actingTenantConfig(tenantId))
} }
+14 -9
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。 * 分页 `{ list, total }`(适配层会补一份 `records`)。
*/ */
import http from '@/utils/http' import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 组织树节点(§6.1) */ /** 组织树节点(§6.1) */
export interface OrgTreeVO { export interface OrgTreeVO {
@@ -34,24 +35,28 @@ export interface OrgUpsertReq {
/** /**
* 组织树。 * 组织树。
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开) * @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
* @param tenantId **仅平台运营**:代管哪个学校的组织(见 `acting-tenant.ts`)。
* 不传 = 不代管 —— 此时服务端只会返回平台自己租户的组织(空树),
* **不会**再把所有租户的组织混在一起端出来。
*/ */
export function getOrgTree(params: { withCount?: boolean, rootId?: number } = {}) { export function getOrgTree(params: { withCount?: boolean, rootId?: number, tenantId?: number | null } = {}) {
return http.get<OrgTreeVO[]>('/orgs/tree', params) const { tenantId, ...query } = params
return http.get<OrgTreeVO[]>('/orgs/tree', query, actingTenantConfig(tenantId))
} }
/** 新增,返回新节点 id */ /** 新增,返回新节点 id(`tenantId` 仅平台运营:新建到哪个学校下) */
export function addOrg(data: OrgUpsertReq) { export function addOrg(data: OrgUpsertReq, tenantId?: number | null) {
return http.post<number>('/orgs', data) return http.post<number>('/orgs', data, actingTenantConfig(tenantId))
} }
/** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */ /** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */
export function updateOrg(id: number, data: OrgUpsertReq) { export function updateOrg(id: number, data: OrgUpsertReq, tenantId?: number | null) {
return http.put<void>(`/orgs/${id}`, data) return http.put<void>(`/orgs/${id}`, data, actingTenantConfig(tenantId))
} }
/** 删除;有下级组织或账号时返回 40015 并给出占用清单 */ /** 删除;有下级组织或账号时返回 40015 并给出占用清单 */
export function deleteOrg(id: number) { export function deleteOrg(id: number, tenantId?: number | null) {
return http.delete<void>(`/orgs/${id}`) return http.delete<void>(`/orgs/${id}`, actingTenantConfig(tenantId))
} }
/** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */ /** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */
+11 -5
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。 * 分页 `{ list, total }`(适配层会补一份 `records`)。
*/ */
import http from '@/utils/http' import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
import { getUserList } from './user' import { getUserList } from './user'
/** 审核流配置(§3.2) */ /** 审核流配置(§3.2) */
@@ -117,9 +118,14 @@ export interface TaskListQuery {
pageSize?: number 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) { export function getTaskDetail(id: number) {
@@ -343,6 +349,6 @@ export interface TaskStatisticsVO {
* ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**: * ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**:
* 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。 * 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。
*/ */
export function getTaskStatistics(taskId: number) { export function getTaskStatistics(taskId: number, tenantId?: number | null) {
return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`) return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`, undefined, actingTenantConfig(tenantId))
} }
+28 -13
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。 * 分页 `{ list, total }`(适配层会补一份 `records`)。
*/ */
import http from '@/utils/http' import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 账号列表项(§5.6) */ /** 账号列表项(§5.6) */
export interface UserVO { export interface UserVO {
@@ -75,43 +76,57 @@ export interface UserListQuery {
pageSize?: number pageSize?: number
} }
// ── 账号接口 ────────────────────────────────────────
//
// ⚠️ 每个接口的最后都有一个可选 `tenantId`:**仅平台运营**用来指明"代管哪所学校"
// (见 `acting-tenant.ts`)。学校管理员的页面一律不传 —— 他们的租户由 token 决定。
// 不传时服务端只返回平台自己租户的账号,不会再把所有租户的账号混在一起。
/** 账号分页列表 */ /** 账号分页列表 */
export function getUserList(params: UserListQuery) { export function getUserList(params: UserListQuery, tenantId?: number | null) {
return http.get<{ list: UserVO[], total: number }>('/users', params) return http.get<{ list: UserVO[], total: number }>('/users', params, actingTenantConfig(tenantId))
} }
/** 新增账号,返回初始密码(仅此一次) */ /** 新增账号,返回初始密码(仅此一次) */
export function addUser(data: UserUpsertReq) { export function addUser(data: UserUpsertReq, tenantId?: number | null) {
return http.post<UserCreateResult>('/users', data) return http.post<UserCreateResult>('/users', data, actingTenantConfig(tenantId))
} }
/** 修改账号;登录名与密码不在此接口修改 */ /** 修改账号;登录名与密码不在此接口修改 */
export function updateUser(id: number, data: UserUpsertReq) { export function updateUser(id: number, data: UserUpsertReq, tenantId?: number | null) {
return http.put<void>(`/users/${id}`, data) return http.put<void>(`/users/${id}`, data, actingTenantConfig(tenantId))
} }
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */ /** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
export function deleteUser(id: number) { export function deleteUser(id: number, tenantId?: number | null) {
return http.delete<void>(`/users/${id}`) return http.delete<void>(`/users/${id}`, actingTenantConfig(tenantId))
} }
/** 启用 / 停用(只改 status,数据保留可再启用) */ /** 启用 / 停用(只改 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), // status 后端设计为查询参数(PUT /users/{id}/status?status=0),
// 所以走 config.params 而不是 body // 所以走 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(服务端生成)会同时解除该账号的登录锁定 */ /** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
export function resetUserPassword(id: number, data: { mode?: string, newPassword?: string } = {}) { export function resetUserPassword(
return http.post<UserResetResult>(`/users/${id}/reset-password`, data) id: number,
data: { mode?: string, newPassword?: string } = {},
tenantId?: number | null
) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data, actingTenantConfig(tenantId))
} }
/** 批量导入账号(Excel ≤10MB),返回体检报告 */ /** 批量导入账号(Excel ≤10MB),返回体检报告 */
export function importUsers(file: File, defaultRoleCode?: string) { export function importUsers(file: File, defaultRoleCode?: string, tenantId?: number | null) {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
return http.post<UserImportResult>('/users/import', fd, { return http.post<UserImportResult>('/users/import', fd, {
...actingTenantConfig(tenantId),
params: defaultRoleCode ? { defaultRoleCode } : undefined params: defaultRoleCode ? { defaultRoleCode } : undefined
}) })
} }
+2
View File
@@ -91,6 +91,7 @@ declare module 'vue' {
GiTable: typeof import('./components/GiTable/GiTable.vue')['default'] GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default'] GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
GiTag: typeof import('./components/GiTag/GiTag.tsx')['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'] GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
@@ -179,6 +180,7 @@ declare global {
const GiTable: typeof import('./components/GiTable/GiTable.vue')['default'] const GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default'] const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
const GiTag: typeof import('./components/GiTag/GiTag.tsx')['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 GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink'] const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView'] 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 ready: boolean
/** 待建说明 */ /** 待建说明 */
note?: string note?: string
/**
* 这个功能**其实已经有了**,只是入口不在左侧菜单里(例如"名单管理"是按任务的,
* 入口在「收集任务」每一行的链接里)。
*
* ⚠️ 有它的时候,「待建」页就不再显示"还没做",而是给出真实入口的指路 + 跳转按钮。
* 没有它 = 确实还没做。两者的区别很重要:说"还没做"而功能其实在隔壁,
* 会让人**得出错误结论**(比找不到更糟)。
*/
entryAt?: {
/** 跳到哪个路由 */
path: string
/** 到了之后该点哪里(要具体到"点哪一行里的哪个链接") */
hint: string
}
} }
/** 分组顺序(决定左侧菜单顺序) */ /** 分组顺序(决定左侧菜单顺序) */
@@ -67,22 +53,6 @@ export const XT_MENUS: XtMenu[] = [
sort: 11, sort: 11,
ready: true 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', key: 'join',
title: '加入申请', 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', key: 'cleanup',
title: '历史整理器', title: '历史整理器',
@@ -338,17 +278,7 @@ function readyItem(menu: XtMenu): UserRouteItem {
} as 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 { export function findMenuByPath(path: string): XtMenu | undefined {
return XT_MENUS.find((m) => m.path === path) 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-button>
</a-tooltip> </a-tooltip>
<!-- 消息通知下拉框 --> <!--
<a-popover position="bottom" trigger="click"> 消息通知(铃铛)**暂时去掉**(2026-10-09 客户要求):
<a-badge :count="9" dot> 原来的铃铛是一个写死 9 条假数据的下拉框,站内信还没接到这个入口上。
<a-button size="mini" class="g-hover-btn"> 真要做的时候,数据源是「通知记录」(`/collect/notify`,接口已实现),
<template #icon> 不要恢复成写死的假列表。
<icon-notification :size="18" /> -->
</template>
</a-button>
</a-badge>
<template #content>
<Notice></Notice>
</template>
</a-popover>
<!-- 全屏切换按钮 --> <!-- 全屏切换按钮 -->
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom"> <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 { useBreakpoint, useTheme } from '@/hooks'
import { useAppStore, useUserStore } from '@/stores' import { useAppStore, useUserStore } from '@/stores'
import ChangePasswordModal from './ChangePasswordModal.vue' import ChangePasswordModal from './ChangePasswordModal.vue'
import Notice from './Notice.vue'
import SettingDrawerPanel from './SettingDrawerPanel.vue' import SettingDrawerPanel from './SettingDrawerPanel.vue'
/** 组件名称 */ /** 组件名称 */
+1
View File
@@ -1,6 +1,7 @@
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export * from './useActingTenantStore'
export * from './useAppStore' export * from './useAppStore'
export * from './useDictStore' export * from './useDictStore'
export * from './useMenuBadgeStore' 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 { resetRouter } from '@/router'
import { clearToken, getToken, setToken } from '@/utils/auth' import { clearToken, getToken, setToken } from '@/utils/auth'
import { isHttp } from '@/utils/validate' import { isHttp } from '@/utils/validate'
import { useActingTenantStore } from './useActingTenantStore'
import { useRouteStore } from './useRouteStore' import { useRouteStore } from './useRouteStore'
/** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */ /** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */
@@ -101,6 +102,9 @@ const storeSetup = () => {
resetRouter() resetRouter()
profile.value = null profile.value = null
menuKeys.value = [] menuKeys.value = []
// 平台运营的"当前学校"属于**上一次身份**的上下文,退出时一起清掉:
// 留着它,下一个登录的人(可能是学校管理员)会继承一个自己没选过的租户
useActingTenantStore().clear()
} }
return true return true
} }
+47
View File
@@ -190,6 +190,53 @@
margin-bottom: 12px; 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权重优先级问题 // 以下这些类名要放到下面,涉及到css权重优先级问题
.g-mt { .g-mt {
margin-top: var(--margin); margin-top: var(--margin);
+16 -65
View File
@@ -8,43 +8,17 @@
</a-space> </a-space>
</template> </template>
<template #extra> <template #extra>
<a-tag :color="menu?.entryAt ? 'green' : 'orange'"> <a-tag color="orange">{{ milestone }}</a-tag>
{{ menu?.entryAt ? '功能已就绪' : milestone }}
</a-tag>
</template> </template>
<!-- <a-alert type="info" :show-icon="false">
⚠️ 两种说法必须分开,不能都说「还没做」。
名单管理 / 材料规则 / 表单设计 这三个菜单项对应的功能**早就做好了**,
只是它们**按任务**(入口在「收集任务」每一行的链接里)。
以前这里一律说"这个页面还没做" + 说"已实现的页面只有工作台、组织架构、租户管理"——
而那句话早就过期了,且**没有任何机制会让它不过期**。
说"还没做"而功能其实在隔壁,比找不到更糟:**它让人得出错误结论。**
-->
<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">
<template #title> <template #title>
<strong>这个页面还没做。</strong> <strong>这个页面还没做。</strong>
{{ menu?.note ?? '该功能尚未实现。' }} {{ menu?.note ?? '该功能尚未实现。' }}
</template> </template>
</a-alert> </a-alert>
<div v-if="menu?.entryAt" class="coming-soon__go"> <p class="coming-soon__why">
<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">
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它, 左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
说明你的角色确实有这个权限 —— 只是界面还没排到。 说明你的角色确实有这个权限 —— 只是界面还没排到。
</p> </p>
@@ -53,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <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' import { findMenuByPath } from '@/config/xt-routes'
/** /**
@@ -64,40 +38,27 @@ import { findMenuByPath } from '@/config/xt-routes'
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息; * 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
* 3. 一个页面服务所有占位路由,成本极低。 * 3. 一个页面服务所有占位路由,成本极低。
* *
* ⚠️ 但这个页面**过去在说谎**:它对"功能已经有了、只是入口在别处"的菜单项 * ⚠️ 这一页**只服务"确实还没做"的菜单**(现在只剩「角色与授权」)。
* 也说"还没做",还列了一句早已过期的"已实现的页面"清单。 * 曾经它还要区分另一种情况 —— "功能已经有了、只是入口在别处"(名单管理 / 表单设计 /
* 现在按 `entryAt` 分两种说法,并**删掉了那句清单** —— * 材料规则,都在「收集任务」行内的链接里)。那三条菜单已按客户要求**直接去掉**:
* 任何"现状清单"写死在代码里都会静默过期,而没人会记得回来改它。 * 导航里挂着一条点进去只有指路的条目,既占位置又让人以为这里应该有事可做。
*/ * 所以 `entryAt` 那套分支("功能已就绪"标签 + 跳转按钮)也一并删了 ——
defineOptions({ name: 'ComingSoon' }) * 留着就是一段永远不会被走到的代码。
*
const route = useRoute()
const router = useRouter()
/**
* ⚠️ 按**路径**找,不要按 `route.meta.menu`。 * ⚠️ 按**路径**找,不要按 `route.meta.menu`。
* *
* 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里 * 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里
* **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )—— * **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )——
* 于是这里永远拿到 `undefined`,页面永远显示兜底文案 * 于是这里永远拿到 `undefined`,页面永远显示兜底文案
* 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。 * 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。
* 而 note 恰恰是这一页最有用的东西(它写着"真实入口在哪")。 * 而 note 恰恰是这一页最有用的东西。
*/ */
defineOptions({ name: 'ComingSoon' })
const route = useRoute()
const menu = computed(() => findMenuByPath(route.path)) 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-开发任务拆解》对齐) */ /** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
const MILESTONES: Record<string, string> = { const MILESTONES: Record<string, string> = {
role: '二期' role: '二期'
@@ -117,14 +78,4 @@ const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中
color: var(--color-text-1); 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> </style>
+116 -21
View File
@@ -57,20 +57,33 @@
<template #title>数据看板</template> <template #title>数据看板</template>
<template #extra> <template #extra>
<a-space> <a-space>
<!--
平台运营必须先选学校:任务、统计、审核数都是**租户内**的数据,
而服务端对平台的租户过滤是关掉的 —— 不选就会把各学校的任务混在一起。
-->
<GiTenantPicker
v-if="isPlatform"
v-model="actingStore.tenantId"
width="200px"
placeholder="选择学校(平台运营)"
/>
<a-select <a-select
v-model="boardTaskId" v-model="boardTaskId"
placeholder="选择一个任务" placeholder="选择一个任务"
allow-search allow-search
style="width: 260px" style="width: 260px"
:loading="taskLoading" :loading="taskLoading"
:disabled="needPickTenant"
:options="taskOptions" :options="taskOptions"
@change="loadBoard" @change="loadBoard"
/> />
<a-button :loading="boardLoading" @click="loadBoard">刷新</a-button> <a-button :loading="boardLoading" :disabled="needPickTenant" @click="loadBoard">刷新</a-button>
</a-space> </a-space>
</template> </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"> <a-spin v-else :loading="boardLoading" style="display: block">
<!-- 进度四格:应填 / 已提交 / 已通过 / 待审 --> <!-- 进度四格:应填 / 已提交 / 已通过 / 待审 -->
@@ -116,6 +129,7 @@
</span> </span>
</div> </div>
<a-table <a-table
v-if="canViewAllProgress"
:data="auditRows" :data="auditRows"
:pagination="false" :pagination="false"
size="small" size="small"
@@ -127,6 +141,11 @@
<a-table-column title="说明" data-index="foot" ellipsis tooltip /> <a-table-column title="说明" data-index="foot" ellipsis tooltip />
</template> </template>
</a-table> </a-table>
<!--
班主任(只有 progress:viewOwn)在这里看到的是**空表**,而空表不解释任何事。
如实说明"这里为什么不给你看",比留一张空表更清楚。
-->
<a-empty v-else description="审核统计按全租户口径,需要「全租户进度」权限" />
</a-col> </a-col>
</a-row> </a-row>
@@ -151,7 +170,7 @@ import * as orgApi from '@/apis/xt/org'
import * as taskApi from '@/apis/xt/task' import * as taskApi from '@/apis/xt/task'
import * as tenantApi from '@/apis/xt/tenant' import * as tenantApi from '@/apis/xt/tenant'
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes' 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(之前就是这样:平台管理员也看到"仅平台运营可见") // 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? 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(() => const shortcuts = computed(() =>
XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key)) XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key))
@@ -186,9 +246,11 @@ const stats = computed(() => [
{ {
label: '组织节点', label: '组织节点',
icon: IconFolder, icon: IconFolder,
value: loading.value ? '—' : (orgCount.value ?? '—'), value: orgCardShowable.value && !loading.value ? (orgCount.value ?? '—') : '—',
unit: '个', unit: orgCardShowable.value ? '个' : '',
foot: '学校 / 院系 / 班级', foot: isPlatform.value
? (needPickTenant.value ? '选定学校后展示该校的组织' : '所选学校的节点数')
: (canViewOrg.value ? '学校 / 院系 / 班级' : '需要「组织查看」权限'),
tone: '' tone: ''
}, },
{ {
@@ -250,10 +312,10 @@ const boardCards = computed(() => {
}, },
{ {
label: '待审', label: '待审',
value: audit.value?.pendingCount ?? '—', value: canViewAllProgress.value ? (audit.value?.pendingCount ?? '—') : '—',
foot: audit.value?.timeoutCount foot: canViewAllProgress.value
? `其中超时 ${audit.value.timeoutCount} 件` ? (audit.value?.timeoutCount ? `其中超时 ${audit.value.timeoutCount} 件` : '没有超时的')
: '没有超时的', : '需要「全租户进度」权限',
tone: audit.value?.timeoutCount ? 'is-warn' : '' tone: audit.value?.timeoutCount ? 'is-warn' : ''
} }
] ]
@@ -294,10 +356,33 @@ function fmtHours(h: number | null | undefined): string {
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时` 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() { async function loadTasks() {
// 平台没选学校时不发请求:拿到的是空列表,界面上会显示成"这所学校没有任务"
if (needPickTenant.value) {
taskOptions.value = []
boardTaskId.value = undefined
stat.value = null
audit.value = null
return
}
taskLoading.value = true taskLoading.value = true
try { 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 ?? [] const list = res.data?.list ?? []
taskOptions.value = list.map((x) => ({ taskOptions.value = list.map((x) => ({
label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`, label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`,
@@ -308,6 +393,10 @@ async function loadTasks() {
if (collecting) { if (collecting) {
boardTaskId.value = collecting.id boardTaskId.value = collecting.id
loadBoard() loadBoard()
} else {
boardTaskId.value = undefined
stat.value = null
audit.value = null
} }
} finally { } finally {
taskLoading.value = false taskLoading.value = false
@@ -318,13 +407,17 @@ async function loadBoard() {
if (!boardTaskId.value) return if (!boardTaskId.value) return
boardLoading.value = true boardLoading.value = true
try { try {
// 两个接口互不依赖,并行取;任一个失败不影响另一个显示 // ⚠️ 审核统计要 `progress:viewAll`,班主任没有 —— 不能"调了再 catch":
// 403 是 HTTP 状态码,`http.ts` 的传输层分支会**先弹一个「拒绝访问(403)」**,
// catch 只能拦住异常,拦不住那个提示。所以按权限**决定发不发这个请求**。
const [s, a] = await Promise.allSettled([ const [s, a] = await Promise.allSettled([
taskApi.getTaskStatistics(boardTaskId.value), taskApi.getTaskStatistics(boardTaskId.value, actingStore.tenantId),
auditApi.getAuditStatistics({ taskId: boardTaskId.value }) canViewAllProgress.value
? auditApi.getAuditStatistics({ taskId: boardTaskId.value }, actingStore.tenantId)
: Promise.resolve(null)
]) ])
stat.value = s.status === 'fulfilled' ? s.value.data : 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 { } finally {
boardLoading.value = false boardLoading.value = false
} }
@@ -334,13 +427,15 @@ function countNodes(nodes: OrgTreeVO[]): number {
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0) return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
} }
// 换学校 = 换一整套数字(组织节点、任务下拉、看板)。不清空的话,
// 上一所学校的任务还留在下拉里,选中它看到的是**另一所学校**的进度
watch(() => actingStore.tenantId, () => {
loadOrgCount()
loadTasks()
})
onMounted(async () => { onMounted(async () => {
try { await loadOrgCount()
const res = await orgApi.getOrgTree()
orgCount.value = countNodes(res.data ?? [])
} catch {
orgCount.value = null
}
if (isPlatform.value) { if (isPlatform.value) {
try { try {
const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 }) const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
+69 -16
View File
@@ -1,6 +1,6 @@
<template> <template>
<GiPageLayout> <GiPageLayout>
<a-card :bordered="false"> <a-card :bordered="false" class="g-list-card">
<template #title>操作日志</template> <template #title>操作日志</template>
<template #extra> <template #extra>
<span class="log__hint"> <span class="log__hint">
@@ -8,14 +8,6 @@
</span> </span>
</template> </template>
<a-alert type="normal" class="log__tip">
<template #title>
证件号在这里**只有脱敏值与指纹**(日志泄露不该比业务库泄露更严重)。
IP 取代理链第一跳、**可以被伪造** —— 用于排查,不作合规证据;
真正的证据是「哪个账号在什么时候做了什么」。
</template>
</a-alert>
<div class="g-row-tool"> <div class="g-row-tool">
<a-space wrap> <a-space wrap>
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small" <a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
@@ -58,9 +50,27 @@
</a-space> </a-space>
</div> </div>
<!--
`y: '100%'` 不是装饰:它让 Arco 把表头与表体**拆开**
(内部 `splitTable`),表体自己滚、表头钉住、分页器留在卡片底部。
没有它的话,`.arco-table-content` 的 `overflow-y` 是 hidden(横向滚动条专用),
多出来的行会被**直接裁掉**,而且没有任何滚动条能救回来。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination" <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> <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"> <a-table-column title="时间" :width="150">
<template #cell="{ record }"> <template #cell="{ record }">
<span class="log__muted">{{ fmt(record.createTime) }}</span> <span class="log__muted">{{ fmt(record.createTime) }}</span>
@@ -68,8 +78,12 @@
</a-table-column> </a-table-column>
<a-table-column title="操作人" :width="120"> <a-table-column title="操作人" :width="120">
<template #cell="{ record }"> <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> </template>
</a-table-column> </a-table-column>
<a-table-column title="动作" :width="150"> <a-table-column title="动作" :width="150">
@@ -119,6 +133,8 @@ import {
OPERATION_ACTIONS, OPERATION_ACTIONS,
SENSITIVE_ACTIONS SENSITIVE_ACTIONS
} from '@/apis/xt/log' } from '@/apis/xt/log'
import { getTenantList } from '@/apis/xt/tenant'
import { useUserStore } from '@/stores'
/** /**
* 操作日志(《03-接口设计》§13.4)。 * 操作日志(《03-接口设计》§13.4)。
@@ -134,9 +150,22 @@ import {
* *
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击, * 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
* 而合规核查往往是几十条连着看的。 * 而合规核查往往是几十条连着看的。
*
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
*
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定(`LogController.logScopeTenantId`)。
* ⚠️ 平台运营看到的每条日志属于哪个单位,靠这一列「租户」——它**只对平台显示**。
* 以前平台的 tenantId 是 0 而日志挂在各租户下,于是这一屏对平台**永远是空的**,
* 且不报任何错(PRD 要求的却是「全量操作日志 ✅」)。
*/ */
defineOptions({ name: 'OperationLogs' }) 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 loading = ref(false)
const list = ref<OperationLog[]>([]) const list = ref<OperationLog[]>([])
const total = ref(0) const total = ref(0)
@@ -220,20 +249,44 @@ function onPageChange(page: number) {
load() 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() load()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 卡片/表格的高度链路(分页器为什么必须钉在底部)见 `styles/global.scss` 的 `.g-list-card` */
.log { .log {
&__hint { &__hint {
font-size: 12px; font-size: 12px;
color: var(--color-text-3); color: var(--color-text-3);
} }
&__tip {
margin-bottom: 12px;
}
&__num { &__num {
width: 110px; width: 110px;
} }
+9 -2
View File
@@ -52,9 +52,16 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
* 1. 组织编码租户内唯一 * 1. 组织编码租户内唯一
* 2. 组织类型必须比上级更低一级 * 2. 组织类型必须比上级更低一级
* 3. 修改时不提供改上级(避免误操作把整棵子树搬走) * 3. 修改时不提供改上级(避免误操作把整棵子树搬走)
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:它决定这个组织建到哪所学校下
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。学校管理员传 null,行为与以前一致。
*/ */
defineOptions({ name: 'OrgFormModal' }) defineOptions({ name: 'OrgFormModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'save-success'): void (e: 'save-success'): void
}>() }>()
@@ -132,7 +139,7 @@ async function confirm() {
orgType: form.orgType, orgType: form.orgType,
orgCode: form.orgCode.trim(), orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0 sortNo: form.sortNo ?? 0
}) }, props.tenantId)
Message.success('已保存') Message.success('已保存')
} else { } else {
await orgApi.addOrg({ await orgApi.addOrg({
@@ -141,7 +148,7 @@ async function confirm() {
orgType: form.orgType, orgType: form.orgType,
orgCode: form.orgCode.trim(), orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0 sortNo: form.sortNo ?? 0
}) }, props.tenantId)
Message.success('已新增') Message.success('已新增')
} }
visible.value = false visible.value = false
+67 -7
View File
@@ -13,11 +13,34 @@
<span class="org__hint">{{ totalNodes }} 个节点</span> <span class="org__hint">{{ totalNodes }} 个节点</span>
</template> </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-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 v-if="canManage" type="primary" size="small" @click="onAdd(null)">
新增顶级组织 新增顶级组织
</a-button> </a-button>
@@ -35,7 +58,7 @@
</a-tree> </a-tree>
<template #footer> <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> <template #icon><IconPlus /></template>
新增顶级组织 新增顶级组织
</a-button> </a-button>
@@ -111,7 +134,7 @@
</a-card> </a-card>
</template> </template>
<OrgFormModal ref="formModalRef" @save-success="load" /> <OrgFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
</GiPageLayout> </GiPageLayout>
</template> </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 { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org' import * as orgApi from '@/apis/xt/org'
import { ORG_TYPE_NAMES } 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' import OrgFormModal from './OrgFormModal.vue'
/** /**
@@ -135,6 +158,18 @@ defineOptions({ name: 'Org' })
const userStore = useUserStore() 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 loading = ref(false)
const tree = ref<OrgTreeVO[]>([]) const tree = ref<OrgTreeVO[]>([])
const selected = ref<OrgTreeVO | null>(null) const selected = ref<OrgTreeVO | null>(null)
@@ -174,9 +209,16 @@ function findById(nodes: OrgTreeVO[], id: number): OrgTreeVO | null {
} }
async function load() { async function load() {
// 平台没选学校时不发请求(服务端会返回空树,徒增一次往返与一次困惑)
if (needPickTenant.value) {
tree.value = []
selected.value = null
selectedKeys.value = []
return
}
loading.value = true loading.value = true
try { try {
const res = await orgApi.getOrgTree({ withCount: true }) const res = await orgApi.getOrgTree({ withCount: true, tenantId: actingStore.tenantId })
tree.value = res.data ?? [] tree.value = res.data ?? []
// 刷新后保持选中 // 刷新后保持选中
if (selected.value) { 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) { async function onDelete(row: OrgTreeVO) {
await orgApi.deleteOrg(row.id) await orgApi.deleteOrg(row.id, actingStore.tenantId)
Message.success('已删除') Message.success('已删除')
if (selected.value?.id === row.id) { if (selected.value?.id === row.id) {
selected.value = null selected.value = null
@@ -236,6 +287,10 @@ onMounted(load)
margin-bottom: 10px; margin-bottom: 10px;
} }
&__tenant {
margin-bottom: 10px;
}
&__loading { &__loading {
display: block; display: block;
padding: 40px 0; padding: 40px 0;
@@ -247,6 +302,11 @@ onMounted(load)
color: var(--color-text-3); color: var(--color-text-3);
} }
&__empty-title {
margin-bottom: 4px;
color: var(--color-text-1);
}
&__node { &__node {
display: inline-flex; display: inline-flex;
gap: 6px; gap: 6px;
+69 -12
View File
@@ -1,11 +1,20 @@
<template> <template>
<GiPageLayout> <GiPageLayout>
<a-card :bordered="false"> <a-card :bordered="false" class="g-list-card">
<template #title>收集任务</template> <template #title>收集任务</template>
<template #extra> <template #extra>
<span class="task__hint">共 {{ total }} 个任务</span> <span class="task__hint">共 {{ total }} 个任务</span>
</template> </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"> <div class="g-row-tool">
<a-space wrap> <a-space wrap>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd"> <a-button v-if="canManage" type="primary" size="small" @click="onAdd">
@@ -15,10 +24,6 @@
</a-space> </a-space>
<a-space wrap> <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" <a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch"
@press-enter="search" /> @press-enter="search" />
<a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear <a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear
@@ -38,8 +43,13 @@
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置) 名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。 · 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
--> -->
<!--
`y: '100%'`:让 Arco 拆开表头与表体(表体自己滚、表头钉住),
配合卡片上的 `.g-list-card` 把分页器留在卡片底部。原因见 `styles/global.scss`。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination" <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> <template #columns>
<a-table-column title="任务" :width="200" ellipsis tooltip> <a-table-column title="任务" :width="200" ellipsis tooltip>
<template #cell="{ record }"> <template #cell="{ record }">
@@ -181,6 +191,13 @@ import TaskRosterDrawer from './TaskRosterDrawer.vue'
*/ */
defineOptions({ name: 'Task' }) defineOptions({ name: 'Task' })
/** 状态 tab 的一项:tab 的 key 是字符串,发给接口的是 status */
interface StatusTab {
key: string
label: string
value: number | undefined
}
const userStore = useUserStore() const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('task:manage')) const canManage = computed(() => userStore.permissions.includes('task:manage'))
@@ -214,14 +231,26 @@ const BLOCKING_HINTS: Record<number, string> = {
40020: '命名规则有问题,请点「编辑」检查文件命名模板' 40020: '命名规则有问题,请点「编辑」检查文件命名模板'
} }
const statusChips = [ /**
{ label: '全部', value: undefined }, * 状态 tab。
{ label: '草稿', value: TASK_STATUS_DRAFT }, *
{ label: '收集中', value: TASK_STATUS_COLLECTING }, * `key` 必须是字符串(Arco 的 tab key 不支持 undefined),
{ label: '已暂停', value: TASK_STATUS_PAUSED }, * `value` 才是发给接口的 `status` —— 「全部」= 不带 status 参数,
{ label: '已截止', value: TASK_STATUS_CLOSED } * 这个映射只在这一处定义,别在别处再写一份 `?? 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(() => ({ const pagination = computed(() => ({
current: query.pageNum, current: query.pageNum,
pageSize: query.pageSize, pageSize: query.pageSize,
@@ -261,6 +290,23 @@ function setStatus(v: number | undefined) {
search() 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) { function onPageChange(page: number) {
query.pageNum = page query.pageNum = page
load() load()
@@ -407,6 +453,17 @@ onMounted(load)
color: var(--color-text-3); 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 { &__batch {
width: 120px; width: 120px;
} }
+27 -4
View File
@@ -150,9 +150,17 @@ import { useUserStore } from '@/stores'
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定, * 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号, * 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。 * 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"这个账号建到哪所学校下"
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定角色的可选范围:
* 给某所学校建账号时只能发**学校那套角色**,发 PLATFORM_ADMIN 会被服务端拒(40301)。
*/ */
defineOptions({ name: 'UserFormModal' }) defineOptions({ name: 'UserFormModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'save-success'): void (e: 'save-success'): void
}>() }>()
@@ -187,8 +195,17 @@ const getInitForm = () => ({
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前 // 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
const form = reactive(getInitForm()) const form = reactive(getInitForm())
/**
* 可选角色。
*
* ⚠️ 判据不只是"我是不是平台租户",还要看**有没有代管学校**:
* · 平台运营 + 没选学校 → 建的是平台自己的账号 → 只能发 PLATFORM_ADMIN;
* · 平台运营 + 选了学校 → 建的是**那所学校**的账号 → 只能发学校那套角色
* (发 PLATFORM_ADMIN 会被服务端按租户白名单拒掉,报 40301);
* · 学校管理员 → 学校那套。
*/
const roleOptions = computed(() => 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() { async function loadOrgTree() {
if (orgTree.value.length) return if (orgTree.value.length) return
const res = await getOrgTree() const res = await getOrgTree({ tenantId: props.tenantId })
orgTree.value = res.data ?? [] orgTree.value = res.data ?? []
} }
// 换了学校就把缓存的组织树丢掉:留着上一所学校的树,用户会在"组织"下拉里
// 看到别人学校的班级,而选中它提交会得到一个指向不存在组织的 orgId
watch(() => props.tenantId, () => {
orgTree.value = []
})
/** 新增 */ /** 新增 */
async function add() { async function add() {
detailId.value = null detailId.value = null
@@ -306,7 +329,7 @@ async function confirm() {
roleCodes: form.roleCodes, roleCodes: form.roleCodes,
userType, userType,
status: form.status status: form.status
}) }, props.tenantId)
Message.success('已保存') Message.success('已保存')
visible.value = false visible.value = false
emit('save-success') emit('save-success')
@@ -320,7 +343,7 @@ async function confirm() {
orgId: form.orgId ?? null, orgId: form.orgId ?? null,
roleCodes: form.roleCodes, roleCodes: form.roleCodes,
userType userType
}) }, props.tenantId)
const data = res.data const data = res.data
if (!data) { if (!data) {
Message.error('创建失败,请重试') Message.error('创建失败,请重试')
+12 -2
View File
@@ -174,9 +174,16 @@ import { useUserStore } from '@/stores'
* ② 哪几行没进去、为什么 * ② 哪几行没进去、为什么
* ③ 新账号的初始密码(只此一次) * ③ 新账号的初始密码(只此一次)
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。 * 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"导进哪所学校"
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定默认角色的可选范围。
*/ */
defineOptions({ name: 'UserImportModal' }) defineOptions({ name: 'UserImportModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'save-success'): void (e: 'save-success'): void
}>() }>()
@@ -191,8 +198,11 @@ const report = ref<UserImportResult | null>(null)
const fileInput = useTemplateRef<HTMLInputElement>('fileInput') const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN')) const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
/** 与 UserFormModal 同一口径:代管某所学校时只能发学校那套角色 */
const roleOptions = computed(() => 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,不进这个对象 */ /** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
@@ -228,7 +238,7 @@ async function submit() {
if (!file.value) return if (!file.value) return
submitting.value = true submitting.value = true
try { 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 report.value = res.data ?? null
emit('save-success') emit('save-success')
} finally { } finally {
+48 -9
View File
@@ -10,7 +10,17 @@
</a-space> </a-space>
</template> </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> <template v-else>
<a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'" <a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'"
@@ -151,8 +161,8 @@
</a-table> </a-table>
</a-card> </a-card>
<UserFormModal ref="formModalRef" @save-success="load" /> <UserFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
<UserImportModal ref="importModalRef" @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" <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 { getOrgTree } from '@/apis/xt/org'
import * as userApi from '@/apis/xt/user' import * as userApi from '@/apis/xt/user'
import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } 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 UserFormModal from './UserFormModal.vue'
import UserImportModal from './UserImportModal.vue' import UserImportModal from './UserImportModal.vue'
@@ -215,6 +225,17 @@ const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('user:manage')) const canManage = computed(() => userStore.permissions.includes('user:manage'))
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN')) 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 ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
const loading = ref(false) const loading = ref(false)
@@ -255,9 +276,13 @@ const pagination = computed(() => ({
})) }))
async function loadOrgTree() { async function loadOrgTree() {
if (needPickTenant.value) {
orgTree.value = []
return
}
orgLoading.value = true orgLoading.value = true
try { try {
const res = await getOrgTree() const res = await getOrgTree({ tenantId: actingStore.tenantId })
orgTree.value = res.data ?? [] orgTree.value = res.data ?? []
} finally { } finally {
orgLoading.value = false orgLoading.value = false
@@ -265,6 +290,11 @@ async function loadOrgTree() {
} }
async function load() { async function load() {
if (needPickTenant.value) {
list.value = []
total.value = 0
return
}
loading.value = true loading.value = true
try { try {
const res = await userApi.getUserList({ const res = await userApi.getUserList({
@@ -274,7 +304,7 @@ async function load() {
status: statusFilter.value, status: statusFilter.value,
pageNum: query.pageNum, pageNum: query.pageNum,
pageSize: query.pageSize pageSize: query.pageSize
}) }, actingStore.tenantId)
list.value = res.data?.list ?? [] list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0 total.value = res.data?.total ?? 0
} finally { } finally {
@@ -282,6 +312,15 @@ async function load() {
} }
} }
// 换学校 = 换一整套数据(组织树、列表、已选的班级都要重来)。
// 留着上一所学校的 orgId 去查新学校,会得到"一个空的列表" —— 看起来像这所学校没有账号。
watch(() => actingStore.tenantId, () => {
orgId.value = undefined
query.pageNum = 1
loadOrgTree()
load()
})
function search() { function search() {
query.pageNum = 1 query.pageNum = 1
load() load()
@@ -325,7 +364,7 @@ async function onToggleStatus(row: UserVO, status: number) {
content: '停用后该账号将无法登录,但数据保留,可以随时再启用。', content: '停用后该账号将无法登录,但数据保留,可以随时再启用。',
hideCancel: false, hideCancel: false,
onBeforeOk: async () => { onBeforeOk: async () => {
await userApi.setUserStatus(row.id, status) await userApi.setUserStatus(row.id, status, actingStore.tenantId)
Message.success(`已${action}`) Message.success(`已${action}`)
await load() await load()
resolve() 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}`) Message.success(`已${action}`)
await load() await load()
} }
@@ -346,7 +385,7 @@ async function onResetPassword(row: UserVO) {
content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。', content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。',
hideCancel: false, hideCancel: false,
onBeforeOk: async () => { 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 resetResult.value = res.data ?? null
resetVisible.value = true resetVisible.value = true
return 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())
+202 -22
View File
@@ -1,31 +1,47 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
清理 e2e-m0.py 产生的测试数据。 开发库与本地磁盘的**测试残留**清理。
E2E 每跑一次会: E2E 每跑一次会:
1. 新建一个租户(编码 e2e######)并给它建角色与账号; 1. 新建一个租户(编码 e2e######)并给它建角色与账号;
2. **在 demo 租户下**建三个组织(学校/院系/班级,编码带时间戳)。 2. **在 demo 租户下**建三个组织(学校/院系/班级,编码带时间戳)。
第 2 点容易被忽略——这些组织挂在正常租户下,不清掉会让组织树里 第 2 点容易被忽略——这些组织挂在正常租户下,不清掉会让组织树里堆满
堆满 "E2E 示范学校 586923" 这种名字。 "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 # 只看会删什么(默认 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 from __future__ import annotations
import argparse import argparse
import os
import subprocess import subprocess
import sys import sys
if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") 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 = [ TENANT_CHILD_TABLES = [
("角色授权", "sys_role_permission"), ("角色授权", "sys_role_permission"),
@@ -45,6 +61,13 @@ DEMO_ORG_FILTER = (
"OR org_name LIKE 'E2E %' OR org_name LIKE '重复编码%' OR org_name LIKE '层级颠倒%'" "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: class Db:
def __init__(self, container: str, user: str, name: str): 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()] 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: def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="真正执行删除(默认只预览)") 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("--container", default="xt-pg")
ap.add_argument("--user", default="fill") ap.add_argument("--user", default="fill")
ap.add_argument("--db", default="filldb") ap.add_argument("--db", default="filldb")
args = ap.parse_args() args = ap.parse_args()
if args.only_orphan_files:
args.orphan_files = True
db = Db(args.container, args.user, args.db) 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"SELECT id || '|' || tenant_code || '|' || tenant_name "
f"FROM sys_tenant WHERE {TENANT_FILTER} ORDER BY id") f"FROM sys_tenant WHERE {TENANT_FILTER} ORDER BY id")
demo_orgs = db.rows( # 在用组织保护:先算好(任何删除发生之前),删除阶段只动"没被引用的"
f"SELECT id || '|' || org_name FROM sys_org " keep_orgs = orgs_referenced_by_live_tasks(db) if not args.only_orphan_files else set()
f"WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id") 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: if not any((tenants, demo_orgs, demo_tasks, orphans)):
print("没有 e2e 测试数据,无需清理") print("没有需要清理的测试数据")
return 0 return 0
if tenants: if tenants:
@@ -92,13 +204,32 @@ def main() -> int:
print(f" ... 另有 {len(tenants) - 15} 个") print(f" ... 另有 {len(tenants) - 15} 个")
if demo_orgs: 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]: for r in demo_orgs[:15]:
p = r.split("|") p = r.split("|")
print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else "")) print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else ""))
if len(demo_orgs) > 15: if len(demo_orgs) > 15:
print(f" ... 另有 {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: if not args.apply:
print("\n这是预览。加 --apply 才会真正删除。") print("\n这是预览。加 --apply 才会真正删除。")
return 0 return 0
@@ -106,20 +237,69 @@ def main() -> int:
print("\n开始清理:") print("\n开始清理:")
total = 0 total = 0
for label, table in TENANT_CHILD_TABLES: if not args.only_orphan_files:
n = len(db.rows(f"DELETE FROM {table} WHERE {SUB_FILTER} RETURNING id")) 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 total += n
print(" %-10s %-22s 删除 %d 行" % (label, table, n)) print(" %-10s %-22s 删除 %d 行" % ("租户", "sys_tenant", n))
n = len(db.rows(f"DELETE FROM sys_tenant WHERE {TENANT_FILTER} RETURNING id")) # 测试组织:按 id 删(keep_orgs 已在预览阶段剔除;这里再校验一次,
total += n # 因为上面可能刚删过任务,引用集合应该只减不增 —— 双保险,宁可少删)
print(" %-10s %-22s 删除 %d 行" % ("租户", "sys_tenant", n)) 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"))
n = len(db.rows(f"DELETE FROM sys_org WHERE {DEMO_ORG_FILTER} RETURNING id")) if args.demo_tasks:
total += n # 先按任务 id 删子表(同样动态枚举"带 task_id 的表")
print(" %-10s %-22s 删除 %d 行" % ("测试组织", "sys_org", n)) 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))
print(f"\n完成,共删除 {total} 行。") 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 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')}") f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token) 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. 草稿路径与删除 ─────────────────────────────── # ── 8. 草稿路径与删除 ───────────────────────────────
print("\n[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'") f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'")
check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}") 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 时不猜租户 ───────────────────────── # ── 5. 没有 scene 时不猜租户 ─────────────────────────
print("\n[5] 没带 scene 时") print("\n[5] 没带 scene 时")
code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}") code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}")
@@ -219,8 +228,9 @@ def main() -> int:
print("\n[7] 清理测试数据") print("\n[7] 清理测试数据")
for uid in CREATED_USER_IDS: for uid in CREATED_USER_IDS:
sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}") 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}") 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("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") 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. 边界与权限 ── # ── 9. 边界与权限 ──
print("\n[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) stok = mp(S1)
b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id}) b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id})
check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403), check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403),
+21 -10
View File
@@ -292,11 +292,21 @@ def main():
# ── 4. 审核超时提醒 ── # ── 4. 审核超时提醒 ──
print("\n[4] 审核超时提醒(时效与 §11.6 同口径)") 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"}) body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)", check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)",
sql("select count(*) from collect_message where biz_type=2 " timeout_msgs_to(STU2) == "0",
"and title like '%%审核超时%%';") == "0", "乙收到的审核超时提醒 = " + timeout_msgs_to(STU2))
sql("select count(*) from collect_message where biz_type=2;"))
# 把提交时间改到 5 天前(> 48 小时)→ 应该提醒 # 把提交时间改到 5 天前(> 48 小时)→ 应该提醒
st = one("select status from collect_submission where task_id=%d and submitter_id=%d;" st = one("select status from collect_submission where task_id=%d and submitter_id=%d;"
% (urge_task, STU2)) % (urge_task, STU2))
@@ -304,20 +314,21 @@ def main():
"submission.status = " + st) "submission.status = " + st)
R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' " R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' "
"where task_id=%d and submitter_id=%d;" % (urge_task, STU2)) "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"}) body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
n = sql("select count(*) from collect_message where biz_type=2 " n = sql("select count(*) from collect_message where biz_type=2 "
"and title like '%%审核超时%%';") "and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';")
check("⭐ 超时 5 天的被提醒了", n != "0", "提醒条数 = " + n) check("⭐ 超时 5 天的被提醒了", n != "0", "本次夹具的提醒条数 = " + n)
check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)", check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)",
"48" in sql("select content from collect_message where biz_type=2 " "48" in sql("select content from collect_message where biz_type=2 "
"and title like '%%审核超时%%' order by id desc limit 1;"), "and title like '%%审核超时%%' and content like '%%E2E 自动催报%%' "
sql("select content from collect_message where biz_type=2 order by id desc limit 1;")) "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("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)", check("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)",
sql("select count(distinct user_id) from collect_message where biz_type=2 " 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 " 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. 原始件清理 ── # ── 5. 原始件清理 ──
print("\n[5] 原始件清理(raw_expire_time 到期)") print("\n[5] 原始件清理(raw_expire_time 到期)")
+43 -2
View File
@@ -262,12 +262,52 @@ def main():
check("⭐ 模板的 useCount 被 +1(用户能看出哪个模板常用)", check("⭐ 模板的 useCount 被 +1(用户能看出哪个模板常用)",
use_after == use_before + 1, "前 %d → 后 %d" % (use_before, use_after)) 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, ap2 = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["FORM"]}) {"targetTaskId": tgt, "parts": ["FORM"]})
check("⭐ 只应用 FORM 时明确 skipped + 原因(公共模板的 form_schema 为空)", check("⭐ 只应用 FORM 时明确 skipped + 原因(公共模板的 form_schema 为空)",
len((ap2.get("data") or {}).get("skipped") or []) >= 1, len((ap2.get("data") or {}).get("skipped") or []) >= 1,
json.dumps(ap2.get("data"), ensure_ascii=False)[:300]) 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, bad_parts = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["NOPE"]}) {"targetTaskId": tgt, "parts": ["NOPE"]})
check("parts 写错 → 明确报错并列出合法值", check("parts 写错 → 明确报错并列出合法值",
@@ -568,9 +608,10 @@ def cleanup():
R.sql("delete from collect_roster where task_id=%d;" % tid) 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_task_org where task_id=%d;" % tid)
R.sql("delete from collect_material_def 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_task where id=%d;" % tid)
R.sql("delete from collect_rule_template where template_name like 'E2E自建模板%' " 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("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 collect_message where user_id in (995111, 995112);")
R.sql("delete from sys_user_role where user_id = 995101;") 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={}) rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={})
check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0, check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0,
json.dumps(rp2[1], ensure_ascii=False)[:200]) 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 同口径)", check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)",
"身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log " "身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
"where submission_id=%d limit 1;" % sid) or "") "where submission_id=%d limit 1;" % sid) or "")
+528 -28
View File
@@ -44,7 +44,13 @@ import uuid
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 默认打 dev 后端;`--base` 可改(例如为了量上传而在 18080 另起一个
# `--fill.storage.provider=local` 的实例,避免把测试文件灌进真实对象存储)
BASE = "http://localhost:8080" BASE = "http://localhost:8080"
# 演示班主任(scripts/dev-seed-audit.py 摆出来的)—— 一期初审恒为开启,
# 所以"审批闭环"这类场景必须有它
TEACHER_USER = "demot1"
TEACHER_PWD = "Demo1234"
SUFFIX = uuid.uuid4().hex[:6] SUFFIX = uuid.uuid4().hex[:6]
TMP_ROSTER = os.path.join(ROOT, "scripts", f"_lt-roster-{SUFFIX}.xlsx") TMP_ROSTER = os.path.join(ROOT, "scripts", f"_lt-roster-{SUFFIX}.xlsx")
TMP_IMG = os.path.join(ROOT, "scripts", f"_lt-img-{SUFFIX}.jpg") 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。""" """造一个任务 + N 个名册人(每人一个已知证件号)+ 对应 uid 的 MP token。"""
_, orgs = call("GET", "/api/v1/orgs/tree", admin) _, orgs = call("GET", "/api/v1/orgs/tree", admin)
classes = [] classes = []
@@ -161,17 +188,32 @@ def build_fixture(admin):
"taskName": f"压测任务 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工", "taskName": f"压测任务 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "collectMode": 1, "certType": 1,
"startTime": "2026-01-01", "endTime": "2099-12-31", "startTime": "2026-01-01", "endTime": "2099-12-31",
# ⚠️ **只走终审**(needClassAudit=false)是刻意的:初审的判据是"这份单在你负责的
# 班级里"(AuditService.auditDenyReason),而夹具的名册导进的是组织树里第一个班,
# admin 并不是那个班的班主任 —— 于是逐份通过会整齐地报 40301「不在你负责的班级里」。
# 要按真实两段审核走,就得再造一个"带班级范围的班主任"账号;
# 而导出这条路与"谁审的"无关,所以这里选便宜且不引入额外夹具的那条。
"auditConfig": {"needClassAudit": False, "needAdminAudit": True},
}) })
tid = b["data"]["id"] tid = b["data"]["id"]
TASK_IDS.append(tid) TASK_IDS.append(tid)
call("POST", f"/api/v1/tasks/{tid}/materials", admin, { call("POST", f"/api/v1/tasks/{tid}/materials", admin, {
"materialCode": "IDCARD", "materialName": "身份证", "required": True, "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, "minSizeKb": 1, "minLongSide": 100,
}) })
call("PUT", f"/api/v1/tasks/{tid}/orgs", admin, # ⚠️ **必须指定 headUserId**:初审的判据是"这个班归不归我审"
{"orgs": [{"orgId": org["id"], "expectedCount": GATE_USERS}]}) # (`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 位递增,校验位由脚本补) # N 个证件号(前 17 位递增,校验位由脚本补)
rows = [] rows = []
@@ -189,9 +231,16 @@ def build_fixture(admin):
seq = "%03d" % (i % 1000) seq = "%03d" % (i % 1000)
idc = R.with_checksum(area + "20080101" + seq) idc = R.with_checksum(area + "20080101" + seq)
rows.append((f"压测{i:04d}", idc)) rows.append((f"压测{i:04d}", idc))
t_excel = time.perf_counter()
build_roster(rows) build_roster(rows)
excel_s = time.perf_counter() - t_excel
t_imp = time.perf_counter()
body = R.upload_roster(BASE, admin, tid, TMP_ROSTER, body = R.upload_roster(BASE, admin, tid, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]}) 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.created,不是 code**:导入了 0 行也是 code=0
# (体检报告在 data 里)。判错的话压测会"没有样本", # (体检报告在 data 里)。判错的话压测会"没有样本",
# 而那个现象看起来像压测脚本坏了 —— 与 F-84 是同一个坑 # 而那个现象看起来像压测脚本坏了 —— 与 F-84 是同一个坑
@@ -212,7 +261,9 @@ def build_fixture(admin):
if len(roster_rows) != GATE_USERS: if len(roster_rows) != GATE_USERS:
raise SystemExit("夹具名册行数不对:期望 %d,实际 %d" raise SystemExit("夹具名册行数不对:期望 %d,实际 %d"
% (GATE_USERS, len(roster_rows))) % (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() cfg = R.read_dev_config()
tokens = [] tokens = []
for i, r in enumerate(roster_rows): for i, r in enumerate(roster_rows):
@@ -245,15 +296,36 @@ def build_roster(rows):
wb.save(TMP_ROSTER) wb.save(TMP_ROSTER)
def make_img(size_kb=400): def make_img(size_kb=400, px=(2000, 1500)):
"""造一张尽量接近真实照片的图(纯色会被压得很小,失真)。""" """
import random 造一张**真实**的 JPEG(Pillow 噪声图)。
random.seed(7)
# 最小合法 JPEG 头 + 随机填充(不是真 JPEG,但上传接口只看扩展名与大小) ⚠️ 原来是"JPEG 头 + 随机字节"的假图。它能过扩展名与大小校验,
with open(TMP_IMG, "wb") as f: 但**服务端做的根本不是同一件事**:真图要解码 → 按 EXIF 转正 → 重编码,
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))) 用压测去回答"压缩是 CPU 密集",却喂它一个不需要压缩的文件 ——
f.write(b"\xff\xd9") 量到的不是压缩成本,而是错误处理的成本。**这类"夹具不对"的红/绿都不算数。**
噪声图是有意选的:纯色图会被压到极小,量不出 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)
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(): def reset_gate():
@@ -335,16 +407,57 @@ def scenario_read(tid, admin, conc, rounds):
return stats return stats
def scenario_upload(tid, tokens, conc): def scenario_upload(tid, tokens, conc, px=(2000, 1500)):
"""上传:并发上传图片(压缩是 CPU 密集)。""" """上传:并发上传图片(压缩是 CPU 密集)。"""
st = Stat("材料上传") 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 = {} sub_ids = {}
# 每个用户先建草稿 for t in tokens[:conc + 1]: # 多要一份:串行基线占用第 0 个
for t in tokens[:conc]:
c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid}) c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid})
if c == 200 and isinstance(b, dict) and b.get("code") == 0: if c == 200 and isinstance(b, dict) and b.get("code") == 0:
sub_ids[t["uid"]] = b["data"]["id"] 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): def one_req(t):
sid = sub_ids.get(t["uid"]) 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 return ms, int(biz if biz is not None else code), body
jobs = [] jobs = []
for t in tokens[:conc]: # 并发用 tokens[1:conc+1]:第 0 个已经被串行基线用掉了,混进来会让"并发里的某个用户
# 比别人多传一次",而样本数对不上时最难判断的是"到底谁慢"
for t in tokens[1:conc + 1]:
for _ in range(UPLOAD_PER_USER): for _ in range(UPLOAD_PER_USER):
jobs.append(t) jobs.append(t)
with cf.ThreadPoolExecutor(max_workers=conc) as ex: 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)} 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): def scenario_export(tid, admin):
"""导出:前置检查(只读)+ 创建(同步打包)。""" """导出:前置检查(只读)+ 创建(同步打包)。"""
st_pre = Stat("导出前置检查") st_pre = Stat("导出前置检查")
@@ -442,37 +875,80 @@ def scenario_export(tid, admin):
# ══════════════ 清理 ══════════════ # ══════════════ 清理 ══════════════
def cleanup(): 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 tid in TASK_IDS:
for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record", 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"): "collect_task_org", "collect_material_def"):
sql("delete from %s where task_id=%s;" % (tbl, tid)) sql("delete from %s where task_id=%s;" % (tbl, tid))
sql("delete from collect_task where id=%s;" % tid) sql("delete from collect_task where id=%s;" % tid)
for uid in UIDS: for uid in UIDS:
sql("delete from collect_message where user_id=%d;" % uid) 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): for p in (TMP_ROSTER, TMP_IMG):
if os.path.exists(p): if os.path.exists(p):
os.remove(p) os.remove(p)
print(" 已清理:压测任务 / 名册 / 材料 / 消息 / 临时文件") print(" 已清理:压测任务 / 名册 / 材料 / 消息 / 临时文件(本地文件 %d 个)" % removed)
def main(): def main():
# ⚠️ `global` 必须出现在**同一函数里第一次使用该名字之前** —— # ⚠️ `global` 必须出现在**同一函数里第一次使用该名字之前** ——
# 放晚了会报 "name 'X' is used prior to global declaration" # 放晚了会报 "name 'X' is used prior to global declaration"
global GATE_USERS global GATE_USERS
global BASE
ap = argparse.ArgumentParser(description="压测(门禁/上传/读/导出)") ap = argparse.ArgumentParser(description="压测(门禁/上传/读/导出/长跑)")
ap.add_argument("--only", default="", help="只跑一个场景:gate / read / upload / export") 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("--conc", type=int, default=0, help="覆盖并发数")
ap.add_argument("--clean", action="store_true", help="只清理上次残留") ap.add_argument("--clean", action="store_true", help="只清理上次残留")
ap.add_argument("--rows", type=int, default=0, ap.add_argument("--rows", type=int, default=0,
help="名册人数(默认 %d)。⚠️ 谈响应时间必须带上规模" % GATE_USERS) help="名册人数(默认 %d)。⚠️ 谈响应时间必须带上规模" % GATE_USERS)
ap.add_argument("--base", default="",
help="后端地址(默认 %s)。要量上传就另起一个 local 存储的实例" % BASE)
ap.add_argument("--with-upload", action="store_true", ap.add_argument("--with-upload", action="store_true",
help="跑上传压测(⚠️ 会往真实对象存储写文件,默认关闭)") 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() args = ap.parse_args()
if args.base:
BASE = args.base.rstrip("/")
if args.rows > 0: if args.rows > 0:
GATE_USERS = args.rows GATE_USERS = args.rows
px = tuple(int(x) for x in args.img_px.lower().split("x"))
if args.clean: if args.clean:
# 残留任务按名字前缀找 # 残留任务按名字前缀找
@@ -497,12 +973,29 @@ def main():
print("管理员登录失败:" + str(adm)) print("管理员登录失败:" + str(adm))
return 2 return 2
admin = adm["data"]["token"] 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── 造夹具 ──") print("\n── 造夹具 ──")
t0 = time.perf_counter() t0 = time.perf_counter()
tid, tokens = build_fixture(admin) tid, tokens = build_fixture(admin, teacher)
print(" 任务 %d,名册 %d 人,夹具耗时 %.1fs" print(" 任务 %d,名册 %d 人,夹具耗时 %.1fs(含名册导入本身)"
% (tid, len(tokens), time.perf_counter() - t0)) % (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 = {} stats = {}
conc = args.conc conc = args.conc
@@ -516,15 +1009,22 @@ def main():
stats["read"] = scenario_read(tid, admin, conc or READ_CONC, READ_ROUNDS) stats["read"] = scenario_read(tid, admin, conc or READ_CONC, READ_ROUNDS)
if args.only == "upload" or (args.with_upload and args.only == ""): if args.only == "upload" or (args.with_upload and args.only == ""):
print("\n── ③ 材料上传(%d 人同时传,压缩是 CPU 密集)──" % UPLOAD_USERS) 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: if args.only == "" and not args.with_upload:
print("\n── ③ 材料上传(**默认跳过**)──") print("\n── ③ 材料上传(**默认跳过**)──")
print(" ⚠️ 当前 dev 的存储是七牛**真实空间**(见 backend/application-local.yml),") print(" ⚠️ 当前 dev 的存储是七牛**真实空间**(见 backend/application-local.yml),")
print(" 压测上传会打到外部服务并把测试文件灌进真实桶。要跑就加 --with-upload,") print(" 压测上传会打到外部服务并把测试文件灌进真实桶。两种跑法:")
print(" 且最好先把存储切成本地:XT_STORAGE_PROVIDER=local 重启后端。") print(" ① `--base http://localhost:18080` 打另起的 local 实例(推荐,见 docs/09 §9.5);")
print(" ② `XT_STORAGE_PROVIDER=local` 重启当前后端后再加 --with-upload。")
if args.only in ("", "export"): if args.only in ("", "export"):
print("\n── ④ 导出(前置检查 + 创建)──") print("\n── ④ 导出(前置检查 + 创建)──")
scenario_export(tid, admin) 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: finally:
print("\n── 清理 ──") print("\n── 清理 ──")
cleanup() cleanup()
+4 -1
View File
@@ -11,7 +11,8 @@
| 实体 ↔ 建表 | `check-entity-columns.py` | 无 | | 实体 ↔ 建表 | `check-entity-columns.py` | 无 |
| 文档自身一致 | `verify-docs.py` | 无 | | 文档自身一致 | `verify-docs.py` | 无 |
| 手册 ↔ 代码文案 | `check-manual.py` | 无 | | 手册 ↔ 代码文案 | `check-manual.py` | 无 |
| 接口行为(1548 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** | | 接线(定义 ↔ 使用) | `check-wiring.py` | 无 |
| 接口行为(1577+ 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** | | 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** |
| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis | | CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis |
@@ -87,6 +88,8 @@ def main():
r"(\d+) 个实体全部与迁移一致((\d+) 张表)"), r"(\d+) 个实体全部与迁移一致((\d+) 张表)"),
("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"), ("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"),
("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"), ("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"),
("接线(定义↔使用)", "python scripts/check-wiring.py --quiet",
r"未接线的声明 (\d+) 条"),
] ]
for name, cmd, pat in checks: for name, cmd, pat in checks:
code, out = run(cmd, timeout=300) code, out = run(cmd, timeout=300)
+12 -2
View File
@@ -50,6 +50,16 @@ def read(*parts: str) -> str:
# ── 1. 表格列数 ──────────────────────────────────────── # ── 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: def check_tables(name: str, text: str) -> None:
lines = text.split("\n") lines = text.split("\n")
bad_rows = 0 bad_rows = 0
@@ -57,10 +67,10 @@ def check_tables(name: str, text: str) -> None:
while i < len(lines): while i < len(lines):
if lines[i].strip().startswith("|") and i + 1 < len(lines) \ if lines[i].strip().startswith("|") and i + 1 < len(lines) \
and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()): and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
n = lines[i].count("|") n = table_pipes(lines[i])
j = i + 2 j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"): while j < len(lines) and lines[j].strip().startswith("|"):
if lines[j].count("|") != n: if table_pipes(lines[j]) != n:
bad_rows += 1 bad_rows += 1
j += 1 j += 1
i = j i = j