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:
@@ -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();
|
||||
|
||||
+65
@@ -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));
|
||||
}
|
||||
}
|
||||
+103
@@ -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));
|
||||
}
|
||||
}
|
||||
+72
@@ -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));
|
||||
}
|
||||
}
|
||||
+77
@@ -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));
|
||||
}
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
}
|
||||
+111
@@ -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<>();
|
||||
}
|
||||
}
|
||||
+109
@@ -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;
|
||||
}
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
+52
@@ -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";
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
+104
@@ -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 <} 会被原样发给 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 < total
|
||||
</script>
|
||||
""")
|
||||
int consume(@Param("tenantId") Long tenantId,
|
||||
@Param("userId") Long userId,
|
||||
@Param("templateCode") String templateCode);
|
||||
}
|
||||
+319
@@ -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);
|
||||
}
|
||||
+336
@@ -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();
|
||||
}
|
||||
}
|
||||
+190
@@ -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;
|
||||
}
|
||||
}
|
||||
+419
@@ -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();
|
||||
}
|
||||
}
|
||||
+150
@@ -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;
|
||||
}
|
||||
}
|
||||
+373
@@ -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) {
|
||||
}
|
||||
}
|
||||
+109
@@ -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) {
|
||||
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+159
@@ -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<>();
|
||||
}
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
+81
@@ -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 <script>} 包住:{@code @Update} 里不带 {@code <script>} 时
|
||||
* MyBatis **不做 XML 解析**,而这里用了 {@code <foreach>}。
|
||||
* 另外 {@code <} 在 XML 里必须写成 {@code <}(本项目已在这上面栽过,见 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);
|
||||
}
|
||||
+560
@@ -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);
|
||||
}
|
||||
}
|
||||
+84
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 || '—'
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user