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());
+ }
+}