diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 36ac36c..2fc347c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -2,8 +2,8 @@
# GitLab CI(M0-04 / M7-10)
#
# 设计原则:**流水线跑的必须是我们真正依赖的那套验证**。
-# 这个仓库没有单元测试(`src/test` 是空的),验证靠三样东西:
-# ① 三个机械校验脚本(契约对账 / 实体列 / 文档一致性)
+# 这个仓库的单元测试很少(`src/test` 只有 12 条纯函数),验证靠三样东西:
+# ① 五个机械校验脚本(契约对账 / 实体列 / 文档一致性 / 手册文案 / **接线**)
# ② 编译与构建(后端 jar、PC、H5)
# ③ **`scripts/e2e-*.py` 打真实接口 + 查库断言**(1500+ 条)
# 所以 e2e 阶段不是"锦上添花",它是这个项目的主验证手段 ——
@@ -58,6 +58,8 @@ variables:
- python scripts/verify-docs.py
- echo "── 手册里引用的错误码与文案是否真的存在(客服照着它回答) ──"
- python scripts/check-manual.py
+ - echo "── 声明了但没接上的东西(权限码 / 错误码 / 通知模板 / 菜单键) ──"
+ - python scripts/check-wiring.py --quiet
- echo "── 全部 e2e 脚本的语法(引号写错会当场红,而不是跑到一半才红) ──"
- |
python - <<'PY'
diff --git a/AGENTS.md b/AGENTS.md
index 221a05d..99021ba 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,15 +83,15 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
## 三、怎么验证(本仓库最不显然的一点)
-**后端没有单元测试**(`src/test` 是空的)。验证靠 `scripts/e2e-*.py`:
-打真实接口 + `docker exec` 进 PG 查库断言。
+**后端的单元测试很少**(`backend/*/src/test` 只有 12 条纯函数断言,`mvn test` 跑)。
+主验证手段是 `scripts/e2e-*.py`:打真实接口 + `docker exec` 进 PG 查库断言。
```bash
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
```
-- 全量:把 `scripts/e2e-*.py` 依次跑一遍(`e2e-m0*` / `e2e-m1-*` / `e2e-m2-roster` / `e2e-m3-*`),
- 目前合计 613 条断言
+- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1620 条断言**),
+ 或一条命令 `python scripts/verify-all.py --with-e2e`
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
- 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转
@@ -103,6 +103,13 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
`python scripts/dev-clean-test-users.py`、`python scripts/dev-clean-qiniu.py`
(清真实桶里 `xiaotutongxue/` 前缀的测试对象,**默认 dry-run**,删前先核实库中零引用)
- 文档一致性:`python scripts/verify-docs.py`
+- 接线检查:`python scripts/check-wiring.py`(**"声明了但没接上"** ——
+ 权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、
+ `bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上)。
+ ⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
+ 只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)
+- 一条命令跑完上面这些:`python scripts/verify-all.py --with-e2e`
+ (9 项;**跳过项会如实列出来**,不是静默跳过)
## 四、接口与数据契约(不显然、容易做错)
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java
index 73709c8..7ac10d5 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java
@@ -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();
+ }
+
+ /**
+ * 姓名(模板变量用)。
+ *
+ *
**先取名册上的名字**:那才是"他在这个任务里叫什么"(老师导入的名单),
+ * 而账号里的 {@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 codes) {
+ if (codes == null || codes.isEmpty()) {
+ return "全部材料";
+ }
+ return String.join("、", codes);
+ }
+
+ /**
+ * 驳回次数达上限:**通知该校的管理员**(契约 §11.4「同时通知管理员」)。
+ *
+ * 《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);
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java
index 6abb02c..95e5549 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java
@@ -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 = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java
index bbfc5db..d657173 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java
@@ -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);
+ }
+
+ /**
+ * 导出结束的通知(成功与失败都发)。
+ *
+ *
⚠️ **正文里不放下载链接**,只让他去「导出中心」取。
+ * 理由不是省事:七牛是私有空间,链接是**签名 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());
}
/**
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java
index 1857e74..4e69adf 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java
@@ -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})。"
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java
index 4c886c6..f18002e 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/mapper/CollectJoinRequestMapper.java
@@ -78,6 +78,49 @@ public interface CollectJoinRequestMapper extends BaseMapper
}
}
+ /**
+ * 该通知谁去审批这条申请(《08》F-102:以前一个都不通知)。
+ *
+ * 三类人,去重后返回:
+ *
+ * - 这个任务覆盖班级的**班主任**({@code collect_task_org.head_user_id});
+ * - 这些班级的**副班主任**({@code deputy_user_ids} 存的是
+ * {@code ,1,2,} 这种带逗号的串,所以要用 {@code LIKE '%,id,%'} 判);
+ * - 本租户的**学校管理员** —— 兜底:任务没配班主任时也得有人管这件事。
+ *
+ *
+ * ⚠️ 不去查"谁有 join:audit 权限":权限是"能不能做",收件人是"该谁做"。
+ * 一个学校有 50 个班主任,把申请推给全部 50 个等于推给没人。
+ *
+ *
⚠️ {@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 auditUserIdsOfTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
+
/**
* 审批通过后回写申请单。
*
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java
index a29889a..dad769f 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/join/service/JoinService.java
@@ -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;
}
+ /**
+ * 通知该审批这条申请的人(班主任 / 副班主任 / 学校管理员兜底)。
+ *
+ * 《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;
}
}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java
index 4ff0fcb..c5c931d 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/material/controller/MaterialDefController.java
@@ -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 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