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:
+4
-2
@@ -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'
|
||||||
|
|||||||
@@ -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 项;**跳过项会如实列出来**,不是静默跳过)
|
||||||
|
|
||||||
## 四、接口与数据契约(不显然、容易做错)
|
## 四、接口与数据契约(不显然、容易做错)
|
||||||
|
|
||||||
|
|||||||
+104
-11
@@ -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);
|
||||||
|
|||||||
+4
-4
@@ -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 = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
|
||||||
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
|
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
|
||||||
|
|||||||
+43
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -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})。"
|
||||||
|
|||||||
+43
@@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审批通过后回写申请单。
|
* 审批通过后回写申请单。
|
||||||
*
|
*
|
||||||
|
|||||||
+93
-6
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -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,
|
||||||
|
|||||||
+25
@@ -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
|
||||||
|
|||||||
+124
-13
@@ -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,
|
||||||
|
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,
|
String linkType, Long linkId, String templateCode,
|
||||||
Map<String, String> templateParams) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染模板。
|
* 渲染模板。
|
||||||
*
|
*
|
||||||
|
|||||||
+131
@@ -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>库里改过的 > 内置的</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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-15
@@ -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,7 +227,12 @@ 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 天"和"就是今天"说同一句"今天是最后一天",
|
||||||
|
// 学生就无法判断到底还剩几个小时 —— 而那正是他此刻唯一需要知道的事。
|
||||||
|
String extra = daysLeft <= 0
|
||||||
|
? "今天是截止日,请在今天 24 点前提交,过后就不能再交了。"
|
||||||
|
: daysLeft == 1
|
||||||
? "今天是最后一天了,请务必今天提交。"
|
? "今天是最后一天了,请务必今天提交。"
|
||||||
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
|
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
|
||||||
for (UrgeMapper.UrgeTarget t : targets) {
|
for (UrgeMapper.UrgeTarget t : targets) {
|
||||||
@@ -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 (t.getTaskId() != null) {
|
||||||
|
vars.put("提交进度", progressOf(t.getTaskId()));
|
||||||
|
if (t.getEndTime() != null) {
|
||||||
|
vars.put("截止日", t.getEndTime().toLocalDate()
|
||||||
|
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy年M月d日")));
|
||||||
|
long days = java.time.temporal.ChronoUnit.DAYS
|
||||||
|
.between(java.time.LocalDate.now(), t.getEndTime().toLocalDate());
|
||||||
|
vars.put("剩余天数", String.valueOf(Math.max(0, days)));
|
||||||
}
|
}
|
||||||
if (extra != null && !extra.isBlank()) {
|
|
||||||
// 老师自己写的话原样附在后面 —— 它往往才是学生真正会看的那句
|
|
||||||
content.append("\n").append(extra.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
||||||
|
|||||||
+16
-2
@@ -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);
|
||||||
|
|||||||
+8
-2
@@ -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};
|
||||||
|
|||||||
+1
-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 并返回差集 —— "
|
||||||
+ "漏一个人,导出包就会缺人,而人工核对发现不了")
|
+ "漏一个人,导出包就会缺人,而人工核对发现不了")
|
||||||
|
|||||||
+1
-1
@@ -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 = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级),"
|
||||||
+ "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。"
|
+ "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。"
|
||||||
|
|||||||
+4
-4
@@ -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 说明** —— "
|
||||||
|
|||||||
+18
@@ -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
@@ -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 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
|
||||||
|
|||||||
@@ -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 不更新用户名 → 看起来像密码错) |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
@@ -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 到期)")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user