From 5db629cfdbf1599d7961d2d708c022919d8dc29b Mon Sep 17 00:00:00 2001 From: chenxing Date: Sat, 19 Sep 2026 18:55:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(msg):=20=E6=8E=A5=E5=85=A5=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=B8=AD=E5=BF=83=E2=80=94=E2=80=94NoticeUserMsgNotif?= =?UTF-8?q?ier=EF=BC=88=E5=8F=91=E5=B8=83/=E8=BD=AC=E5=8F=91/=E5=82=AC?= =?UTF-8?q?=E5=8A=9E=E2=86=92UserMsg=20WS=20=E5=AE=9E=E6=97=B6=E6=8E=A8?= =?UTF-8?q?=E9=80=81=EF=BC=8CafterCommit=20=E5=AE=B9=E9=94=99+=E5=88=86?= =?UTF-8?q?=E7=89=87+=E5=B9=82=E7=AD=89=EF=BC=89+=20SDK=20=E9=97=A8?= =?UTF-8?q?=E9=9D=A2=E5=AE=A2=E6=88=B7=E7=AB=AF=20+=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=BD=92=E4=B8=80=EF=BC=8809-19=20=E4=BA=8C?= =?UTF-8?q?=E6=9C=9F=E9=AA=8C=E6=94=B6=209/9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/service/impl/NoticeService.java | 26 ++- .../service/msg/NoticeUserMsgNotifier.java | 141 ++++++++++++++ .../service/msg/NoticeUserMsgProperties.java | 19 ++ .../notice/service/msg/SdkUserMsgClient.java | 20 ++ .../service/msg/UserCenterUserDirectory.java | 38 ++++ .../notice/service/msg/UserDirectory.java | 16 ++ .../notice/service/msg/UserMsgClient.java | 13 ++ src/main/resources/application.yml | 19 +- .../service/flow/NoticeFlowPublishTest.java | 6 + .../service/impl/NoticeDetailReadTest.java | 6 + .../impl/NoticeResubmitClearResultTest.java | 6 + .../msg/NoticeUserMsgNotifierTest.java | 173 ++++++++++++++++++ 12 files changed, 474 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/yunhai/notice/service/msg/NoticeUserMsgNotifier.java create mode 100644 src/main/java/com/yunhai/notice/service/msg/NoticeUserMsgProperties.java create mode 100644 src/main/java/com/yunhai/notice/service/msg/SdkUserMsgClient.java create mode 100644 src/main/java/com/yunhai/notice/service/msg/UserCenterUserDirectory.java create mode 100644 src/main/java/com/yunhai/notice/service/msg/UserDirectory.java create mode 100644 src/main/java/com/yunhai/notice/service/msg/UserMsgClient.java create mode 100644 src/test/java/com/yunhai/notice/service/msg/NoticeUserMsgNotifierTest.java diff --git a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java index bafcf76..df38fa9 100644 --- a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java +++ b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java @@ -89,6 +89,9 @@ public class NoticeService implements INoticeService { @Autowired private com.yunhai.notice.service.flow.NoticeFlowGuard noticeFlowGuard; + @Autowired + private com.yunhai.notice.service.msg.NoticeUserMsgNotifier noticeUserMsgNotifier; + private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode(); private static final int UNPUBLISHED = NoticeStatusEnum.UNPUBLISHED.getCode(); private static final int AUDIT_APPROVING = com.yunhai.notice.enums.FlowAuditStatusEnum.APPROVING.getCode(); @@ -161,6 +164,9 @@ public class NoticeService implements INoticeService { } else { // 立即发布且开启短信时预留短信发送(真实通道后续接入) trySendSms(entity); + // 站内消息推送(afterCommit,指定人员;ALL 全员无法枚举一期不推,见 NoticeUserMsgNotifier) + noticeUserMsgNotifier.notifyPublishedAfterCommit(entity, + parseReceiveAccounts(entity.getReceiveList())); } return entity.getId(); } @@ -191,6 +197,7 @@ public class NoticeService implements INoticeService { Objects.requireNonNull(id, "通知ID不得为空"); Notice entity = noticeRepository.findById(id) .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); + boolean wasPublished = entity.getStatus() == PUBLISHED; // 审批中锁定:防审批做一半被抽走(删除需先撤回);驳回/撤回态允许编辑后重提 if (entity.getAuditStatus() != null && entity.getAuditStatus() == AUDIT_APPROVING) { throw new BizException(ErrorCode.FORBIDDEN, "审批中不可编辑,请先撤回"); @@ -247,9 +254,16 @@ public class NoticeService implements INoticeService { if (needAudit) { // 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续 noticeFlowService.submitAfterCommit(entity.getId()); - } else if (wasFlowBound) { - // 由审核轨道转直发:审核期从未发布过,此刻才真正可见——补发布后置(sms_sent_at 原子认领防重发) - trySendSms(entity); + } else { + if (wasFlowBound && !wasPublished) { + // 由审核轨道转直发:审核期从未发布过,此刻才真正可见——补发布后置(sms_sent_at 原子认领防重发) + trySendSms(entity); + } + if (!wasPublished) { + // 首次从非发布态转直发:推送站内消息(已发布通知的普通编辑不重复打扰) + noticeUserMsgNotifier.notifyPublishedAfterCommit(entity, + parseReceiveAccounts(entity.getReceiveList())); + } } } @@ -364,6 +378,9 @@ public class NoticeService implements INoticeService { noticeFlowService.submitAfterCommit(entity.getId()); } else { trySendSms(entity); + // 转发直发后推送站内消息(与 publish 同口径) + noticeUserMsgNotifier.notifyPublishedAfterCommit(entity, + parseReceiveAccounts(entity.getReceiveList())); } return entity.getId(); } @@ -783,7 +800,8 @@ public class NoticeService implements INoticeService { .filter(a -> !readAccounts.contains(a)) .collect(Collectors.toList()); log.info("[expedite] 通知 {} 催收未读 {} 人: {}", serid, unreadAccounts.size(), unreadAccounts); - // TODO: 接入 yh-platform 消息推送后,对 unreadAccounts 发送催办消息 + // 接入消息中心:对未读接收人推送催办站内消息(推送失败仅记日志,不阻断催收) + noticeUserMsgNotifier.notifyExpedited(notice, unreadAccounts); } // ============================ 统计 ============================ diff --git a/src/main/java/com/yunhai/notice/service/msg/NoticeUserMsgNotifier.java b/src/main/java/com/yunhai/notice/service/msg/NoticeUserMsgNotifier.java new file mode 100644 index 0000000..3cbf790 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/msg/NoticeUserMsgNotifier.java @@ -0,0 +1,141 @@ +package com.yunhai.notice.service.msg; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.platform.openapi.msg.model.SendMsgRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 通知公告 → 消息中心推送器(方案《消息中心建设方案》§6.1 试点生产方)。
+ * 接入点:publish/forward 直发分支、edit 转直发(从未发布到发布)、expedite 催办未读。 + * 先审后发(ALL + 绑定流程)审核通过才可见,但该路径接收人恒为 ALL 无法枚举——一期不推 + * (催办入口可对已发布通知补推未读提醒)。
+ * 容错语义(对齐 PartyLearn 范式):发送失败仅记日志,绝不阻断通知主流程; + * msgNo 幂等键 "ntc-{action}-{id}-{yyMMddHHmmss}"——同一秒内重试安全,跨秒重复动作视为再次提醒。 + */ +@Slf4j +@Component +public class NoticeUserMsgNotifier { + + /** SDK 单次接收人上限(oapi 契约:去重后 ≤1000,超出报错) */ + public static final int MAX_RECV_PER_SEND = 1000; + + private static final DateTimeFormatter MSG_NO_TS = DateTimeFormatter.ofPattern("yyMMddHHmmss"); + + private final UserMsgClient userMsgClient; + private final UserDirectory userDirectory; + private final NoticeUserMsgProperties properties; + + public NoticeUserMsgNotifier(UserMsgClient userMsgClient, UserDirectory userDirectory, + NoticeUserMsgProperties properties) { + this.userMsgClient = userMsgClient; + this.userDirectory = userDirectory; + this.properties = properties; + } + + /** + * 发布/转发/编辑转直发后推送(在发送事务内调用:注册 afterCommit, + * 避免通知主体尚未提交时收件端先收到消息)。 + * + * @param accounts 接收人账号串列表(数字ID或登录名;ALL 通知为空列表 → 不推) + */ + public void notifyPublishedAfterCommit(Notice notice, List accounts) { + if (!properties.isPushEnabled()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + doNotify(notice, accounts, "pub", "notice"); + } + }); + } else { + doNotify(notice, accounts, "pub", "notice"); + } + } + + /** 催办即时推送(expedite 无事务上下文,读侧已查完库) */ + public void notifyExpedited(Notice notice, List unreadAccounts) { + if (!properties.isPushEnabled()) { + return; + } + doNotify(notice, unreadAccounts, "exp", "notice.expedite"); + } + + private void doNotify(Notice notice, List accounts, String action, String bizType) { + if (accounts == null || accounts.isEmpty()) { + log.info("[usermsg] 通知 {} 无可枚举接收人(ALL 全员),跳过站内消息", notice.getId()); + return; + } + try { + Set userIds = new LinkedHashSet<>(); + for (String account : accounts) { + Integer userId = userDirectory.toUserId(account); + if (userId == null) { + log.warn("[usermsg] 接收人无法解析为用户ID,跳过 notice={} account={}", notice.getId(), account); + } else { + userIds.add(userId); + } + } + if (userIds.isEmpty()) { + log.warn("[usermsg] 通知 {} 接收人全部无法解析,未推送", notice.getId()); + return; + } + List all = new ArrayList<>(userIds); + int chunks = (all.size() + MAX_RECV_PER_SEND - 1) / MAX_RECV_PER_SEND; + for (int i = 0; i < chunks; i++) { + List part = all.subList(i * MAX_RECV_PER_SEND, Math.min((i + 1) * MAX_RECV_PER_SEND, all.size())); + try { + userMsgClient.sendUserMsg(buildRequest(notice, action, bizType, part, chunks > 1 ? i + 1 : 0)); + } catch (Exception e) { + log.warn("[usermsg] 站内消息发送失败(不影响通知主流程) notice={} part={}/{}:{}", + notice.getId(), i + 1, chunks, e.toString()); + } + } + log.info("[usermsg] 通知 {} 站内消息已推送 {} 人(action={})", notice.getId(), all.size(), action); + } catch (Exception e) { + log.warn("[usermsg] 站内消息推送异常(不影响通知主流程) notice={}:{}", notice.getId(), e.toString()); + } + } + + private SendMsgRequest buildRequest(Notice notice, String action, String bizType, + List userIds, int chunkIndex) { + SendMsgRequest request = new SendMsgRequest(); + request.setUserIds(userIds); + request.setTitle("通知公告"); + request.setContent(buildContent(notice)); + String msgNo = "ntc-" + action + "-" + notice.getId() + "-" + + MSG_NO_TS.format(LocalDateTime.now()) + + (chunkIndex > 0 ? "-" + chunkIndex : ""); + request.setMsgNo(msgNo); + request.setBizType(bizType); + request.setBizId(String.valueOf(notice.getId())); + // 0系统通知;跳转 Notice 前端详情页(type=1 打开即标读),UserHome 侧按 bizType=notice 基址拼绝对地址 + request.setMsgType(0); + request.setJumpType(1); + request.setJumpPath("/list/detail?serid=" + notice.getId() + "&type=1"); + request.setSchId(notice.getSchId()); + return request; + } + + /** 消息文案:对齐旧版"您有一条来自xx老师的xx通知"口吻 */ + private String buildContent(Notice notice) { + String publisher = StringUtils.hasText(notice.getPublisherName()) ? notice.getPublisherName() : "管理员"; + String title = StringUtils.hasText(notice.getTitle()) ? notice.getTitle() : ""; + if (title.length() > 120) { + title = title.substring(0, 120) + "…"; + } + return "您有一条来自「" + publisher + "」的通知《" + title + "》,请及时查阅。"; + } +} diff --git a/src/main/java/com/yunhai/notice/service/msg/NoticeUserMsgProperties.java b/src/main/java/com/yunhai/notice/service/msg/NoticeUserMsgProperties.java new file mode 100644 index 0000000..2901a9e --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/msg/NoticeUserMsgProperties.java @@ -0,0 +1,19 @@ +package com.yunhai.notice.service.msg; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 通知公告 → 消息中心(UserMsg)推送配置。
+ * push-enabled 默认关闭:UserMsg 未部署的环境(本地无配置/生产上线前)不产生空转告警—— + * 上线 UserMsg 后在环境配置显式打开(对齐 flow-center.usermsg 空转的反面教材)。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "notice.usermsg") +public class NoticeUserMsgProperties { + + /** 是否向 UserMsg 推送站内消息(发布/转发/编辑转直发/催办) */ + private boolean pushEnabled = false; +} diff --git a/src/main/java/com/yunhai/notice/service/msg/SdkUserMsgClient.java b/src/main/java/com/yunhai/notice/service/msg/SdkUserMsgClient.java new file mode 100644 index 0000000..2b6c732 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/msg/SdkUserMsgClient.java @@ -0,0 +1,20 @@ +package com.yunhai.notice.service.msg; + +import com.yunhai.platform.openapi.msg.YschMsgOpenApi; +import com.yunhai.platform.openapi.msg.model.SendMsgRequest; +import org.springframework.stereotype.Component; + +/** + * SDK 实现:委托 yh-openapi-client 的 msg 域门面(服务目录 key={@code usermsg}, + * 地址解析 targets > yh.open.services.usermsg > 枚举 base-host 兜底;签名/信封全由 SDK 承载)。 + * 门面由 yh-openapi-spring-boot-starter 自动装配;未装配时调用抛 IllegalStateException + * (推送方按容错语义捕获记日志,不阻断主流程)。 + */ +@Component +public class SdkUserMsgClient implements UserMsgClient { + + @Override + public void sendUserMsg(SendMsgRequest request) { + YschMsgOpenApi.sendUserMsg(request); + } +} diff --git a/src/main/java/com/yunhai/notice/service/msg/UserCenterUserDirectory.java b/src/main/java/com/yunhai/notice/service/msg/UserCenterUserDirectory.java new file mode 100644 index 0000000..80b937d --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/msg/UserCenterUserDirectory.java @@ -0,0 +1,38 @@ +package com.yunhai.notice.service.msg; + +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 用户中心实现:纯数字视为 userId 直接收敛;其余按登录名查用户中心取 id + * (对齐 NoticeService.getReadDetailGrouped 的既有归一化语义)。 + */ +@Slf4j +@Component +public class UserCenterUserDirectory implements UserDirectory { + + @Override + public Integer toUserId(String account) { + if (!StringUtils.hasText(account)) { + return null; + } + String trimmed = account.trim(); + if (trimmed.chars().allMatch(Character::isDigit)) { + try { + return Integer.valueOf(trimmed); + } catch (NumberFormatException e) { + return null; + } + } + try { + BoaUser user = YschBaseOpenApi.getUserInfo(trimmed); + return user == null ? null : user.getId(); + } catch (Exception e) { + log.warn("[usermsg] 登录名换 userId 失败 account={}:{}", trimmed, e.toString()); + return null; + } + } +} diff --git a/src/main/java/com/yunhai/notice/service/msg/UserDirectory.java b/src/main/java/com/yunhai/notice/service/msg/UserDirectory.java new file mode 100644 index 0000000..4e7b550 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/msg/UserDirectory.java @@ -0,0 +1,16 @@ +package com.yunhai.notice.service.msg; + +/** + * 接收人账号 → 数字用户ID 归一接口(测试缝)。
+ * 新数据 receiveList 存数字用户ID(组织树叶 id,UserBase userId); + * 历史数据可能存登录名——登录名经用户中心换 userId。 + */ +public interface UserDirectory { + + /** + * 归一为数字用户ID:纯数字直接解析;否则按登录名查用户中心。 + * + * @return 数字用户ID;解析失败返回 null(调用方跳过该人) + */ + Integer toUserId(String account); +} diff --git a/src/main/java/com/yunhai/notice/service/msg/UserMsgClient.java b/src/main/java/com/yunhai/notice/service/msg/UserMsgClient.java new file mode 100644 index 0000000..83a594a --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/msg/UserMsgClient.java @@ -0,0 +1,13 @@ +package com.yunhai.notice.service.msg; + +import com.yunhai.platform.openapi.msg.model.SendMsgRequest; + +/** + * 消息中心发送客户端接口(测试缝):生产实现委托 SDK 门面 YschMsgOpenApi, + * 单测可 mock 以验证发送参数与容错语义。 + */ +public interface UserMsgClient { + + /** POST /oapi/msg/sendUserMsg(应用签名;同 appKey+msgNo 幂等返回原消息 id) */ + void sendUserMsg(SendMsgRequest request); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c3394c0..cc13ac7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,11 +69,17 @@ notice: sms: catchup-enabled: true catchup-fixed-delay-ms: 300000 + # 消息中心(UserMsg)站内消息推送(发布/转发/编辑转直发/催办): + # push-enabled 默认 false(未部署 UserMsg 的环境无空转告警);UserMsg 上线后打开 + usermsg: + push-enabled: true # 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) yh: sso: - server-url-prefix: http://sso.yunhaixingtong.com/cas/ + # 本地浏览器联调:登录页=SSO 前端 /login(读取 service 参数回跳), + # 票据校验=根路径 /serviceValidate(160 实测;/cas/ 前缀仅存量本地默认值) + server-url-prefix: http://sso.yunhaixingtong.com/ client-host-url: http://localhost:8101 allow-headers: all allow-origins: all @@ -81,10 +87,11 @@ yh: mobi-api-pattern: /mobiapi/* app-auth: enabled: true - # 本应用自身身份(客户端签名用):本地联调一律用 100617 与生产权威(deploy/notice.yaml)一致, - # 消除与 UserHome 撞号的 100001 占位(升级方案 D8);flow-center trusted-apps 已同步收此号段 - app-key: 100617 - app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + # 本应用自身身份(客户端签名用)。本地联调用 100009(160 UserBase/中央密钥链在册身份, + # 互通验证通过;100617 在 160 侧未被 UserBase 信任会导致出站 401)。生产仍为 100617 + #(deploy/notice.yaml 唯一真源);flow-center trusted-apps 已同步收 100009 号段 + app-key: 100009 + app-secret: dcc36c98299a4d36bb0ec39d42008e8c # 提供方强制验签(两级模型 §4.1 试点 P2.1):/oapi/** 匿名 401、注册应用签名直调通过 server: enforce-patterns: [/oapi/*] @@ -102,6 +109,8 @@ yh: # 生产改容器直连 http://flow-center-service:10008/flow-center(projman 注入)。 services: flowcenter: http://localhost:8085 + # usermsg 显式指向本地消息中心(生产部署 UserMsg 后改容器直连,如 http://usermsg-service:8080/usermsg) + usermsg: http://localhost:8080 # env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见 # config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。 # 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java index 4178c9c..2b517d0 100644 --- a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java @@ -142,6 +142,12 @@ class NoticeFlowPublishTest { new NoticeFlowGuard(flowClient, props)); noticeService = new NoticeService(); + ReflectionTestUtils.setField(noticeService, "noticeUserMsgNotifier", + new com.yunhai.notice.service.msg.NoticeUserMsgNotifier( + org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class), + org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class), + new com.yunhai.notice.service.msg.NoticeUserMsgProperties())); + ReflectionTestUtils.setField(noticeService, "noticeRepository", noticeRepository); ReflectionTestUtils.setField(noticeService, "noticeReadRepository", mock(NoticeReadRepository.class)); ReflectionTestUtils.setField(noticeService, "noticeReceiverRepository", mock(NoticeReceiverRepository.class)); diff --git a/src/test/java/com/yunhai/notice/service/impl/NoticeDetailReadTest.java b/src/test/java/com/yunhai/notice/service/impl/NoticeDetailReadTest.java index 463d57b..000624a 100644 --- a/src/test/java/com/yunhai/notice/service/impl/NoticeDetailReadTest.java +++ b/src/test/java/com/yunhai/notice/service/impl/NoticeDetailReadTest.java @@ -37,6 +37,12 @@ class NoticeDetailReadTest { noticeRepository = mock(NoticeRepository.class); noticeReadRepository = mock(NoticeReadRepository.class); service = new NoticeService(); + ReflectionTestUtils.setField(service, "noticeUserMsgNotifier", + new com.yunhai.notice.service.msg.NoticeUserMsgNotifier( + org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class), + org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class), + new com.yunhai.notice.service.msg.NoticeUserMsgProperties())); + ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository); ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository); ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class)); diff --git a/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java b/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java index 5061c3a..3004528 100644 --- a/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java +++ b/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java @@ -41,6 +41,12 @@ class NoticeResubmitClearResultTest { noticeRepository = mock(NoticeRepository.class); noticeFlowService = mock(NoticeFlowService.class); service = new NoticeService(); + ReflectionTestUtils.setField(service, "noticeUserMsgNotifier", + new com.yunhai.notice.service.msg.NoticeUserMsgNotifier( + org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserMsgClient.class), + org.mockito.Mockito.mock(com.yunhai.notice.service.msg.UserDirectory.class), + new com.yunhai.notice.service.msg.NoticeUserMsgProperties())); + ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository); ReflectionTestUtils.setField(service, "noticeFlowService", noticeFlowService); ReflectionTestUtils.setField(service, "noticeReceiverRepository", diff --git a/src/test/java/com/yunhai/notice/service/msg/NoticeUserMsgNotifierTest.java b/src/test/java/com/yunhai/notice/service/msg/NoticeUserMsgNotifierTest.java new file mode 100644 index 0000000..f1ea562 --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/msg/NoticeUserMsgNotifierTest.java @@ -0,0 +1,173 @@ +package com.yunhai.notice.service.msg; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.platform.openapi.msg.model.SendMsgRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 通知 → 消息中心推送器单测(mock UserMsgClient/UserDirectory 缝,纯 Mockito 不起 Spring): + * 归一化/跳过语义/事务 afterCommit 时机/分片/容错/文案字段。 + */ +class NoticeUserMsgNotifierTest { + + private UserMsgClient client; + private UserDirectory directory; + private NoticeUserMsgProperties properties; + private NoticeUserMsgNotifier notifier; + + @BeforeEach + void setUp() { + client = mock(UserMsgClient.class); + directory = mock(UserDirectory.class); + properties = new NoticeUserMsgProperties(); + properties.setPushEnabled(true); + notifier = new NoticeUserMsgNotifier(client, directory, properties); + } + + @AfterEach + void tearDown() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + private Notice notice(int id) { + Notice notice = new Notice(); + ReflectionTestUtils.setField(notice, "id", id); + notice.setTitle("关于开展2026年秋季学期教学检查的通知"); + notice.setPublisherName("刘文娟"); + notice.setSchId(3); + return notice; + } + + @Test + void publish_numericAccounts_sentAsUserIdsAfterCommit() { + when(directory.toUserId("10006")).thenReturn(10006); + when(directory.toUserId("10029")).thenReturn(10029); + + TransactionSynchronizationManager.initSynchronization(); + notifier.notifyPublishedAfterCommit(notice(1), Arrays.asList("10006", "10029")); + // 事务未提交:不应即时发送 + verify(client, never()).sendUserMsg(any()); + for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) { + sync.afterCommit(); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMsgRequest.class); + verify(client).sendUserMsg(captor.capture()); + SendMsgRequest req = captor.getValue(); + assertEquals(Arrays.asList(10006, 10029), req.getUserIds()); + assertEquals("通知公告", req.getTitle()); + assertTrue(req.getContent().contains("刘文娟") && req.getContent().contains("教学检查")); + assertEquals("notice", req.getBizType()); + assertEquals("1", req.getBizId()); + assertEquals(0, req.getMsgType()); + assertEquals(1, req.getJumpType()); + assertEquals("/list/detail?serid=1&type=1", req.getJumpPath()); + assertEquals(3, req.getSchId()); + assertTrue(req.getMsgNo().startsWith("ntc-pub-1-")); + } + + @Test + void publish_disabled_noSend() { + properties.setPushEnabled(false); + notifier.notifyPublishedAfterCommit(notice(1), Arrays.asList("10006")); + verify(client, never()).sendUserMsg(any()); + } + + @Test + void publish_allReceivers_emptyAccounts_skipped() { + notifier.notifyPublishedAfterCommit(notice(1), Collections.emptyList()); + verify(client, never()).sendUserMsg(any()); + } + + @Test + void publish_unresolvableAccount_skippedOthersStillSent() { + when(directory.toUserId("ghost")).thenReturn(null); + when(directory.toUserId("10006")).thenReturn(10006); + + notifier.notifyPublishedAfterCommit(notice(2), Arrays.asList("ghost", "10006")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMsgRequest.class); + verify(client).sendUserMsg(captor.capture()); + assertEquals(Collections.singletonList(10006), captor.getValue().getUserIds()); + } + + @Test + void publish_allAccountsUnresolvable_noSend() { + when(directory.toUserId(any())).thenReturn(null); + notifier.notifyPublishedAfterCommit(notice(3), Arrays.asList("a", "b")); + verify(client, never()).sendUserMsg(any()); + } + + @Test + void publish_overThousandReceivers_chunkedWithDistinctMsgNo() { + List accounts = IntStream.rangeClosed(1, 1005).mapToObj(String::valueOf).collect(Collectors.toList()); + IntStream.rangeClosed(1, 1005).forEach(i -> when(directory.toUserId(String.valueOf(i))).thenReturn(i)); + + notifier.notifyPublishedAfterCommit(notice(4), accounts); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMsgRequest.class); + verify(client, org.mockito.Mockito.times(2)).sendUserMsg(captor.capture()); + List sent = captor.getAllValues(); + assertEquals(1000, sent.get(0).getUserIds().size()); + assertEquals(5, sent.get(1).getUserIds().size()); + assertEquals(500500, sent.get(0).getUserIds().stream().reduce(0, Integer::sum)); + assertEquals(1001 + 1002 + 1003 + 1004 + 1005, sent.get(1).getUserIds().stream().reduce(0, Integer::sum)); + // 同一 action 同秒:主体 msgNo 相同,分片加 -1/-2 后缀,保证 (appKey,msgNo) 不冲突 + assertTrue(sent.get(0).getMsgNo().endsWith("-1")); + assertTrue(sent.get(1).getMsgNo().endsWith("-2")); + } + + @Test + void publish_sendThrows_swallowed() { + when(directory.toUserId("10006")).thenReturn(10006); + doThrow(new RuntimeException("usermsg down")).when(client).sendUserMsg(any()); + + notifier.notifyPublishedAfterCommit(notice(5), Collections.singletonList("10006")); + + verify(client).sendUserMsg(any()); + } + + @Test + void expedite_usesExpediteBizTypeAndMsgNo() { + when(directory.toUserId("10006")).thenReturn(10006); + + notifier.notifyExpedited(notice(6), Collections.singletonList("10006")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMsgRequest.class); + verify(client).sendUserMsg(captor.capture()); + assertEquals("notice.expedite", captor.getValue().getBizType()); + assertTrue(captor.getValue().getMsgNo().startsWith("ntc-exp-6-")); + } + + @Test + void publish_noTransactionContext_sendsImmediately() { + when(directory.toUserId("10006")).thenReturn(10006); + + notifier.notifyPublishedAfterCommit(notice(7), Collections.singletonList("10006")); + + verify(client).sendUserMsg(any()); + } +}