feat(notify): 消息中心、催报、通知模板与微信回调 + 校验问题清单(V8 / V14)

消息中心 + 订阅额度(原子扣减,先扣再发——订阅消息是一次性授权)+ 催报(定向/一键,
按 bizType 去重 24 小时);通知降级链只做能真正走通的两级(订阅消息 → 站内信),
公众号模板与短信如实少做并写明理由:永远失败的通道会把真正的原因淹掉。

校验问题清单对齐语义:已有→只更新内容不碰 resolved / 新增→插入 / 消失→标已解决;
上传时把「系统改过文件名」记成 VAL-06 自动修复,让 byRule.autoFixed 有真实数据。

微信回调必须偏离两条全局约定(匿名入口、响应体是 {"errcode":0}):只写推送状态不读
业务数据 + 必须验签 + 凭发出时记的 id 反查租户(查不到就报错,绝不猜一个租户去写)。

验证:e2e-m5-notify 48/48、e2e-m5-urge 40/40、e2e-m7-validation 70/70。
This commit is contained in:
2026-09-30 09:02:53 +08:00
parent bb91515590
commit 29c8e932ae
34 changed files with 5027 additions and 1 deletions
@@ -2,6 +2,8 @@ package cn.tianbaotong.fill.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.Map;
/**
* 本项目业务配置(前缀 {@code fill})。
*
@@ -245,6 +247,10 @@ public record FillProperties(
* 留空时回退成"相对路径",由前端自行补域名
* @param codeCacheDays 小程序码缓存天数。同一个 scene 生成的图是一样的,
* 而生成接口有日调用限额,所以必须缓存
* @param templates 订阅消息的**模板 id** 映射:模板码 → 微信模板 id。
* 模板 id 是客户在微信后台建好模板之后才有的(形如 {@code ZG8k…}),
* 所以只能配;没配的模板码在通知时**静默降级到站内信**,
* 而不是拿模板码去顶替(微信会报 40037,日志里一片失败)
*/
public record MiniProgram(
String appId,
@@ -252,7 +258,19 @@ public record FillProperties(
String envVersion,
String page,
String publicBaseUrl,
Integer codeCacheDays
Integer codeCacheDays,
Map<String, String> templates,
/**
* 微信消息推送回调的校验 token(微信后台「服务器配置」里填的那个)。
*
* <p>⚠️ 它<b>不是</b> appSecret:appSecret 用来换 access_token,
* 而这个 token 只用于校验回调签名。两者混用是常见错误 ——
* 表现是「回调永远验签失败」,而日志上看起来一切正常(配置读到了、非空)。
*
* <p>为空时回调接口会<b>明确拒绝</b>(并说清缺哪个配置),而不是跳过验签 ——
* 跳过验签等于把那个匿名写接口完全敞开。
*/
String callbackToken
) {
public String envVersionOrDefault() {
return envVersion == null || envVersion.isBlank() ? "release" : envVersion;
@@ -266,6 +284,11 @@ public record FillProperties(
return codeCacheDays == null ? 30 : codeCacheDays;
}
/** 订阅消息模板 id 映射;没配时返回空表(不是 null,省掉调用方的判空)。 */
public Map<String, String> templatesOrEmpty() {
return templates == null ? Map.of() : templates;
}
/** 凭据是否已配置。没配时二维码相关接口要给出明确提示,而不是让调用方猜。 */
public boolean configured() {
return appId != null && !appId.isBlank() && appSecret != null && !appSecret.isBlank();
@@ -0,0 +1,65 @@
package cn.tianbaotong.fill.module.notify.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.notify.dto.UrgeDtos;
import cn.tianbaotong.fill.module.notify.service.UrgeService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 班主任端催报(《03-接口设计》§10.7)。
*
* <p>与 {@code UrgeController} 共用 {@code UrgeService},只是路径与范围不同:
* <ul>
* <li>管理端({@code /tasks/{id}/roster/notify-unsubmitted})可催**任意**班;</li>
* <li>教师端(这里)**只能催自己负责的班** —— 请求里就算带了别人的 orgId,
* Service 也会与"我负责的班"取交集(见 {@code UrgeService.urgeUnsubmitted})。</li>
* </ul>
*
* <p>权限用 {@code notify:send}(契约 §3 的权限矩阵里,催报给"管理/教师")。
* 范围不靠权限码守 —— 权限只管"他是不是老师这一类人",
* 具体能催哪个班由 Service 的 SQL 决定。这两层缺一不可。
*/
@Tag(name = "班主任端 · 催报")
@RestController
@RequestMapping("/api/v1/mp/teacher/notify")
@RequiredArgsConstructor
@RequirePerm(PermissionDef.NOTIFY_SEND)
public class MpTeacherNotifyController {
private final UrgeService urgeService;
@PostMapping("/unsubmitted")
@Operation(summary = "一键催报(仅未提交者)",
description = "请求 `{ orgIds[], channel, excludeRecentHours }`。"
+ "**只催未提交者**;`excludeRecentHours`(默认 24)防重复骚扰。"
+ "返回里逐条说明谁被跳过、为什么 —— 只回「成功 5 条、跳过 1 条」的话,"
+ "老师会反复点")
public ApiResult<UrgeDtos.UrgeResultVO> urgeUnsubmitted(
@RequestBody(required = false) UrgeDtos.UrgeUnsubmittedReq req) {
return ApiResult.ok(urgeService.urgeUnsubmitted(
req == null ? new UrgeDtos.UrgeUnsubmittedReq() : req));
}
@GetMapping("/records")
@Operation(summary = "催报历史(只看得到我负责的班)",
description = "带 `deliveryRate`。⚠️ 范围**必须收窄**:通知记录里带别人的姓名,"
+ "一个班主任没有理由看到别的班催过谁")
public ApiResult<UrgeDtos.NotifyRecordPageVO> records(
@RequestParam(required = false) Long taskId,
@RequestParam(required = false) Integer bizType,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long pageNum,
@RequestParam(required = false) Long pageSize) {
return ApiResult.ok(urgeService.records(taskId, bizType, status, pageNum, pageSize, true));
}
}
@@ -0,0 +1,103 @@
package cn.tianbaotong.fill.module.notify.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.notify.dto.NotifyAdminDtos;
import cn.tianbaotong.fill.module.notify.service.NotifyTemplateService;
import cn.tianbaotong.fill.module.notify.service.NotifyTemplates;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 通知模板与手工发送(《03-接口设计》§12.7)。
*
* <p>⚠️ 注意路径前缀是 {@code /api/v1/notify},与 {@code /api/v1/mp/messages}(消息中心)
* 是两个不同的东西:前者是**管理端**的"模板与发送",后者是**用户端**的"我看到什么"。
*/
@Tag(name = "通知模板与手工发送")
@RestController
@RequestMapping("/api/v1/notify")
@RequiredArgsConstructor
public class NotifyAdminController {
private final NotifyTemplateService templateService;
@GetMapping("/templates")
@RequirePerm(PermissionDef.NOTIFY_SEND)
@Operation(summary = "通知模板列表",
description = "内置模板(写在代码里)+ 库里被改过的覆盖。"
+ "**没有 `id` 的行就是内置的**,界面据此区分「默认」与「已自定义」—— "
+ "否则用户找不到「恢复默认」的入口。"
+ "`unknownVars` 是正文里的越界变量:保存时就提示,不等发出去才发现")
public ApiResult<List<NotifyAdminDtos.TemplateVO>> templates(
@RequestParam(required = false) Long taskId) {
return ApiResult.ok(templateService.list(taskId));
}
@GetMapping("/templates/variables")
@RequirePerm(PermissionDef.NOTIFY_SEND)
@Operation(summary = "可用变量白名单",
description = "必须由服务端给:前端抄一份的话,两边迟早不一致,"
+ "而模板是**非技术人员**改的,写错变量名在界面上看不出任何异常")
public ApiResult<Map<String, String>> variables() {
return ApiResult.ok(NotifyTemplates.VARIABLES);
}
@PutMapping("/templates/{id}")
@RequirePerm(PermissionDef.NOTIFY_SEND)
@Operation(summary = "修改通知模板",
description = "只能改**已被自定义过**的行(`id` 来自列表)。"
+ "内置模板没有行可改 —— 想改就先 `POST /notify/templates/{code}/customize`")
public ApiResult<NotifyAdminDtos.TemplateVO> update(
@PathVariable Long id, @RequestBody NotifyAdminDtos.TemplateUpdateReq req) {
return ApiResult.ok(templateService.update(id, req));
}
@PostMapping("/templates/{code}/customize")
@RequirePerm(PermissionDef.NOTIFY_SEND)
@Operation(summary = "把内置模板变成可编辑的(加出来的接口,见 F-52)",
description = "契约只给了 GET/PUT,但 GET 返回的内置模板**没有 id**,"
+ "PUT 无从下手 —— 用户第一步就卡住了。这里补上「自定义」这一步:"
+ "把内置文案复制成库里的一行,之后就能改")
public ApiResult<NotifyAdminDtos.TemplateVO> customize(
@PathVariable String code, @RequestParam(required = false) Long taskId) {
return ApiResult.ok(templateService.customize(code, taskId));
}
@DeleteMapping("/templates/{code}")
@RequirePerm(PermissionDef.NOTIFY_SEND)
@Operation(summary = "恢复内置文案(加出来的接口,见 F-52)",
description = "没有它「自定义」就是一条单行道:用户改错了只能自己想办法改回去,"
+ "而他记不住原文")
public ApiResult<Map<String, Object>> reset(
@PathVariable String code, @RequestParam(required = false) Long taskId) {
templateService.reset(code, taskId);
return ApiResult.ok(Map.of("reset", true));
}
@PostMapping("/send")
@RequirePerm(PermissionDef.NOTIFY_SEND)
@Operation(summary = "手工发送通知",
description = "收件人三种给法(可并用,**取并集去重**):`receiverIds` / `rosterIds` / "
+ "`taskId` + `all=true`。"
+ "⚠️ `sent` 的口径是「**站内信写入成功**」—— 与通知记录一致;"
+ "微信推送是尽力而为,单独用 `pushSent` 报。"
+ "把它算进 `sent` 会让「发送成功 100%」变成一句谎话")
public ApiResult<NotifyAdminDtos.SendVO> send(@RequestBody NotifyAdminDtos.SendReq req) {
return ApiResult.ok(templateService.send(req));
}
}
@@ -0,0 +1,72 @@
package cn.tianbaotong.fill.module.notify.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.notify.dto.NotifyDtos;
import cn.tianbaotong.fill.module.notify.service.NotificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 消息中心与订阅额度(《03-接口设计》§13.5、§13.6)。
*
* <p>学生端与教师端**同构**:都是"我的消息",只是 {@code bizType} 集合不同
* (教师多一条 {@code 7} 加入审批)。所以两个路径共用一套实现 ——
* 分成两套的话,"已读"的语义迟早分叉。
*
* <p>不挂权限码:消息是"我自己的东西",登录即可。
* 越权由 {@code user_id = 当前用户} 守住(在 Service 层,不在权限码上)。
*/
@Tag(name = "消息中心 · 订阅额度")
@RestController
@RequestMapping("/api/v1/mp")
@RequiredArgsConstructor
public class NotifyController {
private final NotificationService notificationService;
@GetMapping("/messages")
@Operation(summary = "我的消息(学生端)",
description = "返回未读数;`read=false` 只看未读,`bizType` 按类型筛(3驳回 4通过 …)。"
+ "消息内容与**发送通道结果**是两张表:这里只给用户看到的东西")
public ApiResult<NotifyDtos.MessagePageVO> messages(
@RequestParam(required = false) Integer bizType,
@RequestParam(required = false) Boolean read,
@RequestParam(required = false) Long pageNum,
@RequestParam(required = false) Long pageSize) {
return ApiResult.ok(notificationService.myMessages(bizType, read, pageNum, pageSize));
}
@GetMapping("/teacher/messages")
@Operation(summary = "我的消息(教师端)",
description = "与 `/mp/messages` 同构(同一份实现)—— 教师的专属类型是 7(加入名单审批)")
public ApiResult<NotifyDtos.MessagePageVO> teacherMessages(
@RequestParam(required = false) Integer bizType,
@RequestParam(required = false) Boolean read,
@RequestParam(required = false) Long pageNum,
@RequestParam(required = false) Long pageSize) {
return ApiResult.ok(notificationService.myMessages(bizType, read, pageNum, pageSize));
}
@PostMapping("/messages/read")
@Operation(summary = "标记已读",
description = "`all=true` 一键全读;否则按 `messageIds`。"
+ "**只能标自己的**(条件里带 user_id)—— ids 是前端传来的,可以随便改")
public ApiResult<NotifyDtos.ReadVO> read(@RequestBody NotifyDtos.ReadReq req) {
return ApiResult.ok(notificationService.markRead(req));
}
@PostMapping("/subscribe")
@Operation(summary = "登记订阅消息额度",
description = "微信订阅消息是**一次性授权**:用户每点一次「允许」只能发一条,"
+ "所以要在这里登记。额度用尽时通知会**静默降级为站内信**(学生端不显示额度告警)")
public ApiResult<NotifyDtos.QuotaVO> subscribe(@RequestBody NotifyDtos.SubscribeReq req) {
return ApiResult.ok(notificationService.subscribe(req));
}
}
@@ -0,0 +1,77 @@
package cn.tianbaotong.fill.module.notify.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.notify.dto.UrgeDtos;
import cn.tianbaotong.fill.module.notify.service.UrgeService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 催报(《03-接口设计》§8.6 的定向/一键催报)。
*
* <h3>为什么催报要有独立的限流口径</h3>
*
* <p>契约 §3 的表里写了"催报 10 次/分钟/任务"。
* 这个限流不是防攻击,是**防误触**:老师点一下"一键催报"可能给几十个学生发消息,
* 而它没有"撤回"。所以宁可让他等一下,也不要一次点出几百条通知。
*
* <p>本轮的实现只做了 Service 层的**去重**({@code excludeRecentHours}),
* 分钟级限流还没做 —— 见 F-29(记录在案,属 P1 剩余)。
*/
@Tag(name = "催报")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class UrgeController {
private final UrgeService urgeService;
@PostMapping("/roster/{rid}/notify")
@RequirePerm(PermissionDef.NOTIFY_SEND)
@Operation(summary = "定向催报(单个名册项)",
description = "只催这一个学生。**会校验他是不是在你负责的班里** —— "
+ "rosterId 是前端传来的,不校验就变成任何老师都能催任何学生")
public ApiResult<UrgeDtos.UrgeResultVO> urgeOne(@PathVariable Long rid,
@RequestBody(required = false)
UrgeDtos.UrgeOneReq req) {
return ApiResult.ok(urgeService.urgeOne(rid, req));
}
@PostMapping("/tasks/{id}/roster/notify-unsubmitted")
@RequirePerm(PermissionDef.NOTIFY_SEND)
@Operation(summary = "一键催报未提交者",
description = "**仅催未提交者**(未填 + 草稿:填了一半忘了交的人与没填的人,"
+ "对老师来说要做的是同一件事)。已驳回的不催 —— 他要的是「改哪里」。"
+ "`excludeRecentHours`(默认 24)**防重复骚扰**:老师点两下不该发两条")
public ApiResult<UrgeDtos.UrgeResultVO> urgeUnsubmitted(
@PathVariable Long id,
@RequestBody(required = false) UrgeDtos.UrgeUnsubmittedReq req) {
UrgeDtos.UrgeUnsubmittedReq r = req == null ? new UrgeDtos.UrgeUnsubmittedReq() : req;
r.setTaskId(id);
return ApiResult.ok(urgeService.urgeUnsubmitted(r));
}
@GetMapping("/notify/records")
@RequirePerm(PermissionDef.PROGRESS_VIEW_ALL)
@Operation(summary = "通知记录(管理端)",
description = "带 `deliveryRate`。⚠️ 分母**包含「额度不足跳过」** —— "
+ "把跳过排除掉会让送达率虚高,而跳过的那批恰恰是最没收到的人")
public ApiResult<UrgeDtos.NotifyRecordPageVO> records(
@RequestParam(required = false) Long taskId,
@RequestParam(required = false) Integer bizType,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long pageNum,
@RequestParam(required = false) Long pageSize) {
return ApiResult.ok(urgeService.records(taskId, bizType, status, pageNum, pageSize, false));
}
}
@@ -0,0 +1,75 @@
package cn.tianbaotong.fill.module.notify.controller;
import cn.tianbaotong.fill.module.notify.service.NotifyCallbackService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 微信订阅消息回调(《03-接口设计》§12.7 的 {@code POST /callback/wechat/msg})。
*
* <h3>这个接口为什么必须是**公开**的,以及公开之后靠什么保护</h3>
*
* <p>它是微信服务器来调的,不可能带我们的登录态 —— 所以它天然是匿名接口。
* 匿名接口在"租户拦截器默认拒绝"的体系里是**危险区**,因此这里的三条约束必须写清:
*
* <ol>
* <li>它<b>只写"消息推送结果",不读任何业务数据</b>:即使被人伪造调用,
* 最坏后果是"某条推送的状态被写错",拿不到任何学生信息;</li>
* <li>它<b>不依赖 tenant_id</b>:调用方是微信,它不知道我们有几个租户。
* 所以这个 Mapper 必须 {@code @InterceptorIgnore(tenantLine="true")},
* 而业务归属靠 "我们发出去时记的那条通知记录 id / msgid" 反查(见 service);</li>
* <li>它<b>必须校验微信签名</b>({@code msg_signature} + {@code timestamp} + {@code nonce}),
* 验不过就拒绝。签名算法是 SHA1 排序拼接,属于协议要求而非我们的安全设计。</li>
* </ol>
*
* <h3>返回体为什么是 {@code {"errcode":0}} 而不是 {@code {code,msg,data}}</h3>
*
* <p>因为调用方是**微信**,它只认自己那套格式。本项目"响应恒为 {@code {code,msg,data}}"
* 的约定适用于给人和自家前端用的接口;对第三方协议的回调端点按对方协议回。
* 这条例外记在 F-53。
*/
@Slf4j
@Tag(name = "微信回调")
@RestController
@RequestMapping("/api/v1/callback")
@RequiredArgsConstructor
public class WechatCallbackController {
private final NotifyCallbackService callbackService;
@PostMapping("/wechat/msg")
@Operation(summary = "微信订阅消息回调(公开,靠微信签名保护)",
description = "记录推送结果(成功/失败/用户拒收)。"
+ "**只写推送状态、不读业务数据** —— 匿名接口即使被伪造调用也拿不到学生信息。"
+ "⚠️ 响应体是微信格式 `{\"errcode\":0}` 而不是本项目的 `{code,msg,data}`,"
+ "因为调用方只认它自己那套(见 F-53)")
public Map<String, Object> wechatMsg(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
Map<String, Object> out = new LinkedHashMap<>();
try {
callbackService.handle(rawBody, request.getParameter("msg_signature"),
request.getParameter("timestamp"), request.getParameter("nonce"),
request.getParameter("signature"));
out.put("errcode", 0);
out.put("errmsg", "ok");
} catch (RuntimeException e) {
// ⚠️ 对微信**也不要**回 500:它会按"失败"重试,而重试解决不了
// "签名不对"或"报文看不懂"这类问题,只会反复打我们的日志。
// 回 errcode 让它停止重试,同时我们这边记一条 WARN 供排查。
log.warn("微信消息回调处理失败:{}", e.getMessage());
out.put("errcode", -1);
out.put("errmsg", e.getMessage() == null ? "error" : e.getMessage());
}
return out;
}
}
@@ -0,0 +1,111 @@
package cn.tianbaotong.fill.module.notify.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** 通知模板与手工发送(《03-接口设计》§12.7)。 */
public final class NotifyAdminDtos {
private NotifyAdminDtos() {
}
/** 一个通知模板。 */
@Getter
@Setter
public static class TemplateVO {
/** 为空 = 这是**内置**模板,还没被改过(因此也改不了,要先"自定义")。 */
private Long id;
private String templateCode;
private Integer bizType;
private String channel;
private String title;
private String content;
private Boolean enabled;
/** 是否已被自定义(界面上要区分"默认"与"已改过",否则找不到恢复默认的入口)。 */
private Boolean customized;
private String remark;
/** 正文里出现的**越界变量**(保存时就提示,不等发出去才发现)。 */
private List<String> unknownVars = new ArrayList<>();
}
/** 修改模板。 */
@Getter
@Setter
@Schema(description = "修改通知模板(三个字段都可选,只改传了的)")
public static class TemplateUpdateReq {
private String title;
private String content;
private Boolean enabled;
}
/** 手工发送。 */
@Getter
@Setter
@Schema(description = "手工发送通知")
public static class SendReq {
private Integer bizType;
/** 显式指定用户 id。 */
private List<Long> receiverIds;
/** 按名册行指定(没绑账号的人会进 skipped 并给出原因)。 */
private List<Long> rosterIds;
/** 配合 `all=true` 发整个任务。 */
private Long taskId;
private Boolean all;
private String channel;
private String templateCode;
/** 不指定 templateCode 时直接给的文案。 */
private String title;
private String content;
private String extraText;
/** 显式变量值(例如导出完成的 `导出包名` / `下载链接`)。 */
private Map<String, String> vars;
}
/** 发送结果。 */
@Getter
@Setter
public static class SendVO {
/** 站内信写入成功的条数(**这是"发出去"的口径**,与通知记录一致)。 */
private int sent;
private int failed;
/** 被跳过的人数(没绑账号等)。 */
private int skipped;
/** 其中微信推送真正发出去的条数。 */
private int pushSent;
private List<String> skipReasons = new ArrayList<>();
}
}
@@ -0,0 +1,109 @@
package cn.tianbaotong.fill.module.notify.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 消息中心与订阅额度(《03-接口设计》§13.5、§13.6)。
*/
public final class NotifyDtos {
private NotifyDtos() {
}
// ── 站内信 ────────────────────────────────────
/** 一条消息。 */
@Getter
@Setter
@Schema(description = "站内信")
public static class MessageVO {
private Long id;
/** 1任务发布 2临期 3驳回 4通过 5催报 6导出 7加入审批 */
private Integer bizType;
private String title;
private String content;
private Boolean read;
/** SUBMISSION / TASK / NONE —— 点开跳哪儿 */
private String linkType;
private Long linkId;
private String channel;
private LocalDateTime createTime;
}
/** 消息列表。 */
@Getter
@Setter
public static class MessagePageVO {
private List<MessageVO> list = new ArrayList<>();
private long total;
private long unreadCount;
}
/** 标记已读的请求。 */
@Getter
@Setter
@Schema(description = "标记已读")
public static class ReadReq {
/** 要标的那些消息 id;{@code all=true} 时忽略 */
private List<Long> messageIds = new ArrayList<>();
/** true = 全部已读(用户不想一条条点) */
private Boolean all = Boolean.FALSE;
}
/** 标记已读的结果。 */
@Getter
@Setter
public static class ReadVO {
private long unreadCount;
}
// ── 订阅额度 ──────────────────────────────────
/** 登记订阅授权的请求。 */
@Getter
@Setter
@Schema(description = "登记订阅消息额度")
public static class SubscribeReq {
/** 模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY / JOIN_APPROVE */
private String templateCode;
/** 本次授权次数(微信返回,默认 1) */
private Integer subscribeCount;
}
/** 额度。 */
@Getter
@Setter
public static class QuotaVO {
private String templateCode;
private int total;
private int used;
private int remain;
}
}
@@ -0,0 +1,157 @@
package cn.tianbaotong.fill.module.notify.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 催报与通知记录(《03-接口设计》§8.6、§10.7、§13.3)。
*/
public final class UrgeDtos {
private UrgeDtos() {
}
// ── 催报请求 ──────────────────────────────────
/** 定向催报(单个名册项)。 */
@Getter
@Setter
@Schema(description = "定向催报")
public static class UrgeOneReq {
/** 1 微信订阅消息 / 2 公众号 / 3 短信 / 4 仅站内 —— **一期只有 1 与 4 真能走通** */
private Integer channel;
/** 模板码,默认 NOTIFY_UNSUBMITTED */
private String templateCode;
/** 附加说明,会拼在正文后面(老师想说的话) */
private String extraText;
}
/** 一键催报(一批班里的未提交者)。 */
@Getter
@Setter
@Schema(description = "一键催报未提交者")
public static class UrgeUnsubmittedReq {
/** 要催的班级;与 taskId 至少要有一个 */
private List<Long> orgIds = new ArrayList<>();
private Long taskId;
private Integer channel;
private String templateCode;
private String extraText;
/**
* 多少小时内催过就不再催(默认 24)。
*
* <p>契约 §8.6 明确写了这个参数,用途是**防重复骚扰**:
* 老师点两下"一键催报",学生就该收到一条还是两条?
* 一条。所以去重责任在服务端,不在老师的自觉。
*/
private Integer excludeRecentHours;
}
// ── 催报结果 ──────────────────────────────────
/** 一个收件人的结果。 */
@Getter
@Setter
public static class UrgeDetail {
private Long rosterId;
private Long userId;
private String realName;
private String orgName;
/** 被跳过时为 true */
private Boolean skipped;
/** 跳过/失败的原因(**给老师看的**,要说清"为什么这个人没催到") */
private String reason;
}
/** 催报结果。 */
@Getter
@Setter
@Schema(description = "催报结果")
public static class UrgeResultVO {
/** 真的发出去了(站内信一定算发出 —— 学生进小程序能看到) */
private int sent;
private int failed;
private int skipped;
private List<UrgeDetail> details = new ArrayList<>();
}
// ── 通知记录查询(§10.7 / §13.3)──────────────
/** 一条通知记录。 */
@Getter
@Setter
public static class NotifyRecordVO {
private Long id;
private Long taskId;
private String channel;
/** 业务类型码的字符串形式(见 F-26:库里这列是 varchar) */
private String bizType;
private Long receiverId;
private String receiverName;
private String receiverRole;
private String templateCode;
private String content;
/** {@code 0} 待发 / {@code 1} 成功 / {@code 2} 失败 / {@code 3} 额度不足跳过 */
private Integer status;
private String statusText;
private String failReason;
private LocalDateTime sendTime;
}
/** 通知记录列表。 */
@Getter
@Setter
public static class NotifyRecordPageVO {
private List<NotifyRecordVO> list = new ArrayList<>();
private long total;
/**
* 送达率(成功 / 总数)。
*
* <p>契约 §13.3 的 `GET /notify/records` 带这个字段。
* ⚠️ 分母把"额度不足跳过"算进去吗?**算**。
* 因为运营看这个数字是为了回答"我发的通知有多少人真收到了",
* 把跳过排除在外会让送达率虚高(跳过的人恰恰是最没收到的那批)。
*/
private String deliveryRate;
}
}
@@ -0,0 +1,64 @@
package cn.tianbaotong.fill.module.notify.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 站内信({@code collect_message},契约 §13.5 的消息中心)。
*
* <p>与 {@code collect_notify_record} 的分工:那张表记**发送通道结果**
* (发出去没有、到没到、为什么失败),这张表记**用户看到什么**。
* 两者通过 {@code bizType} 关联 —— 契约 §13.5 明确写了这个区别。
*
* <p><b>为什么"已读"必须放在这里</b>:已读是用户维度、可以反复改的状态;
* 而发送记录是**只增不改**的凭据(要能回答"到底通知过没有")。
* 把已读塞进发送记录,那批数据就再也不能当证据用了。
*/
@Getter
@Setter
@TableName("collect_message")
public class CollectMessage extends TenantEntity {
/** 业务类型(与 {@code collect_notify_record.biz_type} 同一套码)。 */
public static final int BIZ_TASK_PUBLISH = 1;
public static final int BIZ_DEADLINE = 2;
public static final int BIZ_REJECT = 3;
public static final int BIZ_APPROVE = 4;
public static final int BIZ_URGE = 5;
public static final int BIZ_EXPORT = 6;
public static final int BIZ_JOIN_AUDIT = 7;
/** 点开消息跳哪儿。 */
public static final String LINK_SUBMISSION = "SUBMISSION";
public static final String LINK_TASK = "TASK";
public static final String LINK_NONE = "NONE";
/** 最终落地的通道(降级链的结果)。 */
public static final String CHANNEL_IN_APP = "IN_APP";
public static final String CHANNEL_WECHAT_SUB = "WECHAT_SUB";
public static final String CHANNEL_MP_TEMPLATE = "MP_TEMPLATE";
public static final String CHANNEL_SMS = "SMS";
private Long userId;
private Integer bizType;
private String title;
private String content;
private String linkType;
private Long linkId;
/** {@code 0} 未读 / {@code 1} 已读。 */
private Integer isRead;
private LocalDateTime readTime;
private String channel;
}
@@ -0,0 +1,52 @@
package cn.tianbaotong.fill.module.notify.entity;
import cn.tianbaotong.fill.support.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 通知模板({@code collect_notify_template},契约 §12.7)。
*
* <h3>为什么库里只存"被改过的那些"</h3>
*
* <p>内置模板写在代码里({@link cn.tianbaotong.fill.module.notify.service.NotifyTemplates}):
* <ul>
* <li>升级版本时,新增或修正的内置文案能**自动生效** —— 如果内置模板也在库里,
* 用户手里的老文案会把新版覆盖掉,每次改文案都得写迁移脚本;</li>
* <li>用户改过的那几条存在这张表里,之后一直以库里为准。
* (`sys_dict_disabled` 用的是同一个思路,见 V10。)</li>
* </ul>
*
* <p>因此"恢复默认"就是**删掉这一行**,内置文案自然回来。
*/
@Getter
@Setter
@TableName("collect_notify_template")
public class CollectNotifyTemplate extends BaseEntity {
/** 为空 = 租户级模板;有值 = 该任务专属(对应 {@code GET /notify/templates?taskId=})。 */
private Long taskId;
private String templateCode;
/** 消息中心的 {@code bizType}(契约 §2.x:1 任务发布 / 2 临期 / 3 驳回 / 4 通过 / 5 催报 / 6 导出 / 7 加入审批)。 */
private Integer bizType;
/** {@code IN_APP} / {@code WX_SUBSCRIBE} / {@code WX_OFFICIAL} / {@code SMS}。 */
private String channel;
private String title;
private String content;
private Integer enabled;
public static final int ENABLED_YES = 1;
public static final int ENABLED_NO = 0;
public static final String CHANNEL_IN_APP = "IN_APP";
public static final String CHANNEL_WX_SUBSCRIBE = "WX_SUBSCRIBE";
public static final String CHANNEL_WX_OFFICIAL = "WX_OFFICIAL";
public static final String CHANNEL_SMS = "SMS";
}
@@ -0,0 +1,49 @@
package cn.tianbaotong.fill.module.notify.mapper;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 站内信。租户条件由拦截器注入;下面的批量已读是手写 SQL,所以显式带 {@code tenant_id}。
*/
public interface CollectMessageMapper extends BaseMapper<CollectMessage> {
/**
* 把某人的全部未读标为已读。
*
* <p>手写 SQL 而不是 `update(entity, wrapper)`:批量已读要**一次**改完,
* 而按 id 逐条改在几十条消息时是几十次往返(用户点"全部已读"就要等)。
*
* @return 影响行数
*/
@Update("""
UPDATE collect_message
SET is_read = 1, read_time = NOW(), update_time = NOW()
WHERE tenant_id = #{tenantId} AND user_id = #{userId}
AND is_read = 0 AND deleted = 0
""")
int markAllRead(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
/**
* 按 id 批量标已读。
*
* <p>⚠️ 条件里必须有 {@code user_id = #{userId}}:ids 是前端传来的,
* 不校验归属就等于"任何人可以把别人的消息标成已读"(虽然危害小,
* 但那是**改别人的数据**,性质与越权读一样)。
*/
@Update("""
<script>
UPDATE collect_message
SET is_read = 1, read_time = NOW(), update_time = NOW()
WHERE tenant_id = #{tenantId} AND user_id = #{userId}
AND is_read = 0 AND deleted = 0
AND id IN
<foreach collection="ids" item="mid" open="(" separator="," close=")">#{mid}</foreach>
</script>
""")
int markReadByIds(@Param("tenantId") Long tenantId,
@Param("userId") Long userId,
@Param("ids") java.util.List<Long> ids);
}
@@ -0,0 +1,101 @@
package cn.tianbaotong.fill.module.notify.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Select;
/**
* 通知发送记录({@code collect_notify_record})。
*
* <p>这张表记的是**通道结果**:发出去没有、走的哪个通道、为什么失败。
* 与 {@code collect_message}(用户看到什么)分开两张表,理由见契约 §13.5
* 与 {@code CollectMessage} 的类注释。
*
* <p>用一条窄插入而不是建实体:这张表只增不改、字段固定,
* 建实体反而要处理"没有 deleted 列"的老问题({@code @TableLogic} 会加不存在的列,
* 这个坑在 {@code collect_task_org} 与 {@code collect_export_task} 上都踩过)。
*
* <h3>⚠️ biz_type 是 <b>varchar</b>,不是数字</h3>
*
* <p>契约 §6 的字段表把 {@code bizType} 定义成数字码(1 任务发布 … 7 加入名单审批),
* 而 V1 的 DDL 是 {@code biz_type varchar(32)} + 注释只写"业务类型"。
* 于是"传 3 进去"会被 PG 直接拒绝({@code column "biz_type" is of type character varying
* but expression is of type integer}),而失败只落在日志里 —— 表现是"表里一行都没有"。
*
* <p>本方法的入参因此是 {@code String},由调用方把数字码转成字符串。
* **不要**为了"看起来一致"去改 V1 的 DDL:迁移发布后不许改(AGENTS.md),
* 而且这张表只有我们写。
*/
public interface NotifyRecordMapper {
/**
* 记一条发送结果。
*
* @param taskId 所属任务(可空)。⚠️ **催报去重要用它** ——
* 不带 task_id 的话,"最近 24 小时内是否催过这个人"永远查不到,
* 去重会静默失效(本轮踩过:表里 task_id 全是空)
* @param bizType 业务类型码的**字符串形式**(如 {@code "3"})
* @param status {@code 1} 成功 / {@code 2} 失败 / {@code 3} 额度不足跳过
* (三态来自 V1 的列注释,别自造第四种)
*/
@InterceptorIgnore(tenantLine = "true")
@Insert("""
INSERT INTO collect_notify_record
(tenant_id, task_id, biz_type, receiver_id, channel, template_code, content,
status, fail_reason, send_time)
VALUES
(#{tenantId}, #{taskId}, #{bizType}, #{receiverId}, #{channel}, #{templateCode},
#{content}, #{status}, #{failReason}, NOW())
""")
int insertRecord(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("bizType") String bizType,
@Param("receiverId") Long receiverId,
@Param("channel") String channel,
@Param("templateCode") String templateCode,
@Param("content") String content,
@Param("status") Integer status,
@Param("failReason") String failReason);
/**
* 凭"通知记录 id"或"微信 MsgID"反查这条推送属于哪个租户。
*
* <p>⚠️ **这是微信回调路径上唯一的无租户查询**,也是"关掉框架保护"的全部范围。
* 微信不知道我们有几个租户,所以它来的请求不带租户 —— 只能靠我们自己发出去时
* 记下的 id 反查。查不到时回调侧会**明确报错**,而不是猜一个租户去写:
* 写错租户等于把数据丢进别人家。
*
* <p>两个条件都用 {@code OR}:不同微信接口回的东西不一样(有的带 MsgID,
* 有的只回我们透传的 recordId)。少一个分支就会出现"一部分回调永远找不到记录"。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT tenant_id FROM collect_notify_record
WHERE (#{recordId} IS NOT NULL AND id = #{recordId})
OR (#{msgId} IS NOT NULL AND msg_id = #{msgId})
ORDER BY id DESC LIMIT 1
""")
Long tenantIdOfRecord(@Param("recordId") Long recordId, @Param("msgId") String msgId);
/** 按记录 id 更新推送结果(回调)。 */
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE collect_notify_record
SET status = #{status}, fail_reason = #{reason}, send_time = NOW()
WHERE id = #{recordId}
""")
int markCallback(@Param("recordId") Long recordId, @Param("status") Integer status,
@Param("reason") String reason);
/** 按微信 MsgID 更新推送结果(回调)。 */
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE collect_notify_record
SET status = #{status}, fail_reason = #{reason}, send_time = NOW()
WHERE msg_id = #{msgId}
""")
int markCallbackByMsgId(@Param("msgId") String msgId, @Param("status") Integer status,
@Param("reason") String reason);
}
@@ -0,0 +1,39 @@
package cn.tianbaotong.fill.module.notify.mapper;
import cn.tianbaotong.fill.module.notify.entity.CollectNotifyTemplate;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
/** 通知模板(库里只存被用户改过的,见实体类注释)。 */
public interface NotifyTemplateMapper extends BaseMapper<CollectNotifyTemplate> {
/**
* **物理**删掉一条覆盖行("恢复默认")。
*
* <p>⚠️ 为什么不用 {@code delete()}:实体有 {@code @TableLogic},
* MyBatis-Plus 的 {@code delete()} 会做成 {@code UPDATE deleted=1} ——
* 那一行仍在表里,而 {@code uk_notify_tpl} 唯一索引**不看 deleted**,
* 于是"恢复默认 → 再自定义"会撞唯一约束(表现是 500,看不出原因)。
*
* <p>而且这里软删除本来就没有价值:它是配置记录,用户改过什么在操作日志里,
* 留一份 deleted=1 的副本只是垃圾。
*
* <p>⚠️ 条件用 {@code <choose>} 拼,而**不是**写
* {@code (#{taskId} IS NULL AND task_id IS NULL OR task_id = #{taskId})}:
* PostgreSQL 推断不出 {@code ? IS NULL} 里那个参数的类型,直接报
* {@code could not determine data type of parameter $1}(对外是 500)。
* 传 null 的写法必须让那个参数干脆不出现在 SQL 里。
*/
@Delete("""
<script>
DELETE FROM collect_notify_template
WHERE template_code = #{code}
<choose>
<when test="taskId != null"> AND task_id = #{taskId}</when>
<otherwise> AND task_id IS NULL</otherwise>
</choose>
</script>
""")
int deleteOverride(@Param("code") String code, @Param("taskId") Long taskId);
}
@@ -0,0 +1,104 @@
package cn.tianbaotong.fill.module.notify.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 订阅消息额度({@code collect_subscribe_quota},契约 §13.6)。
*
* <h3>为什么额度要单独记一张表</h3>
*
* <p>微信订阅消息是**一次性授权**:用户每点一次"允许"只能发一条。
* 所以"我还能给这个学生发几条"必须由我们自己记 —— 微信不会告诉你,
* 而额度用尽时发送接口会直接报错(43101)。
*
* <p>唯一索引 {@code (user_id, template_code)}:一个人一个模板一行,
* 所以"登记额度"是 UPSERT 而不是 INSERT。
*/
public interface SubscribeQuotaMapper {
/** 一条额度。 */
class QuotaRow {
private Long id;
private Long userId;
private String templateCode;
private Integer total;
private Integer used;
public Long getId() { return id; }
public void setId(Long v) { this.id = v; }
public Long getUserId() { return userId; }
public void setUserId(Long v) { this.userId = v; }
public String getTemplateCode() { return templateCode; }
public void setTemplateCode(String v) { this.templateCode = v; }
public Integer getTotal() { return total; }
public void setTotal(Integer v) { this.total = v; }
public Integer getUsed() { return used; }
public void setUsed(Integer v) { this.used = v; }
}
/** 取一条额度(没有就是还没索取过授权)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT id, user_id AS userId, template_code AS templateCode, total, used
FROM collect_subscribe_quota
WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND template_code = #{templateCode}
""")
QuotaRow find(@Param("tenantId") Long tenantId,
@Param("userId") Long userId,
@Param("templateCode") String templateCode);
/**
* 登记额度(UPSERT)。
*
* <p>用 {@code ON CONFLICT} 而不是"先查再插":学生可能连点两次"允许",
* 并发下"先查再插"会撞唯一索引(表现为 500,而用户只是多点了一下)。
*
* <p>⚠️ 必须是 {@code @Insert} 而**不是** {@code @Update}:
* PG 驱动对 {@code INSERT ... ON CONFLICT} 走的是"可能返回生成键"的路径,
* 而 MyBatis 的 {@code @Update} 会用 {@code executeUpdate} + 取生成键,
* 于是抛 {@code UnsupportedOperationException}(这个坑本轮踩过,见 F-24)。
*/
@InterceptorIgnore(tenantLine = "true")
@Insert("""
INSERT INTO collect_subscribe_quota (tenant_id, user_id, template_code, total, used, update_time)
VALUES (#{tenantId}, #{userId}, #{templateCode}, #{count}, 0, NOW())
ON CONFLICT (user_id, template_code)
DO UPDATE SET total = collect_subscribe_quota.total + EXCLUDED.total,
update_time = NOW()
""")
int addQuota(@Param("tenantId") Long tenantId,
@Param("userId") Long userId,
@Param("templateCode") String templateCode,
@Param("count") int count);
/**
* 扣一次额度。
*
* <p>条件里带 {@code used < total}:**扣减必须与"还有余额"在同一个语句里判**。
* "先查够不够、再扣"在并发下会扣成负数(同一个人同时触发两条通知),
* 而负余额看起来像"还能发",于是继续发、继续失败。
*
* <p>⚠️ 外面套 {@code <script>} 不是为了动态 SQL,而是为了**转义**:
* 裸的 {@code @Update} 文本**不经过 XML 解析**,写 {@code &lt;} 会被原样发给 PG
* (报 {@code syntax error at or near "&"})。只有 {@code <script>} 包起来的那部分
* 才按 XML 解析、才会把实体还原成 {@code <}。这个坑本轮踩了两次(F-24/F-25)。
*
* @return 影响行数;0 = 没扣成(额度不够)
*/
@InterceptorIgnore(tenantLine = "true")
@Update("""
<script>
UPDATE collect_subscribe_quota
SET used = used + 1, update_time = NOW()
WHERE tenant_id = #{tenantId} AND user_id = #{userId}
AND template_code = #{templateCode} AND used &lt; total
</script>
""")
int consume(@Param("tenantId") Long tenantId,
@Param("userId") Long userId,
@Param("templateCode") String templateCode);
}
@@ -0,0 +1,319 @@
package cn.tianbaotong.fill.module.notify.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 催报要用到的只读查询(《03-接口设计》§8.6、§10.7)。
*
* <h3>"未提交"到底怎么算</h3>
*
* <p>{@code collect_roster.submit_status} 有五个值(0 未填 / 1 草稿 / 2 待审 / 3 已通过 / 4 已驳回)。
* **未提交 = 0 或 1** —— 也就是"还没交上来"。
*
* <p>为什么把"草稿"也算未提交:学生点过"开始填写"但没交,对老师来说与没填**没有区别** ——
* 他要做的动作都是"催一下"。只看 {@code submit_status = 0} 会把最常见的那批人漏掉
* (点进来了、填了一半、忘了交),而那恰恰是最该催的。
*
* <p>同理,**已驳回(4)不算未提交**:那已经不是"催他填"的问题,而是要告诉他"改哪里"
* (走驳回通知),催报文案("请尽快完成填报")对他没有意义。
*/
public interface UrgeMapper {
/** 一个待催的人。 */
class UrgeTarget {
private Long rosterId;
private Long userId;
private String realName;
private Long orgId;
private String orgName;
private Long taskId;
private String taskName;
private Integer submitStatus;
public Long getRosterId() { return rosterId; }
public void setRosterId(Long v) { this.rosterId = v; }
public Long getUserId() { return userId; }
public void setUserId(Long v) { this.userId = v; }
public String getRealName() { return realName; }
public void setRealName(String v) { this.realName = v; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long v) { this.orgId = v; }
public String getOrgName() { return orgName; }
public void setOrgName(String v) { this.orgName = v; }
public Long getTaskId() { return taskId; }
public void setTaskId(Long v) { this.taskId = v; }
public String getTaskName() { return taskName; }
public void setTaskName(String v) { this.taskName = v; }
public Integer getSubmitStatus() { return submitStatus; }
public void setSubmitStatus(Integer v) { this.submitStatus = v; }
}
/**
* 我在这个任务里、这些班里的未提交者。
*
* <p>范围条件与初审完全一致(三级回退)——**催报和审核必须是同一批人**。
* 只管权限不管范围的话,一个班主任能催全校。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT r.id AS rosterId,
r.bind_user_id AS userId,
r.real_name AS realName,
r.org_id AS orgId,
o.org_name AS orgName,
t.id AS taskId,
t.task_name AS taskName,
r.submit_status AS submitStatus
FROM collect_roster r
JOIN collect_task t
ON t.id = r.task_id AND t.deleted = 0 AND t.tenant_id = r.tenant_id
JOIN collect_task_org cto
ON cto.task_id = r.task_id AND cto.org_id = r.org_id AND cto.status = 1
AND cto.tenant_id = r.tenant_id
LEFT JOIN sys_org o
ON o.id = r.org_id AND o.deleted = 0 AND o.tenant_id = r.tenant_id
WHERE r.tenant_id = #{tenantId}
AND r.deleted = 0
-- ⚠️ **不要**过滤 bind_user_id IS NULL:名册里有这个人、但他还没扫码核验,
-- 正是老师最需要知道的情况("催不到,因为还没核对身份")。
-- 过滤掉他们,老师会以为"我班上没人没交" —— 而那批人恰恰一个都没交。
-- 这类行由 Service 归到 skipped 并给出可读理由。
-- 未提交 = 未填(0) 或 草稿(1)。已驳回(4) 不算:那要发"改哪里"而不是"快点交"
AND r.submit_status IN (0, 1)
<if test="taskId != null">
AND r.task_id = #{taskId}
</if>
<if test="orgIds != null and orgIds.size() > 0">
AND r.org_id IN
<foreach collection="orgIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
<if test="scopeUserId != null">
AND (cto.head_user_id = #{scopeUserId}
OR cto.deputy_user_ids LIKE ('%,' || #{scopeUserId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{scopeUserId}))
</if>
ORDER BY o.org_name, r.id
</script>
""")
List<UrgeTarget> listUnsubmitted(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("orgIds") List<Long> orgIds,
@Param("scopeUserId") Long scopeUserId);
/** 一个名册项(定向催报要先确认"这个人归我管")。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT r.id AS rosterId,
r.bind_user_id AS userId,
r.real_name AS realName,
r.org_id AS orgId,
o.org_name AS orgName,
t.id AS taskId,
t.task_name AS taskName,
r.submit_status AS submitStatus
FROM collect_roster r
JOIN collect_task t
ON t.id = r.task_id AND t.deleted = 0 AND t.tenant_id = r.tenant_id
LEFT JOIN sys_org o
ON o.id = r.org_id AND o.deleted = 0 AND o.tenant_id = r.tenant_id
WHERE r.tenant_id = #{tenantId} AND r.id = #{rosterId} AND r.deleted = 0
""")
UrgeTarget findRoster(@Param("tenantId") Long tenantId, @Param("rosterId") Long rosterId);
/**
* 最近一次催报的时间(防重复骚扰)。
*
* <p>⚠️ 两个坑都踩过:
* <ol>
* <li>{@code biz_type} 是 varchar(F-26),要拿**字符串**比。写成数字会被 PG 拒绝,
* 而那个异常会被通知层的降级逻辑吞掉 → 返回 null → 去重**静默失效**
* (表现是"老师点两下,学生收到两条",谁也不会觉得是这里的问题)。</li>
* <li>用的码是**消息中心的催报码 {@code 5}**,不是契约 §6 字段表里的 {@code 6}
* (见 {@code UrgeService.BIZ_URGE} 的说明)。</li>
* </ol>
*
* <p>{@code task_id} 一起带上:去重是**按任务**的。学生在两个任务里都没交,
* 老师在这两个任务里各催一次是合理的(两次说的不是同一件事)。
* 顺手也吃上了 V1 建的 {@code (task_id, biz_type, create_time)} 索引。
*
* @return 最近一次催报的时间;从没催过时返回 null
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT MAX(send_time)
FROM collect_notify_record
WHERE tenant_id = #{tenantId} AND receiver_id = #{userId}
AND task_id = #{taskId} AND biz_type = '5'
""")
java.time.LocalDateTime lastUrgeTime(@Param("tenantId") Long tenantId,
@Param("userId") Long userId,
@Param("taskId") Long taskId);
/** 一条通知记录(查询用,带收件人姓名)。 */
class NotifyRecordRow {
private Long id;
private Long taskId;
private String channel;
private String bizType;
private Long receiverId;
private String receiverName;
private String receiverRole;
private String templateCode;
private String content;
private Integer status;
private String failReason;
private java.time.LocalDateTime sendTime;
public Long getId() { return id; }
public void setId(Long v) { this.id = v; }
public Long getTaskId() { return taskId; }
public void setTaskId(Long v) { this.taskId = v; }
public String getChannel() { return channel; }
public void setChannel(String v) { this.channel = v; }
public String getBizType() { return bizType; }
public void setBizType(String v) { this.bizType = v; }
public Long getReceiverId() { return receiverId; }
public void setReceiverId(Long v) { this.receiverId = v; }
public String getReceiverName() { return receiverName; }
public void setReceiverName(String v) { this.receiverName = v; }
public String getReceiverRole() { return receiverRole; }
public void setReceiverRole(String v) { this.receiverRole = v; }
public String getTemplateCode() { return templateCode; }
public void setTemplateCode(String v) { this.templateCode = v; }
public String getContent() { return content; }
public void setContent(String v) { this.content = v; }
public Integer getStatus() { return status; }
public void setStatus(Integer v) { this.status = v; }
public String getFailReason() { return failReason; }
public void setFailReason(String v) { this.failReason = v; }
public java.time.LocalDateTime getSendTime() { return sendTime; }
public void setSendTime(java.time.LocalDateTime v) { this.sendTime = v; }
}
/** 通知记录分页(§10.7 教师端 / §13.3 管理端同一个查询)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT n.id, n.task_id AS taskId, n.channel, n.biz_type AS bizType,
n.receiver_id AS receiverId, u.real_name AS receiverName,
n.receiver_role AS receiverRole, n.template_code AS templateCode,
n.content, n.status, n.fail_reason AS failReason, n.send_time AS sendTime
FROM collect_notify_record n
LEFT JOIN sys_user u ON u.id = n.receiver_id AND u.deleted = 0
WHERE n.tenant_id = #{tenantId}
<if test="taskId != null">
AND n.task_id = #{taskId}
</if>
<if test="bizType != null">
AND n.biz_type = #{bizType}
</if>
<if test="status != null">
AND n.status = #{status}
</if>
<if test="receiverIds != null and receiverIds.size() > 0">
AND n.receiver_id IN
<foreach collection="receiverIds" item="rid" open="(" separator="," close=")">#{rid}</foreach>
</if>
<if test="receiverIds != null and receiverIds.size() == 0">
AND 1 = 0
</if>
ORDER BY n.id DESC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<NotifyRecordRow> listRecords(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("bizType") String bizType,
@Param("status") Integer status,
@Param("receiverIds") List<Long> receiverIds,
@Param("limit") int limit,
@Param("offset") int offset);
/** 记录总数(同一个过滤条件)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(*)
FROM collect_notify_record n
WHERE n.tenant_id = #{tenantId}
<if test="taskId != null">
AND n.task_id = #{taskId}
</if>
<if test="bizType != null">
AND n.biz_type = #{bizType}
</if>
<if test="status != null">
AND n.status = #{status}
</if>
<if test="receiverIds != null and receiverIds.size() > 0">
AND n.receiver_id IN
<foreach collection="receiverIds" item="rid" open="(" separator="," close=")">#{rid}</foreach>
</if>
<if test="receiverIds != null and receiverIds.size() == 0">
AND 1 = 0
</if>
</script>
""")
long countRecords(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("bizType") String bizType,
@Param("status") Integer status,
@Param("receiverIds") List<Long> receiverIds);
/**
* 我负责的班里那些学生的 user_id(教师端看通知记录时收窄范围用)。
*
* <p>⚠️ <b>必须收窄</b>:通知记录里带**别人的姓名**,
* 一个班主任没有理由看到"别的班催过谁"。返回空列表时调用方要当成
* "什么都看不到",而不是"不过滤"。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT DISTINCT r.bind_user_id
FROM collect_roster r
JOIN collect_task_org cto
ON cto.task_id = r.task_id AND cto.org_id = r.org_id AND cto.status = 1
AND cto.tenant_id = r.tenant_id
LEFT JOIN sys_org o
ON o.id = r.org_id AND o.deleted = 0 AND o.tenant_id = r.tenant_id
WHERE r.tenant_id = #{tenantId}
AND r.deleted = 0
AND r.bind_user_id IS NOT NULL
<if test="taskId != null">
AND r.task_id = #{taskId}
</if>
AND (cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
</script>
""")
List<Long> receiverIdsOfMyOrgs(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("userId") Long userId);
/** 送达率用:按状态计数(0 待发 / 1 成功 / 2 失败 / 3 额度不足跳过)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT n.status AS status, COUNT(*) AS cnt
FROM collect_notify_record n
WHERE n.tenant_id = #{tenantId}
<if test="taskId != null">
AND n.task_id = #{taskId}
</if>
<if test="bizType != null">
AND n.biz_type = #{bizType}
</if>
GROUP BY n.status
</script>
""")
List<java.util.Map<String, Object>> countByStatus(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("bizType") String bizType);
}
@@ -0,0 +1,336 @@
package cn.tianbaotong.fill.module.notify.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.module.notify.dto.NotifyDtos;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import cn.tianbaotong.fill.module.notify.mapper.CollectMessageMapper;
import cn.tianbaotong.fill.module.notify.mapper.NotifyRecordMapper;
import cn.tianbaotong.fill.module.notify.mapper.SubscribeQuotaMapper;
import cn.tianbaotong.fill.module.qrcode.service.WxMiniProgramService;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 通知与消息中心(《03-接口设计》§13.5、§13.6)。
*
* <h3>这个类解决的核心问题:把"通知到底发出去没有"变成可回答的</h3>
*
* <p>在此之前,驳回与审批的响应里只能如实回 {@code notifySent=false}
* (通知渠道没做),前端提示"请人工告知学生"。现在:
*
* <ol>
* <li>每次通知都写一条**站内信** —— 学生下次进小程序一定看得到。
* 所以"他能不能知道"这件事有了确定答案,而不是"我们没发,他自己看着办"。</li>
* <li>如果这个学生**索取过订阅消息授权且还有额度**,就顺带发一条微信推送,
* 并把 {@code channel} 记成 {@code WECHAT_SUB};发失败就静默降级到站内信。</li>
* </ol>
*
* <h3>降级链为什么只做到"订阅消息 → 站内信"</h3>
*
* <p>契约 §13.6 的完整链条是:订阅消息 → 公众号模板 → 短信 → 仅站内信。
* 中间两级**现在做不了**,而且不是代码问题:
* <ul>
* <li>公众号模板消息要**已认证服务号 + 用户已关注**,一期没有公众号;</li>
* <li>短信要买通道与签名(按条计费,属商务动作)。</li>
* </ul>
* 硬写两级"看起来有、其实永远失败"的通道,比少写更糟:日志里会全是失败,
* 而真正的原因(没配通道)被淹掉。所以这里只实现**能真正走通的两级**,
* 并在 {@code channel} 里如实标出这条消息最终是怎么到的。
*
* <h3>降级对用户是静默的</h3>
*
* <p>契约 §13.6:学生端不显示"你的额度不够了"。额度只影响"有没有微信推送",
* 不影响"他能不能看到内容" —— 内容永远在站内信里。
*
* <p>⚠️ 注意:这个类**不吞异常**,但也**不抛**——通知失败绝不能让审核动作回滚。
* 一次驳回因为"微信挂了"而失败,是比"没通知到"更严重的问题。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {
/** 模板码(契约 §13.6 的表)。 */
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";
public static final String TPL_EXPORT_READY = "EXPORT_READY";
public static final String TPL_JOIN_APPROVE = "JOIN_APPROVE";
private final CollectMessageMapper messageMapper;
private final SubscribeQuotaMapper quotaMapper;
private final NotifyRecordMapper notifyRecordMapper;
private final UserMapper userMapper;
private final WxMiniProgramService wxService;
/**
* 发一条通知。
*
* @param req 内容与收件人(见 {@link NotifyRequest})
* @return 结果:{@code pushSent} 是"微信推送有没有发出去",
* {@code messageId} 是站内信 id(**一定存在**,除非写库本身失败)
*/
public NotifyResult notify(NotifyRequest req) {
Long tenantId = req.tenantId() == null ? UserContext.tenantId() : req.tenantId();
Long userId = req.userId();
// ① 先写站内信 —— 这是"用户一定能看到"的那条路,所以放在最前面。
// 顺序很关键:先试推送再写站内信的话,推送抛异常就可能把站内信也丢了。
CollectMessage msg = new CollectMessage();
msg.setUserId(userId);
msg.setBizType(req.bizType());
msg.setTitle(clip(req.title(), 128));
msg.setContent(clip(req.content(), 1024));
msg.setLinkType(req.linkType() == null ? CollectMessage.LINK_NONE : req.linkType());
msg.setLinkId(req.linkId());
msg.setIsRead(0);
msg.setChannel(CollectMessage.CHANNEL_IN_APP);
messageMapper.insert(msg);
// ② 试着走微信推送(有 openid + 有额度才试)
boolean pushSent = false;
String failReason = null;
/** 是否"因为额度不够而跳过" —— status 要记 3,与"发失败"分开(V1 的列注释定义了这三态) */
boolean skippedNoQuota = false;
String templateCode = req.templateCode();
if (templateCode != null && wxService.configured()) {
SysUser user = userMapper.selectById(userId);
String openid = user == null ? null : user.getOpenId();
if (openid == null || openid.isBlank()) {
failReason = "该用户没有小程序 openid(没有从微信进过)";
} else if (quotaMapper.consume(tenantId, userId, templateCode) == 0) {
// 额度不够:**这是正常情况,不是错误**(订阅消息是一次性授权)
failReason = "订阅消息额度不足(用户没有授权或已用完)";
skippedNoQuota = true;
} else {
try {
wxService.sendSubscribeMessage(openid, wxTemplateIdOf(templateCode),
"pages/tasks/tasks", buildTemplateData(req));
pushSent = true;
// 落地通道改成微信推送(站内信仍然在,学生随时能回来看)
messageMapper.update(null, Wrappers.<CollectMessage>lambdaUpdate()
.eq(CollectMessage::getId, msg.getId())
.set(CollectMessage::getChannel, CollectMessage.CHANNEL_WECHAT_SUB));
} catch (RuntimeException e) {
failReason = e.getMessage();
// ⚠️ 发失败要把额度**还回去**?不还。
// 微信的语义是"用户拒收也消耗一次授权"(43101 就是拒收),
// 还回去会让额度看起来比实际多,于是继续发、继续失败。
log.warn("订阅消息发送失败,降级为仅站内信:user={} tpl={} 原因={}",
userId, templateCode, failReason);
}
}
} else {
failReason = templateCode == null ? "没有指定模板码" : "小程序未配置(无法发订阅消息)";
log.debug("不发微信推送,仅站内信:user={} 原因={}", userId, failReason);
}
// ③ 如实记录发送通道结果(collect_notify_record 的定位就是这个)
// 注意:站内信本身"发成功"了,所以 status 记 1;推送那条另记一行。
recordNotify(tenantId, req, pushSent ? CollectMessage.CHANNEL_WECHAT_SUB
: CollectMessage.CHANNEL_IN_APP, pushSent, skippedNoQuota, failReason);
return new NotifyResult(msg.getId(), pushSent, failReason, msg.getChannel());
}
/**
* 通知结果。
*
* @param messageId 站内信 id(用户一定能在消息中心看到)
* @param pushSent 微信推送有没有真的发出去
* @param failReason 推送没发出去的原因(**只写日志,不给学生看**,见类注释)
*/
public record NotifyResult(Long messageId, boolean pushSent, String failReason, String channel) {
}
/** 通知请求。 */
public record NotifyRequest(Long tenantId, Long userId, int bizType, String title, String content,
String linkType, Long linkId, String templateCode,
Map<String, String> templateParams) {
/** 常用构造:学生端(带跳转)。{@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());
}
/**
* 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务)。
*
* <p>从 {@code linkType} 推:跳任务是任务本身,跳填报单则要靠调用方另外传
* (这里返回 null 也行,只是那条记录没法按任务筛)。
*/
public Long taskIdOrNull() {
return CollectMessage.LINK_TASK.equals(linkType) ? linkId : null;
}
}
/**
* 写一条通道发送记录(与站内信**分开**两张表,见契约 §13.5 的说明)。
*
* @param ok 这次投递成功了没有
* @param skippedNoQuota 是不是"因为额度不够跳过了" —— 记 status 3 而不是 2:
* 运营要看的是"多少人因为没授权而收不到推送"(该去引导授权),
* 而不是"推送失败了多少次"(那要查通道故障)。两者混在一起就分不出来。
*/
private void recordNotify(Long tenantId, NotifyRequest req, 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,
clip(failReason, 512));
if (n <= 0) {
log.warn("通知发送记录没写进去(返回 {} 行):user={} biz={}", n, req.userId(), req.bizType());
}
} catch (RuntimeException e) {
// 记录失败不影响主流程:站内信已经写了,用户能看到
log.warn("写通知发送记录失败(不影响站内信):user={} biz={} 原因={}",
req.userId(), req.bizType(), e.toString());
}
}
/**
* 模板数据。
*
* <p>微信订阅消息的字段名是**模板自己定义的**(thing1 / time2 / phrase3…),
* 所以只能由客户在微信后台建好模板后,用 {@code templateParams} 传进来。
* 这里做一个兜底:只有内容没有参数时,用通用字段名 —— 发不出去也不影响站内信。
*/
private Map<String, Object> buildTemplateData(NotifyRequest req) {
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)));
return data;
}
/** 模板码 → 微信模板 id(从配置取,见 {@code fill.mini-program.templates})。 */
private String wxTemplateIdOf(String templateCode) {
String id = wxService.subscribeTemplateId(templateCode);
// 没配就返回空字符串,微信会报 40037(invalid template_id)→ 降级到站内信。
// 这里**不抛异常**:模板 id 是上线前才拿到的,缺它不该让业务动作失败。
return id == null ? "" : id;
}
// ── 消息中心(§13.5)──────────────────────────
/** 我的消息(学生端与教师端同构,只是 bizType 集合不同)。 */
public NotifyDtos.MessagePageVO myMessages(Integer bizType, Boolean read,
Long pageNum, Long pageSize) {
Long uid = UserContext.uid();
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
long ps = pageSize == null || pageSize < 1 ? 20 : Math.min(pageSize, 100);
var q = Wrappers.<CollectMessage>lambdaQuery()
.eq(CollectMessage::getUserId, uid)
.eq(bizType != null, CollectMessage::getBizType, bizType)
.eq(read != null, CollectMessage::getIsRead, Boolean.TRUE.equals(read) ? 1 : 0)
.orderByDesc(CollectMessage::getId);
var page = messageMapper.selectPage(
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pn, ps), q);
NotifyDtos.MessagePageVO vo = new NotifyDtos.MessagePageVO();
vo.setList(page.getRecords().stream().map(this::toVo).toList());
vo.setTotal(page.getTotal());
vo.setUnreadCount(unreadCount(uid));
return vo;
}
private long unreadCount(Long uid) {
return messageMapper.selectCount(Wrappers.<CollectMessage>lambdaQuery()
.eq(CollectMessage::getUserId, uid)
.eq(CollectMessage::getIsRead, 0));
}
/** 标记已读(支持"全部")。 */
public NotifyDtos.ReadVO markRead(NotifyDtos.ReadReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
if (req != null && Boolean.TRUE.equals(req.getAll())) {
messageMapper.markAllRead(tenantId, uid);
} else if (req != null && req.getMessageIds() != null && !req.getMessageIds().isEmpty()) {
List<Long> ids = req.getMessageIds().stream().filter(java.util.Objects::nonNull).toList();
if (!ids.isEmpty()) {
messageMapper.markReadByIds(tenantId, uid, ids);
}
}
NotifyDtos.ReadVO vo = new NotifyDtos.ReadVO();
vo.setUnreadCount(unreadCount(uid));
return vo;
}
private NotifyDtos.MessageVO toVo(CollectMessage m) {
NotifyDtos.MessageVO vo = new NotifyDtos.MessageVO();
vo.setId(m.getId());
vo.setBizType(m.getBizType());
vo.setTitle(m.getTitle());
vo.setContent(m.getContent());
vo.setRead(m.getIsRead() != null && m.getIsRead() == 1);
vo.setLinkType(m.getLinkType());
vo.setLinkId(m.getLinkId());
vo.setChannel(m.getChannel());
vo.setCreateTime(m.getCreateTime());
return vo;
}
// ── 订阅额度(§13.6)──────────────────────────
/** 登记一次授权(学生点了"允许")。 */
public NotifyDtos.QuotaVO subscribe(NotifyDtos.SubscribeReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
String code = req == null || req.getTemplateCode() == null || req.getTemplateCode().isBlank()
? TPL_AUDIT_RESULT : req.getTemplateCode().trim();
int count = req == null || req.getSubscribeCount() == null
? 1 : Math.max(1, Math.min(10, req.getSubscribeCount()));
quotaMapper.addQuota(tenantId, uid, code, count);
return quotaOf(code);
}
/** 查额度(学生端不需要看到,运营与排查用)。 */
public NotifyDtos.QuotaVO quotaOf(String templateCode) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
SubscribeQuotaMapper.QuotaRow row = quotaMapper.find(tenantId, uid, templateCode);
NotifyDtos.QuotaVO vo = new NotifyDtos.QuotaVO();
vo.setTemplateCode(templateCode);
vo.setTotal(row == null || row.getTotal() == null ? 0 : row.getTotal());
vo.setUsed(row == null || row.getUsed() == null ? 0 : row.getUsed());
vo.setRemain(Math.max(0, vo.getTotal() - vo.getUsed()));
return vo;
}
private String clip(String s, int max) {
if (s == null) {
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
/** 便于排查:把一条通知完整打印出来(开发期用)。 */
String debug(NotifyRequest req) {
return JSONUtil.createObj()
.set("userId", req.userId())
.set("bizType", req.bizType())
.set("title", req.title())
.toString();
}
}
@@ -0,0 +1,190 @@
package cn.tianbaotong.fill.module.notify.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.module.notify.mapper.NotifyRecordMapper;
import cn.tianbaotong.fill.module.qrcode.service.WxMiniProgramService;
import cn.tianbaotong.fill.support.TenantScope;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 处理微信订阅消息回调(契约 §12.7)。
*
* <h3>微信回调在"租户隔离"体系里的位置</h3>
*
* <p>这是本项目**唯一**一个既匿名、又需要写库的入口(扫码落地页是匿名但只读)。
* "租户拦截器默认拒绝"的原则在这里必须让路 —— 微信不知道我们有几个租户。
* 所以做法是:
* <ol>
* <li>先用 {@code @InterceptorIgnore(tenantLine="true")} 的专用查询,
* 凭 <b>我们发出去时记的那条通知记录的 id</b> 反查到它属于哪个租户;</li>
* <li>再用 {@link TenantScope#runAs} 把租户上下文摆好,之后走正常的带租户写入。</li>
* </ol>
* 这样"关掉框架保护"的范围被压到**一个查询**,而不是整个写入路径 ——
* 与扫码落地页的处理方式一致(`MpSceneMapper`)。
*
* <h3>"用户拒收"必须单独记,不能混进"失败"</h3>
*
* <p>微信回的错误码里,{@code 43101}(用户拒收)的含义与"发送失败"完全不同:
* 前者是**用户的选择**,后者是我们的问题。把它们混成一类,
* 会让"推送成功率"这个指标失去意义 —— 用户拒收多的时候看起来像系统坏了,
* 而真实原因是我们索取授权的时机不对。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotifyCallbackService {
private final NotifyRecordMapper recordMapper;
private final WxMiniProgramService wxService;
/** 微信错误码:用户拒收(不是系统故障)。 */
private static final int ERR_USER_REFUSE = 43101;
/**
* 处理一次回调。
*
* @param rawBody 微信回调报文(JSON;也可能是加密的 XML,见下方说明)
* @param signature 微信签名参数(任一种)
*/
public void handle(String rawBody, String msgSignature, String timestamp, String nonce,
String signature) {
if (!wxService.configured()) {
// 没配微信时不该有回调;真收到了说明是别人在打这个地址(或配错了)。
// 明确拒绝而不是安静地记一条 —— 否则日志里会出现一堆来源不明的推送结果
throw new IllegalStateException("未配置微信,不接受回调");
}
String sig = msgSignature != null ? msgSignature : signature;
if (sig != null && !sig.isBlank() && !verify(sig, timestamp, nonce, rawBody)) {
throw new IllegalStateException("签名校验不通过");
}
if (rawBody == null || rawBody.isBlank()) {
throw new IllegalStateException("回调报文为空");
}
JSONObject json;
try {
json = JSONUtil.parseObj(rawBody);
} catch (RuntimeException e) {
// 微信的"消息推送结果"回调在部分配置下是 **XML**(不是 JSON)。
// 这里如实报错而不是猜着解析 —— 猜错会把成功当失败,那比不处理更糟
throw new IllegalStateException("回调报文不是 JSON(配置里请选 JSON 格式)");
}
Long recordId = firstLong(json, "recordId", "record_id");
String msgId = json.getStr("MsgID", json.getStr("msgid"));
Integer status = json.getInt("Status", json.getInt("status"));
Integer errcode = json.getInt("errcode", json.getInt("ErrorCode"));
// 成功/失败/拒收的口径:
// status=1 且无错误码 → 送达成功
// errcode=43101 → 用户拒收(单独一类,见类注释)
// 其它错误码 → 失败
int newStatus;
String reason = null;
if (errcode != null && errcode == ERR_USER_REFUSE) {
// 记成失败,但原因写清是"用户拒收" —— 界面据此区分"通道问题"与"用户选择"
newStatus = 2;
reason = "用户拒收(43101):他拒绝接收这类订阅消息,请在他操作时重新索取授权";
} else if (errcode != null && errcode != 0) {
newStatus = 2;
reason = "微信返回错误码 " + errcode
+ (json.getStr("errmsg") != null ? ":" + json.getStr("errmsg") : "");
} else if (status != null && status == 1) {
newStatus = 1;
} else {
newStatus = 2;
reason = "微信未报告成功状态(status=" + status + ")";
}
if (recordId == null && (msgId == null || msgId.isBlank())) {
throw new IllegalStateException("回调里既没有 recordId 也没有 MsgID,无法确定是哪一条推送");
}
// ★ 匿名入口:先无租户地查到归属,再把租户上下文摆好写入。
// 不这么做的话,写入会因为没有 tenant_id 而被拦截器拒绝(40101)——
// 而那个报错在这里完全看不出原因
Long tenantId = recordMapper.tenantIdOfRecord(recordId, msgId);
if (tenantId == null) {
// 查不到就**明确报错**,不猜一个租户去写 —— 写错租户等于把数据丢进别人家
throw new IllegalStateException("找不到对应的通知记录(recordId=" + recordId
+ " msgId=" + msgId + ")");
}
// lambda 只能捕获 effectively final 的局部变量,而下面几个都在上面被算过 —— 定成 final
final Integer st = newStatus;
final String reasonText = reason;
TenantScope.runAs(tenantId, null, Set.of(), () -> {
// lambda 只能捕获 effectively final 的局部变量,而上面几个都被重新赋过值 —— 定成 final
final Long recId = recordId;
final String mid = msgId;
int n = recId != null
? recordMapper.markCallback(recId, st, reasonText)
: recordMapper.markCallbackByMsgId(mid, st, reasonText);
if (n == 0) {
log.warn("微信回调:记录已更新过或不存在(recordId={} msgId={})", recId, mid);
}
});
log.info("微信消息回调:tenantId={} recordId={} msgId={} → status={} reason={}",
tenantId, recordId, msgId, newStatus, reason);
}
/**
* 微信签名校验:把 token、timestamp、nonce(以及加密模式下的密文)**字典序排序**
* 后拼成一个字符串做 SHA1,与签名比对。
*
* <p>这是微信协议规定的算法,不是我们的安全设计;**参数少一个会验不过**,
* 而"验不过"与"微信没配 token"看起来是一样的,所以失败时要说清缺什么。
*/
private boolean verify(String signature, String timestamp, String nonce, String body) {
String token = wxService.callbackToken();
if (token == null || token.isBlank()) {
throw new IllegalStateException("未配置 fill.mp-h5.callback-token,无法校验微信签名");
}
List<String> parts = new ArrayList<>();
parts.add(token);
if (timestamp != null) {
parts.add(timestamp);
}
if (nonce != null) {
parts.add(nonce);
}
if (body != null && !body.isBlank()) {
parts.add(body);
}
parts.sort(String::compareTo);
String joined = String.join("", parts);
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] dig = md.digest(joined.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : dig) {
hex.append(String.format("%02x", b));
}
return hex.toString().equalsIgnoreCase(signature);
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("环境不支持 SHA-1,无法校验微信签名", e);
}
}
private static Long firstLong(JSONObject json, String... keys) {
for (String k : keys) {
Object v = json.get(k);
if (v != null) {
try {
return Long.valueOf(String.valueOf(v));
} catch (NumberFormatException ignored) {
// 继续找下一个键
}
}
}
return null;
}
}
@@ -0,0 +1,419 @@
package cn.tianbaotong.fill.module.notify.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.notify.dto.NotifyAdminDtos;
import cn.tianbaotong.fill.module.notify.entity.CollectNotifyTemplate;
import cn.tianbaotong.fill.module.notify.mapper.NotifyTemplateMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 通知模板管理与手工发送(《03-接口设计》§12.7)。
*
* <h3>模板:库里的优先级高于内置,但只覆盖它自己那一条</h3>
*
* <p>列表 = 内置清单 ⟵ 用库里的行覆盖同 {@code (taskId, templateCode, channel)} 的那几条。
* 所以"恢复默认"就是删掉库里那一行(内置文案自然回来)。
*
* <h3>手工发送:为什么它必须复用 {@link NotificationService}</h3>
*
* <p>手工发送最容易写成"另一条发送路径":自己拼消息、自己推微信、自己写通知记录。
* 那样做的后果是**站内信与通知记录的口径分叉** ——
* 自动发的消息在消息中心能看到,手工发的看不到(或反之),而用户完全无法理解为什么。
* 所以这里只做"取人 → 渲染 → 逐个走 NotificationService"。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotifyTemplateService {
private final NotifyTemplateMapper templateMapper;
private final TaskMapper taskMapper;
private final CollectRosterMapper rosterMapper;
private final UserMapper userMapper;
private final NotificationService notificationService;
// ── 模板 ──────────────────────────────────────
public List<NotifyAdminDtos.TemplateVO> list(Long taskId) {
Map<String, CollectNotifyTemplate> overrides = new HashMap<>();
for (CollectNotifyTemplate t : templateMapper.selectList(
Wrappers.<CollectNotifyTemplate>lambdaQuery()
// 任务级优先于租户级:同一个 templateCode 下任务级的覆盖租户级的
.and(taskId != null, w -> w
.isNull(CollectNotifyTemplate::getTaskId)
.or().eq(CollectNotifyTemplate::getTaskId, taskId))
.isNull(taskId == null, CollectNotifyTemplate::getTaskId))) {
String key = key(t.getTaskId(), t.getTemplateCode(), t.getChannel());
// 任务级的后写,覆盖租户级的
overrides.put(key, t);
}
List<NotifyAdminDtos.TemplateVO> out = new ArrayList<>();
for (NotifyTemplates.Def d : NotifyTemplates.all()) {
String k = key(taskId, d.code(), CollectNotifyTemplate.CHANNEL_IN_APP);
CollectNotifyTemplate over = overrides.get(k);
NotifyAdminDtos.TemplateVO vo = new NotifyAdminDtos.TemplateVO();
vo.setTemplateCode(d.code());
vo.setBizType(d.bizType());
vo.setChannel(CollectNotifyTemplate.CHANNEL_IN_APP);
vo.setRemark(d.remark());
if (over == null) {
// 没改过:用内置文案,并标出来 —— 界面上要能区分"默认"与"已自定义",
// 否则用户不知道自己改过哪些、也就找不到"恢复默认"的入口
vo.setId(null);
vo.setTitle(d.title());
vo.setContent(d.content());
vo.setEnabled(true);
vo.setCustomized(false);
} else {
vo.setId(over.getId());
vo.setTitle(over.getTitle());
vo.setContent(over.getContent());
vo.setEnabled(Integer.valueOf(CollectNotifyTemplate.ENABLED_YES)
.equals(over.getEnabled()));
vo.setCustomized(true);
}
vo.setUnknownVars(NotifyTemplates.unknownVars(vo.getContent()));
out.add(vo);
}
return out;
}
/** 变量白名单(界面上的"可用变量"提示必须由服务端给,不能前端抄一份)。 */
public Map<String, String> variables() {
return new LinkedHashMap<>(NotifyTemplates.VARIABLES);
}
@Transactional(rollbackFor = Exception.class)
public NotifyAdminDtos.TemplateVO update(Long id, NotifyAdminDtos.TemplateUpdateReq req) {
CollectNotifyTemplate t = templateMapper.selectById(id);
if (t == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "模板不存在(可能是内置模板,无需修改)");
}
if (req != null && req.getTitle() != null) {
if (req.getTitle().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "标题不能为空");
}
t.setTitle(req.getTitle().length() > 120
? req.getTitle().substring(0, 120) : req.getTitle());
}
if (req != null && req.getContent() != null) {
if (req.getContent().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "内容不能为空");
}
t.setContent(req.getContent().length() > 1000
? req.getContent().substring(0, 1000) : req.getContent());
}
if (req != null && req.getEnabled() != null) {
t.setEnabled(Boolean.TRUE.equals(req.getEnabled())
? CollectNotifyTemplate.ENABLED_YES : CollectNotifyTemplate.ENABLED_NO);
}
templateMapper.updateById(t);
// 越界变量**在保存时就告警**,不等发出去才发现
List<String> unknown = NotifyTemplates.unknownVars(t.getContent());
if (!unknown.isEmpty()) {
log.warn("通知模板 {} 含越界变量 {}(渲染时会原样保留)", t.getTemplateCode(), unknown);
}
return list(t.getTaskId()).stream()
.filter(x -> t.getTemplateCode().equals(x.getTemplateCode()))
.findFirst()
.orElseThrow(() -> new BizException(ErrorCode.VALIDATION_BLOCKED, "模板读取失败"));
}
/**
* 把内置模板变成可编辑的(契约没写这一步,见 F-52)。
*
* <h3>为什么必须有它</h3>
*
* <p>契约只给了 {@code GET /notify/templates} 与 {@code PUT /notify/templates/{id}}。
* 而 GET 返回的内置模板**没有 id**(它不在库里)—— 于是 PUT 无从下手:
* 用户点"编辑"时前端拿不出一个 id 来。这条链断在第一步,而文档上看起来是通的。
*
* <p>做法:把内置文案**复制成库里的一行**(这就是"自定义"),之后 PUT 就能改了。
* 与字典的"启用/停用内置项"是同一个形状:**内置内容在代码里,用户改动落在库里**。
*/
@Transactional(rollbackFor = Exception.class)
public NotifyAdminDtos.TemplateVO customize(String code, Long taskId) {
NotifyTemplates.Def d = NotifyTemplates.byCode(code);
if (d == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "没有这个内置模板:" + code);
}
CollectNotifyTemplate exist = templateMapper.selectOne(
Wrappers.<CollectNotifyTemplate>lambdaQuery()
.eq(CollectNotifyTemplate::getTemplateCode, code)
.eq(CollectNotifyTemplate::getChannel, CollectNotifyTemplate.CHANNEL_IN_APP)
.eq(taskId != null, CollectNotifyTemplate::getTaskId, taskId)
.isNull(taskId == null, CollectNotifyTemplate::getTaskId)
.last("limit 1"));
if (exist != null) {
// 幂等:重复点"自定义"不该造出两行(唯一索引也会拦,但报错读起来像系统故障)
return list(taskId).stream()
.filter(x -> code.equals(x.getTemplateCode()))
.findFirst().orElseThrow();
}
CollectNotifyTemplate row = new CollectNotifyTemplate();
row.setTaskId(taskId);
row.setTemplateCode(d.code());
row.setBizType(d.bizType());
row.setChannel(CollectNotifyTemplate.CHANNEL_IN_APP);
row.setTitle(d.title());
row.setContent(d.content());
row.setEnabled(CollectNotifyTemplate.ENABLED_YES);
templateMapper.insert(row);
log.info("通知模板 {} 已自定义(taskId={},库里 id={})", code, taskId, row.getId());
return list(taskId).stream()
.filter(x -> code.equals(x.getTemplateCode()))
.findFirst()
.orElseThrow(() -> new BizException(ErrorCode.VALIDATION_BLOCKED, "模板读取失败"));
}
/**
* 恢复内置文案(**删掉库里的覆盖行**)。
*
* <p>契约 §12.7 没写这个接口,但没有它"自定义"就是一条单行道 ——
* 用户改错了文案之后只能自己想办法改回去,而他记不住原文。
* 记在 F-52。
*/
@Transactional(rollbackFor = Exception.class)
public void reset(String templateCode, Long taskId) {
if (NotifyTemplates.byCode(templateCode) == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "没有这个内置模板:" + templateCode);
}
// 物理删除:软删除会让"恢复默认 → 再自定义"撞唯一索引,见 mapper 的注释
int n = templateMapper.deleteOverride(templateCode, taskId);
log.info("通知模板 {}(taskId={})恢复默认,删除覆盖行 {} 条", templateCode, taskId, n);
}
// ── 手工发送 ───────────────────────────────────
/**
* 手工发送通知。
*
* <p>接收人三种给法(契约只写了 {@code receiverIds},另两种是加出来的,记在 F-52):
* <ol>
* <li>{@code receiverIds}:显式指定用户 id;</li>
* <li>{@code rosterIds}:指定名册行 —— 界面上选的是"学生",而学生可能还没绑账号,
* 这时按名册行更自然;没绑账号的人会被计入 {@code skipped} 并说明原因;</li>
* <li>{@code taskId} + {@code all=true}:整任务(催报场景)。</li>
* </ol>
*
* <p>⚠️ 三种都给时**取并集**并去重 —— 用户同时勾了名单又填了 id 是常事,
* 按"最后一个生效"处理会让一部分人安静地收不到。
*/
@Transactional(rollbackFor = Exception.class)
public NotifyAdminDtos.SendVO send(NotifyAdminDtos.SendReq req) {
UserContext.require();
if (req == null || req.getBizType() == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定 bizType");
}
if (!NotifyTemplates.BIZ_TYPES.contains(req.getBizType())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"bizType 只能是 1~7(1 任务发布 / 2 临期 / 3 驳回 / 4 通过 / 5 催报 / 6 导出 / 7 加入审批)");
}
// 收件人:并集
java.util.LinkedHashSet<Long> userIds = new java.util.LinkedHashSet<>();
int skipped = 0;
List<String> skipReasons = new ArrayList<>();
if (req.getReceiverIds() != null) {
userIds.addAll(req.getReceiverIds());
}
if (req.getRosterIds() != null && !req.getRosterIds().isEmpty()) {
for (CollectRoster r : rosterMapper.selectByIds(req.getRosterIds())) {
if (r.getBindUserId() != null) {
userIds.add(r.getBindUserId());
} else {
skipped++;
if (skipReasons.size() < 5) {
skipReasons.add(r.getRealName() + " 还没扫码核验(没有账号),消息发不到他");
}
}
}
}
if (Boolean.TRUE.equals(req.getAll()) && req.getTaskId() != null) {
for (CollectRoster r : rosterMapper.selectList(
Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, req.getTaskId()))) {
if (r.getBindUserId() != null) {
userIds.add(r.getBindUserId());
} else {
skipped++;
}
}
}
if (userIds.isEmpty() && skipped == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"没有收件人:请指定 receiverIds、rosterIds,或用 taskId + all=true");
}
// 上限:手工发送不是群发工具,一次发几千条多半是误操作
if (userIds.size() > 2000) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"一次最多发 2000 人(当前 " + userIds.size() + " 人)。整任务通知请用催报功能");
}
// 文案:优先用请求里给的,其次用模板渲染
Map<String, String> vars = varsOf(req);
String title = req.getTitle();
String content = req.getContent();
if ((title == null || title.isBlank()) && req.getTemplateCode() != null) {
NotifyAdminDtos.TemplateVO tpl = findTemplate(req.getTemplateCode(), req.getTaskId());
if (tpl == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"没有这个模板:" + req.getTemplateCode());
}
if (!tpl.getEnabled()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"模板「" + req.getTemplateCode() + "」已被停用,请先启用或改用自定义文案");
}
title = NotifyTemplates.render(tpl.getTitle(), vars);
content = NotifyTemplates.render(tpl.getContent(), vars);
}
if (title == null || title.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写标题,或指定 templateCode");
}
if (req.getExtraText() != null && !req.getExtraText().isBlank()) {
// 补充说明直接追加:它是"客服想说的一句话",塞进变量体系反而绕
content = (content == null ? "" : content + "\n") + req.getExtraText();
}
int sent = 0;
int failed = 0;
int pushed = 0;
for (Long uid : userIds) {
try {
NotificationService.NotifyResult r = notificationService.notify(
NotificationService.NotifyRequest.of(uid, req.getBizType(), title, content,
// linkType=TASK + linkId=taskId:消息中心点进去能跳到任务,
// 而且 `taskIdOrNull()` 会因此拿到 taskId,
// 通知记录上的 task_id 才不是空的(催报去重要用它)
req.getTaskId() == null ? null : cn.tianbaotong.fill.module.notify
.entity.CollectMessage.LINK_TASK,
req.getTaskId(), req.getTemplateCode()));
if (r == null) {
failed++;
} else {
// ⚠️ "发出去"的口径:站内信写成功就算 sent ——
// 微信推送是尽力而为(额度、授权、通道都可能不满足),
// 把它算进 sent 会让"发送成功 100%"变成一句谎话(与 F-30 同一口径)
sent++;
if (r.pushSent()) {
pushed++;
}
}
} catch (RuntimeException e) {
failed++;
log.warn("手工发送失败 userId={} bizType={}", uid, req.getBizType(), e);
}
}
NotifyAdminDtos.SendVO vo = new NotifyAdminDtos.SendVO();
vo.setSent(sent);
vo.setFailed(failed);
vo.setSkipped(skipped);
vo.setPushSent(pushed);
vo.setSkipReasons(skipReasons);
log.info("手工发送 bizType={} 收件人 {} 成功 {} 失败 {} 跳过 {}(其中微信推送成功 {})经办人={}",
req.getBizType(), userIds.size(), sent, failed, skipped, pushed,
UserContext.require().uid());
return vo;
}
private NotifyAdminDtos.TemplateVO findTemplate(String code, Long taskId) {
return list(taskId).stream()
.filter(t -> code.equals(t.getTemplateCode()))
.findFirst().orElse(null);
}
/**
* 渲染变量。**能查到的就填,查不到的不编** —— 契约说"越界变量渲染为空并告警",
* 而白名单内的变量如果在具体场景下没有值(例如给一批人发通知时没有"姓名"),
* 同样留空,而不是填一个假的。
*/
private Map<String, String> varsOf(NotifyAdminDtos.SendReq req) {
Map<String, String> vars = new HashMap<>();
if (req.getVars() != null) {
// 前端可以传显式的变量值(例如导出完成时的包名与下载链接)——
// 白名单外的键会被 render 忽略(原样保留),所以这里不需要过滤
vars.putAll(req.getVars());
}
if (req.getTaskId() != null) {
CollectTask t = taskMapper.selectById(req.getTaskId());
if (t != null) {
vars.putIfAbsent("任务名", t.getTaskName());
if (t.getEndTime() != null) {
vars.putIfAbsent("截止日", t.getEndTime().toLocalDate()
.format(DateTimeFormatter.ofPattern("yyyy年M月d日")));
long days = ChronoUnit.DAYS.between(LocalDate.now(), t.getEndTime().toLocalDate());
vars.putIfAbsent("剩余天数", String.valueOf(Math.max(0, days)));
}
long total = rosterMapper.selectCount(Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, t.getId()));
long done = rosterMapper.selectCount(Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, t.getId())
.ge(CollectRoster::getSubmitStatus, 2));
vars.putIfAbsent("提交进度", done + "/" + total);
}
}
// 只给一个人发时,把"姓名"填上:这是模板里最常用的变量
if (req.getReceiverIds() != null && req.getReceiverIds().size() == 1) {
SysUser u = userMapper.selectById(req.getReceiverIds().get(0));
if (u != null && u.getRealName() != null) {
vars.putIfAbsent("姓名", u.getRealName());
}
}
return vars;
}
private static String key(Long taskId, String code, String channel) {
return (taskId == null ? "T" : String.valueOf(taskId)) + "|" + code + "|" + channel;
}
/** 供其它模块按编码取模板(找不到时退回内置)。 */
public NotifyAdminDtos.TemplateVO templateOf(String code, Long taskId) {
NotifyAdminDtos.TemplateVO vo = findTemplate(code, taskId);
if (vo != null) {
return vo;
}
NotifyTemplates.Def d = NotifyTemplates.byCode(code);
if (d == null) {
return null;
}
NotifyAdminDtos.TemplateVO out = new NotifyAdminDtos.TemplateVO();
out.setTemplateCode(d.code());
out.setBizType(d.bizType());
out.setChannel(CollectNotifyTemplate.CHANNEL_IN_APP);
out.setTitle(d.title());
out.setContent(d.content());
out.setEnabled(true);
out.setCustomized(false);
return out;
}
/** 当前登录人(供 controller 打日志用)。 */
LoginUser me() {
return UserContext.get();
}
}
@@ -0,0 +1,150 @@
package cn.tianbaotong.fill.module.notify.service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 内置通知模板(契约 §12.7 的变量白名单)。
*
* <h3>为什么模板正文写在代码里而不是库表</h3>
*
* <p>见 {@link cn.tianbaotong.fill.module.notify.entity.CollectNotifyTemplate} 的类注释:
* 内置文案要能随版本升级自动生效。库里只放"被用户改过的"。
*
* <h3>变量白名单是**硬约束**,不是文档</h3>
*
* <p>契约 §12.7 写明「越界变量渲染为空并告警」。
* 这条要求有实际原因:模板是**非技术人员**在界面上改的,写错一个变量名(`{姓名 }`、`{名字}`)
* 在界面上看不出任何异常 —— 直到发出去的消息里出现一个字面的 `{名字}`,
* 或者更糟:整条消息变成空的。
*
* <p>所以渲染时:白名单内的变量替换成值;白名单外的**原样保留**并记一条 WARN
* —— 原样保留比渲染成空更有用:用户自己就能看到"哦我这个变量名写错了",
* 而渲染成空只会让他以为"系统没数据"。
*/
public final class NotifyTemplates {
private NotifyTemplates() {
}
/** 模板编码 → 内置定义。 */
public record Def(String code, int bizType, String title, String content, String remark) {
}
/** 契约 §12.7 的变量白名单:变量名 → 说明(说明会发给前端做提示)。 */
public static final Map<String, String> VARIABLES = new LinkedHashMap<>();
static {
VARIABLES.put("姓名", "学生姓名(全部场景)");
VARIABLES.put("班级", "班级名称(全部场景)");
VARIABLES.put("任务名", "收集任务名称(全部场景)");
VARIABLES.put("截止日", "任务截止日期(全部场景)");
VARIABLES.put("剩余天数", "距截止还有几天(全部场景)");
VARIABLES.put("驳回原因", "审核驳回时填写的原因(驳回场景)");
VARIABLES.put("材料名", "涉及的材料名称(驳回 / 催报)");
VARIABLES.put("提交进度", "已提交人数 / 应填人数(催报)");
VARIABLES.put("导出包名", "导出产物文件名(导出完成)");
VARIABLES.put("下载链接", "导出产物下载地址(导出完成,**有有效期**)");
}
/** 内置模板清单。 */
private static final List<Def> BUILTIN = List.of(
new Def("TASK_PUBLISH", 1, "新的填报任务",
"{任务名} 已开始收集,请在 {截止日} 前完成填报。", "任务发布时发给学生"),
new Def("TASK_DEADLINE", 2, "距截止还有 {剩余天数} 天",
"{任务名} 的截止日期是 {截止日},请尽快完成填报。", "临期提醒"),
new Def("AUDIT_REJECT", 3, "你的材料被退回",
"{任务名} 的填报被退回:{驳回原因}。涉及材料:{材料名},请重新提交。",
"审核驳回"),
new Def("AUDIT_PASS", 4, "填报已通过",
"{任务名} 的填报已审核通过,无需再操作。", "终审通过"),
new Def("URGE", 5, "请尽快完成填报",
"{任务名} 目前提交进度 {提交进度},请在 {截止日} 前完成填报。", "催报"),
new Def("EXPORT_DONE", 6, "导出完成",
"导出包 {导出包名} 已生成,请及时下载:{下载链接}(链接有有效期,过期请重新导出)",
"导出完成"),
new Def("JOIN_APPROVE", 7, "加入名单申请已处理",
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员")
);
public static List<Def> all() {
return BUILTIN;
}
public static Def byCode(String code) {
for (Def d : BUILTIN) {
if (d.code().equals(code)) {
return d;
}
}
return null;
}
/** 业务类型集合(用于校验 `POST /notify/send` 的 bizType)。 */
public static final Set<Integer> BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7);
/**
* 渲染模板。
*
* @return 渲染后的文本;越界变量**原样保留**(见类注释)
*/
public static String render(String template, Map<String, String> vars) {
if (template == null || template.isBlank()) {
return "";
}
StringBuilder sb = new StringBuilder();
int i = 0;
while (i < template.length()) {
int open = template.indexOf('{', i);
if (open < 0) {
sb.append(template, i, template.length());
break;
}
int close = template.indexOf('}', open);
if (close < 0) {
sb.append(template, i, template.length());
break;
}
sb.append(template, i, open);
String key = template.substring(open + 1, close).trim();
if (VARIABLES.containsKey(key)) {
String v = vars.get(key);
sb.append(v == null ? "" : v);
} else {
// 越界变量:原样留着,让写模板的人自己看见
sb.append(template, open, close + 1);
}
i = close + 1;
}
return sb.toString();
}
/** 找出模板里的越界变量(用于 `PUT` 时的即时提示,不等发出去才发现)。 */
public static List<String> unknownVars(String template) {
List<String> out = new java.util.ArrayList<>();
if (template == null) {
return out;
}
int i = 0;
while (i < template.length()) {
int open = template.indexOf('{', i);
if (open < 0) {
break;
}
int close = template.indexOf('}', open);
if (close < 0) {
break;
}
String key = template.substring(open + 1, close).trim();
if (!VARIABLES.containsKey(key) && !out.contains(key)) {
out.add(key);
}
i = close + 1;
}
return out;
}
}
@@ -0,0 +1,373 @@
package cn.tianbaotong.fill.module.notify.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.audit.mapper.AuditScopeMapper;
import cn.tianbaotong.fill.module.notify.dto.UrgeDtos;
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
import cn.tianbaotong.fill.module.notify.mapper.UrgeMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 催报与通知记录(《03-接口设计》§8.6、§10.7、§13.3)。
*
* <h3>这一屏的难点不在"怎么发",而在"发给谁 / 谁不该再收到"</h3>
*
* <ol>
* <li><b>只催未提交者</b>(契约 §8.6 明确写了)。"未提交"= 未填 + 草稿 ——
* 填了一半忘了交的人与完全没填的人,对老师来说要做的是同一件事。
* 已驳回的人**不催**:他要的是"改哪里",催报文案对他没意义。</li>
* <li><b>范围 = 我负责的班</b>(与初审、看板同一套三级回退)。
* 只查权限不查范围,一个班主任就能催全校 —— 而且他自己不会知道。</li>
* <li><b>不重复骚扰</b>({@code excludeRecentHours},默认 24 小时)。
* 老师点两下"一键催报"是常事,学生不该收到两条。去重责任在服务端。</li>
* <li><b>没绑定的人催不到,要说清为什么</b>:名册里有这个人、但他还没扫码核验过身份,
* 就没有 openid、也没有要发到哪个账号。这时 skip 的理由必须能读懂
* ("还没核对身份,无法触达"),而不是笼统的"发送失败"。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UrgeService {
/** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */
public static final String TPL_URGE = "NOTIFY_UNSUBMITTED";
/**
* 催报用的业务类型码 = {@code 5}。
*
* <p>⚠️ 这里用的是**消息中心**的码 {@code 5}({@code CollectMessage.BIZ_URGE}),
* 而契约 §259 那行 {@code notifyBizType} 写的是 {@code 5} 导出完成 / {@code 6} 催报 ——
* **两处对调了**(§259 与 §2655 的示例能对上,说明不是我看错行)。
*
* <p>我按消息中心的码统一(那张表是本轮新建、由我定),并把不一致记成 F-30:
* 「改哪一边」要客户确认,但「两处不一致」这件事必须有人知道 ——
* 否则下一个人写催报时会照着契约写 6,于是去重永远不命中(正是本轮踩的那个坑)。
*/
private static final int BIZ_URGE = CollectMessage.BIZ_URGE;
/** 默认去重窗口(小时)。 */
private static final int DEFAULT_EXCLUDE_HOURS = 24;
private final UrgeMapper urgeMapper;
private final AuditScopeMapper auditScopeMapper;
private final NotificationService notificationService;
// ── 定向催报(§8.6 的 `/roster/{rid}/notify`)──
/**
* 催一个名册项。
*
* <p>⚠️ 范围校验**不能省**:{@code rosterId} 是前端传来的,
* 不校验就变成"任何老师都能催任何学生"(危害不只是骚扰,
* 还会让学生以为"老师找我了"而去问错人)。
*/
public UrgeDtos.UrgeResultVO urgeOne(Long rosterId, UrgeDtos.UrgeOneReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
UrgeMapper.UrgeTarget t = urgeMapper.findRoster(tenantId, rosterId);
if (t == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "名册项不存在或已删除");
}
if (!inMyScope(tenantId, uid, t)) {
throw new BizException(ErrorCode.FORBIDDEN, "这个学生不在你负责的班级里");
}
if (t.getUserId() == null) {
// 还没核对身份 → 没有账号可发。如实说明,别报"发送失败"
return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)")));
}
String extra = req == null ? null : req.getExtraText();
NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false);
return result(List.of(detail(t, o.skipped, o.reason)));
}
// ── 一键催报(§8.6 的 `notify-unsubmitted` / §10.7)──
/** 一键催报一批班里的未提交者。 */
public UrgeDtos.UrgeResultVO urgeUnsubmitted(UrgeDtos.UrgeUnsubmittedReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
List<Long> asked = req == null ? List.of() : (req.getOrgIds() == null ? List.of() : req.getOrgIds());
Long taskId = req == null ? null : req.getTaskId();
if (asked.isEmpty() && taskId == null) {
// 两个都不给就等于"催所有任务的所有班",这是不该被一次点击触发的量
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定要催报的任务或班级");
}
// ★ 范围收口:请求里的 orgIds 必须与我负责的班取交集。
// 直接信前端传来的 orgIds,等于把"能催谁"交给前端决定。
List<Long> myOrgs = auditScopeMapper.classAuditOrgIds(tenantId, taskId, uid);
if (myOrgs.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "你没有负责任何一个班,无法催报");
}
List<Long> orgIds = asked.isEmpty() ? myOrgs
: asked.stream().filter(myOrgs::contains).toList();
if (orgIds.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "指定的班级都不在你负责的范围内");
}
List<UrgeMapper.UrgeTarget> targets =
urgeMapper.listUnsubmitted(tenantId, taskId, orgIds, uid);
if (targets.isEmpty()) {
return result(List.of());
}
int hours = req == null || req.getExcludeRecentHours() == null
? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours());
LocalDateTime now = LocalDateTime.now();
String extra = req == null ? null : req.getExtraText();
String tpl = req == null ? null : req.getTemplateCode();
// 同一个人在多个任务里被催时,只发一次(用 Set 记 user 维度)
Set<Long> sentUsers = new LinkedHashSet<>();
List<UrgeDtos.UrgeDetail> details = new ArrayList<>();
for (UrgeMapper.UrgeTarget t : targets) {
if (t.getUserId() == null) {
details.add(detail(t, true, "还没扫码核对身份,无法触达"));
continue;
}
if (!sentUsers.add(t.getUserId())) {
details.add(detail(t, true, "同一个人在多个任务里,本次只发了一条"));
continue;
}
LocalDateTime last = urgeMapper.lastUrgeTime(tenantId, t.getUserId(), t.getTaskId());
if (hours > 0 && last != null && Duration.between(last, now).toHours() < hours) {
details.add(detail(t, true, "最近 " + hours + " 小时内已催过"));
continue;
}
NotifyOutcome o = send(t, tpl, extra, t.getSubmitStatus() != null && t.getSubmitStatus() == 1);
details.add(detail(t, o.skipped, o.reason));
}
return result(details);
}
// ── 自动催报(PRD 附录 E「自动催报」,定时任务调用)──
/**
* 自动催报一个任务的**未提交者**(没有登录用户,由定时任务调)。
*
* <p>与一键催报的三点不同,都是"没有操作人"带来的:
* <ol>
* <li><b>不做范围校验</b>:没有"我负责的班"这个概念 —— 定时任务是系统行为,
* 催的是这个任务下**所有**未提交的人(本来就该催全);</li>
* <li><b>不传 scopeUserId</b>(传 null 时 SQL 会跳过范围条件);</li>
* <li><b>去重窗口仍然是 24 小时</b>:7/3/1 天三次催报之间隔着好几天,
* 但"学生今天自己交了、晚上任务又催一次"是可能的 —— 去重拦的是这种。</li>
* </ol>
*
* @param daysLeft 距截止还有几天(文案里要说,学生才知道急不急)
* @return 实际发出的条数
*/
public int autoUrge(Long tenantId, Long taskId, int daysLeft) {
List<UrgeMapper.UrgeTarget> targets =
urgeMapper.listUnsubmitted(tenantId, taskId, List.of(), null);
if (targets.isEmpty()) {
return 0;
}
LocalDateTime now = LocalDateTime.now();
Set<Long> sentUsers = new LinkedHashSet<>();
int sent = 0;
String extra = daysLeft <= 1
? "今天是最后一天了,请务必今天提交。"
: "距截止还有 " + daysLeft + " 天,请尽早提交。";
for (UrgeMapper.UrgeTarget t : targets) {
// 还没扫码核验的人**没有账号可发**,跳过(一键催报会给老师看这个理由,
// 自动催报没人看,所以只记 debug)
if (t.getUserId() == null || !sentUsers.add(t.getUserId())) {
continue;
}
LocalDateTime last = urgeMapper.lastUrgeTime(tenantId, t.getUserId(), t.getTaskId());
if (last != null && Duration.between(last, now).toHours() < DEFAULT_EXCLUDE_HOURS) {
continue;
}
NotifyOutcome o = send(t, null, extra,
t.getSubmitStatus() != null && t.getSubmitStatus() == 1);
if (!o.skipped) {
sent++;
}
}
return sent;
}
// ── 通知记录(§10.7 / §13.3)──────────────────
/**
* 通知记录。
*
* @param onlyMine 只能看跟自己有关的记录(教师端)。管理端看全部。
* ⚠️ 教师端必须为 true:通知记录里有**别人的姓名与手机号片段**,
* 一个班主任没有理由看到别的班被催过谁
*/
public UrgeDtos.NotifyRecordPageVO records(Long taskId, Integer bizType, Integer status,
Long pageNum, Long pageSize, boolean onlyMine) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
long ps = pageSize == null || pageSize < 1 ? 20 : Math.min(pageSize, 100);
List<Long> receiverIds = null;
if (onlyMine) {
receiverIds = auditScopeMapper.classAuditOrgIds(tenantId, taskId, uid).isEmpty()
? List.of(-1L) // 一个班都不归我 → 什么都看不到(不是"全部")
: urgeMapper.receiverIdsOfMyOrgs(tenantId, taskId, uid);
}
long total = urgeMapper.countRecords(tenantId, taskId, bizType == null ? null : String.valueOf(bizType),
status, receiverIds);
List<UrgeMapper.NotifyRecordRow> rows = urgeMapper.listRecords(tenantId, taskId,
bizType == null ? null : String.valueOf(bizType), status, receiverIds,
(int) ps, (int) ((pn - 1) * ps));
UrgeDtos.NotifyRecordPageVO vo = new UrgeDtos.NotifyRecordPageVO();
vo.setList(rows.stream().map(this::toVo).toList());
vo.setTotal(total);
// 送达率:成功 / 全部(**跳过也算分母** —— 见 DTO 上的说明)
long ok = 0;
long all = 0;
for (var m : urgeMapper.countByStatus(tenantId, taskId,
bizType == null ? null : String.valueOf(bizType))) {
long c = toLong(m.get("cnt"));
all += c;
if (toLong(m.get("status")) == 1) {
ok = c;
}
}
vo.setDeliveryRate(all == 0 ? "—" : (Math.round(1000.0 * ok / all) / 10.0) + "%");
return vo;
}
private long toLong(Object o) {
return o instanceof Number n ? n.longValue() : 0L;
}
// ── 内部 ──────────────────────────────────────
/** 发一条催报。返回"跳过了没有 + 为什么"。 */
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());
}
try {
var r = notificationService.notify(new NotificationService.NotifyRequest(
null, t.getUserId(), BIZ_URGE, title, content.toString(),
CollectMessage.LINK_TASK, t.getTaskId(),
templateCode == null || templateCode.isBlank() ? TPL_URGE : templateCode,
java.util.Map.of()));
// 站内信一定发出去了 → 算 sent。微信推送没出去只影响"他多久看到"
return new NotifyOutcome(false, r.pushSent() ? null : r.failReason());
} catch (RuntimeException e) {
log.warn("催报失败:roster={} user={}", t.getRosterId(), t.getUserId(), e);
return new NotifyOutcome(true, "发送失败:" + e.getMessage());
}
}
/** 范围判断:这个人所在的班归不归我。 */
private boolean inMyScope(Long tenantId, Long uid, UrgeMapper.UrgeTarget t) {
List<Long> mine = auditScopeMapper.classAuditOrgIds(tenantId, t.getTaskId(), uid);
return t.getOrgId() != null && mine.contains(t.getOrgId());
}
private String nz(String s) {
return s == null ? "本次填报" : s;
}
private UrgeDtos.UrgeDetail detail(UrgeMapper.UrgeTarget t, boolean skipped, String reason) {
UrgeDtos.UrgeDetail d = new UrgeDtos.UrgeDetail();
d.setRosterId(t.getRosterId());
d.setUserId(t.getUserId());
d.setRealName(t.getRealName());
d.setOrgName(t.getOrgName());
d.setSkipped(skipped);
d.setReason(reason);
return d;
}
/**
* 汇总三个计数。
*
* <p>⚠️ 判定顺序是"失败 → 跳过 → 成功",因为**一个条目可能既是跳过又带原因**。
* 我第一版写成"先加 skipped、发现是失败再减一",那种加减法在加第三种状态时
* 必然出错(而且错得很难看出来:总数还是对的)。
* 这里按优先级**只归一类**,三个数相加恒等于明细条数。
*/
private UrgeDtos.UrgeResultVO result(List<UrgeDtos.UrgeDetail> details) {
UrgeDtos.UrgeResultVO vo = new UrgeDtos.UrgeResultVO();
int sent = 0;
int failed = 0;
int skipped = 0;
for (UrgeDtos.UrgeDetail d : details) {
if (d.getReason() != null && d.getReason().startsWith("发送失败")) {
failed++;
} else if (Boolean.TRUE.equals(d.getSkipped())) {
skipped++;
} else {
sent++;
}
}
vo.setSent(sent);
vo.setFailed(failed);
vo.setSkipped(skipped);
vo.setDetails(details);
return vo;
}
private UrgeDtos.NotifyRecordVO toVo(UrgeMapper.NotifyRecordRow r) {
UrgeDtos.NotifyRecordVO vo = new UrgeDtos.NotifyRecordVO();
vo.setId(r.getId());
vo.setTaskId(r.getTaskId());
vo.setChannel(r.getChannel());
vo.setBizType(r.getBizType());
vo.setReceiverId(r.getReceiverId());
vo.setReceiverName(r.getReceiverName());
vo.setReceiverRole(r.getReceiverRole());
vo.setTemplateCode(r.getTemplateCode());
vo.setContent(r.getContent());
vo.setStatus(r.getStatus());
vo.setStatusText(statusText(r.getStatus()));
vo.setFailReason(r.getFailReason());
vo.setSendTime(r.getSendTime());
return vo;
}
/** 状态文案:三态来自 V1 的列注释,**别自造第四种**。 */
private String statusText(Integer status) {
if (status == null) {
return "未知";
}
return switch (status) {
case 0 -> "待发送";
case 1 -> "已送达";
case 2 -> "发送失败";
case 3 -> "额度不足,仅站内信";
default -> "未知(" + status + ")";
};
}
/** 一次发送的结论。 */
private record NotifyOutcome(boolean skipped, String reason) {
}
}
@@ -144,6 +144,115 @@ public class WxMiniProgramService {
}
}
// ── 订阅消息(《03-接口设计》§13.6 的通道之一)──────
/**
* 发一条微信订阅消息。
*
* <p>⚠️ <b>订阅消息是一次性授权</b>:用户每点一次"允许"只能发一条。
* 所以调用方({@code NotificationService})必须**先扣额度再发**,
* 而不是"发了再说" —— 额度用尽时微信会直接返回 {@code 43101}(用户拒收),
* 而那条错误从字面上看不出是"额度不够"。
*
* @param openid 收件人的小程序 openid
* @param templateId 微信后台的模板 id(**不是**我们的 templateCode)
* @param page 点开消息跳转的小程序页面(可为 null)
* @param data 模板数据,形如 {@code {"thing1": {"value": "…"}}}
* @throws BizException 发送失败时抛出,**message 里带微信的原始 errcode/errmsg** ——
* 降级链要靠它判断"是不是额度问题"
*/
public void sendSubscribeMessage(String openid, String templateId, String page,
Map<String, Object> data) {
FillProperties.MiniProgram cfg = requireConfigured();
String url = WX_BASE + "/cgi-bin/message/subscribe/send?access_token=" + getAccessToken(cfg);
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("touser", openid);
body.put("template_id", templateId);
if (page != null && !page.isBlank()) {
body.put("page", page);
}
body.put("data", data);
body.put("miniprogram_state", cfg.envVersionOrDefault());
Map<String, Object> r = postJson(url, JSONUtil.toJsonStr(body));
Object errcode = r.get("errcode");
if (errcode == null || "0".equals(String.valueOf(errcode))) {
return;
}
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"微信订阅消息发送失败:" + errcode + " " + str(r.get("errmsg")));
}
/** 发 JSON 并解析成 Map(订阅消息的请求-响应都是 JSON,与出图不同)。 */
private Map<String, Object> postJson(String url, String jsonBody) {
HttpResponse<byte[]> resp = send(url, jsonBody);
String text = new String(resp.body(), StandardCharsets.UTF_8);
try {
Map<String, Object> m = JSONUtil.toBean(text, Map.class);
return m == null ? Map.of() : m;
} catch (RuntimeException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"微信返回了无法解析的内容(HTTP " + resp.statusCode() + ")");
}
}
/**
* 小程序是否已配置 appid + secret。
*
* <p>通知服务用它决定"要不要试推送":没配就**直接降级到站内信**,
* 而不是打一次注定失败的微信请求(那会在日志里刷一片失败,
* 让人以为"微信接口坏了",其实只是没配)。
*/
public boolean configured() {
return props.miniProgram() != null && props.miniProgram().configured();
}
/**
* 微信消息推送回调的校验 token(微信后台「服务器配置」里的那个)。
*
* <p>回调服务用它做 SHA1 验签。没配时返回 null,回调侧会**明确拒绝**
* 而不是跳过验签 —— 那个接口是匿名的、还会写库,不能敞开。
*/
/**
* 取 access_token(绑手机号要用它调微信的 `getuserphonenumber`)。
*
* <p>仍然走内部的缓存逻辑 —— 微信对 access_token 的获取有频次限制,
* 各处自己取会让"两个功能互相把对方挤下线"(新 token 一取,旧的立刻失效)。
*/
public String accessToken() {
FillProperties.MiniProgram cfg = props.miniProgram();
if (cfg == null || !cfg.configured()) {
throw new cn.tianbaotong.fill.common.exception.BizException(
cn.tianbaotong.fill.common.api.ErrorCode.SCOPE_NOT_SUPPORTED,
"没配置小程序,无法调用微信接口");
}
return getAccessToken(cfg);
}
public String callbackToken() {
return props.miniProgram() == null ? null : props.miniProgram().callbackToken();
}
/**
* 模板码 → 微信模板 id。
*
* <p>模板 id 是客户在微信后台建好模板后才有的(形如
* {@code ZG8k...}),所以走配置:{@code fill.mini-program.templates.AUDIT_RESULT=xxx}。
* 没配就返回 null,调用方降级 —— **不能**用模板码顶替(微信会报 40037)。
*/
public String subscribeTemplateId(String templateCode) {
if (templateCode == null || props.miniProgram() == null) {
return null;
}
Map<String, String> tpl = props.miniProgram().templatesOrEmpty();
if (tpl == null || tpl.isEmpty()) {
return null;
}
String id = tpl.get(templateCode);
return id == null || id.isBlank() ? null : id.trim();
}
// ── 微信调用 ──────────────────────────────────
private String getAccessToken(FillProperties.MiniProgram cfg) {
@@ -0,0 +1,85 @@
package cn.tianbaotong.fill.module.validation.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.validation.dto.ValidationDtos;
import cn.tianbaotong.fill.module.validation.service.ValidationService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 校验问题清单(《03-接口设计》§12.1~12.3)。
*/
@Tag(name = "校验问题清单")
@RestController
@RequestMapping("/api/v1/validation")
@RequiredArgsConstructor
public class ValidationController {
private final ValidationService validationService;
@PostMapping("/run")
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "触发任务级全量校验",
description = "把这一轮算出来的问题**对齐**进清单:已有的更新内容、不再存在的标成已解决。"
+ "⚠️ 重跑**不会**把用户点过「已处理」的问题改回未解决 —— 那等于系统推翻用户的判断。"
+ "`scope=ISSUES_ONLY` 只重跑上轮有问题的那些人(上千人的任务里这是唯一跑得动的范围)")
public ApiResult<ValidationDtos.RunVO> run(@RequestBody ValidationDtos.RunReq req) {
return ApiResult.ok(validationService.run(req));
}
@GetMapping("/issues")
@RequirePerm(PermissionDef.AUDIT_FIRST)
@Operation(summary = "问题清单",
description = "带 `counts`(各级别数量)与 `byRule`(按规则分组,含已自动修复数)—— "
+ "客户看到「双扩展名 56 项(已自动修复 56)」才会放心。"
+ "严重的问题排在前面:第一屏必须是「最该先看的」")
public ApiResult<ValidationDtos.IssuePageVO> issues(
@RequestParam Long taskId,
@RequestParam(required = false) Integer severity,
@RequestParam(required = false) String ruleCode,
@RequestParam(required = false) Long orgId,
@RequestParam(required = false) Boolean resolved,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
return ApiResult.ok(validationService.issues(taskId, severity, ruleCode, orgId, resolved,
pageNum, pageSize));
}
@GetMapping("/issues/export")
@RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
@Operation(summary = "导出异常清单 Excel",
description = "列:姓名/班级/材料/规则码/级别/问题/期望值/实际值/是否自动修复/处理人。"
+ "级别写「错误/警告/提示」而不是 1/2/3,因为这**是给客户看的**。"
+ "期望值/实际值本身就是脱敏值(库里存的也是脱敏值,这里不解密)")
public ResponseEntity<byte[]> export(
@RequestParam Long taskId,
@RequestParam(required = false) Integer severity,
@RequestParam(required = false) String ruleCode,
@RequestParam(required = false) Long orgId,
@RequestParam(required = false) Boolean resolved) {
byte[] bytes = validationService.exportIssues(taskId, severity, ruleCode, orgId, resolved);
String name = "校验问题清单-" + taskId + ".xlsx";
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded)
.body(bytes);
}
}
@@ -0,0 +1,159 @@
package cn.tianbaotong.fill.module.validation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 校验问题清单(《03-接口设计》§12.1~12.3)。
*/
public final class ValidationDtos {
private ValidationDtos() {
}
/** 触发全量校验。 */
@Getter
@Setter
@Schema(description = "任务级全量校验")
public static class RunReq {
private Long taskId;
/** {@code ALL} / {@code ISSUES_ONLY} / {@code APPOINTED}。 */
private String scope = "ALL";
/** {@code scope=APPOINTED} 时必填。 */
private List<Long> submissionIds;
/** 只跑指定规则(如 {@code ["VAL-02","VAL-04"]});不传表示全跑。 */
private List<String> rules;
private Boolean async = Boolean.TRUE;
}
/** 校验运行结果。 */
@Getter
@Setter
public static class RunVO {
/** 本次运行的标识。 */
private String jobId;
private Integer estimatedSeconds;
/**
* 本次实际校验了多少份填报单。
*
* <p>契约的响应只有 {@code jobId} + {@code estimatedSeconds};这两个是**加出来的**,
* 因为一期是同步跑完的(见 F-51),不加的话界面上"跑完了没有、跑了多少"无从判断。
*/
private Integer checkedSubmissions;
/** 本次算出来的问题数(含已存在的)。 */
private Integer issuesFound;
/** 跑完之后**还剩多少未解决**的 —— 这是用户最想立刻知道的那个数。 */
private Long unresolvedLeft;
private Long elapsedMs;
}
/** 问题清单里的一条。 */
@Getter
@Setter
public static class IssueVO {
private Long id;
private Long taskId;
private Long submissionId;
private Long fileId;
private String ruleCode;
private Integer severity;
private String fieldKey;
private String title;
private String detail;
private String expectedVal;
private String actualVal;
private Boolean autoFixed;
private Boolean resolved;
private Long resolvedBy;
private String resolvedByName;
private LocalDateTime resolvedTime;
private LocalDateTime createTime;
// ── 便利字段:清单上一定要显示"这是谁、哪个班、什么时候" ──
// 不加的话前端要为每一行再查一次名册(N+1),而清单动辄几百行
private Long rosterId;
private String realName;
private Long orgId;
private String orgName;
/** 材料名(把 {@code extra.materialCode} 或文件名转成人能看懂的中文)。 */
private String materialName;
}
/** 按规则分组的统计。 */
@Getter
@Setter
public static class RuleStat {
private String ruleCode;
private String title;
private long count;
private long autoFixed;
}
/** 级别计数。 */
@Getter
@Setter
public static class SeverityCounts {
private long severity1;
private long severity2;
private long severity3;
}
/** 问题清单响应。 */
@Getter
@Setter
public static class IssuePageVO {
private List<IssueVO> list = new ArrayList<>();
private long total;
private SeverityCounts counts = new SeverityCounts();
private List<RuleStat> byRule = new ArrayList<>();
}
}
@@ -0,0 +1,77 @@
package cn.tianbaotong.fill.module.validation.entity;
import cn.tianbaotong.fill.support.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 一条校验问题({@code collect_validation_issue},契约 §3.9 的 {@code ValidationIssueVO})。
*
* <h3>这张表的存在方式与别的表不同</h3>
*
* <p>它不是"业务的当前状态",而是**历次校验的快照**:
* {@code POST /validation/run} 每跑一次,就把这一次算出来的问题**对齐**进表里 ——
* 已经存在的更新标题/详情,不再存在的标成"已解决"。
*
* <p>所以有两件事必须说清:
* <ol>
* <li><b>问题不删,只"解决"</b>({@code resolved})。保留历史才能回答
* "上轮 47 个问题,这轮还剩 5 个" —— 而那是这个界面的全部意义。
* 因此 {@code deleted} 这一列会一直是 0(基类要求有它,见 V14 的注释)。</li>
* <li><b>重跑不能"复活"已解决的问题</b>:用户在界面上点了"已处理",
* 下次跑校验时如果那一条**仍然算出来**,我们不能把它改回未解决 ——
* 那等于用户的判断被系统推翻。所以重跑只更新内容,不碰 {@code resolved}。</li>
* </ol>
*
* <p>只有一种情况系统会自己把 {@code resolved} 置 1:**这一轮没再算出来**
* (问题真的修好了),此时 {@code resolvedBy} 留空,表示"系统判定"而不是"某人处理的"。
*/
@Getter
@Setter
@TableName("collect_validation_issue")
public class CollectValidationIssue extends BaseEntity {
private Long taskId;
private Long submissionId;
/** 关联文件(可为空:表单类问题没有文件)。 */
private Long fileId;
/** PRD §5.7 的规则编号,如 {@code VAL-05}。 */
private String ruleCode;
/** {@code 1} 信息 / {@code 2} 警告 / {@code 3} 错误。口径见 {@code SubmitDtos.Issue}。 */
private Integer severity;
/** 关联表单字段(表单类问题)。 */
private String fieldKey;
private String title;
private String detail;
/** 期望值(**脱敏**)。 */
private String expectedVal;
/** 实际值(**脱敏**)。 */
private String actualVal;
/** 是否已**自动**修复(例如系统把"身份证.jpg.jpg"改成了规范名)。 */
private Integer autoFixed;
private Integer resolved;
/** 处理人;为空而 {@code resolved=1} 时表示"系统判定已修复"。 */
private Long resolvedBy;
private LocalDateTime resolvedTime;
public static final int AUTO_FIXED_NO = 0;
public static final int AUTO_FIXED_YES = 1;
public static final int RESOLVED_NO = 0;
public static final int RESOLVED_YES = 1;
}
@@ -0,0 +1,81 @@
package cn.tianbaotong.fill.module.validation.mapper;
import cn.tianbaotong.fill.module.validation.entity.CollectValidationIssue;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
/**
* 校验问题清单。
*/
public interface ValidationIssueMapper extends BaseMapper<CollectValidationIssue> {
/**
* 按规则分组统计(契约 §12.2 的 {@code byRule})。
*
* <p>{@code autoFixed} 与 {@code count} 一起返回,因为它们回答的是两个不同的问题:
* 「这条规则命中了多少」与「其中多少是我不用管的」。界面要把后者显示成"已自动修复",
* 客户看到"双扩展名 56 项(已自动修复 56)"才会放心。
*
* <p>⚠️ <b>别名必须加双引号</b>:PostgreSQL 对未加引号的标识符一律转小写,
* 写成 {@code AS ruleCode} 返回的键其实是 {@code rulecode},Java 侧取到的就是 null ——
* 而 SQL 本身完全正确,日志里也看不出问题(查询成功、行数也对)。
*
* <p>⚠️ 统计**不按 {@code resolved} 过滤** —— 一次校验的全貌要保留,
* 否则"自动修复了多少"永远显示 0(自动修复的那些本来就是已解决状态)。
*/
@Select("""
SELECT rule_code AS "ruleCode",
MIN(title) AS title,
COUNT(*) AS cnt,
SUM(CASE WHEN auto_fixed = 1 THEN 1 ELSE 0 END) AS "autoFixed"
FROM collect_validation_issue
WHERE task_id = #{taskId} AND deleted = 0
GROUP BY rule_code
ORDER BY rule_code
""")
List<Map<String, Object>> byRule(@Param("taskId") Long taskId);
/** 级别计数(契约 §12.2 的 {@code counts})。 */
@Select("""
SELECT severity AS severity, COUNT(*) AS cnt
FROM collect_validation_issue
WHERE task_id = #{taskId} AND deleted = 0
GROUP BY severity
""")
List<Map<String, Object>> countBySeverity(@Param("taskId") Long taskId);
/**
* 把这一轮**没再算出来**的问题标成已解决。
*
* <p>{@code resolved_by} 留空表示"系统判定"(问题真的修好了),
* 与"某人在界面上点了已处理"区分开 —— 这两种"已解决"的可信度不一样。
*
* <p>⚠️ 用 {@code &lt;script&gt;} 包住:{@code @Update} 里不带 {@code <script>} 时
* MyBatis **不做 XML 解析**,而这里用了 {@code <foreach>}。
* 另外 {@code <} 在 XML 里必须写成 {@code &lt;}(本项目已在这上面栽过,见 F-23)。
*/
@Update("""
<script>
UPDATE collect_validation_issue
SET resolved = 1, resolved_time = NOW(), update_time = NOW()
WHERE task_id = #{taskId} AND deleted = 0 AND resolved = 0
AND id NOT IN
<foreach collection="keepIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</script>
""")
int resolveMissing(@Param("taskId") Long taskId, @Param("keepIds") List<Long> keepIds);
/** 一次校验之后,这个任务还剩多少未解决问题(写进响应,让用户立刻知道结果)。 */
@Select("""
SELECT COUNT(*) FROM collect_validation_issue
WHERE task_id = #{taskId} AND deleted = 0 AND resolved = 0
""")
long countUnresolved(@Param("taskId") Long taskId);
}
@@ -0,0 +1,560 @@
package cn.tianbaotong.fill.module.validation.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
import cn.tianbaotong.fill.module.fill.dto.SubmitDtos;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.fill.service.SubmissionValidator;
import cn.tianbaotong.fill.module.material.service.MaterialDefService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.validation.dto.ValidationDtos;
import cn.tianbaotong.fill.module.validation.entity.CollectValidationIssue;
import cn.tianbaotong.fill.module.validation.mapper.ValidationIssueMapper;
import cn.tianbaotong.fill.module.org.entity.SysOrg;
import cn.tianbaotong.fill.module.org.mapper.OrgMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 校验问题清单(《03-接口设计》§12.1~12.3)。
*
* <h3>这一层的价值不在"跑校验",而在"把结果对齐成一件事"</h3>
*
* <p>真正的校验逻辑早在 {@link SubmissionValidator} 里(提交、导出前置检查、审核详情
* 三处都在用它)。这里做的事是:**把"某一刻算出来的问题集合"落成可以反复看的清单**,
* 并且让两次校验之间能对比。
*
* <h3>重跑的语义(这是最容易做错的地方)</h3>
*
* <p>三种情况必须分开处理:
* <ol>
* <li><b>算出来了、表里也有</b> → 只更新标题/详情/级别,<b>不碰 {@code resolved}</b>。
* 否则用户在界面上点过"已处理",下一次跑校验又变回未解决 ——
* 那等于系统推翻用户的判断,而这会让人再也不敢点那个按钮。</li>
* <li><b>算出来了、表里没有</b> → 插入。</li>
* <li><b>表里有、这次没算出来</b> → 标成已解决({@code resolvedBy} 留空 =
* 系统判定)。这一条是"修好了"的唯一证据,也是"上轮 47 → 这轮 5"的来源。</li>
* </ol>
*
* <p>对齐用的是**同一个键**:{@code (submissionId, ruleCode, fileId, fieldKey)}。
* 键选错会让清单要么重复膨胀、要么把不同的问题合成一条。选这四个是因为它们正好
* 构成"同一个人的同一个问题上"的最小标识:规则说"什么问题",
* 文件/字段说"哪一处"。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ValidationService {
private final ValidationIssueMapper issueMapper;
private final SubmissionMapper submissionMapper;
private final CollectRosterMapper rosterMapper;
private final TaskMapper taskMapper;
private final FileMapper fileMapper;
private final OrgMapper orgMapper;
private final UserMapper userMapper;
private final SubmissionValidator validator;
private final MaterialDefService materialDefService;
// ── §12.1 触发 ────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public ValidationDtos.RunVO run(ValidationDtos.RunReq req) {
if (req == null || req.getTaskId() == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定要校验的任务");
}
CollectTask task = taskMapper.selectById(req.getTaskId());
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
String scope = req.getScope() == null ? "ALL" : req.getScope().toUpperCase();
List<CollectSubmission> subs;
switch (scope) {
case "APPOINTED" -> {
if (req.getSubmissionIds() == null || req.getSubmissionIds().isEmpty()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"scope=APPOINTED 时必须指定 submissionIds");
}
subs = submissionMapper.selectByIds(req.getSubmissionIds()).stream()
.filter(s -> task.getId().equals(s.getTaskId()))
.toList();
}
case "ISSUES_ONLY" -> {
// 只重跑"上轮有问题的人" —— 大任务(上千人)里这是唯一跑得动的范围
Set<Long> bad = new LinkedHashSet<>();
for (CollectValidationIssue i : issueMapper.selectList(
Wrappers.<CollectValidationIssue>lambdaQuery()
.eq(CollectValidationIssue::getTaskId, task.getId())
.eq(CollectValidationIssue::getResolved,
CollectValidationIssue.RESOLVED_NO))) {
bad.add(i.getSubmissionId());
}
if (bad.isEmpty()) {
ValidationDtos.RunVO vo = new ValidationDtos.RunVO();
vo.setJobId(jobId(task.getId()));
vo.setEstimatedSeconds(0);
vo.setCheckedSubmissions(0);
vo.setIssuesFound(0);
vo.setUnresolvedLeft(0L);
vo.setElapsedMs(0L);
return vo;
}
subs = submissionMapper.selectByIds(bad);
}
default -> subs = submissionMapper.selectList(
Wrappers.<CollectSubmission>lambdaQuery()
.eq(CollectSubmission::getTaskId, task.getId()));
}
Set<String> onlyRules = req.getRules() == null ? Set.of()
: new LinkedHashSet<>(req.getRules());
long t0 = System.currentTimeMillis();
Map<Long, CollectRoster> rosters = new HashMap<>();
if (!subs.isEmpty()) {
for (CollectRoster r : rosterMapper.selectByIds(subs.stream()
.map(CollectSubmission::getRosterId).filter(java.util.Objects::nonNull)
.distinct().toList())) {
rosters.put(r.getId(), r);
}
}
List<Long> keepIds = new ArrayList<>();
int found = 0;
int checked = 0;
for (CollectSubmission s : subs) {
CollectRoster r = s.getRosterId() == null ? null : rosters.get(s.getRosterId());
List<SubmitDtos.Issue> issues;
try {
issues = validator.validate(s, task, r);
} catch (RuntimeException e) {
// 单份校验抛异常不该让整次校验失败 —— 那一份的问题本身就要被记下来,
// 而不是让用户"点了没反应"
log.warn("全量校验:填报单 {} 校验失败", s.getId(), e);
continue;
}
checked++;
for (SubmitDtos.Issue i : issues) {
if (!onlyRules.isEmpty() && !onlyRules.contains(i.ruleCode())) {
continue;
}
found++;
keepIds.add(upsert(task.getId(), s.getId(), i));
}
}
// 这一轮没再算出来的 → 已解决(见类注释第 3 条)
int autoResolved = 0;
if (!keepIds.isEmpty()) {
autoResolved = issueMapper.resolveMissing(task.getId(), keepIds);
} else if (checked > 0) {
// 一份问题都没有:把该任务所有未解决的都收掉。
// ⚠️ 只有**真的跑过**(checked>0)才这么做 —— 否则"范围为 0 人"会被误判成
// "所有问题都修好了",那是把没查当成通过
autoResolved = issueMapper.resolveMissing(task.getId(), List.of(-1L));
}
ValidationDtos.RunVO vo = new ValidationDtos.RunVO();
vo.setJobId(jobId(task.getId()));
vo.setEstimatedSeconds(0);
vo.setCheckedSubmissions(checked);
vo.setIssuesFound(found);
vo.setUnresolvedLeft(issueMapper.countUnresolved(task.getId()));
vo.setElapsedMs(System.currentTimeMillis() - t0);
log.info("全量校验完成 taskId={} scope={} 校验 {} 份 / 发现 {} 条 / 自动收掉 {} 条 / 剩余未解决 {} / 耗时 {}ms",
task.getId(), scope, checked, found, autoResolved, vo.getUnresolvedLeft(),
vo.getElapsedMs());
return vo;
}
/**
* 对齐一条问题,返回它的 id。
*
* <p>为什么不用数据库的 upsert({@code ON CONFLICT}):那个唯一索引用了
* {@code COALESCE(file_id,0)} 这种**表达式索引**,{@code ON CONFLICT} 要写一模一样的
* 表达式才认得它,写错就是静默不生效。查一次再决定插入或更新更好读,
* 而这份数据量(一个任务几百条)不值得为它优化。
*/
private Long upsert(Long taskId, Long submissionId, SubmitDtos.Issue issue) {
Map<String, Object> extra = issue.extra() == null ? Map.of() : issue.extra();
Long fileId = extra.get("fileId") == null ? null
: Long.valueOf(String.valueOf(extra.get("fileId")));
String fieldKey = extra.get("fieldKey") == null ? null
: String.valueOf(extra.get("fieldKey"));
CollectValidationIssue exist = issueMapper.selectOne(
Wrappers.<CollectValidationIssue>lambdaQuery()
.eq(CollectValidationIssue::getTaskId, taskId)
.eq(CollectValidationIssue::getSubmissionId, submissionId)
.eq(CollectValidationIssue::getRuleCode, issue.ruleCode())
.eq(fileId != null, CollectValidationIssue::getFileId, fileId)
.isNull(fileId == null, CollectValidationIssue::getFileId)
.eq(fieldKey != null, CollectValidationIssue::getFieldKey, fieldKey)
.isNull(fieldKey == null, CollectValidationIssue::getFieldKey)
.last("limit 1"));
CollectValidationIssue row = exist == null ? new CollectValidationIssue() : exist;
row.setTaskId(taskId);
row.setSubmissionId(submissionId);
row.setFileId(fileId);
row.setRuleCode(issue.ruleCode());
row.setSeverity(issue.severity());
row.setFieldKey(fieldKey);
row.setTitle(clip(issue.title(), 128));
row.setDetail(clip(issue.detail(), 512));
// 期望值/实际值:校验器把它们放在 extra 里,且**已经脱敏**
// (明文只在导入与门禁时存在,这里永远拿不到明文,也不该拿)
row.setExpectedVal(clip(str(extra.get("expected")), 256));
row.setActualVal(clip(str(extra.get("actual")), 256));
if (exist == null) {
row.setAutoFixed(CollectValidationIssue.AUTO_FIXED_NO);
row.setResolved(CollectValidationIssue.RESOLVED_NO);
issueMapper.insert(row);
} else {
// ⚠️ 刻意不碰 resolved / resolvedBy —— 见类注释第 1 条
issueMapper.updateById(row);
}
return row.getId();
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
private static String clip(String s, int max) {
if (s == null) {
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
/**
* 作业号。
*
* <p>一期是**同步**跑完的(见 F-51),所以这个 id 只表示"本次运行",
* 没有可以查询的状态。不编一个"查不到"的作业表出来,而是把这个事实写进注释与文档。
*/
private String jobId(Long taskId) {
return "v_" + taskId + "_" + Long.toString(System.currentTimeMillis(), 36);
}
// ── §12.2 清单 ────────────────────────────────
public ValidationDtos.IssuePageVO issues(Long taskId, Integer severity, String ruleCode,
Long orgId, Boolean resolved,
int pageNum, int pageSize) {
if (taskId == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定任务");
}
// 按班级筛:先查出这个班的名册 id,再筛填报单 ——
// 问题表里没有 org_id(契约 §3.9 也没这一列),所以这条筛选必然要绕一圈
List<Long> subIdsOfOrg = null;
if (orgId != null) {
List<Long> rosterIds = rosterMapper.selectList(
Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, taskId)
.eq(CollectRoster::getOrgId, orgId))
.stream().map(CollectRoster::getId).toList();
if (rosterIds.isEmpty()) {
return new ValidationDtos.IssuePageVO();
}
subIdsOfOrg = submissionMapper.selectList(
Wrappers.<CollectSubmission>lambdaQuery()
.eq(CollectSubmission::getTaskId, taskId)
.in(CollectSubmission::getRosterId, rosterIds))
.stream().map(CollectSubmission::getId).toList();
if (subIdsOfOrg.isEmpty()) {
return new ValidationDtos.IssuePageVO();
}
}
var q = Wrappers.<CollectValidationIssue>lambdaQuery()
.eq(CollectValidationIssue::getTaskId, taskId)
.eq(severity != null, CollectValidationIssue::getSeverity, severity)
.eq(ruleCode != null && !ruleCode.isBlank(),
CollectValidationIssue::getRuleCode, ruleCode)
.eq(resolved != null, CollectValidationIssue::getResolved,
Boolean.TRUE.equals(resolved)
? CollectValidationIssue.RESOLVED_YES
: CollectValidationIssue.RESOLVED_NO)
.in(subIdsOfOrg != null, CollectValidationIssue::getSubmissionId, subIdsOfOrg)
// 严重的排前面:清单的第一屏必须是"最该先看的"
.orderByDesc(CollectValidationIssue::getSeverity)
.orderByDesc(CollectValidationIssue::getId);
var page = issueMapper.selectPage(
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 200)), q);
ValidationDtos.IssuePageVO vo = new ValidationDtos.IssuePageVO();
vo.setList(toVos(page.getRecords(), taskId));
vo.setTotal(page.getTotal());
vo.setCounts(counts(taskId));
vo.setByRule(byRule(taskId));
return vo;
}
private ValidationDtos.SeverityCounts counts(Long taskId) {
ValidationDtos.SeverityCounts c = new ValidationDtos.SeverityCounts();
for (Map<String, Object> m : issueMapper.countBySeverity(taskId)) {
int sev = ((Number) m.get("severity")).intValue();
long n = ((Number) m.get("cnt")).longValue();
switch (sev) {
case 1 -> c.setSeverity1(n);
case 2 -> c.setSeverity2(n);
case 3 -> c.setSeverity3(n);
default -> {
}
}
}
return c;
}
private List<ValidationDtos.RuleStat> byRule(Long taskId) {
List<ValidationDtos.RuleStat> out = new ArrayList<>();
for (Map<String, Object> m : issueMapper.byRule(taskId)) {
ValidationDtos.RuleStat s = new ValidationDtos.RuleStat();
s.setRuleCode((String) m.get("ruleCode"));
s.setTitle((String) m.get("title"));
s.setCount(((Number) m.get("cnt")).longValue());
Object af = m.get("autoFixed");
s.setAutoFixed(af == null ? 0 : ((Number) af).longValue());
out.add(s);
}
return out;
}
/** 把问题行补上"这是谁、哪个班、哪项材料" —— 清单上没有这些就没法用。 */
private List<ValidationDtos.IssueVO> toVos(List<CollectValidationIssue> rows, Long taskId) {
if (rows.isEmpty()) {
return List.of();
}
Map<Long, CollectSubmission> subs = new HashMap<>();
for (CollectSubmission s : submissionMapper.selectByIds(rows.stream()
.map(CollectValidationIssue::getSubmissionId).distinct().toList())) {
subs.put(s.getId(), s);
}
Map<Long, CollectRoster> rosters = new HashMap<>();
List<Long> rosterIds = subs.values().stream()
.map(CollectSubmission::getRosterId).filter(java.util.Objects::nonNull)
.distinct().toList();
if (!rosterIds.isEmpty()) {
for (CollectRoster r : rosterMapper.selectByIds(rosterIds)) {
rosters.put(r.getId(), r);
}
}
Map<Long, String> orgNames = new HashMap<>();
Map<Long, String> userNames = new HashMap<>();
for (CollectValidationIssue r : rows) {
if (r.getResolvedBy() != null && !userNames.containsKey(r.getResolvedBy())) {
SysUser u = userMapper.selectById(r.getResolvedBy());
userNames.put(r.getResolvedBy(), u == null ? null : u.getRealName());
}
}
// 材料码 → 中文名:清单上写"IDCARD"没人看得懂
Map<String, String> matNames = new HashMap<>();
for (var d : materialDefService.list(taskId)) {
matNames.put(d.getMaterialCode(), d.getMaterialName());
}
List<ValidationDtos.IssueVO> out = new ArrayList<>();
for (CollectValidationIssue r : rows) {
ValidationDtos.IssueVO vo = new ValidationDtos.IssueVO();
vo.setId(r.getId());
vo.setTaskId(r.getTaskId());
vo.setSubmissionId(r.getSubmissionId());
vo.setFileId(r.getFileId());
vo.setRuleCode(r.getRuleCode());
vo.setSeverity(r.getSeverity());
vo.setFieldKey(r.getFieldKey());
vo.setTitle(r.getTitle());
vo.setDetail(r.getDetail());
vo.setExpectedVal(r.getExpectedVal());
vo.setActualVal(r.getActualVal());
vo.setAutoFixed(Integer.valueOf(CollectValidationIssue.AUTO_FIXED_YES)
.equals(r.getAutoFixed()));
vo.setResolved(Integer.valueOf(CollectValidationIssue.RESOLVED_YES)
.equals(r.getResolved()));
vo.setResolvedBy(r.getResolvedBy());
vo.setResolvedByName(r.getResolvedBy() == null ? null : userNames.get(r.getResolvedBy()));
vo.setResolvedTime(r.getResolvedTime());
vo.setCreateTime(r.getCreateTime());
CollectSubmission s = subs.get(r.getSubmissionId());
CollectRoster ro = s == null || s.getRosterId() == null
? null : rosters.get(s.getRosterId());
if (ro != null) {
vo.setRosterId(ro.getId());
vo.setRealName(ro.getRealName());
vo.setOrgId(ro.getOrgId());
if (ro.getOrgId() != null) {
orgNames.computeIfAbsent(ro.getOrgId(), id -> {
SysOrg o = orgMapper.selectById(id);
return o == null ? null : o.getOrgName();
});
vo.setOrgName(orgNames.get(ro.getOrgId()));
}
}
// 材料名:优先看问题自己带的 materialCode(校验器会给),
// 其次看它挂在哪个文件上 —— 两条路都拿不到就留空,不猜
String matCode = matCodeOf(r, matNames);
vo.setMaterialName(matCode == null ? null
: matNames.getOrDefault(matCode, matCode));
out.add(vo);
}
return out;
}
/**
* 问题对应的材料码。
*
* <p>问题行本身**不存** {@code materialCode}(契约 §3.9 的列里没有它,
* 列在"材料"上的信息由 {@code file_id} 表达)。所以:文件类问题反查文件,
* 表单类问题没有材料。**不猜**:拿不到就返回空,清单上留空比写错强。
*/
private String matCodeOf(CollectValidationIssue r, Map<String, String> matNames) {
if (r.getFileId() == null) {
return null;
}
// 文件 → 它属于哪种材料:靠 standard_name 里的材料名反查不稳,
// 所以这里只做"文件名里出现过的材料名"这种弱匹配,拿不到就返回空
CollectFile f = fileMapper.selectById(r.getFileId());
if (f == null) {
return null;
}
String name = f.getStandardName() == null ? f.getOriginalName() : f.getStandardName();
if (name == null) {
return null;
}
for (Map.Entry<String, String> en : matNames.entrySet()) {
if (name.contains(en.getValue())) {
return en.getKey();
}
}
return null;
}
// ── §12.3 导出 ────────────────────────────────
/**
* 异常清单 Excel(契约 §12.3)。
*
* <p>列:姓名 / 班级 / 材料 / 规则码 / 级别 / 问题 / 期望值 / 实际值 / 是否自动修复 / 处理人。
*
* <p>⚠️ 这一份是**给客户看**的,所以:级别写"错误/警告/提示"而不是 1/2/3;
* "是否自动修复"写"是/否";导出的是**脱敏后**的期望值/实际值(库里存的就是脱敏值,
* 这里不需要也不应该再解密)。
*/
public byte[] exportIssues(Long taskId, Integer severity, String ruleCode, Long orgId,
Boolean resolved) {
ValidationDtos.IssuePageVO page = issues(taskId, severity, ruleCode, orgId, resolved, 1, 200);
List<ValidationDtos.IssueVO> rows = page.getList();
// 一次最多导 200 行会让客户以为"只有 200 个问题",所以分页取全量
int total = (int) page.getTotal();
if (total > rows.size()) {
List<ValidationDtos.IssueVO> all = new ArrayList<>(rows);
int pageNum = 2;
while (all.size() < total && pageNum <= 100) {
all.addAll(issues(taskId, severity, ruleCode, orgId, resolved, pageNum, 200)
.getList());
pageNum++;
}
rows = all;
}
try (var wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
var bos = new java.io.ByteArrayOutputStream()) {
var sheet = wb.createSheet("校验问题清单");
String[] header = {"姓名", "班级", "材料", "规则码", "级别", "问题",
"期望值", "实际值", "是否自动修复", "处理人"};
var head = sheet.createRow(0);
for (int i = 0; i < header.length; i++) {
head.createCell(i).setCellValue(header[i]);
}
int r = 1;
for (ValidationDtos.IssueVO v : rows) {
var row = sheet.createRow(r++);
row.createCell(0).setCellValue(nz(v.getRealName()));
row.createCell(1).setCellValue(nz(v.getOrgName()));
row.createCell(2).setCellValue(nz(v.getMaterialName()));
row.createCell(3).setCellValue(nz(v.getRuleCode()));
row.createCell(4).setCellValue(severityText(v.getSeverity()));
row.createCell(5).setCellValue(nz(v.getTitle()));
row.createCell(6).setCellValue(nz(v.getExpectedVal()));
row.createCell(7).setCellValue(nz(v.getActualVal()));
row.createCell(8).setCellValue(Boolean.TRUE.equals(v.getAutoFixed()) ? "是" : "否");
row.createCell(9).setCellValue(v.getResolvedBy() == null
? (Boolean.TRUE.equals(v.getResolved()) ? "系统判定已修复" : "")
: nz(v.getResolvedByName()));
}
int[] widths = {12, 22, 14, 12, 8, 40, 22, 22, 14, 14};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i] * 256);
}
wb.write(bos);
return bos.toByteArray();
} catch (java.io.IOException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "生成 Excel 失败:" + e.getMessage());
}
}
private static String severityText(Integer sev) {
return switch (sev == null ? 0 : sev) {
case 1 -> "提示";
case 2 -> "警告";
case 3 -> "错误";
default -> "";
};
}
private static String nz(String s) {
return s == null ? "" : s;
}
/** 供其它模块登记"系统自动修复过的问题"(例如上传时把文件名规范化了)。 */
@Transactional(rollbackFor = Exception.class)
public void recordAutoFixed(Long taskId, Long submissionId, Long fileId, String ruleCode,
String title, String expected, String actual) {
LoginUser me = UserContext.get();
CollectValidationIssue row = new CollectValidationIssue();
row.setTaskId(taskId);
row.setSubmissionId(submissionId);
row.setFileId(fileId);
row.setRuleCode(ruleCode);
// 自动修复的事**不是**待办,级别给"信息"(蓝):它出现在清单里是为了留痕,
// 而不是为了让人去处理 —— 给红色会让问题数虚高,客户一看就慌
row.setSeverity(SubmitDtos.Issue.INFO);
row.setTitle(clip(title, 128));
row.setDetail("系统已自动处理,无需人工干预");
row.setExpectedVal(clip(expected, 256));
row.setActualVal(clip(actual, 256));
row.setAutoFixed(CollectValidationIssue.AUTO_FIXED_YES);
row.setResolved(CollectValidationIssue.RESOLVED_YES);
row.setResolvedTime(LocalDateTime.now());
if (me != null) {
row.setCreateBy(me.uid());
}
issueMapper.insert(row);
}
}
@@ -0,0 +1,84 @@
-- ============================================================================
-- V14:校验问题清单的审计列 + 通知模板表
--
-- 一、`collect_validation_issue` 早就建好了,但**一直没有代码用它**
-- ---------------------------------------------------------------------------
-- V1 建表时列的列与契约 §3.9 的 `ValidationIssueVO` 完全一致(连
-- `expected_val` / `actual_val` / `auto_fixed` / `resolved_by` 都有),
-- 也就是说"当初就设计好了",只是 §12.1~12.3 三个接口没做(见盘点文档 F-50)。
--
-- 这里补的是**实体基类要求的四列**:实体继承 `BaseEntity` 就必须有
-- `create_by` / `update_by` / `update_time` / `deleted`,否则运行时才炸
-- (F-24/F-35 那类"编译期不报、运行期 column does not exist")。
-- `scripts/check-entity-columns.py` 会机械校验这一点。
--
-- ⚠️ 业务上**从不删**问题行:`deleted` 这一列会一直是 0。
-- 问题不是"删掉"而是"解决"(`resolved`)—— 保留历史才能回答
-- "上轮 47 个问题,这轮还剩 5 个"这种问题,而那正是这张表的价值。
--
-- 二、`collect_notify_template`(契约 §12.7)
-- ---------------------------------------------------------------------------
-- 契约 §12.7 有 `GET/PUT /notify/templates`,但**没有这张表**。
-- 内置模板写在代码里(与字典、内置角色同一套做法),库里只存"被改过的那些" ——
-- 这样升级版本时新增/修正的内置文案能自动生效,
-- 而用户改过的那几条保持不变(`sys_dict_disabled` 用的是同一个思路,见 V10)。
--
-- `task_id` 可空:为空是**租户级**模板,有值是该任务专属(契约里
-- `GET /notify/templates?taskId=` 的语义)。
-- ============================================================================
-- ── 一、校验问题清单 ──────────────────────────
ALTER TABLE collect_validation_issue ADD COLUMN IF NOT EXISTS create_by BIGINT;
ALTER TABLE collect_validation_issue ADD COLUMN IF NOT EXISTS update_by BIGINT;
ALTER TABLE collect_validation_issue ADD COLUMN IF NOT EXISTS update_time TIMESTAMP;
ALTER TABLE collect_validation_issue ADD COLUMN IF NOT EXISTS deleted SMALLINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN collect_validation_issue.deleted IS '业务上恒为 0:问题不删只"解决"(resolved),保留历史才能对比两轮校验';
-- 同一个"人 + 规则 + 材料/字段"只该有一行未解决的问题 —— 重复跑校验靠它做 upsert。
-- ⚠️ 用 COALESCE 把 NULL 归一,因为 PostgreSQL 的唯一索引里 NULL 互不相等,
-- 而 file_id / field_key 大量为空(表单类问题没有文件)。不归一的话
-- "同一个表单问题"会被反复插入,清单越跑越长。
CREATE UNIQUE INDEX IF NOT EXISTS uk_issue_key ON collect_validation_issue (
task_id, submission_id, rule_code,
COALESCE(file_id, 0), COALESCE(field_key, '')
);
-- ── 二、通知模板 ──────────────────────────────
CREATE TABLE IF NOT EXISTS collect_notify_template (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
task_id BIGINT,
template_code VARCHAR(64) NOT NULL,
biz_type SMALLINT NOT NULL,
channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP',
title VARCHAR(128) NOT NULL,
content VARCHAR(1024) NOT NULL,
enabled SMALLINT NOT NULL DEFAULT 1,
create_by BIGINT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by BIGINT,
update_time TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
COMMENT ON TABLE collect_notify_template IS '通知模板(契约 §12.7):库里只存**被改过**的;没改过的走代码里的内置模板,这样升级时新文案能自动生效';
COMMENT ON COLUMN collect_notify_template.task_id IS 'NULL = 租户级模板;有值 = 该任务专属(对应 GET /notify/templates?taskId=)';
COMMENT ON COLUMN collect_notify_template.channel IS 'IN_APP 站内信 / WX_SUBSCRIBE 订阅消息 / WX_OFFICIAL 公众号 / SMS 短信';
COMMENT ON COLUMN collect_notify_template.content IS '支持契约 §12.7 的变量白名单({姓名}{班级}{任务名}{截止日}{剩余天数}{驳回原因}{材料名}{提交进度}{导出包名}{下载链接});越界变量渲染为空并告警';
CREATE UNIQUE INDEX IF NOT EXISTS uk_notify_tpl ON collect_notify_template (
tenant_id, COALESCE(task_id, 0), template_code, channel
);
-- ── 三、通知记录补 `msg_id` ──────────────────────
-- 微信推送出去之后,回调里带回来的是它自己的 MsgID(不一定回我们透传的 recordId)。
-- 没有这一列的话,"这条推送最后到没到"就永远查不到 —— 而 `collect_notify_record`
-- 的整个存在意义就是回答"发出去没有、到没到"(见契约 §13.5 的说明)。
--
-- 可空:非微信通道(站内信/短信)没有 MsgID。
ALTER TABLE collect_notify_record ADD COLUMN IF NOT EXISTS msg_id VARCHAR(64);
COMMENT ON COLUMN collect_notify_record.msg_id IS '微信推送的 MsgID:回调凭它反查是哪一条(微信不一定回我们透传的 recordId,所以两条路都要有)';
CREATE INDEX IF NOT EXISTS idx_notify_msgid ON collect_notify_record (msg_id);
@@ -0,0 +1,55 @@
-- ============================================================================
-- V8:站内信(消息中心)
--
-- 为什么需要一张**新表**,而不是复用 collect_notify_record
-- ---------------------------------------------------------------------------
-- 契约 §13.5 已经把两者的分工写清楚了:
-- · `collect_notify_record` 记的是**发送通道结果**(发出去没有、到没到),
-- 字段是 channel / status / fail_reason;
-- · 消息中心记的是**站内信内容**(用户看到什么),字段是 title / content / read。
-- 二者通过 biz_type 关联。
--
-- 用一张表混装两个用途的后果很具体:
-- · "已读"是用户维度、可反复改的状态;而发送记录是**只增不改**的凭据。
-- 混在一起之后,"标记已读"就变成了修改发送记录 —— 那批数据再也不能当证据用。
-- · 推送降级链会为同一条消息写多条发送记录(订阅消息失败 → 公众号 → 短信),
-- 而消息中心里用户只该看到**一条**。
--
-- 另一个用途:**降级链的终点**。通知发不出去(额度耗尽/未关注/短信超套餐)时,
-- 系统仍然写一条站内信 —— 学生进小程序就能看到,而不是"什么都不知道"。
-- 这也是为什么驳回/审批的 notifySent 现在能如实回 true/false:
-- 站内信一定写得进,所以"用户能不能看到"这件事有了确定的答案。
-- ============================================================================
CREATE TABLE IF NOT EXISTS collect_message (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
-- 与 collect_notify_record.biz_type 同一套码(1任务发布 2临期 3驳回 4通过 5催报 6导出 7加入审批)
biz_type SMALLINT NOT NULL DEFAULT 0,
title VARCHAR(128) NOT NULL,
content VARCHAR(1024),
-- 点开消息跳哪儿:SUBMISSION / TASK / NONE
link_type VARCHAR(16),
link_id BIGINT,
is_read SMALLINT NOT NULL DEFAULT 0,
read_time TIMESTAMP,
-- 这条站内信是通过哪条链路最终落地的(WECHAT_SUB / MP_TEMPLATE / SMS / IN_APP)
channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP',
-- 审计列:业务表都带(create_by/update_by 由 FillMetaObjectHandler 填)。
-- ⚠️ 少了这两列的表现是"插入直接报 column does not exist" —— 本轮踩过一次(F-24)
create_by BIGINT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by BIGINT,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
COMMENT ON TABLE collect_message IS '站内信(消息中心内容),与 collect_notify_record(发送通道结果)通过 biz_type 关联';
COMMENT ON COLUMN collect_message.biz_type IS '1任务发布 2临期提醒 3驳回 4通过 5催报 6导出完成 7加入审批';
COMMENT ON COLUMN collect_message.link_type IS 'SUBMISSION/TASK/NONE —— 点开消息跳哪儿';
COMMENT ON COLUMN collect_message.channel IS '最终落地的通道:WECHAT_SUB/MP_TEMPLATE/SMS/IN_APP';
COMMENT ON COLUMN collect_message.is_read IS '0未读 1已读(用户维度、可改;所以不能塞进只增不改的发送记录表)';
-- 消息中心的主查询:某人的消息按时间倒序 + 未读数
CREATE INDEX IF NOT EXISTS idx_message_user ON collect_message (tenant_id, user_id, is_read, create_time DESC);
+6
View File
@@ -176,6 +176,7 @@ import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
import { compressForUpload, planFor, releaseCompressedPath, type CompressOutcome } from '@/utils/image-compress'
import { requestSubscribe, SUBSCRIBE_TPL } from '@/utils/subscribe'
import { API_BASE_URL as API_BASE } from '@/config'
/**
@@ -721,6 +722,11 @@ async function submitNow(confirmWarnings: boolean) {
content: r.statusText || '已提交,等待班主任初审',
showCancel: false
})
// 提交成功后索取订阅授权(契约 §13.6:SUBMIT_SUCCESS + AUDIT_RESULT 的索取时机)。
// **不 await**:用户可能要在弹窗上停几秒,等它会把"提交成功"的反馈拖住。
// 而且这件事失败也不影响任何东西(拿不到授权就只发站内信)。
requestSubscribe([SUBSCRIBE_TPL.SUBMIT_SUCCESS, SUBSCRIBE_TPL.AUDIT_RESULT])
.catch(() => undefined)
} else {
report.value = r
}
+335
View File
@@ -0,0 +1,335 @@
<template>
<view class="msg">
<view class="msg__wrap">
<view class="msg__bar">
<text class="msg__count">
{{ unread ? `${unread} 条未读` : '没有未读消息' }}
</text>
<text v-if="unread" class="msg__read-all" @click="readAll">全部标为已读</text>
</view>
<!-- 筛选:学生只关心"老师让我改的"和"我过了没有" -->
<view class="msg__tabs">
<text v-for="t in tabs" :key="String(t.value)"
class="msg__tab" :class="{ 'msg__tab--on': bizType === t.value }"
@click="pickBiz(t.value)">{{ t.label }}</text>
</view>
<view v-if="loading" class="msg__empty">正在读取…</view>
<view v-else-if="!list.length" class="msg__empty">
<text>还没有消息。</text>
<text class="msg__empty-hint">
材料被退回、审核通过、临近截止时,这里都会有记录。
</text>
</view>
<view v-for="m in list" :key="m.id" class="msg__card"
:class="{ 'msg__card--unread': !m.read }" @click="open(m)">
<view class="msg__head">
<text class="msg__dot" :class="dotClass(m.bizType)">{{ bizText(m.bizType) }}</text>
<text class="msg__time">{{ fmt(m.createTime) }}</text>
</view>
<text class="msg__title">{{ m.title }}</text>
<text v-if="m.content" class="msg__content">{{ m.content }}</text>
<view v-if="m.linkType && m.linkType !== 'NONE'" class="msg__link">
{{ m.linkType === 'SUBMISSION' ? '查看这份填报 ›' : '打开任务 ›' }}
</view>
</view>
<view class="msg__foot">
一期的推送渠道只有微信订阅消息(一次性授权),拿不到授权时消息**只在这里**,
请偶尔进来看看。
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 消息中心(《03-接口设计》§13.5,学生端)。
*
* ## 为什么这一屏是"通知"的最后一环
*
* 一期的推送渠道只有微信订阅消息,而它是**一次性授权**(用户每点一次「允许」只能发一条)。
* 也就是说:只要学生没授权,或授权用完了,**推送一定发不出去**。
* 那时唯一能看到消息的地方就是这里 —— 所以服务端**每次都写一条站内信**,
* 不管推送成没成功。
*
* 页面底部那句话是刻意留的:让学生知道"别只等推送"。
* 不说的话,没收到推送的人会以为"什么事都没发生"。
*/
defineOptions({ name: 'MyMessages' })
interface Message {
id: number
bizType: number
title: string
content?: string
read: boolean
linkType?: string
linkId?: number
channel?: string
createTime?: string
}
const loading = ref(false)
const list = ref<Message[]>([])
const unread = ref(0)
const bizType = ref<number | undefined>(undefined)
const tabs: Array<{ label: string, value: number | undefined }> = [
{ label: '全部', value: undefined },
{ label: '退回', value: 3 },
{ label: '通过', value: 4 },
{ label: '提醒', value: 2 }
]
const unreadText = computed(() => (unread.value ? `${unread.value} 条未读` : '没有未读消息'))
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : ''
}
/** 业务类型 → 角标文字(与后端 CollectMessage 的码一致)。 */
function bizText(t: number) {
switch (t) {
case 1: return '任务'
case 2: return '提醒'
case 3: return '退回'
case 4: return '通过'
case 5: return '催报'
case 6: return '导出'
case 7: return '审批'
default: return '消息'
}
}
function dotClass(t: number) {
if (t === 3) return 'msg__dot--bad'
if (t === 4) return 'msg__dot--ok'
return 'msg__dot--info'
}
async function load() {
loading.value = true
try {
const q = bizType.value === undefined ? '' : `&bizType=${bizType.value}`
const d = await request<{ list: Message[], total: number, unreadCount: number }>({
url: `/api/v1/mp/messages?pageSize=50${q}`,
token: token()
})
list.value = d.list || []
unread.value = d.unreadCount || 0
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
function pickBiz(v: number | undefined) {
bizType.value = v
load()
}
async function readAll() {
try {
const d = await request<{ unreadCount: number }>({
url: '/api/v1/mp/messages/read',
method: 'POST',
token: token(),
data: { all: true }
})
unread.value = d?.unreadCount ?? 0
list.value = list.value.map((m) => ({ ...m, read: true }))
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '操作失败', icon: 'none' })
}
}
/** 点开一条:标已读 + 跳到对应页面。 */
async function open(m: Message) {
if (!m.read) {
try {
const d = await request<{ unreadCount: number }>({
url: '/api/v1/mp/messages/read',
method: 'POST',
token: token(),
data: { messageIds: [m.id] }
})
unread.value = d?.unreadCount ?? unread.value
m.read = true
} catch {
// 标已读失败不拦跳转:用户点这条消息的目的是去看那件事
}
}
if (m.linkType === 'SUBMISSION' && m.linkId) {
// 填报单详情 → 进度页(那里有材料清单、问题与撤回)
uni.navigateTo({ url: `/pages/progress/progress?submissionId=${m.linkId}` })
} else if (m.linkType === 'TASK' && m.linkId) {
uni.navigateTo({ url: `/pages/fill/fill?taskId=${m.linkId}` })
}
}
onShow(load)
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.msg {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4rpx 16rpx;
}
&__count {
font-size: 28rpx;
font-weight: 600;
color: #1b2430;
}
&__read-all {
font-size: 25rpx;
color: $primary;
}
&__tabs {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
}
&__tab {
padding: 8rpx 24rpx;
font-size: 25rpx;
color: #4a5768;
background: #fff;
border-radius: 28rpx;
&--on {
color: #fff;
background: $primary;
}
}
&__empty {
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 48rpx 28rpx;
font-size: 27rpx;
line-height: 1.7;
color: #4a5768;
background: #fff;
border-radius: 16rpx;
}
&__empty-hint {
font-size: 24rpx;
color: #8b95a3;
}
&__card {
padding: 24rpx 28rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 16rpx;
&--unread {
border-left: 8rpx solid $primary;
}
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
&__dot {
padding: 4rpx 14rpx;
font-size: 21rpx;
border-radius: 6rpx;
&--bad {
color: $danger;
background: #fdecee;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--info {
color: $primary;
background: #eaf1fd;
}
}
&__time {
font-size: 22rpx;
color: #b3b9c1;
}
&__title {
display: block;
font-size: 29rpx;
font-weight: 600;
color: #1b2430;
}
&__content {
display: block;
margin-top: 8rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
}
&__link {
margin-top: 12rpx;
font-size: 25rpx;
color: $primary;
}
&__foot {
margin-top: 32rpx;
font-size: 23rpx;
line-height: 1.8;
color: #b3b9c1;
}
}
</style>
+105
View File
@@ -0,0 +1,105 @@
/**
* 订阅消息授权(《03-接口设计》§13.6)。
*
* ## 为什么要"索取授权"
*
* 微信订阅消息是**一次性授权**:用户每点一次「允许」只能发一条。
* 所以额度必须在关键节点**主动攒** —— 事后想发的时候已经来不及了
* (微信不允许"没授权就发",接口会直接返回 43101)。
*
* ## 索取时机(契约 §13.6 的表)
*
* | 模板码 | 何时要 |
* |---|---|
* | `SUBMIT_SUCCESS` | 提交成功后 |
* | `AUDIT_RESULT` | 提交成功后 |
* | `DEADLINE_REMIND` | 进入填报页时 |
* | `JOIN_APPROVE` | 申请加入名单时 |
*
* ## 三件必须守住的事
*
* 1. **H5 没有订阅消息**。H5 走公众号网页授权,不是小程序,
* 所以这里只在**小程序端**真正调用 `requestSubscribeMessage`;
* H5 上直接返回,不做无意义的调用(那会报 `requestSubscribeMessage:fail`)。
* 2. **用户拒绝不算错误**。他点「取消」是正常选择,不能弹错误提示、不能拦他的下一步
* (提交还是成功的)。所以整个函数**不抛异常**。
* 3. **登记失败不影响业务**。授权拿到了但登记接口挂了,顶多是"这次推送发不出去" ——
* 站内信仍然会写(见后端 `NotificationService`),所以他进小程序还是能看到。
* 绝不能因为登记失败让学生以为"提交没成功"。
*/
import { request } from '@/utils/request'
/** 模板码(与后端 NotificationService 的常量一致,改一边要改两边)。 */
export const SUBSCRIBE_TPL = {
SUBMIT_SUCCESS: 'SUBMIT_SUCCESS',
AUDIT_RESULT: 'AUDIT_RESULT',
DEADLINE_REMIND: 'DEADLINE_REMIND',
JOIN_APPROVE: 'JOIN_APPROVE'
} as const
export type SubscribeTemplateCode = typeof SUBSCRIBE_TPL[keyof typeof SUBSCRIBE_TPL]
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
/**
* 索取订阅授权并登记额度。
*
* @param codes 要索取的模板码(一次最多 3 个 —— 微信的限制)
* @returns 实际登记成功的模板码;H5 或用户拒绝时返回空数组(**不是错误**)
*/
export async function requestSubscribe(codes: SubscribeTemplateCode[]): Promise<string[]> {
// 用**运行期判断**而不是 `#ifdef` 条件编译:
// ① 条件编译要靠重复声明来分叉,而这里两种情况返回的是同一个 Promise ——
// 在 async 函数里写两段反而出现"不可达代码";
// ② H5 上这个 API 根本不存在,判一下就够,而且判了之后小程序端的行为完全一样
// (不依赖编译产物,出问题好排查)。
const api = (uni as unknown as {
requestSubscribeMessage?: (opt: {
tmplIds: string[]
success?: (res: Record<string, string>) => void
fail?: () => void
}) => void
}).requestSubscribeMessage
if (typeof api !== 'function') {
// H5 / 其它平台没有订阅消息。刻意**不**提示"请去小程序授权":
// 他一样能收到站内信,弹一句他做不到的事只会添乱。
return []
}
const list = codes.slice(0, 3)
const granted = await new Promise<string[]>((resolve) => {
try {
api({
tmplIds: list,
success: (res: Record<string, string>) => {
// 微信返回的形如 { ERR_OK: 'accept', AUDIT_RESULT: 'reject' },
// 只有值是 'accept' 的才算真的授权到手
resolve(list.filter((c) => res[c] === 'accept'))
},
// 用户拒绝 / 没配置模板 / 开发者工具限制 —— 都走到这里,一律当"没拿到"
fail: () => resolve([])
})
} catch {
resolve([])
}
})
if (!granted.length) {
return []
}
// 逐个登记。用 allSettled:一个模板登记失败不该影响另一个(额度是分开算的)
const results = await Promise.allSettled(granted.map((code) => request({
url: '/api/v1/mp/subscribe',
method: 'POST',
token: token(),
data: { templateCode: code, subscribeCount: 1 }
})))
return granted.filter((_, i) => results[i].status === 'fulfilled')
}
+138
View File
@@ -0,0 +1,138 @@
/**
* 催报与通知记录(《03-接口设计》§8.6、§10.7、§13.3)。
*
* ## 两个语义要先说清,不然界面文案一定写错
*
* 1. **只催未提交者**(未填 + 草稿)。已驳回的人**不催** —— 他要的是"改哪里",
* 催报文案("请尽快完成填报")对他没有意义。
* 所以 `sent` 小于"我班上的总人数"是**正常的**,不是漏了人。
* 2. **`sent` 只代表"站内信写进去了"**,不代表微信推送发出去了。
* 一期的推送靠学生的一次性订阅授权,拿不到授权就只落站内信
* (见后端 `NotificationService` 的降级链)。所以按钮文案不能写"已通知到人"。
*
* ## 状态 3 不是失败
*
* `status = 3`(额度不足跳过)意味着"内容已进站内信,但没有推送"。
* 把它显示成红色"失败",运营会去查通道故障;而实际该做的是**引导学生授权**。
* 这两种要做的动作完全不同,所以标签颜色与文案都分开。
*/
import http from '@/utils/http'
export interface UrgeDetail {
rosterId?: number
userId?: number
realName?: string
orgName?: string
skipped?: boolean
reason?: string
}
export interface UrgeResult {
sent: number
failed: number
skipped: number
details: UrgeDetail[]
}
export interface NotifyRecord {
id: number
taskId?: number
channel?: string
/** **字符串**形式的业务类型码(库里那列是 varchar,见 F-26) */
bizType?: string
receiverId?: number
receiverName?: string
receiverRole?: string
templateCode?: string
content?: string
/** 0 待发 / 1 成功 / 2 失败 / 3 额度不足跳过 */
status?: number
statusText?: string
failReason?: string
sendTime?: string
}
export interface NotifyRecordPage {
list: NotifyRecord[]
total: number
deliveryRate: string
}
/** 一键催报未提交者(§8.6)。`excludeRecentHours` 默认 24,由服务端去重。 */
export function urgeUnsubmitted(taskId: number, data: {
orgIds?: number[]
channel?: number
templateCode?: string
extraText?: string
excludeRecentHours?: number
} = {}) {
return http.post<UrgeResult>(`/tasks/${taskId}/roster/notify-unsubmitted`, {
...data,
taskId
})
}
/** 定向催报一个人(§8.6)。 */
export function urgeOne(rosterId: number, data: {
channel?: number
templateCode?: string
extraText?: string
} = {}) {
return http.post<UrgeResult>(`/roster/${rosterId}/notify`, data)
}
/** 通知记录(§13.3 管理端)。 */
export function getNotifyRecords(params: {
taskId?: number
bizType?: number
status?: number
pageNum?: number
pageSize?: number
} = {}) {
return http.get<NotifyRecordPage>('/notify/records', params)
}
/** 业务类型码 → 文案。**与后端 `CollectMessage` 的常量一致**。 */
export const BIZ_TYPES: Array<{ value: number, label: string }> = [
{ value: 1, label: '任务发布' },
{ value: 2, label: '临期提醒' },
{ value: 3, label: '驳回通知' },
{ value: 4, label: '审核通过' },
{ value: 5, label: '催报' },
{ value: 6, label: '导出完成' },
{ value: 7, label: '加入审批' }
]
export function bizTypeText(v?: string | number) {
const n = typeof v === 'string' ? Number(v) : v
return BIZ_TYPES.find((b) => b.value === n)?.label || `其它(${v})`
}
/**
* 发送状态 → 标签颜色 + 文案。
*
* ⚠️ `3`(额度不足跳过)**不是失败**:内容已经进了站内信,学生进小程序能看到。
* 显示成红色"失败"会让运营去查通道故障,而实际该做的是引导学生授权。
*/
export const NOTIFY_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '待发送', color: 'gray' },
1: { text: '已送达', color: 'green' },
2: { text: '发送失败', color: 'red' },
3: { text: '额度不足,仅站内信', color: 'orange' }
}
export function notifyStatus(status?: number) {
return NOTIFY_STATUS[status ?? -1] || { text: '未知', color: 'gray' }
}
/** 渠道码 → 文案。 */
export const CHANNEL_TEXT: Record<string, string> = {
WECHAT_SUB: '微信订阅消息',
MP_TEMPLATE: '公众号模板',
SMS: '短信',
IN_APP: '仅站内信'
}
export function channelText(c?: string) {
return CHANNEL_TEXT[c || ''] || c || '—'
}
+244
View File
@@ -0,0 +1,244 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>通知记录</template>
<template #extra>
<span class="notify__hint">
「已发出」= 站内信已写进他的消息中心;微信推送要看学生有没有授权
</span>
</template>
<!--
⚠️ 这一行是刻意留的:一期的推送渠道只有微信订阅消息(一次性授权),
拿不到授权时消息**只落站内信**。不写清这一点,
运营会把「额度不足跳过」当成系统故障去查通道。
-->
<a-alert v-if="rate" type="normal" class="notify__rate">
<template #title>
送达率 <b>{{ rate }}</b>
—— 分母含「额度不足跳过」。
跳过**不是失败**:内容已经进了学生的消息中心,只是没有推送。
要提升这个数字,该做的是引导学生授权,而不是查通道。
</template>
</a-alert>
<div class="g-row-tool">
<a-space wrap>
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
class="notify__task" :loading="taskLoading" @change="search">
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
</a-select>
<a-select v-model="query.bizType" placeholder="全部类型" allow-clear size="small"
class="notify__biz" @change="search">
<a-option v-for="b in BIZ_TYPES" :key="b.value" :value="b.value">{{ b.label }}</a-option>
</a-select>
<a-select v-model="query.status" placeholder="全部状态" allow-clear size="small"
class="notify__status" @change="search">
<a-option v-for="(v, k) in NOTIFY_STATUS" :key="k" :value="Number(k)">{{ v.text }}</a-option>
</a-select>
<a-button size="small" @click="load">
<template #icon><IconRefresh /></template>
刷新
</a-button>
</a-space>
</div>
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1100 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="时间" :width="150">
<template #cell="{ record }">
<span class="notify__muted">{{ fmt(record.sendTime) }}</span>
</template>
</a-table-column>
<a-table-column title="收件人" :width="140">
<template #cell="{ record }">
<div>{{ record.receiverName || '—' }}</div>
<div class="notify__muted">ID {{ record.receiverId }}</div>
</template>
</a-table-column>
<a-table-column title="类型" :width="110">
<template #cell="{ record }">
<a-tag size="small">{{ bizTypeText(record.bizType) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="内容" :width="300">
<template #cell="{ record }">
<div class="notify__content">{{ record.content || '—' }}</div>
</template>
</a-table-column>
<a-table-column title="渠道" :width="130">
<template #cell="{ record }">
<span class="notify__muted">{{ channelText(record.channel) }}</span>
</template>
</a-table-column>
<a-table-column title="状态" :width="150">
<template #cell="{ record }">
<a-tag size="small" :color="notifyStatus(record.status).color">
{{ record.statusText || notifyStatus(record.status).text }}
</a-tag>
<!-- 失败原因要露出来:不然「发送失败」这四个字没法排查 -->
<div v-if="record.failReason" class="notify__fail" :title="record.failReason">
{{ record.failReason }}
</div>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { NotifyRecord } from '@/apis/xt/notify'
import { IconRefresh } from '@arco-design/web-vue/es/icon'
import { computed, reactive, ref } from 'vue'
import {
BIZ_TYPES,
bizTypeText,
channelText,
getNotifyRecords,
NOTIFY_STATUS,
notifyStatus
} from '@/apis/xt/notify'
import { getTaskList } from '@/apis/xt/task'
/**
* 通知记录(《03-接口设计》§13.3)。
*
* ## 这一屏存在的意义:把「通知到底发出去没有」变成可回答的
*
* 在此之前,驳回与审批只能回 `notifySent=false` 并让老师口头告知。
* 现在每次通知都会写一条发送记录,所以运营能看到:
* · **已送达**:站内信写进去了;
* · **额度不足跳过**(3):内容进了站内信,但没推送 —— **这不是故障**,
* 该做的是引导学生授权;
* · **发送失败**(2):真的出了问题(微信接口异常等),要查通道。
*
* 把 2 和 3 混在一起显示成"失败",会让运营一直去查通道故障,
* 而真正的原因(学生没授权)永远没人管。
*/
defineOptions({ name: 'NotifyRecords' })
const loading = ref(false)
const taskLoading = ref(false)
const list = ref<NotifyRecord[]>([])
const total = ref(0)
const rate = ref('')
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
const query = reactive<{
taskId?: number
bizType?: number
status?: number
pageNum: number
pageSize: number
}>({
taskId: undefined,
bizType: undefined,
status: undefined,
pageNum: 1,
pageSize: 20
})
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
async function load() {
loading.value = true
try {
const res = await getNotifyRecords({
taskId: query.taskId,
bizType: query.bizType,
status: query.status,
pageNum: query.pageNum,
pageSize: query.pageSize
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
rate.value = res.data?.deliveryRate ?? ''
} finally {
loading.value = false
}
}
async function loadTasks() {
taskLoading.value = true
try {
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
} finally {
taskLoading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
load()
loadTasks()
</script>
<style lang="scss" scoped>
.notify {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__rate {
margin-bottom: 12px;
}
&__task {
width: 200px;
}
&__biz {
width: 140px;
}
&__status {
width: 180px;
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
&__content {
display: -webkit-box;
overflow: hidden;
font-size: 13px;
line-height: 1.5;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__fail {
margin-top: 4px;
overflow: hidden;
font-size: 12px;
color: rgb(var(--red-6));
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>