From 29c8e932ae675a83f8e142c59108e0d43c2f689b Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Wed, 30 Sep 2026 09:02:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(notify):=20=E6=B6=88=E6=81=AF=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E3=80=81=E5=82=AC=E6=8A=A5=E3=80=81=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E4=B8=8E=E5=BE=AE=E4=BF=A1=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=20+=20=E6=A0=A1=E9=AA=8C=E9=97=AE=E9=A2=98=E6=B8=85=E5=8D=95?= =?UTF-8?q?=EF=BC=88V8=20/=20V14=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消息中心 + 订阅额度(原子扣减,先扣再发——订阅消息是一次性授权)+ 催报(定向/一键, 按 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。 --- .../fill/config/FillProperties.java | 25 +- .../controller/MpTeacherNotifyController.java | 65 ++ .../controller/NotifyAdminController.java | 103 ++++ .../notify/controller/NotifyController.java | 72 +++ .../notify/controller/UrgeController.java | 77 +++ .../controller/WechatCallbackController.java | 75 +++ .../module/notify/dto/NotifyAdminDtos.java | 111 ++++ .../fill/module/notify/dto/NotifyDtos.java | 109 ++++ .../fill/module/notify/dto/UrgeDtos.java | 157 +++++ .../module/notify/entity/CollectMessage.java | 64 ++ .../notify/entity/CollectNotifyTemplate.java | 52 ++ .../notify/mapper/CollectMessageMapper.java | 49 ++ .../notify/mapper/NotifyRecordMapper.java | 101 ++++ .../notify/mapper/NotifyTemplateMapper.java | 39 ++ .../notify/mapper/SubscribeQuotaMapper.java | 104 ++++ .../fill/module/notify/mapper/UrgeMapper.java | 319 ++++++++++ .../notify/service/NotificationService.java | 336 +++++++++++ .../notify/service/NotifyCallbackService.java | 190 ++++++ .../notify/service/NotifyTemplateService.java | 419 +++++++++++++ .../notify/service/NotifyTemplates.java | 150 +++++ .../module/notify/service/UrgeService.java | 373 ++++++++++++ .../qrcode/service/WxMiniProgramService.java | 109 ++++ .../controller/ValidationController.java | 85 +++ .../module/validation/dto/ValidationDtos.java | 159 +++++ .../entity/CollectValidationIssue.java | 77 +++ .../mapper/ValidationIssueMapper.java | 81 +++ .../validation/service/ValidationService.java | 560 ++++++++++++++++++ ...__validation_issue_and_notify_template.sql | 84 +++ .../db/migration/V8__message_center.sql | 55 ++ frontend-mp/src/pages/fill/fill.vue | 6 + frontend-mp/src/pages/messages/messages.vue | 335 +++++++++++ frontend-mp/src/utils/subscribe.ts | 105 ++++ frontend-pc/src/apis/xt/notify.ts | 138 +++++ frontend-pc/src/views/notify/index.vue | 244 ++++++++ 34 files changed, 5027 insertions(+), 1 deletion(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/MpTeacherNotifyController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/NotifyAdminController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/NotifyController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/UrgeController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/WechatCallbackController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/NotifyAdminDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/NotifyDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/UrgeDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/entity/CollectMessage.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/entity/CollectNotifyTemplate.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/CollectMessageMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/NotifyRecordMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/NotifyTemplateMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/SubscribeQuotaMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/UrgeMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotificationService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyCallbackService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplateService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/controller/ValidationController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/dto/ValidationDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/entity/CollectValidationIssue.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/mapper/ValidationIssueMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/service/ValidationService.java create mode 100644 backend/xt-server/src/main/resources/db/migration/V14__validation_issue_and_notify_template.sql create mode 100644 backend/xt-server/src/main/resources/db/migration/V8__message_center.sql create mode 100644 frontend-mp/src/pages/messages/messages.vue create mode 100644 frontend-mp/src/utils/subscribe.ts create mode 100644 frontend-pc/src/apis/xt/notify.ts create mode 100644 frontend-pc/src/views/notify/index.vue diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java index 07160e7..a0c2027 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java @@ -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 templates, + /** + * 微信消息推送回调的校验 token(微信后台「服务器配置」里填的那个)。 + * + *

⚠️ 它不是 appSecret:appSecret 用来换 access_token, + * 而这个 token 只用于校验回调签名。两者混用是常见错误 —— + * 表现是「回调永远验签失败」,而日志上看起来一切正常(配置读到了、非空)。 + * + *

为空时回调接口会明确拒绝(并说清缺哪个配置),而不是跳过验签 —— + * 跳过验签等于把那个匿名写接口完全敞开。 + */ + 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 templatesOrEmpty() { + return templates == null ? Map.of() : templates; + } + /** 凭据是否已配置。没配时二维码相关接口要给出明确提示,而不是让调用方猜。 */ public boolean configured() { return appId != null && !appId.isBlank() && appSecret != null && !appSecret.isBlank(); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/MpTeacherNotifyController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/MpTeacherNotifyController.java new file mode 100644 index 0000000..ee56e2c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/MpTeacherNotifyController.java @@ -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)。 + * + *

与 {@code UrgeController} 共用 {@code UrgeService},只是路径与范围不同: + *

+ * + *

权限用 {@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 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 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)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/NotifyAdminController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/NotifyAdminController.java new file mode 100644 index 0000000..c82ce9d --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/NotifyAdminController.java @@ -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)。 + * + *

⚠️ 注意路径前缀是 {@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> templates( + @RequestParam(required = false) Long taskId) { + return ApiResult.ok(templateService.list(taskId)); + } + + @GetMapping("/templates/variables") + @RequirePerm(PermissionDef.NOTIFY_SEND) + @Operation(summary = "可用变量白名单", + description = "必须由服务端给:前端抄一份的话,两边迟早不一致," + + "而模板是**非技术人员**改的,写错变量名在界面上看不出任何异常") + public ApiResult> variables() { + return ApiResult.ok(NotifyTemplates.VARIABLES); + } + + @PutMapping("/templates/{id}") + @RequirePerm(PermissionDef.NOTIFY_SEND) + @Operation(summary = "修改通知模板", + description = "只能改**已被自定义过**的行(`id` 来自列表)。" + + "内置模板没有行可改 —— 想改就先 `POST /notify/templates/{code}/customize`") + public ApiResult 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 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> 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 send(@RequestBody NotifyAdminDtos.SendReq req) { + return ApiResult.ok(templateService.send(req)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/NotifyController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/NotifyController.java new file mode 100644 index 0000000..e29ab58 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/NotifyController.java @@ -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)。 + * + *

学生端与教师端**同构**:都是"我的消息",只是 {@code bizType} 集合不同 + * (教师多一条 {@code 7} 加入审批)。所以两个路径共用一套实现 —— + * 分成两套的话,"已读"的语义迟早分叉。 + * + *

不挂权限码:消息是"我自己的东西",登录即可。 + * 越权由 {@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 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 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 read(@RequestBody NotifyDtos.ReadReq req) { + return ApiResult.ok(notificationService.markRead(req)); + } + + @PostMapping("/subscribe") + @Operation(summary = "登记订阅消息额度", + description = "微信订阅消息是**一次性授权**:用户每点一次「允许」只能发一条," + + "所以要在这里登记。额度用尽时通知会**静默降级为站内信**(学生端不显示额度告警)") + public ApiResult subscribe(@RequestBody NotifyDtos.SubscribeReq req) { + return ApiResult.ok(notificationService.subscribe(req)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/UrgeController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/UrgeController.java new file mode 100644 index 0000000..ef48b39 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/UrgeController.java @@ -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 的定向/一键催报)。 + * + *

为什么催报要有独立的限流口径

+ * + *

契约 §3 的表里写了"催报 10 次/分钟/任务"。 + * 这个限流不是防攻击,是**防误触**:老师点一下"一键催报"可能给几十个学生发消息, + * 而它没有"撤回"。所以宁可让他等一下,也不要一次点出几百条通知。 + * + *

本轮的实现只做了 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 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 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 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)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/WechatCallbackController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/WechatCallbackController.java new file mode 100644 index 0000000..77ac772 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/controller/WechatCallbackController.java @@ -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})。 + * + *

这个接口为什么必须是**公开**的,以及公开之后靠什么保护

+ * + *

它是微信服务器来调的,不可能带我们的登录态 —— 所以它天然是匿名接口。 + * 匿名接口在"租户拦截器默认拒绝"的体系里是**危险区**,因此这里的三条约束必须写清: + * + *

    + *
  1. 它只写"消息推送结果",不读任何业务数据:即使被人伪造调用, + * 最坏后果是"某条推送的状态被写错",拿不到任何学生信息;
  2. + *
  3. 它不依赖 tenant_id:调用方是微信,它不知道我们有几个租户。 + * 所以这个 Mapper 必须 {@code @InterceptorIgnore(tenantLine="true")}, + * 而业务归属靠 "我们发出去时记的那条通知记录 id / msgid" 反查(见 service);
  4. + *
  5. 它必须校验微信签名({@code msg_signature} + {@code timestamp} + {@code nonce}), + * 验不过就拒绝。签名算法是 SHA1 排序拼接,属于协议要求而非我们的安全设计。
  6. + *
+ * + *

返回体为什么是 {@code {"errcode":0}} 而不是 {@code {code,msg,data}}

+ * + *

因为调用方是**微信**,它只认自己那套格式。本项目"响应恒为 {@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 wechatMsg(@RequestBody(required = false) String rawBody, + HttpServletRequest request) { + Map 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/NotifyAdminDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/NotifyAdminDtos.java new file mode 100644 index 0000000..cbc822f --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/NotifyAdminDtos.java @@ -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 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 receiverIds; + + /** 按名册行指定(没绑账号的人会进 skipped 并给出原因)。 */ + private List 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 vars; + } + + /** 发送结果。 */ + @Getter + @Setter + public static class SendVO { + + /** 站内信写入成功的条数(**这是"发出去"的口径**,与通知记录一致)。 */ + private int sent; + + private int failed; + + /** 被跳过的人数(没绑账号等)。 */ + private int skipped; + + /** 其中微信推送真正发出去的条数。 */ + private int pushSent; + + private List skipReasons = new ArrayList<>(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/NotifyDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/NotifyDtos.java new file mode 100644 index 0000000..309506c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/NotifyDtos.java @@ -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 list = new ArrayList<>(); + + private long total; + + private long unreadCount; + } + + /** 标记已读的请求。 */ + @Getter + @Setter + @Schema(description = "标记已读") + public static class ReadReq { + + /** 要标的那些消息 id;{@code all=true} 时忽略 */ + private List 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/UrgeDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/UrgeDtos.java new file mode 100644 index 0000000..e9631bd --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/dto/UrgeDtos.java @@ -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 orgIds = new ArrayList<>(); + + private Long taskId; + + private Integer channel; + + private String templateCode; + + private String extraText; + + /** + * 多少小时内催过就不再催(默认 24)。 + * + *

契约 §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 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 list = new ArrayList<>(); + + private long total; + + /** + * 送达率(成功 / 总数)。 + * + *

契约 §13.3 的 `GET /notify/records` 带这个字段。 + * ⚠️ 分母把"额度不足跳过"算进去吗?**算**。 + * 因为运营看这个数字是为了回答"我发的通知有多少人真收到了", + * 把跳过排除在外会让送达率虚高(跳过的人恰恰是最没收到的那批)。 + */ + private String deliveryRate; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/entity/CollectMessage.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/entity/CollectMessage.java new file mode 100644 index 0000000..e8df7e4 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/entity/CollectMessage.java @@ -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 的消息中心)。 + * + *

与 {@code collect_notify_record} 的分工:那张表记**发送通道结果** + * (发出去没有、到没到、为什么失败),这张表记**用户看到什么**。 + * 两者通过 {@code bizType} 关联 —— 契约 §13.5 明确写了这个区别。 + * + *

为什么"已读"必须放在这里:已读是用户维度、可以反复改的状态; + * 而发送记录是**只增不改**的凭据(要能回答"到底通知过没有")。 + * 把已读塞进发送记录,那批数据就再也不能当证据用了。 + */ +@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/entity/CollectNotifyTemplate.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/entity/CollectNotifyTemplate.java new file mode 100644 index 0000000..115b6db --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/entity/CollectNotifyTemplate.java @@ -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)。 + * + *

为什么库里只存"被改过的那些"

+ * + *

内置模板写在代码里({@link cn.tianbaotong.fill.module.notify.service.NotifyTemplates}): + *

+ * + *

因此"恢复默认"就是**删掉这一行**,内置文案自然回来。 + */ +@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"; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/CollectMessageMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/CollectMessageMapper.java new file mode 100644 index 0000000..7ab24bc --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/CollectMessageMapper.java @@ -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 { + + /** + * 把某人的全部未读标为已读。 + * + *

手写 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 批量标已读。 + * + *

⚠️ 条件里必须有 {@code user_id = #{userId}}:ids 是前端传来的, + * 不校验归属就等于"任何人可以把别人的消息标成已读"(虽然危害小, + * 但那是**改别人的数据**,性质与越权读一样)。 + */ + @Update(""" + + """) + int markReadByIds(@Param("tenantId") Long tenantId, + @Param("userId") Long userId, + @Param("ids") java.util.List ids); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/NotifyRecordMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/NotifyRecordMapper.java new file mode 100644 index 0000000..100d6ae --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/NotifyRecordMapper.java @@ -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})。 + * + *

这张表记的是**通道结果**:发出去没有、走的哪个通道、为什么失败。 + * 与 {@code collect_message}(用户看到什么)分开两张表,理由见契约 §13.5 + * 与 {@code CollectMessage} 的类注释。 + * + *

用一条窄插入而不是建实体:这张表只增不改、字段固定, + * 建实体反而要处理"没有 deleted 列"的老问题({@code @TableLogic} 会加不存在的列, + * 这个坑在 {@code collect_task_org} 与 {@code collect_export_task} 上都踩过)。 + * + *

⚠️ biz_type 是 varchar,不是数字

+ * + *

契约 §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}),而失败只落在日志里 —— 表现是"表里一行都没有"。 + * + *

本方法的入参因此是 {@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"反查这条推送属于哪个租户。 + * + *

⚠️ **这是微信回调路径上唯一的无租户查询**,也是"关掉框架保护"的全部范围。 + * 微信不知道我们有几个租户,所以它来的请求不带租户 —— 只能靠我们自己发出去时 + * 记下的 id 反查。查不到时回调侧会**明确报错**,而不是猜一个租户去写: + * 写错租户等于把数据丢进别人家。 + * + *

两个条件都用 {@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); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/NotifyTemplateMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/NotifyTemplateMapper.java new file mode 100644 index 0000000..dd89914 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/NotifyTemplateMapper.java @@ -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 { + + /** + * **物理**删掉一条覆盖行("恢复默认")。 + * + *

⚠️ 为什么不用 {@code delete()}:实体有 {@code @TableLogic}, + * MyBatis-Plus 的 {@code delete()} 会做成 {@code UPDATE deleted=1} —— + * 那一行仍在表里,而 {@code uk_notify_tpl} 唯一索引**不看 deleted**, + * 于是"恢复默认 → 再自定义"会撞唯一约束(表现是 500,看不出原因)。 + * + *

而且这里软删除本来就没有价值:它是配置记录,用户改过什么在操作日志里, + * 留一份 deleted=1 的副本只是垃圾。 + * + *

⚠️ 条件用 {@code } 拼,而**不是**写 + * {@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(""" + + """) + int deleteOverride(@Param("code") String code, @Param("taskId") Long taskId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/SubscribeQuotaMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/SubscribeQuotaMapper.java new file mode 100644 index 0000000..5e45110 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/SubscribeQuotaMapper.java @@ -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)。 + * + *

为什么额度要单独记一张表

+ * + *

微信订阅消息是**一次性授权**:用户每点一次"允许"只能发一条。 + * 所以"我还能给这个学生发几条"必须由我们自己记 —— 微信不会告诉你, + * 而额度用尽时发送接口会直接报错(43101)。 + * + *

唯一索引 {@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)。 + * + *

用 {@code ON CONFLICT} 而不是"先查再插":学生可能连点两次"允许", + * 并发下"先查再插"会撞唯一索引(表现为 500,而用户只是多点了一下)。 + * + *

⚠️ 必须是 {@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); + + /** + * 扣一次额度。 + * + *

条件里带 {@code used < total}:**扣减必须与"还有余额"在同一个语句里判**。 + * "先查够不够、再扣"在并发下会扣成负数(同一个人同时触发两条通知), + * 而负余额看起来像"还能发",于是继续发、继续失败。 + * + *

⚠️ 外面套 {@code + """) + int consume(@Param("tenantId") Long tenantId, + @Param("userId") Long userId, + @Param("templateCode") String templateCode); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/UrgeMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/UrgeMapper.java new file mode 100644 index 0000000..aa18131 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/mapper/UrgeMapper.java @@ -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)。 + * + *

"未提交"到底怎么算

+ * + *

{@code collect_roster.submit_status} 有五个值(0 未填 / 1 草稿 / 2 待审 / 3 已通过 / 4 已驳回)。 + * **未提交 = 0 或 1** —— 也就是"还没交上来"。 + * + *

为什么把"草稿"也算未提交:学生点过"开始填写"但没交,对老师来说与没填**没有区别** —— + * 他要做的动作都是"催一下"。只看 {@code submit_status = 0} 会把最常见的那批人漏掉 + * (点进来了、填了一半、忘了交),而那恰恰是最该催的。 + * + *

同理,**已驳回(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; } + } + + /** + * 我在这个任务里、这些班里的未提交者。 + * + *

范围条件与初审完全一致(三级回退)——**催报和审核必须是同一批人**。 + * 只管权限不管范围的话,一个班主任能催全校。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + List listUnsubmitted(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("orgIds") List 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); + + /** + * 最近一次催报的时间(防重复骚扰)。 + * + *

⚠️ 两个坑都踩过: + *

    + *
  1. {@code biz_type} 是 varchar(F-26),要拿**字符串**比。写成数字会被 PG 拒绝, + * 而那个异常会被通知层的降级逻辑吞掉 → 返回 null → 去重**静默失效** + * (表现是"老师点两下,学生收到两条",谁也不会觉得是这里的问题)。
  2. + *
  3. 用的码是**消息中心的催报码 {@code 5}**,不是契约 §6 字段表里的 {@code 6} + * (见 {@code UrgeService.BIZ_URGE} 的说明)。
  4. + *
+ * + *

{@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(""" + + """) + List listRecords(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("bizType") String bizType, + @Param("status") Integer status, + @Param("receiverIds") List receiverIds, + @Param("limit") int limit, + @Param("offset") int offset); + + /** 记录总数(同一个过滤条件)。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + long countRecords(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("bizType") String bizType, + @Param("status") Integer status, + @Param("receiverIds") List receiverIds); + + /** + * 我负责的班里那些学生的 user_id(教师端看通知记录时收窄范围用)。 + * + *

⚠️ 必须收窄:通知记录里带**别人的姓名**, + * 一个班主任没有理由看到"别的班催过谁"。返回空列表时调用方要当成 + * "什么都看不到",而不是"不过滤"。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + List receiverIdsOfMyOrgs(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("userId") Long userId); + + /** 送达率用:按状态计数(0 待发 / 1 成功 / 2 失败 / 3 额度不足跳过)。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + List> countByStatus(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("bizType") String bizType); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotificationService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotificationService.java new file mode 100644 index 0000000..0798390 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotificationService.java @@ -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)。 + * + *

这个类解决的核心问题:把"通知到底发出去没有"变成可回答的

+ * + *

在此之前,驳回与审批的响应里只能如实回 {@code notifySent=false} + * (通知渠道没做),前端提示"请人工告知学生"。现在: + * + *

    + *
  1. 每次通知都写一条**站内信** —— 学生下次进小程序一定看得到。 + * 所以"他能不能知道"这件事有了确定答案,而不是"我们没发,他自己看着办"。
  2. + *
  3. 如果这个学生**索取过订阅消息授权且还有额度**,就顺带发一条微信推送, + * 并把 {@code channel} 记成 {@code WECHAT_SUB};发失败就静默降级到站内信。
  4. + *
+ * + *

降级链为什么只做到"订阅消息 → 站内信"

+ * + *

契约 §13.6 的完整链条是:订阅消息 → 公众号模板 → 短信 → 仅站内信。 + * 中间两级**现在做不了**,而且不是代码问题: + *

+ * 硬写两级"看起来有、其实永远失败"的通道,比少写更糟:日志里会全是失败, + * 而真正的原因(没配通道)被淹掉。所以这里只实现**能真正走通的两级**, + * 并在 {@code channel} 里如实标出这条消息最终是怎么到的。 + * + *

降级对用户是静默的

+ * + *

契约 §13.6:学生端不显示"你的额度不够了"。额度只影响"有没有微信推送", + * 不影响"他能不能看到内容" —— 内容永远在站内信里。 + * + *

⚠️ 注意:这个类**不吞异常**,但也**不抛**——通知失败绝不能让审核动作回滚。 + * 一次驳回因为"微信挂了"而失败,是比"没通知到"更严重的问题。 + */ +@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.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 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()); + } + + /** + * 这条通知属于哪个任务(发送记录表要它 —— 催报去重按任务)。 + * + *

从 {@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()); + } + } + + /** + * 模板数据。 + * + *

微信订阅消息的字段名是**模板自己定义的**(thing1 / time2 / phrase3…), + * 所以只能由客户在微信后台建好模板后,用 {@code templateParams} 传进来。 + * 这里做一个兜底:只有内容没有参数时,用通用字段名 —— 发不出去也不影响站内信。 + */ + private Map buildTemplateData(NotifyRequest req) { + Map 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.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.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 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(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyCallbackService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyCallbackService.java new file mode 100644 index 0000000..ee3c309 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyCallbackService.java @@ -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)。 + * + *

微信回调在"租户隔离"体系里的位置

+ * + *

这是本项目**唯一**一个既匿名、又需要写库的入口(扫码落地页是匿名但只读)。 + * "租户拦截器默认拒绝"的原则在这里必须让路 —— 微信不知道我们有几个租户。 + * 所以做法是: + *

    + *
  1. 先用 {@code @InterceptorIgnore(tenantLine="true")} 的专用查询, + * 凭 我们发出去时记的那条通知记录的 id 反查到它属于哪个租户;
  2. + *
  3. 再用 {@link TenantScope#runAs} 把租户上下文摆好,之后走正常的带租户写入。
  4. + *
+ * 这样"关掉框架保护"的范围被压到**一个查询**,而不是整个写入路径 —— + * 与扫码落地页的处理方式一致(`MpSceneMapper`)。 + * + *

"用户拒收"必须单独记,不能混进"失败"

+ * + *

微信回的错误码里,{@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,与签名比对。 + * + *

这是微信协议规定的算法,不是我们的安全设计;**参数少一个会验不过**, + * 而"验不过"与"微信没配 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 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplateService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplateService.java new file mode 100644 index 0000000..d876ddc --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplateService.java @@ -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)。 + * + *

模板:库里的优先级高于内置,但只覆盖它自己那一条

+ * + *

列表 = 内置清单 ⟵ 用库里的行覆盖同 {@code (taskId, templateCode, channel)} 的那几条。 + * 所以"恢复默认"就是删掉库里那一行(内置文案自然回来)。 + * + *

手工发送:为什么它必须复用 {@link NotificationService}

+ * + *

手工发送最容易写成"另一条发送路径":自己拼消息、自己推微信、自己写通知记录。 + * 那样做的后果是**站内信与通知记录的口径分叉** —— + * 自动发的消息在消息中心能看到,手工发的看不到(或反之),而用户完全无法理解为什么。 + * 所以这里只做"取人 → 渲染 → 逐个走 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 list(Long taskId) { + Map overrides = new HashMap<>(); + for (CollectNotifyTemplate t : templateMapper.selectList( + Wrappers.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 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 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 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)。 + * + *

为什么必须有它

+ * + *

契约只给了 {@code GET /notify/templates} 与 {@code PUT /notify/templates/{id}}。 + * 而 GET 返回的内置模板**没有 id**(它不在库里)—— 于是 PUT 无从下手: + * 用户点"编辑"时前端拿不出一个 id 来。这条链断在第一步,而文档上看起来是通的。 + * + *

做法:把内置文案**复制成库里的一行**(这就是"自定义"),之后 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.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, "模板读取失败")); + } + + /** + * 恢复内置文案(**删掉库里的覆盖行**)。 + * + *

契约 §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); + } + + // ── 手工发送 ─────────────────────────────────── + + /** + * 手工发送通知。 + * + *

接收人三种给法(契约只写了 {@code receiverIds},另两种是加出来的,记在 F-52): + *

    + *
  1. {@code receiverIds}:显式指定用户 id;
  2. + *
  3. {@code rosterIds}:指定名册行 —— 界面上选的是"学生",而学生可能还没绑账号, + * 这时按名册行更自然;没绑账号的人会被计入 {@code skipped} 并说明原因;
  4. + *
  5. {@code taskId} + {@code all=true}:整任务(催报场景)。
  6. + *
+ * + *

⚠️ 三种都给时**取并集**并去重 —— 用户同时勾了名单又填了 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 userIds = new java.util.LinkedHashSet<>(); + int skipped = 0; + List 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.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 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 varsOf(NotifyAdminDtos.SendReq req) { + Map 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.lambdaQuery() + .eq(CollectRoster::getTaskId, t.getId())); + long done = rosterMapper.selectCount(Wrappers.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(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java new file mode 100644 index 0000000..1370c59 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java @@ -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 的变量白名单)。 + * + *

为什么模板正文写在代码里而不是库表

+ * + *

见 {@link cn.tianbaotong.fill.module.notify.entity.CollectNotifyTemplate} 的类注释: + * 内置文案要能随版本升级自动生效。库里只放"被用户改过的"。 + * + *

变量白名单是**硬约束**,不是文档

+ * + *

契约 §12.7 写明「越界变量渲染为空并告警」。 + * 这条要求有实际原因:模板是**非技术人员**在界面上改的,写错一个变量名(`{姓名 }`、`{名字}`) + * 在界面上看不出任何异常 —— 直到发出去的消息里出现一个字面的 `{名字}`, + * 或者更糟:整条消息变成空的。 + * + *

所以渲染时:白名单内的变量替换成值;白名单外的**原样保留**并记一条 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 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 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 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 BIZ_TYPES = Set.of(1, 2, 3, 4, 5, 6, 7); + + /** + * 渲染模板。 + * + * @return 渲染后的文本;越界变量**原样保留**(见类注释) + */ + public static String render(String template, Map 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 unknownVars(String template) { + List 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java new file mode 100644 index 0000000..abb1699 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java @@ -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)。 + * + *

这一屏的难点不在"怎么发",而在"发给谁 / 谁不该再收到"

+ * + *
    + *
  1. 只催未提交者(契约 §8.6 明确写了)。"未提交"= 未填 + 草稿 —— + * 填了一半忘了交的人与完全没填的人,对老师来说要做的是同一件事。 + * 已驳回的人**不催**:他要的是"改哪里",催报文案对他没意义。
  2. + *
  3. 范围 = 我负责的班(与初审、看板同一套三级回退)。 + * 只查权限不查范围,一个班主任就能催全校 —— 而且他自己不会知道。
  4. + *
  5. 不重复骚扰({@code excludeRecentHours},默认 24 小时)。 + * 老师点两下"一键催报"是常事,学生不该收到两条。去重责任在服务端。
  6. + *
  7. 没绑定的人催不到,要说清为什么:名册里有这个人、但他还没扫码核验过身份, + * 就没有 openid、也没有要发到哪个账号。这时 skip 的理由必须能读懂 + * ("还没核对身份,无法触达"),而不是笼统的"发送失败"。
  8. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UrgeService { + + /** 默认模板码(契约 §8.6 的 `NOTIFY_UNSUBMITTED`)。 */ + public static final String TPL_URGE = "NOTIFY_UNSUBMITTED"; + + /** + * 催报用的业务类型码 = {@code 5}。 + * + *

⚠️ 这里用的是**消息中心**的码 {@code 5}({@code CollectMessage.BIZ_URGE}), + * 而契约 §259 那行 {@code notifyBizType} 写的是 {@code 5} 导出完成 / {@code 6} 催报 —— + * **两处对调了**(§259 与 §2655 的示例能对上,说明不是我看错行)。 + * + *

我按消息中心的码统一(那张表是本轮新建、由我定),并把不一致记成 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`)── + + /** + * 催一个名册项。 + * + *

⚠️ 范围校验**不能省**:{@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 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 myOrgs = auditScopeMapper.classAuditOrgIds(tenantId, taskId, uid); + if (myOrgs.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "你没有负责任何一个班,无法催报"); + } + List orgIds = asked.isEmpty() ? myOrgs + : asked.stream().filter(myOrgs::contains).toList(); + if (orgIds.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "指定的班级都不在你负责的范围内"); + } + + List 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 sentUsers = new LinkedHashSet<>(); + List 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「自动催报」,定时任务调用)── + + /** + * 自动催报一个任务的**未提交者**(没有登录用户,由定时任务调)。 + * + *

与一键催报的三点不同,都是"没有操作人"带来的: + *

    + *
  1. 不做范围校验:没有"我负责的班"这个概念 —— 定时任务是系统行为, + * 催的是这个任务下**所有**未提交的人(本来就该催全);
  2. + *
  3. 不传 scopeUserId(传 null 时 SQL 会跳过范围条件);
  4. + *
  5. 去重窗口仍然是 24 小时:7/3/1 天三次催报之间隔着好几天, + * 但"学生今天自己交了、晚上任务又催一次"是可能的 —— 去重拦的是这种。
  6. + *
+ * + * @param daysLeft 距截止还有几天(文案里要说,学生才知道急不急) + * @return 实际发出的条数 + */ + public int autoUrge(Long tenantId, Long taskId, int daysLeft) { + List targets = + urgeMapper.listUnsubmitted(tenantId, taskId, List.of(), null); + if (targets.isEmpty()) { + return 0; + } + LocalDateTime now = LocalDateTime.now(); + Set 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 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 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 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; + } + + /** + * 汇总三个计数。 + * + *

⚠️ 判定顺序是"失败 → 跳过 → 成功",因为**一个条目可能既是跳过又带原因**。 + * 我第一版写成"先加 skipped、发现是失败再减一",那种加减法在加第三种状态时 + * 必然出错(而且错得很难看出来:总数还是对的)。 + * 这里按优先级**只归一类**,三个数相加恒等于明细条数。 + */ + private UrgeDtos.UrgeResultVO result(List 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) { + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/service/WxMiniProgramService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/service/WxMiniProgramService.java index 92aceae..2930eab 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/service/WxMiniProgramService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/service/WxMiniProgramService.java @@ -144,6 +144,115 @@ public class WxMiniProgramService { } } + // ── 订阅消息(《03-接口设计》§13.6 的通道之一)────── + + /** + * 发一条微信订阅消息。 + * + *

⚠️ 订阅消息是一次性授权:用户每点一次"允许"只能发一条。 + * 所以调用方({@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 data) { + FillProperties.MiniProgram cfg = requireConfigured(); + String url = WX_BASE + "/cgi-bin/message/subscribe/send?access_token=" + getAccessToken(cfg); + + Map 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 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 postJson(String url, String jsonBody) { + HttpResponse resp = send(url, jsonBody); + String text = new String(resp.body(), StandardCharsets.UTF_8); + try { + Map 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。 + * + *

通知服务用它决定"要不要试推送":没配就**直接降级到站内信**, + * 而不是打一次注定失败的微信请求(那会在日志里刷一片失败, + * 让人以为"微信接口坏了",其实只是没配)。 + */ + public boolean configured() { + return props.miniProgram() != null && props.miniProgram().configured(); + } + + /** + * 微信消息推送回调的校验 token(微信后台「服务器配置」里的那个)。 + * + *

回调服务用它做 SHA1 验签。没配时返回 null,回调侧会**明确拒绝** + * 而不是跳过验签 —— 那个接口是匿名的、还会写库,不能敞开。 + */ + /** + * 取 access_token(绑手机号要用它调微信的 `getuserphonenumber`)。 + * + *

仍然走内部的缓存逻辑 —— 微信对 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。 + * + *

模板 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 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) { diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/controller/ValidationController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/controller/ValidationController.java new file mode 100644 index 0000000..4043f1f --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/controller/ValidationController.java @@ -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 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 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 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); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/dto/ValidationDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/dto/ValidationDtos.java new file mode 100644 index 0000000..adb629b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/dto/ValidationDtos.java @@ -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 submissionIds; + + /** 只跑指定规则(如 {@code ["VAL-02","VAL-04"]});不传表示全跑。 */ + private List rules; + + private Boolean async = Boolean.TRUE; + } + + /** 校验运行结果。 */ + @Getter + @Setter + public static class RunVO { + + /** 本次运行的标识。 */ + private String jobId; + + private Integer estimatedSeconds; + + /** + * 本次实际校验了多少份填报单。 + * + *

契约的响应只有 {@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 list = new ArrayList<>(); + + private long total; + + private SeverityCounts counts = new SeverityCounts(); + + private List byRule = new ArrayList<>(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/entity/CollectValidationIssue.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/entity/CollectValidationIssue.java new file mode 100644 index 0000000..31c0b50 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/entity/CollectValidationIssue.java @@ -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})。 + * + *

这张表的存在方式与别的表不同

+ * + *

它不是"业务的当前状态",而是**历次校验的快照**: + * {@code POST /validation/run} 每跑一次,就把这一次算出来的问题**对齐**进表里 —— + * 已经存在的更新标题/详情,不再存在的标成"已解决"。 + * + *

所以有两件事必须说清: + *

    + *
  1. 问题不删,只"解决"({@code resolved})。保留历史才能回答 + * "上轮 47 个问题,这轮还剩 5 个" —— 而那是这个界面的全部意义。 + * 因此 {@code deleted} 这一列会一直是 0(基类要求有它,见 V14 的注释)。
  2. + *
  3. 重跑不能"复活"已解决的问题:用户在界面上点了"已处理", + * 下次跑校验时如果那一条**仍然算出来**,我们不能把它改回未解决 —— + * 那等于用户的判断被系统推翻。所以重跑只更新内容,不碰 {@code resolved}。
  4. + *
+ * + *

只有一种情况系统会自己把 {@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/mapper/ValidationIssueMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/mapper/ValidationIssueMapper.java new file mode 100644 index 0000000..237639c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/mapper/ValidationIssueMapper.java @@ -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 { + + /** + * 按规则分组统计(契约 §12.2 的 {@code byRule})。 + * + *

{@code autoFixed} 与 {@code count} 一起返回,因为它们回答的是两个不同的问题: + * 「这条规则命中了多少」与「其中多少是我不用管的」。界面要把后者显示成"已自动修复", + * 客户看到"双扩展名 56 项(已自动修复 56)"才会放心。 + * + *

⚠️ 别名必须加双引号:PostgreSQL 对未加引号的标识符一律转小写, + * 写成 {@code AS ruleCode} 返回的键其实是 {@code rulecode},Java 侧取到的就是 null —— + * 而 SQL 本身完全正确,日志里也看不出问题(查询成功、行数也对)。 + * + *

⚠️ 统计**不按 {@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> 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> countBySeverity(@Param("taskId") Long taskId); + + /** + * 把这一轮**没再算出来**的问题标成已解决。 + * + *

{@code resolved_by} 留空表示"系统判定"(问题真的修好了), + * 与"某人在界面上点了已处理"区分开 —— 这两种"已解决"的可信度不一样。 + * + *

⚠️ 用 {@code <script>} 包住:{@code @Update} 里不带 {@code + """) + int resolveMissing(@Param("taskId") Long taskId, @Param("keepIds") List keepIds); + + /** 一次校验之后,这个任务还剩多少未解决问题(写进响应,让用户立刻知道结果)。 */ + @Select(""" + SELECT COUNT(*) FROM collect_validation_issue + WHERE task_id = #{taskId} AND deleted = 0 AND resolved = 0 + """) + long countUnresolved(@Param("taskId") Long taskId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/service/ValidationService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/service/ValidationService.java new file mode 100644 index 0000000..5e957fb --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/validation/service/ValidationService.java @@ -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)。 + * + *

这一层的价值不在"跑校验",而在"把结果对齐成一件事"

+ * + *

真正的校验逻辑早在 {@link SubmissionValidator} 里(提交、导出前置检查、审核详情 + * 三处都在用它)。这里做的事是:**把"某一刻算出来的问题集合"落成可以反复看的清单**, + * 并且让两次校验之间能对比。 + * + *

重跑的语义(这是最容易做错的地方)

+ * + *

三种情况必须分开处理: + *

    + *
  1. 算出来了、表里也有 → 只更新标题/详情/级别,不碰 {@code resolved}。 + * 否则用户在界面上点过"已处理",下一次跑校验又变回未解决 —— + * 那等于系统推翻用户的判断,而这会让人再也不敢点那个按钮。
  2. + *
  3. 算出来了、表里没有 → 插入。
  4. + *
  5. 表里有、这次没算出来 → 标成已解决({@code resolvedBy} 留空 = + * 系统判定)。这一条是"修好了"的唯一证据,也是"上轮 47 → 这轮 5"的来源。
  6. + *
+ * + *

对齐用的是**同一个键**:{@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 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 bad = new LinkedHashSet<>(); + for (CollectValidationIssue i : issueMapper.selectList( + Wrappers.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.lambdaQuery() + .eq(CollectSubmission::getTaskId, task.getId())); + } + + Set onlyRules = req.getRules() == null ? Set.of() + : new LinkedHashSet<>(req.getRules()); + long t0 = System.currentTimeMillis(); + + Map 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 keepIds = new ArrayList<>(); + int found = 0; + int checked = 0; + for (CollectSubmission s : subs) { + CollectRoster r = s.getRosterId() == null ? null : rosters.get(s.getRosterId()); + List 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。 + * + *

为什么不用数据库的 upsert({@code ON CONFLICT}):那个唯一索引用了 + * {@code COALESCE(file_id,0)} 这种**表达式索引**,{@code ON CONFLICT} 要写一模一样的 + * 表达式才认得它,写错就是静默不生效。查一次再决定插入或更新更好读, + * 而这份数据量(一个任务几百条)不值得为它优化。 + */ + private Long upsert(Long taskId, Long submissionId, SubmitDtos.Issue issue) { + Map 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.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); + } + + /** + * 作业号。 + * + *

一期是**同步**跑完的(见 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 subIdsOfOrg = null; + if (orgId != null) { + List rosterIds = rosterMapper.selectList( + Wrappers.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.lambdaQuery() + .eq(CollectSubmission::getTaskId, taskId) + .in(CollectSubmission::getRosterId, rosterIds)) + .stream().map(CollectSubmission::getId).toList(); + if (subIdsOfOrg.isEmpty()) { + return new ValidationDtos.IssuePageVO(); + } + } + + var q = Wrappers.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 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 byRule(Long taskId) { + List out = new ArrayList<>(); + for (Map 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 toVos(List rows, Long taskId) { + if (rows.isEmpty()) { + return List.of(); + } + Map subs = new HashMap<>(); + for (CollectSubmission s : submissionMapper.selectByIds(rows.stream() + .map(CollectValidationIssue::getSubmissionId).distinct().toList())) { + subs.put(s.getId(), s); + } + Map rosters = new HashMap<>(); + List 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 orgNames = new HashMap<>(); + Map 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 matNames = new HashMap<>(); + for (var d : materialDefService.list(taskId)) { + matNames.put(d.getMaterialCode(), d.getMaterialName()); + } + + List 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; + } + + /** + * 问题对应的材料码。 + * + *

问题行本身**不存** {@code materialCode}(契约 §3.9 的列里没有它, + * 列在"材料"上的信息由 {@code file_id} 表达)。所以:文件类问题反查文件, + * 表单类问题没有材料。**不猜**:拿不到就返回空,清单上留空比写错强。 + */ + private String matCodeOf(CollectValidationIssue r, Map 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 en : matNames.entrySet()) { + if (name.contains(en.getValue())) { + return en.getKey(); + } + } + return null; + } + + // ── §12.3 导出 ──────────────────────────────── + + /** + * 异常清单 Excel(契约 §12.3)。 + * + *

列:姓名 / 班级 / 材料 / 规则码 / 级别 / 问题 / 期望值 / 实际值 / 是否自动修复 / 处理人。 + * + *

⚠️ 这一份是**给客户看**的,所以:级别写"错误/警告/提示"而不是 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 rows = page.getList(); + // 一次最多导 200 行会让客户以为"只有 200 个问题",所以分页取全量 + int total = (int) page.getTotal(); + if (total > rows.size()) { + List 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); + } +} diff --git a/backend/xt-server/src/main/resources/db/migration/V14__validation_issue_and_notify_template.sql b/backend/xt-server/src/main/resources/db/migration/V14__validation_issue_and_notify_template.sql new file mode 100644 index 0000000..59fb49e --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V14__validation_issue_and_notify_template.sql @@ -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); diff --git a/backend/xt-server/src/main/resources/db/migration/V8__message_center.sql b/backend/xt-server/src/main/resources/db/migration/V8__message_center.sql new file mode 100644 index 0000000..352aaea --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V8__message_center.sql @@ -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); diff --git a/frontend-mp/src/pages/fill/fill.vue b/frontend-mp/src/pages/fill/fill.vue index 5f29dc8..0b37d5e 100644 --- a/frontend-mp/src/pages/fill/fill.vue +++ b/frontend-mp/src/pages/fill/fill.vue @@ -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 } diff --git a/frontend-mp/src/pages/messages/messages.vue b/frontend-mp/src/pages/messages/messages.vue new file mode 100644 index 0000000..4e9b284 --- /dev/null +++ b/frontend-mp/src/pages/messages/messages.vue @@ -0,0 +1,335 @@ + + + + + diff --git a/frontend-mp/src/utils/subscribe.ts b/frontend-mp/src/utils/subscribe.ts new file mode 100644 index 0000000..68857d5 --- /dev/null +++ b/frontend-mp/src/utils/subscribe.ts @@ -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 { + // 用**运行期判断**而不是 `#ifdef` 条件编译: + // ① 条件编译要靠重复声明来分叉,而这里两种情况返回的是同一个 Promise —— + // 在 async 函数里写两段反而出现"不可达代码"; + // ② H5 上这个 API 根本不存在,判一下就够,而且判了之后小程序端的行为完全一样 + // (不依赖编译产物,出问题好排查)。 + const api = (uni as unknown as { + requestSubscribeMessage?: (opt: { + tmplIds: string[] + success?: (res: Record) => void + fail?: () => void + }) => void + }).requestSubscribeMessage + if (typeof api !== 'function') { + // H5 / 其它平台没有订阅消息。刻意**不**提示"请去小程序授权": + // 他一样能收到站内信,弹一句他做不到的事只会添乱。 + return [] + } + + const list = codes.slice(0, 3) + const granted = await new Promise((resolve) => { + try { + api({ + tmplIds: list, + success: (res: Record) => { + // 微信返回的形如 { 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') +} diff --git a/frontend-pc/src/apis/xt/notify.ts b/frontend-pc/src/apis/xt/notify.ts new file mode 100644 index 0000000..bc50f39 --- /dev/null +++ b/frontend-pc/src/apis/xt/notify.ts @@ -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(`/tasks/${taskId}/roster/notify-unsubmitted`, { + ...data, + taskId + }) +} + +/** 定向催报一个人(§8.6)。 */ +export function urgeOne(rosterId: number, data: { + channel?: number + templateCode?: string + extraText?: string +} = {}) { + return http.post(`/roster/${rosterId}/notify`, data) +} + +/** 通知记录(§13.3 管理端)。 */ +export function getNotifyRecords(params: { + taskId?: number + bizType?: number + status?: number + pageNum?: number + pageSize?: number +} = {}) { + return http.get('/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 = { + 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 = { + WECHAT_SUB: '微信订阅消息', + MP_TEMPLATE: '公众号模板', + SMS: '短信', + IN_APP: '仅站内信' +} + +export function channelText(c?: string) { + return CHANNEL_TEXT[c || ''] || c || '—' +} diff --git a/frontend-pc/src/views/notify/index.vue b/frontend-pc/src/views/notify/index.vue new file mode 100644 index 0000000..a1481a6 --- /dev/null +++ b/frontend-pc/src/views/notify/index.vue @@ -0,0 +1,244 @@ + + + + +