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 失败。
This commit is contained in:
2026-10-08 14:46:11 +08:00
parent 4a5790d900
commit 9e78e1d239
28 changed files with 1800 additions and 90 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'
+11 -4
View File
@@ -83,15 +83,15 @@ 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 通过,这是最容易踩的空转
@@ -103,6 +103,13 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
`python scripts/dev-clean-test-users.py`、`python scripts/dev-clean-qiniu.py` `python scripts/dev-clean-test-users.py`、`python scripts/dev-clean-qiniu.py`
(清真实桶里 `xiaotutongxue/` 前缀的测试对象,**默认 dry-run**,删前先核实库中零引用) (清真实桶里 `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 项;**跳过项会如实列出来**,不是静默跳过)
## 四、接口与数据契约(不显然、容易做错) ## 四、接口与数据契约(不显然、容易做错)
@@ -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})。"
@@ -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;
} }
} }
@@ -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,
@@ -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}。
* *
@@ -216,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)
@@ -294,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);
@@ -320,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);
@@ -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 并返回差集 —— "
+ "漏一个人,导出包就会缺人,而人工核对发现不了") + "漏一个人,导出包就会缺人,而人工核对发现不了")
@@ -167,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 = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级),"
+ "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。" + "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。"
@@ -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);
} }
@@ -42,7 +42,13 @@ public enum BuiltinRole {
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", "form", "material",
"roster", "join", "audit", "export", "cleanup", "log")), "roster", "join", "audit", "export", "cleanup", "log",
// ⚠️ 这两个菜单键以前**没有任何角色持有** —— 而 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,
+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 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
+20
View File
@@ -439,6 +439,25 @@
| 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-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-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` 的注释 |
--- ---
## 七、建议的执行顺序(按依赖,不按重要性) ## 七、建议的执行顺序(按依赖,不按重要性)
@@ -544,6 +563,7 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核
| 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 | **补测 `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 | **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-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 不更新用户名 → 看起来像密码错) |
+63
View File
@@ -2464,3 +2464,66 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
- `verify-all --with-e2e` → 见提交信息里的最终数字(37 个 E2E 脚本全绿)。 - `verify-all --with-e2e` → 见提交信息里的最终数字(37 个 E2E 脚本全绿)。
- E2E 总数 36 → **37**(新增 `e2e-m1-task-delete.py`),断言数相应增加。 - 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。
+445
View File
@@ -0,0 +1,445 @@
# -*- 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 | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
## 它**不查**什么(免得被当成"全绿就没事")
- 不查"逻辑对不对":它只回答"有没有被引用过",一个被引用了但用错地方的常量
它照样算通过;
- 不查前端有没有把权限码用上(`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 = {}
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 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)
# ── 汇总 ────────────────────────────────────────────────
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())
+521
View File
@@ -0,0 +1,521 @@
# -*- 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] = []
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])
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};")
# 模板的覆盖行:删掉 = 恢复内置(**不能留**,否则后面的脚本会读到改过的文案)
R.sql("delete from collect_notify_template where tenant_id=1;")
# 本脚本造的学生/申请人/班主任
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())
+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 到期)")
+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)