feat(notify): 通知接线检查 + 修 F-101~F-111(模板对自动通知生效 / 催报真的会发 / 该发的通知都发了)

起点:F-1/F-31/F-50/F-52 是同一个形状 —— 每处单看都对,编译/lint/review 全不红,只有从入口走一遍才暴露。先把这一类固化成命令,再按输出逐条修。

新增 scripts/check-wiring.py:权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、bizType 有没有被用过、菜单键 ↔ PC 端 XtMenu 双向核对。有意不接线的写进脚本上方例外表(带理由)。进 verify-all(第 5 项)与 CI 的 check 阶段。检查器自身修了三次假阳性:只数 @RequirePerm 漏掉服务层校验、按标识符数引用被同名常量骗过、注释里的提及被当成接线(改为先去 Java 注释再数)。

F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半):新增 NotifyTextResolver(单向依赖避开循环注入),库里改过的 > 内置;模板停用 = 这类通知不发且如实回 notifySent=false。F-102 加入申请三处通知(提交→审批人、通过/驳回→学生)。F-103 导出完成/失败通知发起人,正文不放签名 URL(模板里原本写着 {下载链接},一并改掉)。F-104 自动催报从来没发出过任何一条(end_time = date::date 与库里的 23:59:59 永不相等,测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」。F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)。F-106 字典/通知记录两个菜单没有任何角色持有(手册还写着去那里办)。F-107 6 个权限码挂到自己的接口。F-109 Map.of 不接受 null → 通知被吞成一行 warn。F-110/F-111 测试自身的两个坑。

验证:新增 e2e-m5-notify-wiring.py 43 条(改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节);verify-all --with-e2e → 9 项全过 0 跳过,38 个脚本 / 1620 条断言 / 0 失败。
This commit is contained in:
2026-10-08 14:46:11 +08:00
parent 4a5790d900
commit 9e78e1d239
28 changed files with 1800 additions and 90 deletions
@@ -761,15 +761,99 @@ public class AuditService {
}
boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED;
String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审";
String content = finalPass
? "本次认定的材料已收妥,无需再操作。"
: "班主任已通过你的材料,接下来由管理员终审。";
safeNotify(NotificationService.NotifyRequest.of(
sub.getSubmitterId(),
finalPass ? CollectMessage.BIZ_APPROVE : CollectMessage.BIZ_TASK_PUBLISH,
title, content,
// ⚠️ bizType 用 4(审核通过),**不是** 1(任务发布):
// 1 是"有新任务了",而"你的材料过审了"是审核进展。
// 以前这里借用了 1,于是消息中心按"任务发布"筛会筛出初审通过(《08》F-105)。
// 文案仍按节点区分 —— "等待终审"与"已通过"是两件不同的事,
// 用同一句话会让他以为已经结束了。
safeNotify(NotificationService.NotifyRequest.ofTemplate(
sub.getSubmitterId(), CollectMessage.BIZ_APPROVE,
"AUDIT_PASS",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"姓名", nameOfSubmission(sub)),
title,
CollectMessage.LINK_SUBMISSION, sub.getId(),
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS));
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS)
.withExtra(finalPass ? "本次认定的材料已收妥,无需再操作。"
: "班主任已通过你的材料,接下来由管理员终审。"));
}
/** 任务名(模板变量用;查不到就给个不空的兜底)。 */
private String taskNameOf(Long taskId) {
if (taskId == null) {
return "本次填报";
}
CollectTask t = taskMapper.selectById(taskId);
return t == null || t.getTaskName() == null ? "本次填报" : t.getTaskName();
}
/**
* 姓名(模板变量用)。
*
* <p>**先取名册上的名字**:那才是"他在这个任务里叫什么"(老师导入的名单),
* 而账号里的 {@code real_name} 可能还没填(学生扫码核验时只建了账号)。
* 两个都没有就不编 —— 模板里那个变量渲染成空(见 {@code NotifyRequest.vars})。
*/
private String nameOfSubmission(CollectSubmission sub) {
if (sub == null) {
return null;
}
if (sub.getRosterId() != null) {
CollectRoster r = rosterMapper.selectById(sub.getRosterId());
if (r != null && r.getRealName() != null && !r.getRealName().isBlank()) {
return r.getRealName();
}
}
return nameOf(sub.getSubmitterId());
}
/** 姓名(从账号取,兜底)。 */
private String nameOf(Long uid) {
if (uid == null) {
return null;
}
SysUser u = userMapper.selectById(uid);
return u == null ? null : u.getRealName();
}
/** 被驳回的材料名(模板的 {材料名}):多个就用顿号连起来。 */
private String materialTextOf(List<String> codes) {
if (codes == null || codes.isEmpty()) {
return "全部材料";
}
return String.join("、", codes);
}
/**
* 驳回次数达上限:**通知该校的管理员**(契约 §11.4「同时通知管理员」)。
*
* <p>《08》F-105:这里原来只有一行 {@code log.warn},而注释写着"站内信也发给管理员" ——
* 于是"需要管理员介入"这件事**没有任何人知道**(学生看到的是被驳回、
* 班主任看到的是"已达上限",而管理员什么都没看到)。日志不是通知。
*/
private void notifyEscalated(CollectSubmission sub, String reason) {
try {
var admins = userMapper.userIdsOfRole(UserContext.tenantId(),
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
if (admins.isEmpty()) {
log.warn("需要管理员介入但没有管理员账号可通知:submission={}", sub.getId());
return;
}
for (Long uid : admins) {
safeNotify(NotificationService.NotifyRequest.ofTemplate(
uid, CollectMessage.BIZ_REJECT,
"AUDIT_ESCALATE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"姓名", nameOfSubmission(sub),
"驳回原因", reason),
"有一条填报单需要你介入",
CollectMessage.LINK_SUBMISSION, sub.getId(), null));
}
} catch (RuntimeException e) {
log.warn("升级通知管理员失败(不影响驳回结果):submission={}", sub.getId(), e);
}
}
/**
@@ -946,10 +1030,18 @@ public class AuditService {
boolean pushSent = false;
if (sub.getSubmitterId() != null) {
try {
var r = notificationService.notify(NotificationService.NotifyRequest.of(
// 文案走模板(FR-1002):管理员在「通知模板」页改了「被退回」那句,
// 学生收到的就是改过的那句 —— 而不是这里硬编码的旧文案(《08》F-101)。
// 提交进度/材料名这些模板表达不了的细节,用 extra 追加。
var r = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
sub.getSubmitterId(), CollectMessage.BIZ_REJECT,
"AUDIT_REJECT",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(sub.getTaskId()),
"驳回原因", fullText,
"材料名", materialTextOf(req.getMaterialCodes()),
"姓名", nameOfSubmission(sub)),
"你的材料被退回了",
fullText,
CollectMessage.LINK_SUBMISSION, sub.getId(),
NotificationService.TPL_AUDIT_RESULT));
pushSent = r.pushSent();
@@ -959,9 +1051,10 @@ public class AuditService {
}
if (escalated) {
// 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。
// 驳回次数达上限:这条留给管理员,所以**也通知管理员**(不只是写日志)。
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
submissionId, sub.getRosterId(), rejectCount, code);
notifyEscalated(sub, fullText);
}
log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}",
submissionId, node, rejectCount, code, pushSent);
@@ -43,7 +43,7 @@ public class CleanupController {
private final CleanupImportService importService;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "创建整理任务(上传压缩包)",
description = "用**与正式收集完全相同的规则**做规范化。"
+ "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
@@ -66,7 +66,7 @@ public class CleanupController {
}
@PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
+ "方便自动化脚本与排障,不必手搓 multipart")
@@ -133,7 +133,7 @@ public class CleanupController {
}
@PostMapping("/{id}/import")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "把整理结果导入为正式任务",
description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
+ "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
@@ -143,7 +143,7 @@ public class CleanupController {
}
@PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.CLEANUP_RUN)
@Operation(summary = "只解析不落库(排障用)",
description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
@@ -16,6 +16,8 @@ import cn.tianbaotong.fill.module.fill.service.SubmissionValidator;
import cn.tianbaotong.fill.module.file.storage.StorageService;
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
import cn.tianbaotong.fill.module.material.service.MaterialDefService;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import cn.tianbaotong.fill.module.notify.service.NotificationService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
@@ -114,6 +116,7 @@ public class ExportService {
private final SubmissionValidator validator;
private final MaterialDefService materialDefService;
private final StorageService storageService;
private final NotificationService notificationService;
private final FillProperties props;
/**
@@ -682,6 +685,43 @@ public class ExportService {
log.info("导出完成 exportId={} 人数={} 分卷={} 包体={}MB(原始 {}MB)",
exportId, total, volumes.size(), round1(size / 1024.0 / 1024.0),
round1(written / 1024.0 / 1024.0));
// ★ 通知发起人(《08》F-103):打包是异步的(几百份材料要十几秒到几分钟),
// 在此之前用户只能守着进度条 —— 而 `collect_message` 的 bizType 6
// (导出完成)一直没有代码写过,消息中心那个分类永远是空的。
notifyExportFinish(exportId, true, null);
}
/**
* 导出结束的通知(成功与失败都发)。
*
* <p>⚠️ **正文里不放下载链接**,只让他去「导出中心」取。
* 理由不是省事:七牛是私有空间,链接是**签名 URL**,而站内信是落库的 ——
* 把签名链接写进消息正文就等于"URL 入库",与《AGENTS.md》里
* 「URL 不能入库、不能发出去让人明天再打开」直接冲突(那个链接几十分钟后就失效)。
* 内置模板里原本写着 {@code {下载链接}},本轮一并改掉了(见《08》F-103)。
*/
private void notifyExportFinish(Long exportId, boolean ok, String failReason) {
try {
CollectExportTask t = exportTaskMapper.selectById(exportId);
if (t == null || t.getCreateBy() == null) {
return;
}
notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
t.getCreateBy(), CollectMessage.BIZ_EXPORT,
"EXPORT_DONE",
NotificationService.NotifyRequest.vars(
"导出包名", t.getExportName() == null ? "导出包" : t.getExportName()),
ok ? "导出完成" : "导出失败",
CollectMessage.LINK_NONE, null, null)
.withExtra(ok
? "共 " + nzInt(t.getFileCount()) + " 份材料,请到「导出中心」下载。"
: "原因:" + (failReason == null ? "打包失败" : failReason)
+ "。请到「导出中心」查看后重试。"));
} catch (RuntimeException e) {
// 通知失败不影响导出结果本身(与审核通知同一口径)
log.warn("导出完成的通知没发出去 exportId={}", exportId, e);
}
}
private long nzLong(Long v) {
@@ -704,6 +744,9 @@ public class ExportService {
} catch (RuntimeException inner) {
log.error("标记导出失败时又出错 exportId={}", exportId, inner);
}
// 失败也要通知:否则用户会一直等一个永远不会完成的包
notifyExportFinish(exportId, false,
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
}
/**
@@ -42,7 +42,7 @@ public class FormSchemaController {
}
@PutMapping
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.FORM_MANAGE)
@Operation(summary = "保存任务表单定义(版本化)",
description = "每次保存插入一个新版本,旧版本保留。baseVersion 与当前最新不符时返回 40025,"
+ "并在 data 里带上最新定义供前端做差异合并(响应格式:{code:40025, data:最新schema})。"
@@ -78,6 +78,49 @@ public interface CollectJoinRequestMapper extends BaseMapper<CollectJoinRequest>
}
}
/**
* 该通知谁去审批这条申请(《08》F-102:以前一个都不通知)。
*
* <p>三类人,去重后返回:
* <ol>
* <li>这个任务覆盖班级的**班主任**({@code collect_task_org.head_user_id});</li>
* <li>这些班级的**副班主任**({@code deputy_user_ids} 存的是
* {@code ,1,2,} 这种带逗号的串,所以要用 {@code LIKE '%,id,%'} 判);</li>
* <li>本租户的**学校管理员** —— 兜底:任务没配班主任时也得有人管这件事。</li>
* </ol>
*
* <p>⚠️ 不去查"谁有 join:audit 权限":权限是"能不能做",收件人是"该谁做"。
* 一个学校有 50 个班主任,把申请推给全部 50 个等于推给没人。
*
* <p>⚠️ {@code collect_task_org} **没有 {@code deleted} 列**(V1 建表就没给,
* 全项目只有 5 张表这样,见《08》F-14 的排查记录)—— 所以这里不能写
* {@code cto.deleted = 0},否则整条 SQL 直接报"列不存在"。
*/
@Select("""
SELECT DISTINCT uid FROM (
SELECT cto.head_user_id AS uid
FROM collect_task_org cto
WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId}
AND cto.status = 1
UNION
SELECT CAST(TRIM(pid) AS BIGINT) AS uid
FROM collect_task_org cto,
LATERAL regexp_split_to_table(COALESCE(cto.deputy_user_ids, ''), ',') AS pid
WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId}
AND cto.status = 1
AND TRIM(pid) <> ''
UNION
SELECT u.id AS uid
FROM sys_user u
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0
WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1
AND ro.role_code = 'SCHOOL_ADMIN'
) x
WHERE uid IS NOT NULL
""")
List<Long> auditUserIdsOfTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/**
* 审批通过后回写申请单。
*
@@ -8,6 +8,8 @@ import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import cn.tianbaotong.fill.module.join.mapper.CollectJoinRequestMapper;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import cn.tianbaotong.fill.module.notify.service.NotificationService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
@@ -74,6 +76,7 @@ public class JoinService {
private final TaskMapper taskMapper;
private final UserMapper userMapper;
private final SensitiveCodec codec;
private final NotificationService notificationService;
// ── 学生侧 ─────────────────────────────────────
@@ -155,6 +158,7 @@ public class JoinService {
joinMapper.insert(r);
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
notifyAuditors(task, r, name);
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
vo.setRequestId(r.getId());
@@ -393,20 +397,82 @@ public class JoinService {
}
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
// ⚠️ 通知(M5-12)还没做:只能靠老师口头/群里说一声。
// 这里如实回 false,**不为了让契约好看而写 true**(同驳回接口的口径)。
log.warn("加入申请已通过但**没有发出通知**(通知渠道未实现):request={} 需人工告知学生", id);
// ★ 通知学生(《08》F-102):以前这里只写一行 log.warn,
// 学生要自己反复进小程序看结果 —— 而这条链路的另一端(通知渠道)
// 早在 M5 就做好了,只是没接上。
boolean notifySent = false;
if (r.getApplicantUid() != null) {
try {
var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_APPROVE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(r.getTaskId()),
"姓名", name),
"加入名单申请已通过",
CollectMessage.LINK_TASK, r.getTaskId(),
NotificationService.TPL_JOIN_APPROVE)
.withExtra("你现在可以扫码核验身份,然后开始填报了。"));
notifySent = nr.pushSent();
} catch (RuntimeException e) {
log.warn("加入申请通过的通知失败(不影响审批结果):request={}", id, e);
}
}
JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
vo.setRosterId(row.getId());
vo.setRosterCreated(true);
vo.setNotifySent(false);
// ⚠️ 语义与驳回接口一致:这是"**微信推送**发出去了没有"。
// 站内信一定写得进去,所以学生一定能在消息中心看到结果。
vo.setNotifySent(notifySent);
vo.setCanFillNow(r.getApplicantUid() != null);
return vo;
}
/**
* 通知该审批这条申请的人(班主任 / 副班主任 / 学校管理员兜底)。
*
* <p>《08》F-102:提交申请之后**一个通知都没有** —— 老师只能自己想到去翻审批页。
* 而"申请加入名单"本来就是一条"学生等着、老师不知道"的链路,
* 没有通知等于这条路走不通(学生会一直等,或者去问班主任"你看到我申请了吗")。
*/
private void notifyAuditors(CollectTask task, CollectJoinRequest r, String name) {
try {
var uids = joinMapper.auditUserIdsOfTask(UserContext.tenantId(), r.getTaskId());
if (uids.isEmpty()) {
log.warn("加入申请无人可通知(该任务没配班主任、租户也没有管理员):request={}", r.getId());
return;
}
for (Long to : uids) {
notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
to, CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_SUBMIT",
NotificationService.NotifyRequest.vars(
"任务名", task.getTaskName(),
"姓名", name,
"班级", r.getOrgHint()),
"有新的加入名单申请",
// 跳任务而不是跳申请单:审批页是按任务筛的(学生端 SCENE 也只带 taskId)
CollectMessage.LINK_TASK, r.getTaskId(), null));
}
log.info("加入申请已通知 {} 位审批人:request={}", uids.size(), r.getId());
} catch (RuntimeException e) {
log.warn("加入申请的通知失败(不影响学生提交):request={}", r.getId(), e);
}
}
private String taskNameOf(Long taskId) {
CollectTask t = taskId == null ? null : taskMapper.selectById(taskId);
return t == null ? null : t.getTaskName();
}
private String nz(String s) {
return s == null ? "" : s;
}
/** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */
private Long matchOrgByName(String orgHint) {
if (orgHint == null || orgHint.isBlank()) {
@@ -449,12 +515,33 @@ public class JoinService {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason);
log.warn("加入申请已驳回但**没有发出通知**(通知渠道未实现):request={}", id);
// ★ 通知学生(《08》F-102):驳回原因学生必须看到,
// 否则他会以为"申请丢了"而反复重提(每人每任务只能提 2 次)。
boolean notifySent = false;
if (r.getApplicantUid() != null) {
try {
var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT,
"JOIN_APPROVE",
NotificationService.NotifyRequest.vars(
"任务名", taskNameOf(r.getTaskId()),
"姓名", r.getRealName(),
"驳回原因", reason),
"加入名单申请未通过",
CollectMessage.LINK_TASK, r.getTaskId(),
NotificationService.TPL_JOIN_APPROVE)
.withExtra("原因:" + reason));
notifySent = nr.pushSent();
} catch (RuntimeException e) {
log.warn("加入申请驳回的通知失败(不影响驳回结果):request={}", id, e);
}
}
JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
vo.setNotifySent(false);
vo.setNotifySent(notifySent);
return vo;
}
}
@@ -47,7 +47,7 @@ public class MaterialDefController {
}
@PostMapping("/tasks/{id}/materials")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "新增材料项",
description = "材料码在任务内唯一;不传名称/别名/压缩档时会按 §2.1 的材料目录补齐默认值。"
+ "压缩档位参数(固定尺寸/分辨率下限/目标DPI/输出格式)由档位决定,客户端不必也不应传")
@@ -57,7 +57,7 @@ public class MaterialDefController {
}
@PutMapping("/materials/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "修改材料项", description = "材料码不可修改(它是文件的关联键)")
public ApiResult<Void> update(@PathVariable Long id,
@Valid @RequestBody MaterialDefSaveReq req) {
@@ -66,7 +66,7 @@ public class MaterialDefController {
}
@DeleteMapping("/materials/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "删除材料项",
description = "已经上传过文件的材料项不可删除(40027);如果只是不想再收,"
+ "请设为选传或加条件显示规则隐藏")
@@ -76,7 +76,7 @@ public class MaterialDefController {
}
@PostMapping("/tasks/{id}/materials/apply-template")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.MATERIAL_MANAGE)
@Operation(summary = "按内置模板初始化材料清单",
description = "幂等:已存在的材料码会跳过。模板见 §7.10 的三个 TPL_*")
public ApiResult<Map<String, Object>> applyTemplate(@PathVariable Long id,
@@ -32,6 +32,8 @@ public interface UrgeMapper {
private String orgName;
private Long taskId;
private String taskName;
/** 截止时间:模板里的 {@code {截止日}} 与 {@code {剩余天数}} 靠它(以前没取,那两个变量永远空)。 */
private java.time.LocalDateTime endTime;
private Integer submitStatus;
public Long getRosterId() { return rosterId; }
@@ -48,6 +50,8 @@ public interface UrgeMapper {
public void setTaskId(Long v) { this.taskId = v; }
public String getTaskName() { return taskName; }
public void setTaskName(String v) { this.taskName = v; }
public java.time.LocalDateTime getEndTime() { return endTime; }
public void setEndTime(java.time.LocalDateTime v) { this.endTime = v; }
public Integer getSubmitStatus() { return submitStatus; }
public void setSubmitStatus(Integer v) { this.submitStatus = v; }
}
@@ -68,6 +72,7 @@ public interface UrgeMapper {
o.org_name AS orgName,
t.id AS taskId,
t.task_name AS taskName,
t.end_time AS endTime,
r.submit_status AS submitStatus
FROM collect_roster r
JOIN collect_task t
@@ -105,6 +110,25 @@ public interface UrgeMapper {
@Param("orgIds") List<Long> orgIds,
@Param("scopeUserId") Long scopeUserId);
/**
* 这个任务的提交进度("{已提交}/{应填}"),给模板变量 {@code {提交进度}} 用。
*
* <p>契约 §12.7 的变量白名单里 {@code {提交进度}} 的适用场景就是催报 ——
* 而在此之前它**永远渲染成空**:内置模板写着"目前提交进度 {提交进度}",
* 没有任何调用方传过这个变量,于是学生收到的是"目前提交进度 ,请…"。
* 一个白名单里有、实际没人填的变量,与"定义了没接上"是同一类问题。
*
* <p>"已提交"按 {@code submit_status >= 2}(已提交 / 初审中 / 已通过…)算,
* 与班主任看板的已交率同一口径。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FILTER (WHERE r.submit_status >= 2) || '/' || COUNT(*)
FROM collect_roster r
WHERE r.tenant_id = #{tenantId} AND r.deleted = 0 AND r.task_id = #{taskId}
""")
String progressOf(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/** 一个名册项(定向催报要先确认"这个人归我管")。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
@@ -115,6 +139,7 @@ public interface UrgeMapper {
o.org_name AS orgName,
t.id AS taskId,
t.task_name AS taskName,
t.end_time AS endTime,
r.submit_status AS submitStatus
FROM collect_roster r
JOIN collect_task t
@@ -60,7 +60,21 @@ import java.util.Map;
@RequiredArgsConstructor
public class NotificationService {
/** 模板码(契约 §13.6 的表)。 */
/**
* <b>微信订阅消息</b>的模板码(契约 §13.6 的「模板码与索取时机」表)。
*
* <p>⚠️ 这套码与 {@link NotifyTemplates} 里那套<b>站内信</b>模板码是
* <b>两个命名空间</b>,别混:
* <ul>
* <li>这一套({@code SUBMIT_SUCCESS} …)是**微信后台建好的订阅消息模板**的键,
* 用途有两个:查 {@code collect_subscribe_quota} 的额度、从
* {@code fill.mini-program.templates} 里取微信那边的 {@code templateId};</li>
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
* </ul>
* 两套里 {@code JOIN_APPROVE} 恰好**同名**,纯属巧合 ——
* 下一个改代码的人很容易当成同一个东西,所以在这里写清楚(见《08》F-106)。
*/
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
@@ -72,10 +86,21 @@ public class NotificationService {
private final NotifyRecordMapper notifyRecordMapper;
private final UserMapper userMapper;
private final WxMiniProgramService wxService;
private final NotifyTextResolver textResolver;
/**
* 发一条通知。
*
* <h3>文案从哪来:模板优先(FR-1002)</h3>
*
* <p>{@code req.subjectCode()} 非空时,标题与正文**以模板为准** ——
* 库里改过就用库里的、没改过用内置的({@link NotifyTextResolver})。
* 调用方传的 {@code title/content} 只在"模板码不认识 / 取模板失败"时兜底。
* 这样「通知模板」页改完就真的生效,包括驳回、通过、催报这些自动通知。
*
* <p>模板**被停用**时这条通知**不发**(这正是那个开关的意义),
* 并把原因放进 {@code failReason} 让调用方如实回 {@code notifySent=false}。
*
* @param req 内容与收件人(见 {@link NotifyRequest})
* @return 结果:{@code pushSent} 是"微信推送有没有发出去",
* {@code messageId} 是站内信 id(**一定存在**,除非写库本身失败)
@@ -84,13 +109,39 @@ public class NotificationService {
Long tenantId = req.tenantId() == null ? UserContext.tenantId() : req.tenantId();
Long userId = req.userId();
// ⓪ 文案:模板优先。这一步必须在写站内信之前 —— 模板停用意味着"根本不发"。
String title = req.title();
String content = req.content();
if (req.subjectCode() != null) {
NotifyTextResolver.Resolved r =
textResolver.resolve(req.subjectCode(), req.taskIdOrNull(), req.subjectVars());
if (r == null) {
log.warn("不认识的通知模板码 {},退回调用方给的文案(bizType={})",
req.subjectCode(), req.bizType());
} else if (!r.enabled()) {
// 停用是用户的显式选择:不发,也不写站内信与发送记录。
// 调用方会因此回 notifySent=false + needManualNotice=true,
// 于是老师知道"系统没发,你得自己说一声"。
return new NotifyResult(null, false,
"通知模板「" + req.subjectCode() + "」已被停用,这条通知未发出", null);
} else {
title = r.title();
content = r.content();
}
}
if (req.extraText() != null && !req.extraText().isBlank()) {
// 模板之外还要补一句(班级、老师附言、"今天是最后一天"这类上下文):
// 追加而不是替换 —— 模板是权威,但模板表达不了"这一次"的临时信息
content = (content == null ? "" : content + "\n") + req.extraText().trim();
}
// ① 先写站内信 —— 这是"用户一定能看到"的那条路,所以放在最前面。
// 顺序很关键:先试推送再写站内信的话,推送抛异常就可能把站内信也丢了。
CollectMessage msg = new CollectMessage();
msg.setUserId(userId);
msg.setBizType(req.bizType());
msg.setTitle(clip(req.title(), 128));
msg.setContent(clip(req.content(), 1024));
msg.setTitle(clip(title, 128));
msg.setContent(clip(content, 1024));
msg.setLinkType(req.linkType() == null ? CollectMessage.LINK_NONE : req.linkType());
msg.setLinkId(req.linkId());
msg.setIsRead(0);
@@ -115,7 +166,7 @@ public class NotificationService {
} else {
try {
wxService.sendSubscribeMessage(openid, wxTemplateIdOf(templateCode),
"pages/tasks/tasks", buildTemplateData(req));
"pages/tasks/tasks", buildTemplateData(req, title, content));
pushSent = true;
// 落地通道改成微信推送(站内信仍然在,学生随时能回来看)
messageMapper.update(null, Wrappers.<CollectMessage>lambdaUpdate()
@@ -137,7 +188,9 @@ public class NotificationService {
// ③ 如实记录发送通道结果(collect_notify_record 的定位就是这个)
// 注意:站内信本身"发成功"了,所以 status 记 1;推送那条另记一行。
recordNotify(tenantId, req, pushSent ? CollectMessage.CHANNEL_WECHAT_SUB
// ⚠️ 标题要记**渲染后**的那一份:用户改过模板时,
// "记录里是旧文案、消息中心是新文案"是查不出来的那种不一致。
recordNotify(tenantId, req, title, pushSent ? CollectMessage.CHANNEL_WECHAT_SUB
: CollectMessage.CHANNEL_IN_APP, pushSent, skippedNoQuota, failReason);
return new NotifyResult(msg.getId(), pushSent, failReason, msg.getChannel());
@@ -153,20 +206,78 @@ public class NotificationService {
public record NotifyResult(Long messageId, boolean pushSent, String failReason, String channel) {
}
/** 通知请求。 */
/**
* 通知请求。
*
* @param subjectCode 站内信**文案模板**码({@link NotifyTemplates},如 {@code AUDIT_REJECT})。
* 非空时标题与正文以模板为准(FR-1002)。为 {@code null} 表示"用调用方给的文案"
* @param subjectVars 模板变量(白名单见 {@link NotifyTemplates#VARIABLES})
* @param extraText 模板之外要补的一句(班级、老师附言、"今天是最后一天")。追加,不替换
* @param templateCode **微信订阅消息**的模板码(契约 §13.6,如 {@code AUDIT_RESULT})——
* 与 {@code subjectCode} 不是一回事,见 {@link #TPL_AUDIT_RESULT} 的注释
*/
public record NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode,
Map<String, String> templateParams) {
Map<String, String> templateParams,
String subjectCode, Map<String, String> subjectVars, String extraText) {
/** 旧签名的兼容构造:不带站内信模板时走"调用方给文案"的老路(无模板可用)。 */
public NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode,
Map<String, String> templateParams) {
this(tenantId, userId, bizType, title, content, linkType, linkId, templateCode,
templateParams, null, Map.of(), null);
}
/** 常用构造:学生端(带跳转)。{@code linkId} 在 {@code linkType=TASK} 时就是 taskId。 */
public static NotifyRequest of(Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode) {
return new NotifyRequest(null, userId, bizType, title, content, linkType, linkId,
templateCode, Map.of());
templateCode, Map.of(), null, Map.of(), null);
}
/**
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务)。
* 按模板发的常用构造:文案由模板决定,调用方只给变量。
*
* <p>{@code fallbackTitle} 只在模板取不到时兜底(正常情况下不会用到)。
*/
public static NotifyRequest ofTemplate(Long userId, int bizType, String subjectCode,
Map<String, String> vars, String fallbackTitle,
String linkType, Long linkId, String templateCode) {
return new NotifyRequest(null, userId, bizType, fallbackTitle, fallbackTitle,
linkType, linkId, templateCode, Map.of(),
subjectCode, vars == null ? Map.of() : vars, null);
}
/** 加一句模板之外的补充(不可变,返回新对象)。 */
public NotifyRequest withExtra(String extra) {
return new NotifyRequest(tenantId, userId, bizType, title, content, linkType, linkId,
templateCode, templateParams, subjectCode, subjectVars, extra);
}
/**
* 拼模板变量:`vars("任务名", name, "姓名", realName)` —— **null 值直接跳过**。
*
* <p>⚠️ 不要用 {@code Map.of(...)} 拼这个:它**不接受 null 值**(连 key 都不接受),
* 而"这个学生查不到姓名"是完全正常的情况 —— 于是它会抛 NPE,
* 而通知调用外面通常包着"通知失败不影响业务"的 try/catch →
* 表现是**一条通知都没发,日志里只有一行 warn**(这个坑在本轮真踩了一次:
* `Map.of("姓名", null)` → 驳回通知全丢,而驳回本身照样成功)。
* 模板渲染对"变量没有值"的处理本来就是渲染成空(见 {@link NotifyTemplates#render}),
* 所以跳过才是对的口径。
*/
public static Map<String, String> vars(String... kv) {
Map<String, String> m = new LinkedHashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) {
if (kv[i] != null && kv[i + 1] != null) {
m.put(kv[i], kv[i + 1]);
}
}
return m;
}
/**
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务;模板也要按任务取覆盖)。
*
* <p>从 {@code linkType} 推:跳任务是任务本身,跳填报单则要靠调用方另外传
* (这里返回 null 也行,只是那条记录没法按任务筛)。
@@ -184,14 +295,14 @@ public class NotificationService {
* 运营要看的是"多少人因为没授权而收不到推送"(该去引导授权),
* 而不是"推送失败了多少次"(那要查通道故障)。两者混在一起就分不出来。
*/
private void recordNotify(Long tenantId, NotifyRequest req, String channel,
private void recordNotify(Long tenantId, NotifyRequest req, String title, String channel,
boolean ok, boolean skippedNoQuota, String failReason) {
try {
// biz_type 是 varchar(32)(V1 的 DDL),所以要转字符串 —— 见 NotifyRecordMapper
int status = ok ? 1 : (skippedNoQuota ? 3 : 2);
int n = notifyRecordMapper.insertRecord(tenantId, req.taskIdOrNull(),
String.valueOf(req.bizType()),
req.userId(), channel, req.templateCode(), req.title(), status,
req.userId(), channel, req.templateCode(), title, status,
clip(failReason, 512));
if (n <= 0) {
log.warn("通知发送记录没写进去(返回 {} 行):user={} biz={}", n, req.userId(), req.bizType());
@@ -210,14 +321,14 @@ public class NotificationService {
* 所以只能由客户在微信后台建好模板后,用 {@code templateParams} 传进来。
* 这里做一个兜底:只有内容没有参数时,用通用字段名 —— 发不出去也不影响站内信。
*/
private Map<String, Object> buildTemplateData(NotifyRequest req) {
private Map<String, Object> buildTemplateData(NotifyRequest req, String title, String content) {
Map<String, Object> data = new LinkedHashMap<>();
if (req.templateParams() != null && !req.templateParams().isEmpty()) {
req.templateParams().forEach((k, v) -> data.put(k, Map.of("value", v)));
return data;
}
data.put("thing1", Map.of("value", clip(req.title(), 20)));
data.put("thing2", Map.of("value", clip(req.content(), 20)));
data.put("thing1", Map.of("value", clip(title, 20)));
data.put("thing2", Map.of("value", clip(content, 20)));
return data;
}
@@ -62,13 +62,20 @@ public final class NotifyTemplates {
"{任务名} 的填报已审核通过,无需再操作。", "终审通过"),
new Def("URGE", 5, "请尽快完成填报",
"{任务名} 目前提交进度 {提交进度},请在 {截止日} 前完成填报。", "催报"),
new Def("EXPORT_DONE", 6, "导出完成",
"导出包 {导出包名} 已生成,请及时下载:{下载链接}(链接有有效期,过期请重新导出)",
new Def("URGE_DRAFT", 5, "你的填报还没有提交",
"你已经开始填写《{任务名}》,但还没有提交。填完记得点「提交」。",
"催报(只建了草稿、没提交的人)"),
new Def("EXPORT_DONE", 6, "{导出包名} 已生成",
"导出包 {导出包名} 已生成,请到「导出中心」下载。"
+ "(下载链接有有效期,过期请重新导出)",
"导出完成"),
new Def("JOIN_APPROVE", 7, "加入名单申请已处理",
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员")
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员"),
new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
"驳回次数达上限时发给学校管理员(契约 §11.4)")
);
public static List<Def> all() {
@@ -87,6 +94,13 @@ public final class NotifyTemplates {
/** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */
public static final Set<Integer> BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7);
/** 模板码全集(脚本与测试用它核对"每个内置模板都有发送点")。 */
public static Set<String> allCodes() {
Set<String> out = new java.util.LinkedHashSet<>();
BUILTIN.forEach(d -> out.add(d.code()));
return out;
}
/**
* 渲染模板。
*
@@ -0,0 +1,131 @@
package cn.tianbaotong.fill.module.notify.service;
import cn.tianbaotong.fill.module.notify.entity.CollectNotifyTemplate;
import cn.tianbaotong.fill.module.notify.mapper.NotifyTemplateMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 按模板码取「真正要发出去的那段文案」。
*
* <h3>为什么需要这个类(FR-1002 的兑现点)</h3>
*
* <p>《08-完成度盘点》F-101:通知模板页能改文案,但**只有"手工发送"走模板** ——
* 驳回、审核通过、催报、临期提醒这些**自动**通知把文案硬编码在各自的 Service 里,
* 于是管理员把「驳回通知」改成自己学校的说法之后,学生收到的还是原来那句。
* 而 FR-1002 点名的 5 类通知(任务发布 / 临期提醒 / 驳回通知 / 审核通过 / 导出完成)
* **全是自动类** —— 也就是说这个 P0 需求当时只兑现了它没点名的那一半。
*
* <p>所以自动通知也必须先来这里取文案:<b>库里改过的 &gt; 内置的</b>,
* 与模板列表页({@code GET /notify/templates})看到的**完全同一份口径** ——
* 否则"界面上显示这句话、实际发出去另一句话"就是同一类问题的另一种形态。
*
* <h3>为什么不直接用 {@code NotifyTemplateService}</h3>
*
* <p>它是**反方向**的依赖:{@code NotifyTemplateService} 手工发送时要调
* {@code NotificationService},而 {@code NotificationService} 现在要在发送前取文案 ——
* 两个 Service 互相注入会形成循环依赖(Spring 允许用 {@code @Lazy} 糊过去,
* 但那是把"设计上分不开"这件事藏起来)。取文案只需要一张表,
* 所以单独放一个类,依赖是单向的:
* {@code NotificationService → NotifyTextResolver → NotifyTemplateMapper}。
*
* <h3>失败一律退回内置文案</h3>
*
* <p>查库失败(没有租户上下文、表被锁、连接抖动…)**绝不能变成"通知不发"**:
* 那会让一次本该校验通过的审核动作连带丢掉通知。所以这里吞掉异常,
* 退回内置文案 + 一条 warn。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotifyTextResolver {
private final NotifyTemplateMapper templateMapper;
/**
* 解析结果。
*
* @param enabled 模板有没有被停用(停用 = 这类通知不发,见 {@link #resolve} 的说明)
* @param customized 是不是用户改过的(用于日志与排查:"他收到的为什么是这句")
*/
public record Resolved(boolean enabled, String title, String content, boolean customized) {
}
/**
* 取文案。变量按白名单渲染(越界变量原样保留,见 {@link NotifyTemplates#render})。
*
* @param code 站内信模板码({@link NotifyTemplates} 里的那一套,如 {@code AUDIT_REJECT})
* @param taskId 任务级覆盖优先;没有就退到租户级;都没有就用内置
* @return 找不到这个模板码时返回 {@code null}(调用方回退到自己拼的文案)
*/
public Resolved resolve(String code, Long taskId, Map<String, String> vars) {
NotifyTemplates.Def def = NotifyTemplates.byCode(code);
CollectNotifyTemplate over = findOverride(code, taskId);
if (over == null) {
if (def == null) {
return null;
}
return new Resolved(true,
NotifyTemplates.render(def.title(), vars),
NotifyTemplates.render(def.content(), vars), false);
}
// 库里这一行只在"它自己那条"上覆盖内置:标题/内容为空时逐项退回内置,
// 而不是发出一封没有标题的消息(那在消息中心里就是一个空白条目)
String title = blank(over.getTitle())
? (def == null ? "" : def.title()) : over.getTitle();
String content = blank(over.getContent())
? (def == null ? "" : def.content()) : over.getContent();
boolean enabled = Integer.valueOf(CollectNotifyTemplate.ENABLED_YES).equals(over.getEnabled());
if (!enabled) {
log.info("通知模板 {} 已被停用,这条自动通知不发(task={})", code, taskId);
}
return new Resolved(enabled,
NotifyTemplates.render(title, vars),
NotifyTemplates.render(content, vars), true);
}
/**
* 取该模板码的覆盖行:**任务级优先于租户级**。
*
* <p>租户拦截器会补 {@code tenant_id},所以这里不用自己带租户条件 ——
* 但这也意味着**必须在租户上下文里调用**(自动通知都在:要么是租户内的用户动作,
* 要么是定时任务里 {@code TenantScope.callAs} 摆好的)。查不到/查失败都退回内置。
*/
private CollectNotifyTemplate findOverride(String code, Long taskId) {
try {
var q = Wrappers.<CollectNotifyTemplate>lambdaQuery()
.eq(CollectNotifyTemplate::getTemplateCode, code)
.eq(CollectNotifyTemplate::getChannel, CollectNotifyTemplate.CHANNEL_IN_APP);
if (taskId != null) {
q.and(w -> w.isNull(CollectNotifyTemplate::getTaskId)
.or().eq(CollectNotifyTemplate::getTaskId, taskId));
} else {
q.isNull(CollectNotifyTemplate::getTaskId);
}
List<CollectNotifyTemplate> rows = templateMapper.selectList(q);
CollectNotifyTemplate tenantLevel = null;
for (CollectNotifyTemplate row : rows) {
if (row.getTaskId() != null && row.getTaskId().equals(taskId)) {
return row;
}
tenantLevel = row;
}
return tenantLevel;
} catch (RuntimeException e) {
// 取模板失败不该让通知消失(退回内置文案)
log.warn("取通知模板失败,退回内置文案:code={} task={} 原因={}", code, taskId, e.toString());
return null;
}
}
private boolean blank(String s) {
return s == null || s.isBlank();
}
}
@@ -44,6 +44,17 @@ public class UrgeService {
/** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */
public static final String TPL_URGE = "NOTIFY_UNSUBMITTED";
/**
* 站内信模板码({@link NotifyTemplates} 那套)。
*
* <p>⚠️ 与上面的 {@link #TPL_URGE} **不是一回事**:那个是**微信订阅消息**的模板码
* (决定从哪取 {@code templateId}、扣哪一份额度),这里两个才是"学生实际读到的那句话"。
* 草稿与"一次都没填"是两条不同的话,所以是两个模板码 ——
* 合成一个就得在模板里写条件,而变量渲染做不到条件。
*/
public static final String SUBJECT_URGE = "URGE";
public static final String SUBJECT_URGE_DRAFT = "URGE_DRAFT";
/**
* 催报用的业务类型码 = {@code 5}。
*
@@ -216,9 +227,14 @@ public class UrgeService {
LocalDateTime now = LocalDateTime.now();
Set<Long> sentUsers = new LinkedHashSet<>();
int sent = 0;
String extra = daysLeft <= 1
? "今天是最后一天了,请务必今天提交。"
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
// 0 天 = 截止当日(FR-1003 要求"7/3/1 天 + 截止当日")。
// 三档文案必须分开:对"还有 1 天"和"就是今天"说同一句"今天是最后一天",
// 学生就无法判断到底还剩几个小时 —— 而那正是他此刻唯一需要知道的事。
String extra = daysLeft <= 0
? "今天是截止日,请在今天 24 点前提交,过后就不能再交了。"
: daysLeft == 1
? "今天是最后一天了,请务必今天提交。"
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
for (UrgeMapper.UrgeTarget t : targets) {
// 还没扫码核验的人**没有账号可发**,跳过(一键催报会给老师看这个理由,
// 自动催报没人看,所以只记 debug)
@@ -294,25 +310,40 @@ public class UrgeService {
/** 发一条催报。返回"跳过了没有 + 为什么"。 */
private NotifyOutcome send(UrgeMapper.UrgeTarget t, String templateCode, String extra,
boolean draft) {
String title = draft ? "你的填报还没有提交" : "请尽快完成填报";
StringBuilder content = new StringBuilder(draft
? "你已经开始填写《" + nz(t.getTaskName()) + "》,但还没有提交。填完记得点「提交」。"
: "你还没有完成《" + nz(t.getTaskName()) + "》的填报,请尽快完成。");
if (t.getOrgName() != null) {
content.append("(").append(t.getOrgName()).append(")");
}
if (extra != null && !extra.isBlank()) {
// 老师自己写的话原样附在后面 —— 它往往才是学生真正会看的那句
content.append("\n").append(extra.trim());
// 文案走模板(FR-1002 的同一口径):管理员在「通知模板」页改了「催报」那句,
// 学生收到的就是改过的那句。草稿与"一次都没填"是两条不同的话,
// 所以是两个模板码 —— 合成一个就得在模板里加条件,那是模板做不到的事。
java.util.Map<String, String> vars = new java.util.LinkedHashMap<>();
vars.put("任务名", nz(t.getTaskName()));
vars.put("姓名", t.getRealName());
vars.put("班级", t.getOrgName());
if (t.getTaskId() != null) {
vars.put("提交进度", progressOf(t.getTaskId()));
if (t.getEndTime() != null) {
vars.put("截止日", t.getEndTime().toLocalDate()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy年M月d日")));
long days = java.time.temporal.ChronoUnit.DAYS
.between(java.time.LocalDate.now(), t.getEndTime().toLocalDate());
vars.put("剩余天数", String.valueOf(Math.max(0, days)));
}
}
try {
var r = notificationService.notify(new NotificationService.NotifyRequest(
null, t.getUserId(), BIZ_URGE, title, content.toString(),
var r = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
t.getUserId(), BIZ_URGE,
draft ? SUBJECT_URGE_DRAFT : SUBJECT_URGE,
vars,
draft ? "你的填报还没有提交" : "请尽快完成填报",
CollectMessage.LINK_TASK, t.getTaskId(),
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode,
java.util.Map.of()));
// 站内信一定发出去了 → 算 sent。微信推送没出去只影响"他多久看到"
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode)
// 班级与老师附言是模板表达不了的"这一次"的信息,追加在后面
.withExtra(orgPart(t) + (extra == null || extra.isBlank() ? "" : extra.trim())));
// 站内信一定发出去了 → 算 sent;模板被停用则 messageId 为 null(什么都没发),
// 这时要如实归到"跳过"并给出理由,否则老师会以为催过了(《08》F-101 的连带)。
if (r.messageId() == null) {
return new NotifyOutcome(true, r.failReason());
}
// 微信推送没出去只影响"他多久看到"
return new NotifyOutcome(false, r.pushSent() ? null : r.failReason());
} catch (RuntimeException e) {
log.warn("催报失败:roster={} user={}", t.getRosterId(), t.getUserId(), e);
@@ -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) {
List<Long> mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid);
@@ -62,11 +62,24 @@ public interface ScheduleMapper {
List<Map<String, Object>> tasksToClose();
/**
* 截止日正好在某一天的任务(给"提前 7/3/1 天催报"用)。
* 截止日正好在某一天的任务(给"提前 7/3/1 天 + 截止当日催报"用)。
*
* <p>为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务,
* 用 `<=` 会让"还剩 2 天"的任务在 7 天、3 天、1 天各被催一次(因为 2 < 3 也成立)
* —— 学生一天收到三条,然后关掉通知。
*
* <p>⚠️⚠️ **必须比 `end_time::date`,不能直接比 `end_time = #{date}::date`**:
* `collect_task.end_time` 存的是**当天 23:59:59**(见 TaskService 的写入),
* 而 `'2026-10-08'::date` 会被 PG 当成 `2026-10-08 00:00:00` ——
* 两者**永远不相等**,于是这个查询**一条都查不出来**:
* 自动催报(FR-1003)在真实数据上从来没发出过任何一条通知,
* 而日志里只有"没有任务"的沉默(`if (!tasks.isEmpty())` 才打日志)。
*
* <p>这个 bug 之所以活到今天,是因为**测试夹具用 SQL 摆截止日**
* (`update collect_task set end_time = CURRENT_DATE + 3` 存进去的是 00:00:00 的日期型),
* 那种形状恰好能匹配 —— 夹具与真实数据的形状不一致,把 bug 盖住了
* (与《08》F-99「假 JPEG」是同一族:**夹具不像真东西,测出来的绿灯就不算数**)。
* 现在 `e2e-m5-notify-wiring.py` 走**真实接口**建任务(end_time 自然是 23:59:59)来守它。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
@@ -74,7 +87,8 @@ public interface ScheduleMapper {
FROM collect_task
WHERE deleted = 0
AND status = 1
AND end_time = #{date}::date
AND end_time IS NOT NULL
AND end_time::date = #{date}::date
ORDER BY id
""")
List<Map<String, Object>> tasksEndingOn(@Param("date") String date);
@@ -78,8 +78,14 @@ public class ScheduleJobs {
/** 审核超时提醒一轮最多处理多少条(防止某天积压上万条时把接口打爆)。 */
private static final int TIMEOUT_BATCH = 500;
/** 提前几天催报(PRD 附录 E 的「7/3/1 天规则」)。 */
private static final int[] URGE_DAYS_BEFORE = {7, 3, 1};
/**
* 提前几天催报(PRD 附录 E 的「7/3/1 天规则」**+ 截止当日**)。
*
* <p>⚠️ {@code 0} 那一档是 FR-1003 原文里的「+ 截止当日」,本轮补上(《08》F-104):
* 原先只有 7/3/1,于是**截止那天一声不响** —— 而那天是唯一"再不做就来不及"的日子。
* 缺的是最该有的那一档。
*/
private static final int[] URGE_DAYS_BEFORE = {7, 3, 1, 0};
/** 租户到期前提醒的天数(PRD 附录 E 的「30/7/1 天」)。 */
private static final int[] EXPIRE_REMIND_DAYS = {30, 7, 1};
@@ -39,7 +39,7 @@ public class PlanScopeController {
}
@PutMapping("/{id}/plan-scope")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.PLAN_IMPORT)
@Operation(summary = "确认 / 修正报考范围",
description = "rosterIds 的并集必须恰好覆盖名册全员,否则 40024 并返回差集 —— "
+ "漏一个人,导出包就会缺人,而人工核对发现不了")
@@ -167,7 +167,7 @@ public class TaskController {
// ══════════════════ 克隆 ══════════════════
@PostMapping("/{id}/clone")
@RequirePerm(PermissionDef.TASK_MANAGE)
@RequirePerm(PermissionDef.TASK_CLONE)
@Operation(summary = "克隆任务",
description = "克隆**配置**(表单 / 材料清单 / 命名模板 / 审核配置 / 覆盖班级),"
+ "**刻意不克隆业务数据**(名册 / 填报单 / 材料 / 审核记录)。"
@@ -36,7 +36,7 @@ public class TenantController {
private final TenantService tenantService;
@GetMapping
@RequirePerm(PermissionDef.TENANT_MANAGE)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户列表")
public ApiResult<PageResult<TenantVO>> list(
@RequestParam(required = false) String keyword,
@@ -48,7 +48,7 @@ public class TenantController {
}
@GetMapping("/{id}")
@RequirePerm(PermissionDef.TENANT_MANAGE)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户详情")
public ApiResult<TenantVO> detail(@PathVariable Long id) {
return ApiResult.ok(tenantService.detail(id));
@@ -64,7 +64,7 @@ public class TenantController {
@PutMapping("/{id}")
@RequirePerm(PermissionDef.LOG_VIEW_ALL)
@RequirePerm(PermissionDef.TENANT_MANAGE)
@Operation(summary = "修改租户 / 套餐(§6.5)",
description = "⚠️ `tenantCode` **不允许改**:它出现在域名解析与对接文档里,"
+ "改了会让已经发出去的文档与配好的域名全部失效,"
@@ -77,7 +77,7 @@ public class TenantController {
}
@GetMapping("/{id}/usage")
@RequirePerm(PermissionDef.LOG_VIEW_ALL)
@RequirePerm(PermissionDef.TENANT_VIEW)
@Operation(summary = "租户用量统计(§6.7)",
description = "`period` 支持 `2026-09`(月)与 `2026Q3`(季);不传是当月。"
+ "⚠️ `ocrCount` / `smsCount` **恒为 0 并带 notes 说明** —— "
@@ -119,4 +119,22 @@ public interface UserMapper extends BaseMapper<SysUser> {
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
""")
Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
/**
* 本租户下持某个内置角色的用户 id(按角色找,而不是按固定账号 —— 换管理员不该改代码)。
*
* <p>用途:需要"管理员介入"的自动通知的收件人(例如驳回次数达上限,契约 §11.4)。
* 与 `ScheduleMapper.usersOfRole` 的区别:那条是**跨租户**的定时任务用的(忽略租户拦截器),
* 这条在租户上下文里用、租户条件显式给,并与拦截器互为印证。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT DISTINCT u.id
FROM sys_user u
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0
WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1
AND ro.role_code = #{roleCode}
""")
List<Long> userIdsOfRole(@Param("tenantId") Long tenantId, @Param("roleCode") String roleCode);
}
@@ -42,7 +42,13 @@ public enum BuiltinRole {
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task", "form", "material",
"roster", "join", "audit", "export", "cleanup", "log")),
"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,